189 lines
7.1 KiB
JavaScript
189 lines
7.1 KiB
JavaScript
import { useEffect, useMemo, useState } from "react";
|
|
import { Toaster, toast } from "sonner";
|
|
import CanvasPane, { CanvasThumbnail } from "./components/CanvasPane.jsx";
|
|
import Controls from "./components/Controls.jsx";
|
|
import HistogramPanel from "./components/HistogramPanel.jsx";
|
|
import { applyStateOperation, combineStates, deleteState, getOperations, listStates, uploadImage } from "./lib/api.js";
|
|
|
|
function showErrorToast(error, fallback = "Something went wrong.") {
|
|
const message = error?.message || fallback;
|
|
toast.error(message, {
|
|
description: "Please check the selected image, operation, and parameters."
|
|
});
|
|
return message;
|
|
}
|
|
|
|
export default function App() {
|
|
const [session, setSession] = useState(null);
|
|
const [states, setStates] = useState([]);
|
|
const [activeState, setActiveState] = useState(null);
|
|
const [operations, setOperations] = useState([]);
|
|
const [selectedOperation, setSelectedOperation] = useState("");
|
|
const [params, setParams] = useState({});
|
|
const [selectedStateIds, setSelectedStateIds] = useState([]);
|
|
const [status, setStatus] = useState("Upload an image to begin.");
|
|
const [busy, setBusy] = useState(false);
|
|
const [transform, setTransform] = useState({ x: 0, y: 0, scale: 1 });
|
|
|
|
useEffect(() => {
|
|
getOperations()
|
|
.then((payload) => {
|
|
setOperations(payload.operations || []);
|
|
const first = payload.operations?.[0];
|
|
if (first) {
|
|
setSelectedOperation(first.id);
|
|
setParams({ _repeat: 1, ...Object.fromEntries(Object.entries(first.params || {}).map(([key, schema]) => [key, schema.default])) });
|
|
}
|
|
})
|
|
.catch((error) => setStatus(showErrorToast(error, "Unable to load operations.")));
|
|
}, []);
|
|
|
|
async function refreshStates(sessionId, nextActiveId = null) {
|
|
const payload = await listStates(sessionId);
|
|
setStates(payload.states || []);
|
|
const nextActive = payload.states?.find((state) => state.state_id === nextActiveId) || payload.states?.at(-1) || null;
|
|
setActiveState(nextActive);
|
|
return nextActive;
|
|
}
|
|
|
|
async function handleUpload(file) {
|
|
if (!file) return;
|
|
setBusy(true);
|
|
setStatus("Uploading image...");
|
|
try {
|
|
const payload = await uploadImage(file);
|
|
setSession(payload);
|
|
const initialStates = payload.states || [];
|
|
setStates(initialStates);
|
|
setActiveState(initialStates[0] || null);
|
|
setSelectedStateIds(initialStates[0] ? [initialStates[0].state_id] : []);
|
|
setTransform({ x: 0, y: 0, scale: 1 });
|
|
setStatus(`${payload.width} x ${payload.height} ${payload.color_mode} image loaded as S0.`);
|
|
} catch (error) {
|
|
setStatus(showErrorToast(error, "Unable to upload image."));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
function handleSelectOperation(operationId, nextParams) {
|
|
setSelectedOperation(operationId);
|
|
setParams(nextParams);
|
|
}
|
|
|
|
function handleParamChange(key, value) {
|
|
setParams((current) => ({ ...current, [key]: value }));
|
|
}
|
|
|
|
async function handleApply() {
|
|
if (!activeState || !selectedOperation) return;
|
|
setBusy(true);
|
|
setStatus(`Applying ${selectedOperation} to ${activeState.label}...`);
|
|
try {
|
|
const state = await applyStateOperation(activeState.state_id, selectedOperation, params);
|
|
await refreshStates(state.session_id, state.state_id);
|
|
setSelectedStateIds([state.state_id]);
|
|
setStatus(`${state.label} created.`);
|
|
} catch (error) {
|
|
setStatus(showErrorToast(error, "Unable to apply operation."));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
async function handleCombine(kind) {
|
|
if (selectedStateIds.length < 2) return;
|
|
setBusy(true);
|
|
setStatus(`Combining ${selectedStateIds.length} states using ${kind}...`);
|
|
try {
|
|
const state = await combineStates(kind, selectedStateIds);
|
|
await refreshStates(state.session_id, state.state_id);
|
|
setSelectedStateIds([state.state_id]);
|
|
setStatus(`${state.label} created.`);
|
|
} catch (error) {
|
|
setStatus(showErrorToast(error, "Unable to combine states."));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
function toggleCombineState(stateId) {
|
|
setSelectedStateIds((current) => current.includes(stateId) ? current.filter((id) => id !== stateId) : [...current, stateId]);
|
|
}
|
|
|
|
async function handleDeleteState(state) {
|
|
if (!state || !session) return;
|
|
setBusy(true);
|
|
setStatus(`Deleting ${state.label}...`);
|
|
try {
|
|
await deleteState(state.state_id);
|
|
const remaining = await listStates(session.session_id);
|
|
const nextStates = remaining.states || [];
|
|
setStates(nextStates);
|
|
const currentActive = nextStates.find((item) => item.state_id === activeState?.state_id);
|
|
const fallback = activeState?.state_id === state.state_id ? nextStates.at(-1) || nextStates[0] || null : currentActive || nextStates.at(-1) || nextStates[0] || null;
|
|
setActiveState(fallback);
|
|
setSelectedStateIds((current) => current.filter((id) => id !== state.state_id));
|
|
setStatus(`${state.label} deleted.`);
|
|
} catch (error) {
|
|
setStatus(showErrorToast(error, "Unable to delete state."));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
const originalState = states[0] || null;
|
|
const viewportTitle = useMemo(() => {
|
|
if (!activeState) return "No active state";
|
|
return `${activeState.label} - ${activeState.width} x ${activeState.height} ${activeState.color_mode}`;
|
|
}, [activeState]);
|
|
|
|
return (
|
|
<div className="flex h-screen overflow-hidden bg-zinc-950 text-zinc-100">
|
|
<Toaster richColors theme="dark" position="top-right" closeButton />
|
|
<Controls
|
|
operations={operations}
|
|
selectedOperation={selectedOperation}
|
|
params={params}
|
|
states={states}
|
|
activeState={activeState}
|
|
selectedStateIds={selectedStateIds}
|
|
busy={busy}
|
|
onUpload={handleUpload}
|
|
onSelectOperation={handleSelectOperation}
|
|
onParamChange={handleParamChange}
|
|
onApply={handleApply}
|
|
onSelectState={(state) => {
|
|
setActiveState(state);
|
|
}}
|
|
onToggleCombineState={toggleCombineState}
|
|
onCombine={handleCombine}
|
|
onDeleteState={handleDeleteState}
|
|
/>
|
|
|
|
<main className="flex h-screen min-w-0 flex-1 flex-col overflow-hidden">
|
|
<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">Professor Slide Workspace</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="relative min-h-0 flex-1 overflow-hidden bg-zinc-800">
|
|
<CanvasThumbnail title="S0 Original" imageData={originalState?.image_data} />
|
|
<CanvasPane
|
|
title="Active State"
|
|
imageData={activeState?.image_data}
|
|
histogram={activeState?.histogram}
|
|
transform={transform}
|
|
onTransform={setTransform}
|
|
/>
|
|
</div>
|
|
|
|
<HistogramPanel original={originalState?.histogram} processed={activeState?.histogram} />
|
|
</main>
|
|
</div>
|
|
);
|
|
}
|