feat(v1): add basic backend and frontend

This commit is contained in:
2026-07-09 00:37:41 +03:30
parent d9eedb3d8e
commit ae240e7ac1
54 changed files with 5829 additions and 0 deletions

152
frontend/src/App.jsx Normal file
View File

@@ -0,0 +1,152 @@
import { useEffect, useMemo, useState } from "react";
import CanvasPane from "./components/CanvasPane.jsx";
import Controls from "./components/Controls.jsx";
import HistogramPanel from "./components/HistogramPanel.jsx";
import { createBatch, getJob, processImage, uploadImage } from "./lib/api.js";
import { useDebouncedEffect } from "./lib/debounce.js";
export default function App() {
const [session, setSession] = useState(null);
const [processed, setProcessed] = useState(null);
const [batchSessions, setBatchSessions] = useState([]);
const [operation, setOperation] = useState("gamma");
const [params, setParams] = useState({ gamma: 1 });
const [status, setStatus] = useState("Upload an image to begin.");
const [busy, setBusy] = useState(false);
const [transform, setTransform] = useState({ x: 0, y: 0, scale: 1 });
const originalHistogram = session?.original_histogram;
const processedHistogram = processed?.processed_histogram || processed?.result_histogram;
async function handleUpload(file) {
if (!file) return;
setBusy(true);
setStatus("Uploading image...");
try {
const payload = await uploadImage(file);
setSession(payload);
setProcessed(null);
setBatchSessions([payload.session_id]);
setTransform({ x: 0, y: 0, scale: 1 });
setStatus(`${payload.width} x ${payload.height} ${payload.color_mode} image loaded.`);
} catch (error) {
setStatus(error.message);
} finally {
setBusy(false);
}
}
async function handleBatchUpload(file) {
if (!file) return;
setBusy(true);
setStatus("Uploading batch image...");
try {
const payload = await uploadImage(file);
setBatchSessions((current) => [...current, payload.session_id]);
setStatus("Batch image added.");
} catch (error) {
setStatus(error.message);
} finally {
setBusy(false);
}
}
async function runBatch(kind) {
setBusy(true);
setStatus(`Starting ${kind} job...`);
try {
const job = await createBatch(kind, batchSessions);
const result = await pollJob(job.job_id);
setProcessed(result);
setStatus(`${kind} complete.`);
} catch (error) {
setStatus(error.message);
} finally {
setBusy(false);
}
}
async function pollJob(jobId) {
for (let attempt = 0; attempt < 80; attempt += 1) {
const job = await getJob(jobId);
setStatus(`Job ${job.status}: ${job.progress}%`);
if (job.status === "complete") return job;
if (job.status === "failed") throw new Error(job.error || "Batch job failed");
await new Promise((resolve) => window.setTimeout(resolve, 1000));
}
throw new Error("Batch job timed out.");
}
useDebouncedEffect(
() => {
if (!session?.session_id || !operation) return;
let cancelled = false;
async function run() {
setBusy(true);
setStatus(`Processing ${operation}...`);
try {
const payload = await processImage(session.session_id, operation, params);
if (!cancelled) {
setProcessed(payload);
setStatus(`${operation} complete in ${payload.elapsed_ms} ms.`);
}
} catch (error) {
if (!cancelled) setStatus(error.message);
} finally {
if (!cancelled) setBusy(false);
}
}
run();
return () => {
cancelled = true;
};
},
[session?.session_id, operation, JSON.stringify(params)],
300
);
const processedImage = processed?.image_data || session?.image_data;
const originalImage = session?.image_data;
const viewportTitle = useMemo(() => {
if (!session) return "No image";
return `${session.width} x ${session.height} ${session.color_mode}`;
}, [session]);
return (
<div className="flex min-h-screen flex-col bg-zinc-950 text-zinc-100 lg:flex-row">
<Controls
selected={operation}
params={params}
onOperationChange={(nextOperation, nextParams) => {
setOperation(nextOperation);
setParams(nextParams);
}}
onParamChange={(key, value) => setParams((current) => ({ ...current, [key]: value }))}
onUpload={handleUpload}
onBatchUpload={handleBatchUpload}
batchCount={batchSessions.length}
onBatchRun={runBatch}
disabled={!session || busy}
busy={busy}
/>
<main className="flex min-h-0 flex-1 flex-col">
<header className="flex flex-wrap items-center justify-between gap-3 border-b border-zinc-800 bg-zinc-950 px-5 py-3">
<div>
<p className="text-xs uppercase tracking-[0.18em] text-cyan-300">Professional Dark Studio</p>
<h2 className="text-sm font-medium text-zinc-200">{viewportTitle}</h2>
</div>
<div className="text-sm text-zinc-400">{busy ? "Working..." : status}</div>
</header>
<div className="grid min-h-0 flex-1 grid-cols-1 gap-px bg-zinc-800 lg:grid-cols-2">
<CanvasPane title="Original" imageData={originalImage} histogram={originalHistogram} transform={transform} onTransform={setTransform} />
<CanvasPane title="Processed" imageData={processedImage} histogram={processedHistogram} transform={transform} onTransform={setTransform} />
</div>
<HistogramPanel original={originalHistogram} processed={processedHistogram} />
</main>
</div>
);
}