feat(v1): add basic backend and frontend
This commit is contained in:
7
frontend/.dockerignore
Normal file
7
frontend/.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
||||
node_modules/
|
||||
dist/
|
||||
coverage/
|
||||
.vite/
|
||||
.env
|
||||
.env.local
|
||||
*.log
|
||||
1
frontend/.env.sample
Normal file
1
frontend/.env.sample
Normal file
@@ -0,0 +1 @@
|
||||
VITE_API_BASE=http://localhost:8000
|
||||
11
frontend/Dockerfile
Normal file
11
frontend/Dockerfile
Normal file
@@ -0,0 +1,11 @@
|
||||
FROM node:22-alpine AS build
|
||||
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm install
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:1.27-alpine
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
12
frontend/index.html
Normal file
12
frontend/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Spatial Image Enhancer Pro</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
11
frontend/nginx.conf
Normal file
11
frontend/nginx.conf
Normal file
@@ -0,0 +1,11 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri /index.html;
|
||||
}
|
||||
}
|
||||
3674
frontend/package-lock.json
generated
Normal file
3674
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
31
frontend/package.json
Normal file
31
frontend/package.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "spatial-image-enhancer-pro",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview --host 0.0.0.0",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vitejs/plugin-react": "latest",
|
||||
"vite": "latest",
|
||||
"react": "latest",
|
||||
"react-dom": "latest",
|
||||
"react-quick-pinch-zoom": "latest",
|
||||
"recharts": "latest",
|
||||
"lucide-react": "latest",
|
||||
"prop-types": "latest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tailwindcss": "3.4.17",
|
||||
"postcss": "8.4.49",
|
||||
"autoprefixer": "10.4.20",
|
||||
"vitest": "latest",
|
||||
"@testing-library/react": "latest",
|
||||
"@testing-library/jest-dom": "latest",
|
||||
"jsdom": "latest"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {}
|
||||
}
|
||||
};
|
||||
152
frontend/src/App.jsx
Normal file
152
frontend/src/App.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
9
frontend/src/App.real-render.test.jsx
Normal file
9
frontend/src/App.real-render.test.jsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import App from "./App.jsx";
|
||||
|
||||
describe("App real render", () => {
|
||||
it("mounts without mocking third-party components", () => {
|
||||
render(<App />);
|
||||
expect(screen.getByText("Spatial Image Enhancer Pro")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
15
frontend/src/App.test.jsx
Normal file
15
frontend/src/App.test.jsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import App from "./App.jsx";
|
||||
|
||||
vi.mock("react-quick-pinch-zoom", () => ({
|
||||
default: ({ children }) => <div>{children}</div>
|
||||
}));
|
||||
|
||||
describe("App", () => {
|
||||
it("renders the processing studio immediately", () => {
|
||||
render(<App />);
|
||||
expect(screen.getByText("Spatial Image Enhancer Pro")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("Original").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText("Processed").length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
49
frontend/src/components/CanvasPane.jsx
Normal file
49
frontend/src/components/CanvasPane.jsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import QuickPinchZoom from "react-quick-pinch-zoom";
|
||||
|
||||
function drawToCanvas(canvas, imageData) {
|
||||
if (!canvas || !imageData) return;
|
||||
const context = canvas.getContext("2d");
|
||||
const image = new Image();
|
||||
image.onload = () => {
|
||||
canvas.width = image.naturalWidth;
|
||||
canvas.height = image.naturalHeight;
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
context.drawImage(image, 0, 0);
|
||||
};
|
||||
image.src = imageData;
|
||||
}
|
||||
|
||||
export default function CanvasPane({ title, imageData, histogram, transform, onTransform }) {
|
||||
const canvasRef = useRef(null);
|
||||
const holderRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
drawToCanvas(canvasRef.current, imageData);
|
||||
}, [imageData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!holderRef.current) return;
|
||||
holderRef.current.style.transform = `translate3d(${transform.x}px, ${transform.y}px, 0) scale(${transform.scale})`;
|
||||
}, [transform]);
|
||||
|
||||
return (
|
||||
<section className="flex min-h-0 flex-1 flex-col overflow-hidden border border-zinc-800 bg-zinc-950">
|
||||
<div className="flex items-center justify-between border-b border-zinc-800 px-4 py-3">
|
||||
<h2 className="text-sm font-semibold text-zinc-100">{title}</h2>
|
||||
<span className="text-xs tabular-nums text-zinc-400">{histogram ? "p(r_k) ready" : "No histogram"}</span>
|
||||
</div>
|
||||
<div className="studio-grid flex min-h-[280px] flex-1 items-center justify-center overflow-hidden bg-zinc-950">
|
||||
{imageData ? (
|
||||
<QuickPinchZoom onUpdate={onTransform} inertia={false} wheelScaleFactor={180}>
|
||||
<div ref={holderRef} className="origin-top-left will-change-transform">
|
||||
<canvas ref={canvasRef} className="block max-h-[68vh] max-w-full shadow-2xl shadow-black/40" />
|
||||
</div>
|
||||
</QuickPinchZoom>
|
||||
) : (
|
||||
<div className="px-6 text-center text-sm text-zinc-500">Upload an image to start processing.</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
167
frontend/src/components/Controls.jsx
Normal file
167
frontend/src/components/Controls.jsx
Normal file
@@ -0,0 +1,167 @@
|
||||
import { Activity, Aperture, Blend, Layers, SlidersHorizontal, Upload } from "lucide-react";
|
||||
|
||||
const groups = [
|
||||
{
|
||||
title: "Intensity",
|
||||
icon: SlidersHorizontal,
|
||||
operations: [
|
||||
{ id: "negative", label: "Negative", params: [] },
|
||||
{ id: "log", label: "Log", params: [{ key: "c", label: "c", min: 0.1, max: 3, step: 0.05, default: 1.44 }] },
|
||||
{ id: "gamma", label: "Gamma", params: [{ key: "gamma", label: "Gamma", min: 0.1, max: 4, step: 0.05, default: 1 }] },
|
||||
{
|
||||
id: "contrast_stretch",
|
||||
label: "Contrast Stretch",
|
||||
params: [
|
||||
{ key: "low", label: "Low", min: 0, max: 254, step: 1, default: 30 },
|
||||
{ key: "high", label: "High", min: 1, max: 255, step: 1, default: 220 }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "gray_slice",
|
||||
label: "Gray Slice",
|
||||
params: [
|
||||
{ key: "start", label: "Start", min: 0, max: 255, step: 1, default: 96 },
|
||||
{ key: "end", label: "End", min: 0, max: 255, step: 1, default: 160 }
|
||||
]
|
||||
},
|
||||
{ id: "bit_plane", label: "Bit Plane", params: [{ key: "bit", label: "Bit", min: 0, max: 7, step: 1, default: 7 }] }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "Histogram",
|
||||
icon: Activity,
|
||||
operations: [
|
||||
{ id: "hist_equalization", label: "Global Equalization", params: [] },
|
||||
{ id: "hist_match", label: "Match Uniform", params: [] },
|
||||
{ id: "local_equalization", label: "Local Equalization", params: [{ key: "size", label: "Window", min: 3, max: 31, step: 2, default: 7 }] }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "Spatial Filters",
|
||||
icon: Aperture,
|
||||
operations: [
|
||||
{ id: "box_filter", label: "Box", params: [{ key: "size", label: "Size", min: 3, max: 35, step: 2, default: 3 }] },
|
||||
{ id: "weighted_average", label: "Weighted Avg", params: [{ key: "size", label: "Size", min: 3, max: 35, step: 2, default: 3 }] },
|
||||
{ id: "median_filter", label: "Median", params: [{ key: "size", label: "Size", min: 3, max: 15, step: 2, default: 3 }] },
|
||||
{ id: "laplacian", label: "Laplacian", params: [] },
|
||||
{
|
||||
id: "high_boost",
|
||||
label: "High Boost",
|
||||
params: [
|
||||
{ key: "amplification", label: "A", min: 1, max: 5, step: 0.1, default: 1.5 },
|
||||
{ key: "size", label: "Size", min: 3, max: 35, step: 2, default: 3 }
|
||||
]
|
||||
},
|
||||
{ id: "sobel", label: "Sobel", params: [] },
|
||||
{ id: "roberts", label: "Roberts", params: [] }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "Color",
|
||||
icon: Blend,
|
||||
operations: [
|
||||
{ id: "pseudo_color_slices", label: "Intensity Slices", params: [] },
|
||||
{ id: "gray_to_color_sinusoidal", label: "HSI Sinusoids", params: [{ key: "hue_frequency", label: "Hue Freq", min: 0.2, max: 4, step: 0.1, default: 1 }] },
|
||||
{ id: "hsi_intensity_filter", label: "HSI Smooth I", params: [{ key: "size", label: "Size", min: 3, max: 15, step: 2, default: 3 }] }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
function initialParams(operation) {
|
||||
return Object.fromEntries(operation.params.map((param) => [param.key, param.default]));
|
||||
}
|
||||
|
||||
export default function Controls({
|
||||
selected,
|
||||
params,
|
||||
onOperationChange,
|
||||
onParamChange,
|
||||
onUpload,
|
||||
onBatchUpload,
|
||||
batchCount,
|
||||
onBatchRun,
|
||||
disabled,
|
||||
busy
|
||||
}) {
|
||||
return (
|
||||
<aside className="flex h-full w-full flex-col border-r border-zinc-800 bg-zinc-950 lg:w-[360px]">
|
||||
<div className="border-b border-zinc-800 px-5 py-4">
|
||||
<h1 className="text-lg font-semibold tracking-normal text-zinc-50">Spatial Image Enhancer Pro</h1>
|
||||
<p className="mt-1 text-xs text-zinc-400">Vectorized spatial-domain processing studio</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 border-b border-zinc-800 p-4">
|
||||
<label className="flex cursor-pointer items-center justify-center gap-2 border border-cyan-700 bg-cyan-950/60 px-3 py-2 text-sm font-medium text-cyan-100 hover:bg-cyan-900/60">
|
||||
<Upload size={16} />
|
||||
Upload Image
|
||||
<input type="file" accept="image/*" className="hidden" onChange={(event) => onUpload(event.target.files?.[0])} />
|
||||
</label>
|
||||
<label className="flex cursor-pointer items-center justify-center gap-2 border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm font-medium text-zinc-100 hover:bg-zinc-800">
|
||||
<Layers size={16} />
|
||||
Add Batch Image ({batchCount})
|
||||
<input type="file" accept="image/*" className="hidden" onChange={(event) => onBatchUpload(event.target.files?.[0])} />
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button disabled={batchCount < 2 || busy} onClick={() => onBatchRun("average")} className="border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm disabled:cursor-not-allowed disabled:opacity-40">
|
||||
Average
|
||||
</button>
|
||||
<button disabled={batchCount < 2 || busy} onClick={() => onBatchRun("subtract")} className="border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm disabled:cursor-not-allowed disabled:opacity-40">
|
||||
Subtract
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-4">
|
||||
{groups.map((group) => {
|
||||
const Icon = group.icon;
|
||||
return (
|
||||
<details key={group.title} open className="mb-3 border border-zinc-800 bg-zinc-900/60">
|
||||
<summary className="flex cursor-pointer list-none items-center gap-2 px-3 py-3 text-sm font-semibold text-zinc-100">
|
||||
<Icon size={16} className="text-cyan-300" />
|
||||
{group.title}
|
||||
</summary>
|
||||
<div className="space-y-2 border-t border-zinc-800 p-3">
|
||||
{group.operations.map((operation) => (
|
||||
<button
|
||||
key={operation.id}
|
||||
disabled={disabled}
|
||||
onClick={() => onOperationChange(operation.id, initialParams(operation))}
|
||||
className={`w-full border px-3 py-2 text-left text-sm transition ${
|
||||
selected === operation.id ? "border-emerald-500 bg-emerald-950/50 text-emerald-100" : "border-zinc-700 bg-zinc-950 text-zinc-200 hover:bg-zinc-800"
|
||||
} disabled:cursor-not-allowed disabled:opacity-40`}
|
||||
>
|
||||
{operation.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="mt-4 border border-zinc-800 bg-zinc-900/60 p-3">
|
||||
<h2 className="mb-3 text-sm font-semibold text-zinc-100">Parameters</h2>
|
||||
{groups
|
||||
.flatMap((group) => group.operations)
|
||||
.find((operation) => operation.id === selected)
|
||||
?.params.map((param) => (
|
||||
<label key={param.key} className="mb-4 block">
|
||||
<div className="mb-2 flex items-center justify-between text-xs text-zinc-300">
|
||||
<span>{param.label}</span>
|
||||
<span className="tabular-nums text-zinc-400">{params[param.key] ?? param.default}</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={param.min}
|
||||
max={param.max}
|
||||
step={param.step}
|
||||
value={params[param.key] ?? param.default}
|
||||
onChange={(event) => onParamChange(param.key, Number(event.target.value))}
|
||||
className="w-full accent-cyan-400"
|
||||
/>
|
||||
</label>
|
||||
)) || <p className="text-sm text-zinc-500">No tunable parameters for this operation.</p>}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
36
frontend/src/components/HistogramPanel.jsx
Normal file
36
frontend/src/components/HistogramPanel.jsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
||||
|
||||
function toChartData(original, processed) {
|
||||
return Array.from({ length: 256 }, (_, level) => ({
|
||||
level,
|
||||
original: original?.[level] ?? 0,
|
||||
processed: processed?.[level] ?? 0
|
||||
}));
|
||||
}
|
||||
|
||||
export default function HistogramPanel({ original, processed }) {
|
||||
const data = toChartData(original, processed);
|
||||
return (
|
||||
<section className="border-t border-zinc-800 bg-zinc-950 px-4 py-3">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h2 className="text-sm font-semibold text-zinc-100">Histogram Analytics</h2>
|
||||
<div className="flex gap-3 text-xs text-zinc-400">
|
||||
<span className="text-cyan-300">Original</span>
|
||||
<span className="text-emerald-300">Processed</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-40">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={data} margin={{ left: 0, right: 8, top: 8, bottom: 0 }}>
|
||||
<CartesianGrid stroke="#27272a" strokeDasharray="3 3" />
|
||||
<XAxis dataKey="level" stroke="#71717a" tick={{ fontSize: 10 }} interval={63} />
|
||||
<YAxis stroke="#71717a" tick={{ fontSize: 10 }} width={44} />
|
||||
<Tooltip contentStyle={{ background: "#18181b", border: "1px solid #3f3f46", color: "#f4f4f5" }} />
|
||||
<Area type="monotone" dataKey="original" stroke="#67e8f9" fill="#0891b2" fillOpacity={0.22} dot={false} />
|
||||
<Area type="monotone" dataKey="processed" stroke="#6ee7b7" fill="#059669" fillOpacity={0.24} dot={false} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
42
frontend/src/lib/api.js
Normal file
42
frontend/src/lib/api.js
Normal file
@@ -0,0 +1,42 @@
|
||||
const API_BASE = import.meta.env.VITE_API_BASE || "";
|
||||
|
||||
async function parseResponse(response) {
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.detail || "Request failed");
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
export async function uploadImage(file) {
|
||||
const body = new FormData();
|
||||
body.append("image", file);
|
||||
const response = await fetch(`${API_BASE}/api/images/`, {
|
||||
method: "POST",
|
||||
body
|
||||
});
|
||||
return parseResponse(response);
|
||||
}
|
||||
|
||||
export async function processImage(sessionId, operation, params) {
|
||||
const response = await fetch(`${API_BASE}/api/process/`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ session_id: sessionId, operation, params })
|
||||
});
|
||||
return parseResponse(response);
|
||||
}
|
||||
|
||||
export async function createBatch(operation, sessionIds, params = {}) {
|
||||
const response = await fetch(`${API_BASE}/api/batch/`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ operation, session_ids: sessionIds, params })
|
||||
});
|
||||
return parseResponse(response);
|
||||
}
|
||||
|
||||
export async function getJob(jobId) {
|
||||
const response = await fetch(`${API_BASE}/api/jobs/${jobId}/`);
|
||||
return parseResponse(response);
|
||||
}
|
||||
14
frontend/src/lib/debounce.js
Normal file
14
frontend/src/lib/debounce.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
export function useDebouncedEffect(effect, deps, delay = 300) {
|
||||
useEffect(() => {
|
||||
let cleanup;
|
||||
const handle = window.setTimeout(() => {
|
||||
cleanup = effect();
|
||||
}, delay);
|
||||
return () => {
|
||||
window.clearTimeout(handle);
|
||||
if (typeof cleanup === "function") cleanup();
|
||||
};
|
||||
}, deps);
|
||||
}
|
||||
10
frontend/src/main.jsx
Normal file
10
frontend/src/main.jsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import App from "./App.jsx";
|
||||
import "./styles/app.css";
|
||||
|
||||
createRoot(document.getElementById("root")).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
29
frontend/src/styles/app.css
Normal file
29
frontend/src/styles/app.css
Normal file
@@ -0,0 +1,29 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
background: #09090b;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
background: #09090b;
|
||||
color: #e4e4e7;
|
||||
}
|
||||
|
||||
canvas {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
image-rendering: auto;
|
||||
}
|
||||
|
||||
.studio-grid {
|
||||
background-image:
|
||||
linear-gradient(rgba(148, 163, 184, 0.08) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(148, 163, 184, 0.08) 1px, transparent 1px);
|
||||
background-size: 24px 24px;
|
||||
}
|
||||
1
frontend/src/test-setup.js
Normal file
1
frontend/src/test-setup.js
Normal file
@@ -0,0 +1 @@
|
||||
import "@testing-library/jest-dom";
|
||||
11
frontend/tailwind.config.js
Normal file
11
frontend/tailwind.config.js
Normal file
@@ -0,0 +1,11 @@
|
||||
export default {
|
||||
content: ["./index.html", "./src/**/*.{js,jsx}"],
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ["Inter", "ui-sans-serif", "system-ui", "sans-serif"]
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: []
|
||||
};
|
||||
18
frontend/vite.config.js
Normal file
18
frontend/vite.config.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
"/api": "http://localhost:8000",
|
||||
"/media": "http://localhost:8000"
|
||||
}
|
||||
},
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
globals: true,
|
||||
setupFiles: "./src/test-setup.js"
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user