feat: added skeleton omr api integration
This commit is contained in:
+28
-1
@@ -1,7 +1,7 @@
|
|||||||
import { app, BrowserWindow, ipcMain, dialog, Menu, nativeTheme } from "electron"
|
import { app, BrowserWindow, ipcMain, dialog, Menu, nativeTheme } from "electron"
|
||||||
import { join, dirname } from "node:path"
|
import { join, dirname } from "node:path"
|
||||||
import { fileURLToPath } from "node:url"
|
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))
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||||
|
|
||||||
@@ -69,6 +69,14 @@ ipcMain.handle("dialog:openFile", async (_event, filters: { name: string; extens
|
|||||||
return result.filePaths
|
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[] }[]) => {
|
ipcMain.handle("dialog:saveFile", async (_event, defaultName: string, filters: { name: string; extensions: string[] }[]) => {
|
||||||
const result = await dialog.showSaveDialog(win!, {
|
const result = await dialog.showSaveDialog(win!, {
|
||||||
defaultPath: defaultName,
|
defaultPath: defaultName,
|
||||||
@@ -97,6 +105,25 @@ ipcMain.handle("file:writeBinary", async (_event, path: string, base64: string)
|
|||||||
writeFileSync(path, Buffer.from(base64, "base64"))
|
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.whenReady().then(createWindow)
|
||||||
|
|
||||||
app.on("window-all-closed", () => {
|
app.on("window-all-closed", () => {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
|||||||
},
|
},
|
||||||
openFileDialog: (filters: { name: string; extensions: string[] }[]) =>
|
openFileDialog: (filters: { name: string; extensions: string[] }[]) =>
|
||||||
ipcRenderer.invoke("dialog:openFile", filters),
|
ipcRenderer.invoke("dialog:openFile", filters),
|
||||||
|
openDirectoryDialog: () => ipcRenderer.invoke("dialog:openDirectory"),
|
||||||
saveFileDialog: (defaultName: string, filters: { name: string; extensions: string[] }[]) =>
|
saveFileDialog: (defaultName: string, filters: { name: string; extensions: string[] }[]) =>
|
||||||
ipcRenderer.invoke("dialog:saveFile", defaultName, filters),
|
ipcRenderer.invoke("dialog:saveFile", defaultName, filters),
|
||||||
readFile: (path: string) => ipcRenderer.invoke("file:read", path),
|
readFile: (path: string) => ipcRenderer.invoke("file:read", path),
|
||||||
@@ -19,4 +20,8 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
|||||||
readImage: (path: string) => ipcRenderer.invoke("file:readImage", path),
|
readImage: (path: string) => ipcRenderer.invoke("file:readImage", path),
|
||||||
writeBinaryFile: (path: string, base64: string) => ipcRenderer.invoke("file:writeBinary", path, base64),
|
writeBinaryFile: (path: string, base64: string) => ipcRenderer.invoke("file:writeBinary", path, base64),
|
||||||
getAppPath: () => ipcRenderer.invoke("app:getPath"),
|
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),
|
||||||
})
|
})
|
||||||
|
|||||||
+49
-13
@@ -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 { Toolbar } from "./components/Toolbar"
|
||||||
import { TitleBar } from "./components/TitleBar"
|
import { TitleBar } from "./components/TitleBar"
|
||||||
import { LeftSidebar } from "./components/LeftSidebar"
|
import { LeftSidebar } from "./components/LeftSidebar"
|
||||||
@@ -7,15 +7,40 @@ import { PropertiesPanel } from "./components/PropertiesPanel"
|
|||||||
import { StatusBar } from "./components/StatusBar"
|
import { StatusBar } from "./components/StatusBar"
|
||||||
import { KeyboardCheatSheet } from "./components/KeyboardCheatSheet"
|
import { KeyboardCheatSheet } from "./components/KeyboardCheatSheet"
|
||||||
import { ResizeHandle } from "./components/ResizeHandle"
|
import { ResizeHandle } from "./components/ResizeHandle"
|
||||||
|
import { TabBar } from "./components/TabBar"
|
||||||
|
import { ApiClientPanel } from "./components/ApiClient/ApiClientPanel"
|
||||||
import { useStore } from "./store/useStore"
|
import { useStore } from "./store/useStore"
|
||||||
import { loadTemplateDialog } from "./utils/loadFile"
|
import { loadTemplateDialog } from "./utils/loadFile"
|
||||||
|
import type { AppTab } from "./components/TabBar"
|
||||||
|
|
||||||
const MIN_SIDEBAR = 160
|
const MIN_SIDEBAR = 160
|
||||||
const MAX_SIDEBAR = 500
|
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 (
|
||||||
|
<div className="p-8 text-error bg-error/5 border border-error/20 rounded-lg m-4">
|
||||||
|
<h2 className="text-title-md font-medium mb-2">{this.props.tab} Tab Crashed</h2>
|
||||||
|
<pre className="text-code whitespace-pre-wrap">{this.state.error}</pre>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return this.props.children
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
const [showCheatSheet, setShowCheatSheet] = useState(false)
|
const [showCheatSheet, setShowCheatSheet] = useState(false)
|
||||||
|
const [activeTab, setActiveTab] = useState<AppTab>("editor")
|
||||||
const [leftWidth, setLeftWidth] = useState(280)
|
const [leftWidth, setLeftWidth] = useState(280)
|
||||||
const [rightWidth, setRightWidth] = useState(320)
|
const [rightWidth, setRightWidth] = useState(320)
|
||||||
const [leftCollapsed, setLeftCollapsed] = useState(false)
|
const [leftCollapsed, setLeftCollapsed] = useState(false)
|
||||||
@@ -142,18 +167,29 @@ export default function App() {
|
|||||||
return (
|
return (
|
||||||
<div className="h-screen flex flex-col overflow-hidden">
|
<div className="h-screen flex flex-col overflow-hidden">
|
||||||
<TitleBar />
|
<TitleBar />
|
||||||
<Toolbar />
|
<TabBar active={activeTab} onChange={setActiveTab} />
|
||||||
<div ref={containerRef} className="flex flex-1 overflow-hidden">
|
{activeTab === "editor" ? (
|
||||||
<LeftSidebar width={leftCollapsed ? 32 : leftWidth} collapsed={leftCollapsed} onToggle={() => setLeftCollapsed((v) => !v)} />
|
<TabErrorBoundary tab="Editor" key="editor">
|
||||||
{!leftCollapsed && <ResizeHandle onResize={(clientX) => setLeftWidth(Math.max(MIN_SIDEBAR, Math.min(MAX_SIDEBAR, clientX)))} />}
|
<Toolbar />
|
||||||
<CanvasView />
|
<div ref={containerRef} className="flex flex-1 overflow-hidden">
|
||||||
{!rightCollapsed && <ResizeHandle onResize={(clientX) => {
|
<LeftSidebar width={leftCollapsed ? 32 : leftWidth} collapsed={leftCollapsed} onToggle={() => setLeftCollapsed((v) => !v)} />
|
||||||
if (!containerRef.current) return
|
{!leftCollapsed && <ResizeHandle onResize={(clientX) => setLeftWidth(Math.max(MIN_SIDEBAR, Math.min(MAX_SIDEBAR, clientX)))} />}
|
||||||
const cr = containerRef.current.getBoundingClientRect()
|
<CanvasView />
|
||||||
setRightWidth(Math.max(MIN_SIDEBAR, Math.min(MAX_SIDEBAR, cr.right - clientX)))
|
{!rightCollapsed && <ResizeHandle onResize={(clientX) => {
|
||||||
}} />}
|
if (!containerRef.current) return
|
||||||
<PropertiesPanel width={rightCollapsed ? 32 : rightWidth} collapsed={rightCollapsed} onToggle={() => setRightCollapsed((v) => !v)} />
|
const cr = containerRef.current.getBoundingClientRect()
|
||||||
</div>
|
setRightWidth(Math.max(MIN_SIDEBAR, Math.min(MAX_SIDEBAR, cr.right - clientX)))
|
||||||
|
}} />}
|
||||||
|
<PropertiesPanel width={rightCollapsed ? 32 : rightWidth} collapsed={rightCollapsed} onToggle={() => setRightCollapsed((v) => !v)} />
|
||||||
|
</div>
|
||||||
|
</TabErrorBoundary>
|
||||||
|
) : (
|
||||||
|
<TabErrorBoundary tab="API Client" key="api">
|
||||||
|
<div className="flex-1 overflow-auto bg-canvas">
|
||||||
|
<ApiClientPanel />
|
||||||
|
</div>
|
||||||
|
</TabErrorBoundary>
|
||||||
|
)}
|
||||||
<StatusBar />
|
<StatusBar />
|
||||||
{showCheatSheet && <KeyboardCheatSheet onClose={() => setShowCheatSheet(false)} />}
|
{showCheatSheet && <KeyboardCheatSheet onClose={() => setShowCheatSheet(false)} />}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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<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() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4 p-4">
|
||||||
|
<SafeWrap name="ServerSettings">
|
||||||
|
<ServerSettings />
|
||||||
|
</SafeWrap>
|
||||||
|
<SafeWrap name="TemplateManager">
|
||||||
|
<TemplateManager />
|
||||||
|
</SafeWrap>
|
||||||
|
<SafeWrap name="JobRunner">
|
||||||
|
<JobRunner />
|
||||||
|
</SafeWrap>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<File> {
|
||||||
|
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<File> {
|
||||||
|
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<string>("")
|
||||||
|
const [scannedPaths, setScannedPaths] = useState<string[]>([])
|
||||||
|
const [mode, setMode] = useState<"sync" | "async">("async")
|
||||||
|
const [statusMsg, setStatusMsg] = useState("")
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
const pollRefs = useRef<Record<string, boolean>>({})
|
||||||
|
|
||||||
|
const selectedTemplate = templates.find((t) => t.id === selectedTemplateId) || null
|
||||||
|
|
||||||
|
const pickFiles = async (opts: { multiple?: boolean; accept?: string } = {}) => {
|
||||||
|
if (!window.electronAPI) {
|
||||||
|
return new Promise<string[]>((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 (
|
||||||
|
<div className="flex flex-col gap-4 p-4">
|
||||||
|
<div className="flex flex-col gap-3 bg-surface-card border border-hairline rounded-lg p-4">
|
||||||
|
<span className="text-title-sm font-medium text-ink">Run Grading</span>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label className="text-caption text-muted">Select Template</label>
|
||||||
|
<select
|
||||||
|
value={selectedTemplateId}
|
||||||
|
onChange={(e) => setSelectedTemplateId(e.target.value)}
|
||||||
|
className="px-3 py-2 rounded-md border border-hairline bg-canvas text-body-md focus:outline-none focus:ring-2 focus:ring-primary/30"
|
||||||
|
>
|
||||||
|
<option value="">-- Choose a template --</option>
|
||||||
|
{templates.map((t) => (
|
||||||
|
<option key={t.id} value={t.id}>{t.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label className="text-caption text-muted">Scanned Sheets</label>
|
||||||
|
<button
|
||||||
|
onClick={pickScannedFiles}
|
||||||
|
className="px-3 py-2 rounded-md border border-hairline bg-surface-soft text-nav hover:bg-surface-cream-strong transition-all text-left"
|
||||||
|
>
|
||||||
|
{scannedPaths.length > 0
|
||||||
|
? `${scannedPaths.length} file(s) selected`
|
||||||
|
: "Select Scanned Images"}
|
||||||
|
</button>
|
||||||
|
{scannedPaths.length > 0 && (
|
||||||
|
<ul className="text-caption text-muted mt-1 max-h-24 overflow-auto bg-surface-soft rounded-md px-2 py-1">
|
||||||
|
{scannedPaths.map((p, i) => (
|
||||||
|
<li key={i}>{p.split(/[/\\]/).pop() || p}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label className="text-caption text-muted">Processing Mode</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{(["async", "sync"] as const).map((m) => (
|
||||||
|
<button
|
||||||
|
key={m}
|
||||||
|
onClick={() => setMode(m)}
|
||||||
|
className={
|
||||||
|
mode === m
|
||||||
|
? "flex-1 px-3 py-2 rounded-md bg-primary/10 text-primary border border-primary/20 text-nav font-medium transition-all"
|
||||||
|
: "flex-1 px-3 py-2 rounded-md border border-hairline bg-surface-soft text-nav hover:bg-surface-cream-strong transition-all"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{m === "async" ? "Async (poll)" : "Sync (block)"}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={submitting || !selectedTemplateId || scannedPaths.length === 0}
|
||||||
|
className="px-4 py-2 bg-primary text-on-primary rounded-md text-button font-medium hover:bg-primary-active active:scale-[0.97] transition-all shadow-sm disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{submitting ? "Submitting..." : "Grade Now"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{statusMsg && (
|
||||||
|
<div className="text-caption px-3 py-2 rounded-md border bg-surface-soft border-hairline text-body">
|
||||||
|
{statusMsg}
|
||||||
|
</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)
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="flex flex-col gap-3 p-4 bg-surface-card border border-hairline rounded-lg">
|
||||||
|
<span className="text-title-sm font-medium text-ink">Server Settings</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={inputUrl}
|
||||||
|
onChange={(e) => setInputUrl(e.target.value)}
|
||||||
|
placeholder="http://localhost:8000"
|
||||||
|
className="flex-1 px-3 py-2 rounded-md border border-hairline bg-canvas text-body-md focus:outline-none focus:ring-2 focus:ring-primary/30"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleCheck}
|
||||||
|
disabled={checking}
|
||||||
|
className="px-4 py-2 bg-primary text-on-primary rounded-md text-button font-medium hover:bg-primary-active active:scale-[0.97] transition-all shadow-sm disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{checking ? "Checking..." : "Check"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{health && (
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
(health.ok
|
||||||
|
? "text-success bg-success/5 border-success/20 "
|
||||||
|
: "text-error bg-error/5 border-error/20 ") +
|
||||||
|
"text-caption px-3 py-2 rounded-md border"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{health.msg}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<File | null>(null)
|
||||||
|
const [metadataFile, setMetadataFile] = useState<File | null>(null)
|
||||||
|
const [bubbleTemplateFile, setBubbleTemplateFile] = useState<File | null>(null)
|
||||||
|
const [filledTemplateFile, setFilledTemplateFile] = useState<File | null>(null)
|
||||||
|
|
||||||
|
// Edit state
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null)
|
||||||
|
const [editName, setEditName] = useState("")
|
||||||
|
|
||||||
|
const 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<string>((resolve) => {
|
||||||
|
tplReader.onload = () => resolve(tplReader.result as string)
|
||||||
|
tplReader.readAsDataURL(templateFile)
|
||||||
|
})
|
||||||
|
const metaText = await new Promise<string>((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<string>((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<string>((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 (
|
||||||
|
<div className="flex flex-col gap-4 p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-title-sm font-medium text-ink">Template Directory</span>
|
||||||
|
{isElectron && (
|
||||||
|
<button
|
||||||
|
onClick={pickDirectory}
|
||||||
|
className="px-3 py-1.5 rounded-md bg-surface-card border border-hairline text-nav hover:bg-surface-cream-strong transition-all"
|
||||||
|
>
|
||||||
|
{templateDir ? "Change Directory" : "Choose Directory"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{templateDir && (
|
||||||
|
<div className="text-caption text-muted bg-surface-soft px-3 py-2 rounded-md border border-hairline">
|
||||||
|
{templateDir}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isElectron && (
|
||||||
|
<div className="text-caption text-warning bg-warning/5 px-3 py-2 rounded-md border border-warning/20">
|
||||||
|
Running in web mode. Templates will not persist across reloads.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3 bg-surface-card border border-hairline rounded-lg p-4">
|
||||||
|
<span className="text-title-sm font-medium text-ink">Add New Template</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Template name"
|
||||||
|
value={newName}
|
||||||
|
onChange={(e) => setNewName(e.target.value)}
|
||||||
|
className="px-3 py-2 rounded-md border border-hairline bg-canvas text-body-md focus:outline-none focus:ring-2 focus:ring-primary/30"
|
||||||
|
/>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => selectFile("image/png,image/jpeg", setTemplateFile)}
|
||||||
|
className="px-3 py-2 rounded-md border border-hairline bg-surface-soft text-nav hover:bg-surface-cream-strong transition-all text-left"
|
||||||
|
>
|
||||||
|
{templateFile ? `Template: ${templateFile.name}` : "Select Template Image"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => selectFile("application/json", setMetadataFile)}
|
||||||
|
className="px-3 py-2 rounded-md border border-hairline bg-surface-soft text-nav hover:bg-surface-cream-strong transition-all text-left"
|
||||||
|
>
|
||||||
|
{metadataFile ? `Metadata: ${metadataFile.name}` : "Select metadata.json"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => selectFile("image/png,image/jpeg", setBubbleTemplateFile)}
|
||||||
|
className="px-3 py-2 rounded-md border border-hairline bg-surface-soft text-nav hover:bg-surface-cream-strong transition-all text-left"
|
||||||
|
>
|
||||||
|
{bubbleTemplateFile ? `Bubble: ${bubbleTemplateFile.name}` : "Select Bubble Template (opt)"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => selectFile("image/png,image/jpeg", setFilledTemplateFile)}
|
||||||
|
className="px-3 py-2 rounded-md border border-hairline bg-surface-soft text-nav hover:bg-surface-cream-strong transition-all text-left"
|
||||||
|
>
|
||||||
|
{filledTemplateFile ? `Filled: ${filledTemplateFile.name}` : "Select Filled Bubble (opt)"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleAddTemplate}
|
||||||
|
disabled={loading}
|
||||||
|
className="flex-1 px-4 py-2 bg-primary text-on-primary rounded-md text-button font-medium hover:bg-primary-active active:scale-[0.97] transition-all shadow-sm disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? "Saving..." : "Save Template"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={resetForm}
|
||||||
|
className="px-4 py-2 rounded-md border border-hairline bg-surface-soft text-nav hover:bg-surface-cream-strong transition-all"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{message && (
|
||||||
|
<div className="text-caption px-3 py-2 rounded-md border bg-surface-soft border-hairline text-body">
|
||||||
|
{message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<span className="text-title-sm font-medium text-ink">Stored Templates</span>
|
||||||
|
{templates.length === 0 && (
|
||||||
|
<div className="text-caption text-muted px-3 py-4 rounded-md border border-hairline bg-surface-soft text-center">
|
||||||
|
No templates yet. Add one above.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{templates.map((t) => (
|
||||||
|
<div
|
||||||
|
key={t.id}
|
||||||
|
className="flex flex-col gap-2 px-3 py-2 rounded-md border border-hairline bg-surface-soft hover:bg-surface-card transition-all"
|
||||||
|
>
|
||||||
|
{editingId === t.id ? (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editName}
|
||||||
|
onChange={(e) => setEditName(e.target.value)}
|
||||||
|
className="px-3 py-2 rounded-md border border-hairline bg-canvas text-body-md focus:outline-none focus:ring-2 focus:ring-primary/30"
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => saveEdit(t.id)}
|
||||||
|
className="px-3 py-1.5 rounded-md bg-primary text-on-primary text-nav font-medium hover:bg-primary-active transition-all"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={cancelEdit}
|
||||||
|
className="px-3 py-1.5 rounded-md border border-hairline bg-surface-soft text-nav hover:bg-surface-cream-strong transition-all"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-nav font-medium text-ink">{t.name}</span>
|
||||||
|
<span className="text-caption text-muted">
|
||||||
|
{new Date(t.createdAt).toLocaleString()}
|
||||||
|
{t.bubbleTemplatePath && " · bubble"}
|
||||||
|
{t.filledTemplatePath && " · filled"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => startEdit(t)}
|
||||||
|
className="p-1.5 rounded-md text-muted hover:text-primary hover:bg-primary/5 transition-all"
|
||||||
|
title="Edit name"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-[18px]">edit</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(t.id)}
|
||||||
|
className="p-1.5 rounded-md text-muted hover:text-error hover:bg-error/5 transition-all"
|
||||||
|
title="Delete"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-[18px]">delete</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="h-10 bg-surface-soft border-b border-hairline flex items-center px-4 gap-1 shrink-0 select-none">
|
||||||
|
{tabs.map((t) => (
|
||||||
|
<button
|
||||||
|
key={t.key}
|
||||||
|
onClick={() => onChange(t.key)}
|
||||||
|
className={
|
||||||
|
active === t.key
|
||||||
|
? "px-4 py-1.5 rounded-md bg-primary/10 text-primary border border-primary/20 text-nav font-medium transition-all"
|
||||||
|
: "px-4 py-1.5 rounded-md text-muted hover:text-ink hover:bg-surface-card text-nav font-medium transition-all"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<JobRun> | ((run: JobRun) => Partial<JobRun>)) => void
|
||||||
|
removeRun: (id: string) => void
|
||||||
|
loadRuns: (list: JobRun[]) => void
|
||||||
|
|
||||||
|
activeRunId: string | null
|
||||||
|
setActiveRunId: (id: string | null) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useApiStore = create<ApiStore>((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<JobRun>)(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 }),
|
||||||
|
}))
|
||||||
@@ -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<string, unknown> | 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
|
||||||
|
}
|
||||||
Vendored
+6
@@ -7,11 +7,17 @@ export interface ElectronAPI {
|
|||||||
isMaximized: () => Promise<boolean>
|
isMaximized: () => Promise<boolean>
|
||||||
onMaximizeChange: (callback: (maximized: boolean) => void) => void
|
onMaximizeChange: (callback: (maximized: boolean) => void) => void
|
||||||
openFileDialog: (filters: { name: string; extensions: string[] }[]) => Promise<string[]>
|
openFileDialog: (filters: { name: string; extensions: string[] }[]) => Promise<string[]>
|
||||||
|
openDirectoryDialog: () => Promise<string>
|
||||||
saveFileDialog: (defaultName: string, filters: { name: string; extensions: string[] }[]) => Promise<string>
|
saveFileDialog: (defaultName: string, filters: { name: string; extensions: string[] }[]) => Promise<string>
|
||||||
readFile: (path: string) => Promise<string>
|
readFile: (path: string) => Promise<string>
|
||||||
writeFile: (path: string, data: string) => Promise<void>
|
writeFile: (path: string, data: string) => Promise<void>
|
||||||
readImage: (path: string) => Promise<string>
|
readImage: (path: string) => Promise<string>
|
||||||
writeBinaryFile: (path: string, base64: string) => Promise<void>
|
writeBinaryFile: (path: string, base64: string) => Promise<void>
|
||||||
|
getAppPath: () => Promise<string>
|
||||||
|
readDir: (path: string) => Promise<{ name: string; isDirectory: boolean }[]>
|
||||||
|
ensureDir: (path: string) => Promise<string>
|
||||||
|
pathExists: (path: string) => Promise<boolean>
|
||||||
|
copyFile: (src: string, dest: string) => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import type { ProcessResponse, JobStatusResponse, HealthResponse } from "../types/api"
|
||||||
|
|
||||||
|
export async function checkHealth(baseUrl: string): Promise<HealthResponse> {
|
||||||
|
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<ProcessResponse> {
|
||||||
|
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<JobStatusResponse> {
|
||||||
|
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<void> {
|
||||||
|
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<JobStatusResponse> {
|
||||||
|
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")
|
||||||
|
}
|
||||||
@@ -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<TemplateEntry[]> {
|
||||||
|
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<void> {
|
||||||
|
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 }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user