mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat: SOTA overhaul of automate pipeline page (#53)
* feat(find-duplicates): upgrade to 128-bit dHash with metadata and thumbnails * feat(find-duplicates): add custom-results display mode and duplicate store * feat(find-duplicates): add results overview grid and detail comparison view * feat(find-duplicates): overhaul settings with sensitivity presets and download actions * feat(find-duplicates): update i18n description * chore: replace jsqr with zxing-wasm for barcode reading * feat(barcode-read): rewrite backend with zxing-wasm for all barcode types * feat(barcode-read): rewrite frontend with multi-file, results table, progress, export - Multi-file sequential processing with per-file progress - Structured results table with type badges and copy per-result - Copy All and Export CSV functionality - Thorough scan toggle (maps to tryHarder in zxing-wasm) - Before/after view shows annotated image with bounding boxes - Updated tool description in constants and i18n * feat(stitch): update tool name and description for redesign * feat(stitch): add grid layout, alignment, border, radius, quality, and new resize modes * feat(stitch): redesign settings UI with grid, alignment, border, radius, quality * test(stitch): add stitch to e2e tool navigation suite * feat(vectorize): redesign with dual-engine backend and preset-driven UI - Backend: potrace for B&W, VTracer (@neplex/vectorizer) for full-color vectorization - Frontend: 5 presets (logo, illustration, photo, sketch, custom) - Settings: color precision, gradient step, detail, smoothing, corner threshold, invert - Updated OpenAPI spec and i18n description * feat(border): redesign with presets, shadow, padding color, swatches - Add 8 one-click presets (Clean White, Gallery Black, Shadow, Rounded, Polaroid, Vintage, Minimal, Cinematic) - Implement proper shadow rendering with blur, offset X/Y, color, opacity - Add padding color control (was hardcoded white) - Add color swatches for quick color selection - Wrap in form for Enter key submission - Add smart validation (requires at least one effect active) - Align frontend/backend slider ranges - Organize UI with sections and collapsible shadow toggle * feat(split): overhaul image splitting with live grid overlay and tile preview - Add interactive-split display mode with SplitCanvas component - Live SVG grid overlay on uploaded image showing split boundaries - Two split modes: Grid (NxM) and Tile Size (px dimensions) - 9 grid presets (2x1, 1x2, 2x2, 3x1, 1x3, 3x3, 2x3, 3x2, 4x4) - Output format selection (original/PNG/JPG/WebP) with quality slider - Post-split tile preview thumbnails with individual download - Download All as ZIP button - HEIC/HEIF preview with loading spinner - Backend: tile-size mode, output format conversion, quality control - Zustand store for split state management * feat(split): rewrite backend and frontend settings Backend: tile-size mode, output format conversion, quality control. Frontend: split modes, presets, format selector, tile preview grid. * feat(border): add live CSS preview and remove before/after slider - Add imageWrapperStyle prop to ImageViewer for live border preview - Add onImageStyle callback through tool-page to settings components - Change border displayMode to no-comparison (no slider) - BorderControls sends live CSS styles (border, padding, radius, shadow) - Preview updates instantly as user adjusts sliders or clicks presets * fix: repair i18n file corrupted by formatter during merge conflict resolution * feat(border): enable live CSS preview in right pane as settings change * fix(border): keep CSS preview visible after processing for WYSIWYG consistency * chore: add @dnd-kit/core and @dnd-kit/sortable for pipeline drag-and-drop * feat(pipeline): add Zustand store for pipeline step management * feat(automate): add pipeline step settings summary utility with tests * feat(automate): add POST /api/v1/pipeline/batch for multi-file pipeline execution * feat(automate): add usePipelineProcessor hook for single and batch pipeline execution * fix(automate): pass settings prop to all pipeline step controls for state restoration * feat(automate): rewrite pipeline builder with dnd-kit drag-and-drop and compact step cards * feat(automate): rewrite page with two-panel layout, image preview, and batch support * test(automate): update e2e tests for new two-panel pipeline layout --------- Co-authored-by: Siddharth Kumar Sah <siddharth123sk@gmail.com>
This commit is contained in:
co-authored by
Siddharth Kumar Sah
parent
a1e11dff74
commit
fb33a46a64
@@ -9,9 +9,12 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import archiver from "archiver";
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import PQueue from "p-queue";
|
||||
import { z } from "zod";
|
||||
import { env } from "../config.js";
|
||||
import { db, schema } from "../db/index.js";
|
||||
import { autoOrient } from "../lib/auto-orient.js";
|
||||
import { validateImageBuffer } from "../lib/file-validation.js";
|
||||
@@ -19,6 +22,7 @@ import { sanitizeFilename } from "../lib/filename.js";
|
||||
import { decodeHeic } from "../lib/heic-converter.js";
|
||||
import { createWorkspace } from "../lib/workspace.js";
|
||||
import { requireAuth } from "../plugins/auth.js";
|
||||
import { type JobProgress, updateJobProgress } from "./progress.js";
|
||||
import { getRegisteredToolIds, getToolConfig } from "./tool-factory.js";
|
||||
|
||||
/** Schema for a single pipeline step. */
|
||||
@@ -339,5 +343,272 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
|
||||
return reply.send({ toolIds: getRegisteredToolIds() });
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v1/pipeline/batch
|
||||
*
|
||||
* Accepts multipart with multiple files + a "pipeline" JSON field.
|
||||
* Runs the full pipeline on each file with concurrency control via p-queue.
|
||||
* Returns a ZIP containing all processed results.
|
||||
*/
|
||||
app.post("/api/v1/pipeline/batch", async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
// ── Parse multipart ──────────────────────────────────────────────
|
||||
interface ParsedFile {
|
||||
buffer: Buffer;
|
||||
filename: string;
|
||||
}
|
||||
|
||||
const files: ParsedFile[] = [];
|
||||
let pipelineRaw: string | null = null;
|
||||
let clientJobId: string | null = null;
|
||||
|
||||
try {
|
||||
const parts = request.parts();
|
||||
for await (const part of parts) {
|
||||
if (part.type === "file") {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of part.file) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
const buffer = Buffer.concat(chunks);
|
||||
if (buffer.length > 0) {
|
||||
files.push({
|
||||
buffer,
|
||||
filename: sanitizeFilename(part.filename ?? "image"),
|
||||
});
|
||||
}
|
||||
} else if (part.fieldname === "pipeline") {
|
||||
pipelineRaw = part.value as string;
|
||||
} else if (part.fieldname === "clientJobId") {
|
||||
clientJobId = part.value as string;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
return reply.status(400).send({
|
||||
error: "Failed to parse multipart request",
|
||||
details: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
|
||||
if (files.length === 0) {
|
||||
return reply.status(400).send({ error: "No image files provided" });
|
||||
}
|
||||
|
||||
// Enforce batch size limit
|
||||
if (files.length > env.MAX_BATCH_SIZE) {
|
||||
return reply.status(400).send({
|
||||
error: `Too many files. Maximum batch size is ${env.MAX_BATCH_SIZE}`,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Parse and validate pipeline definition ───────────────────────
|
||||
if (!pipelineRaw) {
|
||||
return reply.status(400).send({ error: "No pipeline definition provided" });
|
||||
}
|
||||
|
||||
let pipeline: z.infer<typeof pipelineDefinitionSchema>;
|
||||
try {
|
||||
const parsed = JSON.parse(pipelineRaw);
|
||||
const result = pipelineDefinitionSchema.safeParse(parsed);
|
||||
if (!result.success) {
|
||||
return reply.status(400).send({
|
||||
error: "Invalid pipeline definition",
|
||||
details: result.error.issues.map((i) => ({
|
||||
path: i.path.join("."),
|
||||
message: i.message,
|
||||
})),
|
||||
});
|
||||
}
|
||||
pipeline = result.data;
|
||||
} catch {
|
||||
return reply.status(400).send({ error: "Pipeline must be valid JSON" });
|
||||
}
|
||||
|
||||
// Validate all tool IDs exist and settings are valid before processing
|
||||
for (let i = 0; i < pipeline.steps.length; i++) {
|
||||
const step = pipeline.steps[i];
|
||||
const toolConfig = getToolConfig(step.toolId);
|
||||
if (!toolConfig) {
|
||||
return reply.status(400).send({
|
||||
error: `Step ${i + 1}: Tool "${step.toolId}" not found`,
|
||||
});
|
||||
}
|
||||
|
||||
const settingsResult = toolConfig.settingsSchema.safeParse(step.settings);
|
||||
if (!settingsResult.success) {
|
||||
return reply.status(400).send({
|
||||
error: `Step ${i + 1} (${step.toolId}): Invalid settings`,
|
||||
details: settingsResult.error.issues.map(
|
||||
(iss: { path: (string | number)[]; message: string }) => ({
|
||||
path: iss.path.join("."),
|
||||
message: iss.message,
|
||||
}),
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Progress tracking ────────────────────────────────────────────
|
||||
const jobId = clientJobId || randomUUID();
|
||||
|
||||
const progress: JobProgress = {
|
||||
jobId,
|
||||
status: "processing",
|
||||
totalFiles: files.length,
|
||||
completedFiles: 0,
|
||||
failedFiles: 0,
|
||||
errors: [],
|
||||
};
|
||||
updateJobProgress({ ...progress });
|
||||
|
||||
// ── Process files through the pipeline with concurrency control ──
|
||||
const queue = new PQueue({ concurrency: env.CONCURRENT_JOBS });
|
||||
|
||||
const results: ({ buffer: Buffer; filename: string } | null)[] = new Array(files.length).fill(
|
||||
null,
|
||||
);
|
||||
|
||||
try {
|
||||
const tasks = files.map((file, index) =>
|
||||
queue.add(async () => {
|
||||
progress.currentFile = file.filename;
|
||||
updateJobProgress({ ...progress });
|
||||
|
||||
// Validate the image
|
||||
const validation = await validateImageBuffer(file.buffer);
|
||||
if (!validation.valid) {
|
||||
progress.failedFiles++;
|
||||
progress.errors.push({
|
||||
filename: file.filename,
|
||||
error: `Invalid image: ${validation.reason}`,
|
||||
});
|
||||
progress.completedFiles++;
|
||||
updateJobProgress({ ...progress });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
let currentBuffer = file.buffer;
|
||||
let currentFilename = file.filename;
|
||||
|
||||
// Decode HEIC/HEIF if needed
|
||||
if (validation.format === "heif") {
|
||||
currentBuffer = await decodeHeic(currentBuffer);
|
||||
const ext = currentFilename.match(/\.[^.]+$/)?.[0];
|
||||
if (ext) currentFilename = currentFilename.slice(0, -ext.length) + ".png";
|
||||
}
|
||||
|
||||
// Normalize EXIF orientation
|
||||
currentBuffer = await autoOrient(currentBuffer);
|
||||
|
||||
// Run through all pipeline steps sequentially
|
||||
for (let i = 0; i < pipeline.steps.length; i++) {
|
||||
const step = pipeline.steps[i];
|
||||
const toolConfig = getToolConfig(step.toolId);
|
||||
if (!toolConfig) {
|
||||
throw new Error(`Step ${i + 1}: Tool "${step.toolId}" not found`);
|
||||
}
|
||||
|
||||
const settings = toolConfig.settingsSchema.parse(step.settings);
|
||||
const result = await toolConfig.process(currentBuffer, settings, currentFilename);
|
||||
|
||||
currentBuffer = result.buffer;
|
||||
currentFilename = result.filename;
|
||||
}
|
||||
|
||||
results[index] = { buffer: currentBuffer, filename: currentFilename };
|
||||
|
||||
progress.completedFiles++;
|
||||
updateJobProgress({ ...progress });
|
||||
} catch (err) {
|
||||
progress.failedFiles++;
|
||||
progress.errors.push({
|
||||
filename: file.filename,
|
||||
error: err instanceof Error ? err.message : "Pipeline processing failed",
|
||||
});
|
||||
progress.completedFiles++;
|
||||
updateJobProgress({ ...progress });
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
await Promise.all(tasks);
|
||||
} catch (err) {
|
||||
request.log.error({ err }, "Unexpected error in pipeline batch queue");
|
||||
}
|
||||
|
||||
// ── Finalize progress ────────────────────────────────────────────
|
||||
progress.status = progress.failedFiles === progress.totalFiles ? "failed" : "completed";
|
||||
progress.currentFile = undefined;
|
||||
updateJobProgress({ ...progress });
|
||||
|
||||
// ── Deduplicate output filenames ─────────────────────────────────
|
||||
const usedNames = new Set<string>();
|
||||
function getUniqueName(name: string): string {
|
||||
if (!usedNames.has(name)) {
|
||||
usedNames.add(name);
|
||||
return name;
|
||||
}
|
||||
const dotIdx = name.lastIndexOf(".");
|
||||
const base = dotIdx > 0 ? name.slice(0, dotIdx) : name;
|
||||
const ext = dotIdx > 0 ? name.slice(dotIdx) : "";
|
||||
let counter = 1;
|
||||
let candidate = `${base}_${counter}${ext}`;
|
||||
while (usedNames.has(candidate)) {
|
||||
counter++;
|
||||
candidate = `${base}_${counter}${ext}`;
|
||||
}
|
||||
usedNames.add(candidate);
|
||||
return candidate;
|
||||
}
|
||||
|
||||
const fileResultsMap: Record<string, string> = {};
|
||||
for (let i = 0; i < results.length; i++) {
|
||||
const entry = results[i];
|
||||
if (entry) {
|
||||
const uniqueName = getUniqueName(entry.filename);
|
||||
entry.filename = uniqueName;
|
||||
fileResultsMap[String(i)] = uniqueName;
|
||||
}
|
||||
}
|
||||
|
||||
// If every file failed, return an error instead of an empty ZIP
|
||||
if (progress.status === "failed") {
|
||||
return reply.status(422).send({
|
||||
error: "All files failed processing",
|
||||
errors: progress.errors,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Stream ZIP response ──────────────────────────────────────────
|
||||
reply.hijack();
|
||||
reply.raw.writeHead(200, {
|
||||
"Content-Type": "application/zip",
|
||||
"Content-Disposition": `attachment; filename="pipeline-batch-${jobId.slice(0, 8)}.zip"`,
|
||||
"Transfer-Encoding": "chunked",
|
||||
"X-Job-Id": jobId,
|
||||
"X-File-Results": JSON.stringify(fileResultsMap),
|
||||
});
|
||||
|
||||
const archive = archiver("zip", { zlib: { level: 5 } });
|
||||
|
||||
archive.on("error", (err) => {
|
||||
request.log.error({ err }, "Archiver error during pipeline batch processing");
|
||||
if (!reply.raw.writableEnded) {
|
||||
reply.raw.end();
|
||||
}
|
||||
});
|
||||
|
||||
archive.pipe(reply.raw);
|
||||
|
||||
// Append results in original upload order
|
||||
for (const result of results) {
|
||||
if (result) {
|
||||
archive.append(result.buffer, { name: result.filename });
|
||||
}
|
||||
}
|
||||
|
||||
await archive.finalize();
|
||||
});
|
||||
|
||||
app.log.info("Pipeline routes registered");
|
||||
}
|
||||
|
||||
@@ -12,6 +12,9 @@
|
||||
"clean": "rm -rf dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@stirling-image/shared": "workspace:*",
|
||||
"clsx": "^2.1.0",
|
||||
"fflate": "^0.8.2",
|
||||
|
||||
@@ -5,13 +5,23 @@ import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
export interface BlurFacesControlsProps {
|
||||
settings?: Record<string, unknown>;
|
||||
onChange?: (settings: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
export function BlurFacesControls({ onChange }: BlurFacesControlsProps) {
|
||||
export function BlurFacesControls({ settings: initialSettings, onChange }: BlurFacesControlsProps) {
|
||||
const [blurRadius, setBlurRadius] = useState(30);
|
||||
const [sensitivity, setSensitivity] = useState(50);
|
||||
|
||||
const initializedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!initialSettings || initializedRef.current) return;
|
||||
initializedRef.current = true;
|
||||
if (initialSettings.blurRadius != null) setBlurRadius(Number(initialSettings.blurRadius));
|
||||
if (initialSettings.sensitivity != null)
|
||||
setSensitivity(Number(initialSettings.sensitivity) * 100);
|
||||
}, [initialSettings]);
|
||||
|
||||
const onChangeRef = useRef(onChange);
|
||||
useEffect(() => {
|
||||
onChangeRef.current = onChange;
|
||||
|
||||
@@ -215,11 +215,16 @@ function buildPreviewStyle(s: {
|
||||
// ── Controls ─────────────────────────────────────────────────────────
|
||||
|
||||
export interface BorderControlsProps {
|
||||
settings?: Record<string, unknown>;
|
||||
onChange?: (settings: Record<string, unknown>) => void;
|
||||
onImageStyle?: (style: React.CSSProperties | null) => void;
|
||||
}
|
||||
|
||||
export function BorderControls({ onChange, onImageStyle }: BorderControlsProps) {
|
||||
export function BorderControls({
|
||||
settings: initialSettings,
|
||||
onChange,
|
||||
onImageStyle,
|
||||
}: BorderControlsProps) {
|
||||
const [selectedPreset, setSelectedPreset] = useState<string | null>(null);
|
||||
const [borderWidth, setBorderWidth] = useState(10);
|
||||
const [borderColor, setBorderColor] = useState("#000000");
|
||||
@@ -233,6 +238,26 @@ export function BorderControls({ onChange, onImageStyle }: BorderControlsProps)
|
||||
const [shadowColor, setShadowColor] = useState("#000000");
|
||||
const [shadowOpacity, setShadowOpacity] = useState(40);
|
||||
|
||||
const initializedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!initialSettings || initializedRef.current) return;
|
||||
initializedRef.current = true;
|
||||
if (initialSettings.borderWidth != null) setBorderWidth(Number(initialSettings.borderWidth));
|
||||
if (initialSettings.borderColor != null) setBorderColor(String(initialSettings.borderColor));
|
||||
if (initialSettings.padding != null) setPadding(Number(initialSettings.padding));
|
||||
if (initialSettings.paddingColor != null) setPaddingColor(String(initialSettings.paddingColor));
|
||||
if (initialSettings.cornerRadius != null) setCornerRadius(Number(initialSettings.cornerRadius));
|
||||
if (initialSettings.shadow != null) setShadow(Boolean(initialSettings.shadow));
|
||||
if (initialSettings.shadowBlur != null) setShadowBlur(Number(initialSettings.shadowBlur));
|
||||
if (initialSettings.shadowOffsetX != null)
|
||||
setShadowOffsetX(Number(initialSettings.shadowOffsetX));
|
||||
if (initialSettings.shadowOffsetY != null)
|
||||
setShadowOffsetY(Number(initialSettings.shadowOffsetY));
|
||||
if (initialSettings.shadowColor != null) setShadowColor(String(initialSettings.shadowColor));
|
||||
if (initialSettings.shadowOpacity != null)
|
||||
setShadowOpacity(Number(initialSettings.shadowOpacity));
|
||||
}, [initialSettings]);
|
||||
|
||||
const onChangeRef = useRef(onChange);
|
||||
useEffect(() => {
|
||||
onChangeRef.current = onChange;
|
||||
|
||||
@@ -8,11 +8,17 @@ type Effect = "none" | "grayscale" | "sepia" | "invert";
|
||||
|
||||
interface ColorControlsProps {
|
||||
toolId: string;
|
||||
settings?: Record<string, unknown>;
|
||||
onChange?: (settings: Record<string, unknown>) => void;
|
||||
onPreviewFilter?: (filter: string) => void;
|
||||
}
|
||||
|
||||
export function ColorControls({ toolId, onChange, onPreviewFilter }: ColorControlsProps) {
|
||||
export function ColorControls({
|
||||
toolId,
|
||||
settings: initialSettings,
|
||||
onChange,
|
||||
onPreviewFilter,
|
||||
}: ColorControlsProps) {
|
||||
// Light
|
||||
const [brightness, setBrightness] = useState(0);
|
||||
const [contrast, setContrast] = useState(0);
|
||||
@@ -36,6 +42,24 @@ export function ColorControls({ toolId, onChange, onPreviewFilter }: ColorContro
|
||||
// Effects
|
||||
const [effect, setEffect] = useState<Effect>("none");
|
||||
|
||||
const initializedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!initialSettings || initializedRef.current) return;
|
||||
initializedRef.current = true;
|
||||
if (initialSettings.brightness != null) setBrightness(Number(initialSettings.brightness));
|
||||
if (initialSettings.contrast != null) setContrast(Number(initialSettings.contrast));
|
||||
if (initialSettings.exposure != null) setExposure(Number(initialSettings.exposure));
|
||||
if (initialSettings.saturation != null) setSaturation(Number(initialSettings.saturation));
|
||||
if (initialSettings.temperature != null) setTemperature(Number(initialSettings.temperature));
|
||||
if (initialSettings.tint != null) setTint(Number(initialSettings.tint));
|
||||
if (initialSettings.hue != null) setHue(Number(initialSettings.hue));
|
||||
if (initialSettings.sharpness != null) setSharpness(Number(initialSettings.sharpness));
|
||||
if (initialSettings.red != null) setRed(Number(initialSettings.red));
|
||||
if (initialSettings.green != null) setGreen(Number(initialSettings.green));
|
||||
if (initialSettings.blue != null) setBlue(Number(initialSettings.blue));
|
||||
if (initialSettings.effect != null) setEffect(initialSettings.effect as Effect);
|
||||
}, [initialSettings]);
|
||||
|
||||
const onChangeRef = useRef(onChange);
|
||||
onChangeRef.current = onChange;
|
||||
|
||||
|
||||
@@ -7,14 +7,24 @@ import { useFileStore } from "@/stores/file-store";
|
||||
type CompressMode = "quality" | "targetSize";
|
||||
|
||||
export interface CompressControlsProps {
|
||||
settings?: Record<string, unknown>;
|
||||
onChange?: (settings: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
export function CompressControls({ onChange }: CompressControlsProps) {
|
||||
export function CompressControls({ settings: initialSettings, onChange }: CompressControlsProps) {
|
||||
const [mode, setMode] = useState<CompressMode>("quality");
|
||||
const [quality, setQuality] = useState(75);
|
||||
const [targetSizeKb, setTargetSizeKb] = useState("");
|
||||
|
||||
const initializedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!initialSettings || initializedRef.current) return;
|
||||
initializedRef.current = true;
|
||||
if (initialSettings.mode != null) setMode(initialSettings.mode as CompressMode);
|
||||
if (initialSettings.quality != null) setQuality(Number(initialSettings.quality));
|
||||
if (initialSettings.targetSizeKb != null) setTargetSizeKb(String(initialSettings.targetSizeKb));
|
||||
}, [initialSettings]);
|
||||
|
||||
const onChangeRef = useRef(onChange);
|
||||
useEffect(() => {
|
||||
onChangeRef.current = onChange;
|
||||
|
||||
@@ -8,13 +8,22 @@ const OUTPUT_FORMATS = ["jpg", "png", "webp", "avif", "tiff", "gif", "heic", "he
|
||||
const LOSSY_FORMATS = ["jpg", "jpeg", "webp", "avif", "heic", "heif"];
|
||||
|
||||
export interface ConvertControlsProps {
|
||||
settings?: Record<string, unknown>;
|
||||
onChange?: (settings: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
export function ConvertControls({ onChange }: ConvertControlsProps) {
|
||||
export function ConvertControls({ settings: initialSettings, onChange }: ConvertControlsProps) {
|
||||
const [format, setFormat] = useState<string>("png");
|
||||
const [quality, setQuality] = useState(85);
|
||||
|
||||
const initializedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!initialSettings || initializedRef.current) return;
|
||||
initializedRef.current = true;
|
||||
if (initialSettings.format != null) setFormat(String(initialSettings.format));
|
||||
if (initialSettings.quality != null) setQuality(Number(initialSettings.quality));
|
||||
}, [initialSettings]);
|
||||
|
||||
const isLossy = LOSSY_FORMATS.includes(format);
|
||||
|
||||
const onChangeRef = useRef(onChange);
|
||||
|
||||
@@ -402,15 +402,26 @@ export function CropSettings({
|
||||
// ── Pipeline-only crop controls (numeric inputs, no canvas) ──────────
|
||||
|
||||
export interface CropControlsProps {
|
||||
settings?: Record<string, unknown>;
|
||||
onChange?: (settings: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
export function CropControls({ onChange }: CropControlsProps) {
|
||||
export function CropControls({ settings: initialSettings, onChange }: CropControlsProps) {
|
||||
const [left, setLeft] = useState(0);
|
||||
const [top, setTop] = useState(0);
|
||||
const [width, setWidth] = useState("");
|
||||
const [height, setHeight] = useState("");
|
||||
|
||||
const initializedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!initialSettings || initializedRef.current) return;
|
||||
initializedRef.current = true;
|
||||
if (initialSettings.left != null) setLeft(Number(initialSettings.left));
|
||||
if (initialSettings.top != null) setTop(Number(initialSettings.top));
|
||||
if (initialSettings.width != null) setWidth(String(initialSettings.width));
|
||||
if (initialSettings.height != null) setHeight(String(initialSettings.height));
|
||||
}, [initialSettings]);
|
||||
|
||||
const onChangeRef = useRef(onChange);
|
||||
useEffect(() => {
|
||||
onChangeRef.current = onChange;
|
||||
|
||||
@@ -20,10 +20,11 @@ const MODES: { id: GifMode; label: string; requiresAnimation: boolean }[] = [
|
||||
];
|
||||
|
||||
export interface GifToolsControlsProps {
|
||||
settings?: Record<string, unknown>;
|
||||
onChange?: (settings: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
export function GifToolsControls({ onChange }: GifToolsControlsProps) {
|
||||
export function GifToolsControls({ settings: initialSettings, onChange }: GifToolsControlsProps) {
|
||||
const { info, loading: infoLoading } = useGifInfo();
|
||||
const isAnimated = (info?.pages ?? 0) > 1;
|
||||
|
||||
@@ -65,6 +66,17 @@ export function GifToolsControls({ onChange }: GifToolsControlsProps) {
|
||||
const [loopMode, setLoopMode] = useState<LoopMode>("infinite");
|
||||
const [loopCount, setLoopCount] = useState("2");
|
||||
|
||||
// Initialize from saved pipeline settings
|
||||
const initializedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!initialSettings || initializedRef.current) return;
|
||||
initializedRef.current = true;
|
||||
if (initialSettings.mode != null) setMode(initialSettings.mode as GifMode);
|
||||
if (initialSettings.width != null) setWidth(String(initialSettings.width));
|
||||
if (initialSettings.height != null) setHeight(String(initialSettings.height));
|
||||
if (initialSettings.percentage != null) setPercentage(String(initialSettings.percentage));
|
||||
}, [initialSettings]);
|
||||
|
||||
// Initialize loop from metadata
|
||||
useEffect(() => {
|
||||
if (info) {
|
||||
|
||||
@@ -1,72 +1,179 @@
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
type DragEndEvent,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { TOOLS } from "@stirling-image/shared";
|
||||
import * as icons from "lucide-react";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
ChevronUp,
|
||||
Download,
|
||||
FileImage,
|
||||
Loader2,
|
||||
Play,
|
||||
Plus,
|
||||
Save,
|
||||
Upload,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { type SetStateAction, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { GripVertical, Plus, X } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { SearchBar } from "@/components/common/search-bar";
|
||||
import { apiGet } from "@/lib/api";
|
||||
import { cn, generateId } from "@/lib/utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { PipelineStep } from "@/stores/pipeline-store";
|
||||
import { PipelineStepSettings } from "./pipeline-step-settings";
|
||||
import { getSettingsSummary } from "./pipeline-step-summary";
|
||||
|
||||
/** Tools that can be used as pipeline steps (excludes pipeline/batch/multi-file tools). */
|
||||
const PIPELINE_TOOLS_BASE = TOOLS.filter(
|
||||
(t) => !["pipeline", "batch", "compare", "find-duplicates", "collage", "compose"].includes(t.id),
|
||||
);
|
||||
|
||||
export interface PipelineStep {
|
||||
id: string;
|
||||
toolId: string;
|
||||
settings: Record<string, unknown>;
|
||||
}
|
||||
const iconsMap = icons as unknown as Record<string, React.ComponentType<{ className?: string }>>;
|
||||
|
||||
interface PipelineBuilderProps {
|
||||
steps: PipelineStep[];
|
||||
onStepsChange: (action: SetStateAction<PipelineStep[]>) => void;
|
||||
onSave: (name: string, description: string) => void;
|
||||
onExecute: (file: File) => void;
|
||||
saving?: boolean;
|
||||
executing?: boolean;
|
||||
executionResult?: {
|
||||
downloadUrl: string;
|
||||
originalSize: number;
|
||||
processedSize: number;
|
||||
stepsCompleted: number;
|
||||
} | null;
|
||||
executionError?: string | null;
|
||||
expandedStepId: string | null;
|
||||
onAddStep: (toolId: string) => void;
|
||||
onRemoveStep: (id: string) => void;
|
||||
onReorderSteps: (activeId: string, overId: string) => void;
|
||||
onUpdateSettings: (id: string, settings: Record<string, unknown>) => void;
|
||||
onToggleStep: (id: string | null) => void;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* SortableStep */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
interface SortableStepProps {
|
||||
step: PipelineStep;
|
||||
index: number;
|
||||
isExpanded: boolean;
|
||||
onToggle: () => void;
|
||||
onRemove: () => void;
|
||||
onUpdateSettings: (settings: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
function SortableStep({
|
||||
step,
|
||||
index,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
onRemove,
|
||||
onUpdateSettings,
|
||||
}: SortableStepProps) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: step.id,
|
||||
});
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
const tool = TOOLS.find((t) => t.id === step.toolId);
|
||||
if (!tool) return null;
|
||||
|
||||
const Icon = iconsMap[tool.icon] || icons.FileImage;
|
||||
const summary = getSettingsSummary(step.toolId, step.settings);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={cn(
|
||||
"rounded-lg border bg-background overflow-hidden transition-colors",
|
||||
isDragging && "opacity-50",
|
||||
isExpanded ? "border-primary" : "border-border",
|
||||
)}
|
||||
>
|
||||
{/* Header row - click to expand/collapse */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className="flex items-center gap-2 p-3 w-full text-left"
|
||||
>
|
||||
{/* Drag handle */}
|
||||
<span
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="cursor-grab active:cursor-grabbing p-0.5 rounded hover:bg-muted text-muted-foreground"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<GripVertical className="h-4 w-4" />
|
||||
</span>
|
||||
|
||||
{/* Step number badge */}
|
||||
<span className="w-6 h-6 rounded-full bg-primary/10 text-primary text-xs font-semibold flex items-center justify-center shrink-0">
|
||||
{index + 1}
|
||||
</span>
|
||||
|
||||
{/* Tool icon + name */}
|
||||
<Icon className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<span className="text-sm font-medium text-foreground">{tool.name}</span>
|
||||
|
||||
{/* Settings summary when collapsed */}
|
||||
{!isExpanded && summary && (
|
||||
<span className="text-xs text-muted-foreground truncate ml-1">{summary}</span>
|
||||
)}
|
||||
|
||||
<span className="flex-1" />
|
||||
|
||||
{/* Remove button */}
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove();
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.stopPropagation();
|
||||
onRemove();
|
||||
}
|
||||
}}
|
||||
title="Remove"
|
||||
className="p-1 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Inline settings panel */}
|
||||
<div className={isExpanded ? "border-t border-border p-3 bg-muted/10 space-y-3" : "hidden"}>
|
||||
<PipelineStepSettings
|
||||
toolId={step.toolId}
|
||||
settings={step.settings}
|
||||
onChange={onUpdateSettings}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* PipelineBuilder */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export function PipelineBuilder({
|
||||
steps,
|
||||
onStepsChange,
|
||||
onSave,
|
||||
onExecute,
|
||||
saving = false,
|
||||
executing = false,
|
||||
executionResult = null,
|
||||
executionError = null,
|
||||
expandedStepId,
|
||||
onAddStep,
|
||||
onRemoveStep,
|
||||
onReorderSteps,
|
||||
onUpdateSettings,
|
||||
onToggleStep,
|
||||
}: PipelineBuilderProps) {
|
||||
const [showToolPicker, setShowToolPicker] = useState(false);
|
||||
const [expandedStep, setExpandedStep] = useState<string | null>(null);
|
||||
const [saveName, setSaveName] = useState("");
|
||||
const [saveDescription, setSaveDescription] = useState("");
|
||||
const [showSaveForm, setShowSaveForm] = useState(false);
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [toolSearch, setToolSearch] = useState("");
|
||||
const [disabledTools, setDisabledTools] = useState<string[]>([]);
|
||||
const [experimentalEnabled, setExperimentalEnabled] = useState(false);
|
||||
const [pipelineToolIds, setPipelineToolIds] = useState<string[] | null>(null);
|
||||
const [toolSearch, setToolSearch] = useState("");
|
||||
|
||||
/* Fetch settings + pipeline-compatible tool IDs on mount */
|
||||
useEffect(() => {
|
||||
apiGet<{ settings: Record<string, string> }>("/v1/settings")
|
||||
.then((data) => {
|
||||
@@ -77,7 +184,6 @@ export function PipelineBuilder({
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
// Fetch which tools actually support pipeline execution
|
||||
apiGet<{ toolIds: string[] }>("/v1/pipeline/tools")
|
||||
.then((data) => setPipelineToolIds(data.toolIds))
|
||||
.catch(() => {});
|
||||
@@ -88,9 +194,7 @@ export function PipelineBuilder({
|
||||
return PIPELINE_TOOLS_BASE.filter((t) => {
|
||||
if (disabledTools.includes(t.id)) return false;
|
||||
if (t.experimental && !experimentalEnabled) return false;
|
||||
// Only show tools that are registered in the pipeline-compatible tool registry
|
||||
if (pipelineToolIds && !pipelineToolIds.includes(t.id)) return false;
|
||||
// Search filter
|
||||
if (q && !t.name.toLowerCase().includes(q) && !t.description.toLowerCase().includes(q)) {
|
||||
return false;
|
||||
}
|
||||
@@ -98,215 +202,53 @@ export function PipelineBuilder({
|
||||
});
|
||||
}, [disabledTools, experimentalEnabled, pipelineToolIds, toolSearch]);
|
||||
|
||||
const addStep = useCallback(
|
||||
(toolId: string) => {
|
||||
const step: PipelineStep = {
|
||||
id: generateId(),
|
||||
toolId,
|
||||
settings: {},
|
||||
};
|
||||
onStepsChange((prev) => [...prev, step]);
|
||||
setShowToolPicker(false);
|
||||
setToolSearch("");
|
||||
setExpandedStep(step.id);
|
||||
},
|
||||
[onStepsChange],
|
||||
/* dnd-kit sensors */
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
||||
);
|
||||
|
||||
const removeStep = useCallback(
|
||||
(id: string) => {
|
||||
onStepsChange((prev) => prev.filter((s) => s.id !== id));
|
||||
setExpandedStep((prev) => (prev === id ? null : prev));
|
||||
},
|
||||
[onStepsChange],
|
||||
);
|
||||
function handleDragEnd(event: DragEndEvent) {
|
||||
const { active, over } = event;
|
||||
if (over && active.id !== over.id) {
|
||||
onReorderSteps(String(active.id), String(over.id));
|
||||
}
|
||||
}
|
||||
|
||||
const moveStep = useCallback(
|
||||
(id: string, direction: "up" | "down") => {
|
||||
onStepsChange((prev) => {
|
||||
const idx = prev.findIndex((s) => s.id === id);
|
||||
if (idx < 0) return prev;
|
||||
const newIdx = direction === "up" ? idx - 1 : idx + 1;
|
||||
if (newIdx < 0 || newIdx >= prev.length) return prev;
|
||||
const newSteps = [...prev];
|
||||
[newSteps[idx], newSteps[newIdx]] = [newSteps[newIdx], newSteps[idx]];
|
||||
return newSteps;
|
||||
});
|
||||
},
|
||||
[onStepsChange],
|
||||
);
|
||||
|
||||
const updateStepSettings = useCallback(
|
||||
(id: string, newSettings: Record<string, unknown>) => {
|
||||
onStepsChange((prev) => prev.map((s) => (s.id === id ? { ...s, settings: newSettings } : s)));
|
||||
},
|
||||
[onStepsChange],
|
||||
);
|
||||
|
||||
const handleFileSelect = useCallback(() => {
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept = "image/*,.heic,.heif,.hif";
|
||||
input.onchange = (e) => {
|
||||
const f = (e.target as HTMLInputElement).files?.[0];
|
||||
if (f) setFile(f);
|
||||
};
|
||||
input.click();
|
||||
}, []);
|
||||
|
||||
const handleFileDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
const f = e.dataTransfer.files[0];
|
||||
if (f) setFile(f);
|
||||
}, []);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
if (!saveName.trim()) return;
|
||||
onSave(saveName.trim(), saveDescription.trim());
|
||||
setSaveName("");
|
||||
setSaveDescription("");
|
||||
setShowSaveForm(false);
|
||||
}, [saveName, saveDescription, onSave]);
|
||||
|
||||
const handleExecute = useCallback(() => {
|
||||
if (!file) return;
|
||||
onExecute(file);
|
||||
}, [file, onExecute]);
|
||||
|
||||
const iconsMap = icons as unknown as Record<string, React.ComponentType<{ className?: string }>>;
|
||||
function handleAddStep(toolId: string) {
|
||||
onAddStep(toolId);
|
||||
setShowToolPicker(false);
|
||||
setToolSearch("");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* File Upload Area */}
|
||||
<section
|
||||
aria-label="File upload area"
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={handleFileDrop}
|
||||
className={cn(
|
||||
"rounded-xl border-2 border-dashed p-6 text-center transition-colors",
|
||||
file
|
||||
? "border-primary/30 bg-primary/5"
|
||||
: "border-border bg-muted/20 hover:border-primary/30",
|
||||
)}
|
||||
>
|
||||
{file ? (
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<FileImage className="h-5 w-5 text-primary" />
|
||||
<div className="text-sm">
|
||||
<span className="font-medium text-foreground">{file.name}</span>
|
||||
<span className="text-muted-foreground ml-2">
|
||||
({(file.size / 1024).toFixed(0)} KB)
|
||||
</span>
|
||||
<div className="space-y-2">
|
||||
{/* Sortable step list */}
|
||||
{steps.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground text-sm">
|
||||
Add steps to build your pipeline
|
||||
</div>
|
||||
) : (
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<SortableContext items={steps.map((s) => s.id)} strategy={verticalListSortingStrategy}>
|
||||
<div className="space-y-2">
|
||||
{steps.map((step, idx) => (
|
||||
<SortableStep
|
||||
key={step.id}
|
||||
step={step}
|
||||
index={idx}
|
||||
isExpanded={expandedStepId === step.id}
|
||||
onToggle={() => onToggleStep(expandedStepId === step.id ? null : step.id)}
|
||||
onRemove={() => onRemoveStep(step.id)}
|
||||
onUpdateSettings={(s) => onUpdateSettings(step.id, s)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFile(null)}
|
||||
className="p-1 rounded hover:bg-muted text-muted-foreground"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleFileSelect}
|
||||
className="flex items-center gap-2 mx-auto px-4 py-2 rounded-lg border border-primary text-primary hover:bg-primary/5 transition-colors text-sm"
|
||||
>
|
||||
<Upload className="h-4 w-4" />
|
||||
Upload image to process
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
)}
|
||||
|
||||
{/* Pipeline Steps */}
|
||||
<div className="space-y-2">
|
||||
{steps.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground text-sm">
|
||||
Add steps to build your automation pipeline
|
||||
</div>
|
||||
) : (
|
||||
steps.map((step, idx) => {
|
||||
const tool = TOOLS.find((t) => t.id === step.toolId);
|
||||
if (!tool) return null;
|
||||
const Icon = iconsMap[tool.icon] || icons.FileImage;
|
||||
const isExpanded = expandedStep === step.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={step.id}
|
||||
className="rounded-lg border border-border bg-background overflow-hidden"
|
||||
>
|
||||
<div className="flex items-center gap-2 p-3">
|
||||
{/* Step number */}
|
||||
<span className="w-6 h-6 rounded-full bg-primary/10 text-primary text-xs font-semibold flex items-center justify-center shrink-0">
|
||||
{idx + 1}
|
||||
</span>
|
||||
|
||||
{/* Tool icon + name */}
|
||||
<Icon className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<span className="text-sm font-medium text-foreground flex-1">{tool.name}</span>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex items-center gap-0.5 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpandedStep(isExpanded ? null : step.id)}
|
||||
className="p-1 rounded hover:bg-muted text-muted-foreground"
|
||||
title="Settings"
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn("h-4 w-4 transition-transform", isExpanded && "rotate-90")}
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => moveStep(step.id, "up")}
|
||||
disabled={idx === 0}
|
||||
className="p-1 rounded hover:bg-muted text-muted-foreground disabled:opacity-30"
|
||||
title="Move up"
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => moveStep(step.id, "down")}
|
||||
disabled={idx === steps.length - 1}
|
||||
className="p-1 rounded hover:bg-muted text-muted-foreground disabled:opacity-30"
|
||||
title="Move down"
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeStep(step.id)}
|
||||
className="p-1 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive"
|
||||
title="Remove"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Settings panel - hidden when collapsed, never unmounted so state persists */}
|
||||
<div
|
||||
className={
|
||||
isExpanded ? "border-t border-border p-3 bg-muted/10 space-y-3" : "hidden"
|
||||
}
|
||||
>
|
||||
<p className="text-xs text-muted-foreground">{tool.description}</p>
|
||||
<PipelineStepSettings
|
||||
toolId={step.toolId}
|
||||
settings={step.settings}
|
||||
onChange={(s) => updateStepSettings(step.id, s)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add Step */}
|
||||
{/* Tool picker */}
|
||||
{showToolPicker ? (
|
||||
<div className="rounded-lg border border-border bg-background p-3 space-y-2 max-h-80 overflow-y-auto">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
@@ -332,7 +274,7 @@ export function PipelineBuilder({
|
||||
<button
|
||||
key={tool.id}
|
||||
type="button"
|
||||
onClick={() => addStep(tool.id)}
|
||||
onClick={() => handleAddStep(tool.id)}
|
||||
className="flex items-center gap-2 w-full px-3 py-2 rounded-lg hover:bg-muted text-sm text-left transition-colors"
|
||||
>
|
||||
<Icon className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
@@ -358,106 +300,6 @@ export function PipelineBuilder({
|
||||
Add Step
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Execution error */}
|
||||
{executionError && (
|
||||
<div className="rounded-lg border border-red-200 dark:border-red-800 bg-red-50 dark:bg-red-900/20 p-4">
|
||||
<div className="flex items-center gap-2 text-red-700 dark:text-red-400">
|
||||
<icons.AlertCircle className="h-5 w-5 shrink-0" />
|
||||
<span className="text-sm font-medium">{executionError}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Execution result */}
|
||||
{executionResult && (
|
||||
<div className="rounded-lg border border-green-200 dark:border-green-800 bg-green-50 dark:bg-green-900/20 p-4 space-y-2">
|
||||
<div className="flex items-center gap-2 text-green-700 dark:text-green-400">
|
||||
<icons.CheckCircle2 className="h-5 w-5" />
|
||||
<span className="font-medium text-sm">
|
||||
Pipeline completed ({executionResult.stepsCompleted} steps)
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<span>Original: {(executionResult.originalSize / 1024).toFixed(0)} KB</span>
|
||||
<span>Processed: {(executionResult.processedSize / 1024).toFixed(0)} KB</span>
|
||||
</div>
|
||||
<a
|
||||
href={executionResult.downloadUrl}
|
||||
download
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
Download Result
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleExecute}
|
||||
disabled={steps.length === 0 || !file || executing}
|
||||
className="flex items-center gap-2 px-5 py-2.5 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{executing ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Processing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="h-4 w-4" />
|
||||
Process
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{!showSaveForm ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowSaveForm(true)}
|
||||
disabled={steps.length === 0}
|
||||
className="flex items-center gap-2 px-4 py-2.5 rounded-lg border border-border text-sm text-foreground hover:bg-muted transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
Save Pipeline
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
<input
|
||||
type="text"
|
||||
value={saveName}
|
||||
onChange={(e) => setSaveName(e.target.value)}
|
||||
placeholder="Pipeline name"
|
||||
className="px-3 py-2 rounded-lg border border-border bg-background text-sm text-foreground flex-1"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={saveDescription}
|
||||
onChange={(e) => setSaveDescription(e.target.value)}
|
||||
placeholder="Description (optional)"
|
||||
className="px-3 py-2 rounded-lg border border-border bg-background text-sm text-foreground flex-1 hidden sm:block"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={!saveName.trim() || saving}
|
||||
className="px-3 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{saving ? "Saving..." : "Save"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowSaveForm(false)}
|
||||
className="p-2 rounded-lg hover:bg-muted text-muted-foreground"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,23 +24,28 @@ interface PipelineStepSettingsProps {
|
||||
}
|
||||
|
||||
export function PipelineStepSettings({ toolId, settings, onChange }: PipelineStepSettingsProps) {
|
||||
if (toolId === "resize") return <ResizeControls onChange={onChange} />;
|
||||
if (toolId === "crop") return <CropControls onChange={onChange} />;
|
||||
if (toolId === "rotate") return <RotateControls onChange={onChange} />;
|
||||
if (toolId === "convert") return <ConvertControls onChange={onChange} />;
|
||||
if (toolId === "compress") return <CompressControls onChange={onChange} />;
|
||||
if (toolId === "strip-metadata") return <StripMetadataControls onChange={onChange} />;
|
||||
if (toolId === "border") return <BorderControls onChange={onChange} />;
|
||||
if (toolId === "watermark-text") return <WatermarkTextControls onChange={onChange} />;
|
||||
if (toolId === "text-overlay") return <TextOverlayControls onChange={onChange} />;
|
||||
if (toolId === "replace-color") return <ReplaceColorControls onChange={onChange} />;
|
||||
if (toolId === "smart-crop") return <SmartCropControls onChange={onChange} />;
|
||||
if (toolId === "gif-tools") return <GifToolsControls onChange={onChange} />;
|
||||
if (toolId === "upscale") return <UpscaleControls onChange={onChange} />;
|
||||
if (toolId === "blur-faces") return <BlurFacesControls onChange={onChange} />;
|
||||
if (toolId === "resize") return <ResizeControls settings={settings} onChange={onChange} />;
|
||||
if (toolId === "crop") return <CropControls settings={settings} onChange={onChange} />;
|
||||
if (toolId === "rotate") return <RotateControls settings={settings} onChange={onChange} />;
|
||||
if (toolId === "convert") return <ConvertControls settings={settings} onChange={onChange} />;
|
||||
if (toolId === "compress") return <CompressControls settings={settings} onChange={onChange} />;
|
||||
if (toolId === "strip-metadata")
|
||||
return <StripMetadataControls settings={settings} onChange={onChange} />;
|
||||
if (toolId === "border") return <BorderControls settings={settings} onChange={onChange} />;
|
||||
if (toolId === "watermark-text")
|
||||
return <WatermarkTextControls settings={settings} onChange={onChange} />;
|
||||
if (toolId === "text-overlay")
|
||||
return <TextOverlayControls settings={settings} onChange={onChange} />;
|
||||
if (toolId === "replace-color")
|
||||
return <ReplaceColorControls settings={settings} onChange={onChange} />;
|
||||
if (toolId === "smart-crop") return <SmartCropControls settings={settings} onChange={onChange} />;
|
||||
if (toolId === "gif-tools") return <GifToolsControls settings={settings} onChange={onChange} />;
|
||||
if (toolId === "upscale") return <UpscaleControls settings={settings} onChange={onChange} />;
|
||||
if (toolId === "blur-faces") return <BlurFacesControls settings={settings} onChange={onChange} />;
|
||||
if (toolId === "remove-background")
|
||||
return <RemoveBgControls settings={settings} onChange={onChange} />;
|
||||
if (COLOR_TOOL_IDS.has(toolId)) return <ColorControls toolId={toolId} onChange={onChange} />;
|
||||
if (COLOR_TOOL_IDS.has(toolId))
|
||||
return <ColorControls toolId={toolId} settings={settings} onChange={onChange} />;
|
||||
|
||||
return (
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
export function getSettingsSummary(toolId: string, settings: Record<string, unknown>): string {
|
||||
switch (toolId) {
|
||||
case "resize": {
|
||||
if (settings.percentage) return `${settings.percentage}%`;
|
||||
if (settings.width && settings.height) return `${settings.width} x ${settings.height}`;
|
||||
if (settings.width) return `${settings.width}px wide`;
|
||||
if (settings.height) return `${settings.height}px tall`;
|
||||
return "";
|
||||
}
|
||||
case "compress": {
|
||||
if (settings.mode === "targetSize" && settings.targetSizeKb)
|
||||
return `Target ${settings.targetSizeKb} KB`;
|
||||
if (settings.quality != null) return `Quality ${settings.quality}`;
|
||||
return "";
|
||||
}
|
||||
case "convert": {
|
||||
if (settings.format) return String(settings.format).toUpperCase();
|
||||
return "";
|
||||
}
|
||||
case "rotate": {
|
||||
if (settings.angle != null) return `${settings.angle}\u00B0`;
|
||||
return "";
|
||||
}
|
||||
case "watermark-text": {
|
||||
if (settings.text) {
|
||||
const t = String(settings.text);
|
||||
return t.length > 25 ? `${t.slice(0, 24)}...` : t;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
case "text-overlay": {
|
||||
if (settings.text) {
|
||||
const t = String(settings.text);
|
||||
return t.length > 25 ? `${t.slice(0, 24)}...` : t;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
case "crop": {
|
||||
if (settings.width && settings.height) return `${settings.width} x ${settings.height}`;
|
||||
return "";
|
||||
}
|
||||
case "border": {
|
||||
if (settings.width) return `${settings.width}px border`;
|
||||
return "";
|
||||
}
|
||||
case "blur-faces":
|
||||
return "Blur faces";
|
||||
case "remove-background":
|
||||
return "Remove BG";
|
||||
case "strip-metadata":
|
||||
return "Strip EXIF";
|
||||
case "upscale": {
|
||||
if (settings.scale) return `${settings.scale}x`;
|
||||
return "";
|
||||
}
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
@@ -5,15 +5,30 @@ import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
export interface ReplaceColorControlsProps {
|
||||
settings?: Record<string, unknown>;
|
||||
onChange?: (settings: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
export function ReplaceColorControls({ onChange }: ReplaceColorControlsProps) {
|
||||
export function ReplaceColorControls({
|
||||
settings: initialSettings,
|
||||
onChange,
|
||||
}: ReplaceColorControlsProps) {
|
||||
const [sourceColor, setSourceColor] = useState("#FF0000");
|
||||
const [targetColor, setTargetColor] = useState("#00FF00");
|
||||
const [makeTransparent, setMakeTransparent] = useState(false);
|
||||
const [tolerance, setTolerance] = useState(30);
|
||||
|
||||
const initializedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!initialSettings || initializedRef.current) return;
|
||||
initializedRef.current = true;
|
||||
if (initialSettings.sourceColor != null) setSourceColor(String(initialSettings.sourceColor));
|
||||
if (initialSettings.targetColor != null) setTargetColor(String(initialSettings.targetColor));
|
||||
if (initialSettings.makeTransparent != null)
|
||||
setMakeTransparent(Boolean(initialSettings.makeTransparent));
|
||||
if (initialSettings.tolerance != null) setTolerance(Number(initialSettings.tolerance));
|
||||
}, [initialSettings]);
|
||||
|
||||
const onChangeRef = useRef(onChange);
|
||||
useEffect(() => {
|
||||
onChangeRef.current = onChange;
|
||||
|
||||
@@ -29,10 +29,11 @@ function HintIcon({ text }: { text: string }) {
|
||||
}
|
||||
|
||||
export interface ResizeControlsProps {
|
||||
settings?: Record<string, unknown>;
|
||||
onChange?: (settings: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
export function ResizeControls({ onChange }: ResizeControlsProps) {
|
||||
export function ResizeControls({ settings: initialSettings, onChange }: ResizeControlsProps) {
|
||||
const [tab, setTab] = useState<ResizeTab>("custom");
|
||||
const [selectedPreset, setSelectedPreset] = useState<string | null>(null);
|
||||
const [width, setWidth] = useState<string>("");
|
||||
@@ -47,6 +48,29 @@ export function ResizeControls({ onChange }: ResizeControlsProps) {
|
||||
const [sobelThreshold, setSobelThreshold] = useState(2);
|
||||
const [squareMode, setSquareMode] = useState(false);
|
||||
|
||||
const initializedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!initialSettings || initializedRef.current) return;
|
||||
initializedRef.current = true;
|
||||
if (initialSettings.width != null) setWidth(String(initialSettings.width));
|
||||
if (initialSettings.height != null) setHeight(String(initialSettings.height));
|
||||
if (initialSettings.percentage != null) setPercentage(String(initialSettings.percentage));
|
||||
if (initialSettings.fit != null) setFit(initialSettings.fit as FitMode);
|
||||
if (initialSettings.withoutEnlargement != null)
|
||||
setWithoutEnlargement(Boolean(initialSettings.withoutEnlargement));
|
||||
if (initialSettings.contentAware != null)
|
||||
setContentAware(Boolean(initialSettings.contentAware));
|
||||
if (initialSettings.protectFaces != null)
|
||||
setProtectFaces(Boolean(initialSettings.protectFaces));
|
||||
if (initialSettings.blurRadius != null) setBlurRadius(Number(initialSettings.blurRadius));
|
||||
if (initialSettings.sobelThreshold != null)
|
||||
setSobelThreshold(Number(initialSettings.sobelThreshold));
|
||||
if (initialSettings.square != null) setSquareMode(Boolean(initialSettings.square));
|
||||
// Infer tab from settings
|
||||
if (initialSettings.percentage != null) setTab("scale");
|
||||
else if (initialSettings.contentAware) setTab("custom");
|
||||
}, [initialSettings]);
|
||||
|
||||
const onChangeRef = useRef(onChange);
|
||||
useEffect(() => {
|
||||
onChangeRef.current = onChange;
|
||||
|
||||
@@ -11,12 +11,18 @@ export interface PreviewTransform {
|
||||
}
|
||||
|
||||
export interface RotateControlsProps {
|
||||
settings?: Record<string, unknown>;
|
||||
onChange?: (settings: Record<string, unknown>) => void;
|
||||
onPreviewTransform?: (transform: PreviewTransform) => void;
|
||||
resetSignal?: number;
|
||||
}
|
||||
|
||||
export function RotateControls({ onChange, onPreviewTransform, resetSignal }: RotateControlsProps) {
|
||||
export function RotateControls({
|
||||
settings: initialSettings,
|
||||
onChange,
|
||||
onPreviewTransform,
|
||||
resetSignal,
|
||||
}: RotateControlsProps) {
|
||||
// Quick rotation in 90° steps: 0, 90, 180, 270
|
||||
const [rotation, setRotation] = useState(0);
|
||||
// Fine straighten adjustment: -45 to +45
|
||||
@@ -24,6 +30,15 @@ export function RotateControls({ onChange, onPreviewTransform, resetSignal }: Ro
|
||||
const [flipH, setFlipH] = useState(false);
|
||||
const [flipV, setFlipV] = useState(false);
|
||||
|
||||
const initializedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!initialSettings || initializedRef.current) return;
|
||||
initializedRef.current = true;
|
||||
if (initialSettings.angle != null) setRotation(Number(initialSettings.angle));
|
||||
if (initialSettings.horizontal != null) setFlipH(Boolean(initialSettings.horizontal));
|
||||
if (initialSettings.vertical != null) setFlipV(Boolean(initialSettings.vertical));
|
||||
}, [initialSettings]);
|
||||
|
||||
const totalAngle = rotation + straighten;
|
||||
|
||||
const onChangeRef = useRef(onChange);
|
||||
|
||||
@@ -31,10 +31,11 @@ function HintIcon({ text }: { text: string }) {
|
||||
}
|
||||
|
||||
export interface SmartCropControlsProps {
|
||||
settings?: Record<string, unknown>;
|
||||
onChange?: (settings: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
export function SmartCropControls({ onChange }: SmartCropControlsProps) {
|
||||
export function SmartCropControls({ settings: initialSettings, onChange }: SmartCropControlsProps) {
|
||||
const [mode, setMode] = useState<CropMode>("subject");
|
||||
const [subjectTab, setSubjectTab] = useState<SubjectTab>("custom");
|
||||
const [selectedPreset, setSelectedPreset] = useState<string | null>(null);
|
||||
@@ -60,6 +61,26 @@ export function SmartCropControls({ onChange }: SmartCropControlsProps) {
|
||||
// Shared
|
||||
const [quality, setQuality] = useState(95);
|
||||
|
||||
const initializedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!initialSettings || initializedRef.current) return;
|
||||
initializedRef.current = true;
|
||||
if (initialSettings.mode != null) setMode(initialSettings.mode as CropMode);
|
||||
if (initialSettings.strategy != null)
|
||||
setStrategy(initialSettings.strategy as "attention" | "entropy");
|
||||
if (initialSettings.facePreset != null) setFacePreset(String(initialSettings.facePreset));
|
||||
if (initialSettings.sensitivity != null)
|
||||
setSensitivity(Number(initialSettings.sensitivity) * 100);
|
||||
if (initialSettings.width != null) setWidth(String(initialSettings.width));
|
||||
if (initialSettings.height != null) setHeight(String(initialSettings.height));
|
||||
if (initialSettings.padding != null) setPadding(Number(initialSettings.padding));
|
||||
if (initialSettings.threshold != null) setThreshold(Number(initialSettings.threshold));
|
||||
if (initialSettings.padToSquare != null) setPadToSquare(Boolean(initialSettings.padToSquare));
|
||||
if (initialSettings.padColor != null) setPadColor(String(initialSettings.padColor));
|
||||
if (initialSettings.targetSize != null) setTargetSize(String(initialSettings.targetSize));
|
||||
if (initialSettings.quality != null) setQuality(Number(initialSettings.quality));
|
||||
}, [initialSettings]);
|
||||
|
||||
const onChangeRef = useRef(onChange);
|
||||
useEffect(() => {
|
||||
onChangeRef.current = onChange;
|
||||
|
||||
@@ -62,6 +62,7 @@ interface MetadataResult {
|
||||
}
|
||||
|
||||
interface StripMetadataControlsProps {
|
||||
settings?: Record<string, unknown>;
|
||||
onChange?: (settings: Record<string, unknown>) => void;
|
||||
/** Passed from parent to preserve field-count badges in checkbox labels */
|
||||
metadata?: MetadataResult | null;
|
||||
@@ -70,6 +71,7 @@ interface StripMetadataControlsProps {
|
||||
}
|
||||
|
||||
export function StripMetadataControls({
|
||||
settings: initialSettings,
|
||||
onChange,
|
||||
metadata,
|
||||
hasExif,
|
||||
@@ -81,6 +83,17 @@ export function StripMetadataControls({
|
||||
const [stripIcc, setStripIcc] = useState(false);
|
||||
const [stripXmp, setStripXmp] = useState(false);
|
||||
|
||||
const initializedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!initialSettings || initializedRef.current) return;
|
||||
initializedRef.current = true;
|
||||
if (initialSettings.stripAll != null) setStripAll(Boolean(initialSettings.stripAll));
|
||||
if (initialSettings.stripExif != null) setStripExif(Boolean(initialSettings.stripExif));
|
||||
if (initialSettings.stripGps != null) setStripGps(Boolean(initialSettings.stripGps));
|
||||
if (initialSettings.stripIcc != null) setStripIcc(Boolean(initialSettings.stripIcc));
|
||||
if (initialSettings.stripXmp != null) setStripXmp(Boolean(initialSettings.stripXmp));
|
||||
}, [initialSettings]);
|
||||
|
||||
const onChangeRef = useRef(onChange);
|
||||
onChangeRef.current = onChange;
|
||||
|
||||
|
||||
@@ -5,10 +5,14 @@ import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
export interface TextOverlayControlsProps {
|
||||
settings?: Record<string, unknown>;
|
||||
onChange?: (settings: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
export function TextOverlayControls({ onChange }: TextOverlayControlsProps) {
|
||||
export function TextOverlayControls({
|
||||
settings: initialSettings,
|
||||
onChange,
|
||||
}: TextOverlayControlsProps) {
|
||||
const [text, setText] = useState("Your Text Here");
|
||||
const [fontSize, setFontSize] = useState(48);
|
||||
const [color, setColor] = useState("#FFFFFF");
|
||||
@@ -17,6 +21,22 @@ export function TextOverlayControls({ onChange }: TextOverlayControlsProps) {
|
||||
const [backgroundColor, setBackgroundColor] = useState("#000000");
|
||||
const [shadow, setShadow] = useState(true);
|
||||
|
||||
const initializedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!initialSettings || initializedRef.current) return;
|
||||
initializedRef.current = true;
|
||||
if (initialSettings.text != null) setText(String(initialSettings.text));
|
||||
if (initialSettings.fontSize != null) setFontSize(Number(initialSettings.fontSize));
|
||||
if (initialSettings.color != null) setColor(String(initialSettings.color));
|
||||
if (initialSettings.position != null)
|
||||
setPosition(initialSettings.position as "top" | "center" | "bottom");
|
||||
if (initialSettings.backgroundBox != null)
|
||||
setBackgroundBox(Boolean(initialSettings.backgroundBox));
|
||||
if (initialSettings.backgroundColor != null)
|
||||
setBackgroundColor(String(initialSettings.backgroundColor));
|
||||
if (initialSettings.shadow != null) setShadow(Boolean(initialSettings.shadow));
|
||||
}, [initialSettings]);
|
||||
|
||||
const onChangeRef = useRef(onChange);
|
||||
useEffect(() => {
|
||||
onChangeRef.current = onChange;
|
||||
|
||||
@@ -14,10 +14,11 @@ const OUTPUT_FORMATS = ["png", "jpg", "webp", "avif", "tiff", "gif", "heic", "he
|
||||
const LOSSY_FORMATS = ["jpg", "jpeg", "webp", "avif", "heic", "heif"];
|
||||
|
||||
export interface UpscaleControlsProps {
|
||||
settings?: Record<string, unknown>;
|
||||
onChange?: (settings: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
export function UpscaleControls({ onChange }: UpscaleControlsProps) {
|
||||
export function UpscaleControls({ settings: initialSettings, onChange }: UpscaleControlsProps) {
|
||||
const [scale, setScale] = useState(2);
|
||||
const [model, setModel] = useState<"auto" | "realesrgan" | "lanczos">("auto");
|
||||
const [faceEnhance, setFaceEnhance] = useState(false);
|
||||
@@ -25,6 +26,19 @@ export function UpscaleControls({ onChange }: UpscaleControlsProps) {
|
||||
const [outputFormat, setOutputFormat] = useState<string>("png");
|
||||
const [quality, setQuality] = useState(95);
|
||||
|
||||
const initializedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!initialSettings || initializedRef.current) return;
|
||||
initializedRef.current = true;
|
||||
if (initialSettings.scale != null) setScale(Number(initialSettings.scale));
|
||||
if (initialSettings.model != null)
|
||||
setModel(initialSettings.model as "auto" | "realesrgan" | "lanczos");
|
||||
if (initialSettings.faceEnhance != null) setFaceEnhance(Boolean(initialSettings.faceEnhance));
|
||||
if (initialSettings.denoise != null) setDenoise(Number(initialSettings.denoise));
|
||||
if (initialSettings.format != null) setOutputFormat(String(initialSettings.format));
|
||||
if (initialSettings.quality != null) setQuality(Number(initialSettings.quality));
|
||||
}, [initialSettings]);
|
||||
|
||||
const onChangeRef = useRef(onChange);
|
||||
useEffect(() => {
|
||||
onChangeRef.current = onChange;
|
||||
|
||||
@@ -7,10 +7,14 @@ import { useFileStore } from "@/stores/file-store";
|
||||
type Position = "center" | "top-left" | "top-right" | "bottom-left" | "bottom-right" | "tiled";
|
||||
|
||||
export interface WatermarkTextControlsProps {
|
||||
settings?: Record<string, unknown>;
|
||||
onChange?: (settings: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
export function WatermarkTextControls({ onChange }: WatermarkTextControlsProps) {
|
||||
export function WatermarkTextControls({
|
||||
settings: initialSettings,
|
||||
onChange,
|
||||
}: WatermarkTextControlsProps) {
|
||||
const [text, setText] = useState("Sample Watermark");
|
||||
const [fontSize, setFontSize] = useState(48);
|
||||
const [color, setColor] = useState("#000000");
|
||||
@@ -18,6 +22,18 @@ export function WatermarkTextControls({ onChange }: WatermarkTextControlsProps)
|
||||
const [position, setPosition] = useState<Position>("center");
|
||||
const [rotation, setRotation] = useState(0);
|
||||
|
||||
const initializedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!initialSettings || initializedRef.current) return;
|
||||
initializedRef.current = true;
|
||||
if (initialSettings.text != null) setText(String(initialSettings.text));
|
||||
if (initialSettings.fontSize != null) setFontSize(Number(initialSettings.fontSize));
|
||||
if (initialSettings.color != null) setColor(String(initialSettings.color));
|
||||
if (initialSettings.opacity != null) setOpacity(Number(initialSettings.opacity));
|
||||
if (initialSettings.position != null) setPosition(initialSettings.position as Position);
|
||||
if (initialSettings.rotation != null) setRotation(Number(initialSettings.rotation));
|
||||
}, [initialSettings]);
|
||||
|
||||
const onChangeRef = useRef(onChange);
|
||||
useEffect(() => {
|
||||
onChangeRef.current = onChange;
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { formatHeaders } from "@/lib/api";
|
||||
import { generateId } from "@/lib/utils";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
import type { PipelineStep } from "@/stores/pipeline-store";
|
||||
|
||||
interface ProcessResult {
|
||||
jobId: string;
|
||||
downloadUrl: string;
|
||||
previewUrl?: string;
|
||||
originalSize: number;
|
||||
processedSize: number;
|
||||
savedFileId?: string;
|
||||
}
|
||||
|
||||
export interface PipelineProgress {
|
||||
phase: "idle" | "uploading" | "processing" | "complete";
|
||||
percent: number;
|
||||
stage?: string;
|
||||
elapsed: number;
|
||||
}
|
||||
|
||||
const IDLE_PROGRESS: PipelineProgress = {
|
||||
phase: "idle",
|
||||
percent: 0,
|
||||
elapsed: 0,
|
||||
};
|
||||
|
||||
export function usePipelineProcessor() {
|
||||
const { processing, error, processedUrl, originalSize, processedSize, setProcessing, setError } =
|
||||
useFileStore();
|
||||
|
||||
const [progress, setProgress] = useState<PipelineProgress>(IDLE_PROGRESS);
|
||||
const elapsedRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const xhrRef = useRef<XMLHttpRequest | null>(null);
|
||||
const eventSourceRef = useRef<EventSource | null>(null);
|
||||
const processingTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
// Clean up on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (elapsedRef.current) clearInterval(elapsedRef.current);
|
||||
if (processingTimerRef.current) clearInterval(processingTimerRef.current);
|
||||
if (eventSourceRef.current) eventSourceRef.current.close();
|
||||
if (xhrRef.current) xhrRef.current.abort();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const processSingle = useCallback(
|
||||
(file: File, steps: PipelineStep[]) => {
|
||||
// Capture the file index at request time so results are written
|
||||
// to the correct entry even if the user navigates away.
|
||||
const capturedIndex = useFileStore.getState().selectedIndex;
|
||||
|
||||
setError(null);
|
||||
// Mark the target entry as processing and clear any old result
|
||||
useFileStore.getState().updateEntry(capturedIndex, {
|
||||
processedUrl: null,
|
||||
processedPreviewUrl: null,
|
||||
processedFilename: null,
|
||||
status: "processing",
|
||||
error: null,
|
||||
});
|
||||
setProcessing(true);
|
||||
setProgress({ phase: "uploading", percent: 0, elapsed: 0 });
|
||||
|
||||
// Start elapsed timer
|
||||
const startTime = Date.now();
|
||||
elapsedRef.current = setInterval(() => {
|
||||
setProgress((prev) => ({
|
||||
...prev,
|
||||
elapsed: Math.floor((Date.now() - startTime) / 1000),
|
||||
}));
|
||||
}, 1000);
|
||||
|
||||
// Build pipeline payload
|
||||
const pipeline = {
|
||||
steps: steps.map((s) => ({ toolId: s.toolId, settings: s.settings })),
|
||||
};
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
formData.append("pipeline", JSON.stringify(pipeline));
|
||||
|
||||
// Use XHR for upload progress tracking
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhrRef.current = xhr;
|
||||
|
||||
// Pipeline runs multiple steps sequentially, allow up to 3 minutes
|
||||
xhr.timeout = 180_000;
|
||||
|
||||
// Pipeline is always "medium" speed: upload = 0-40%, processing = 40-95%
|
||||
const UPLOAD_WEIGHT = 40;
|
||||
|
||||
xhr.upload.onprogress = (event) => {
|
||||
if (event.lengthComputable) {
|
||||
const uploadPercent = (event.loaded / event.total) * UPLOAD_WEIGHT;
|
||||
setProgress((prev) => {
|
||||
if (prev.phase !== "uploading") return prev;
|
||||
return { ...prev, percent: uploadPercent };
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
xhr.upload.onload = () => {
|
||||
setProgress((prev) => ({
|
||||
...prev,
|
||||
phase: "processing",
|
||||
percent: UPLOAD_WEIGHT,
|
||||
stage: "Processing...",
|
||||
}));
|
||||
|
||||
// Gradually fill from upload weight to 95% over ~45s
|
||||
const start = UPLOAD_WEIGHT;
|
||||
const target = 95;
|
||||
const step = (target - start) / 90; // 90 ticks over ~45s
|
||||
processingTimerRef.current = setInterval(() => {
|
||||
setProgress((prev) => {
|
||||
if (prev.phase !== "processing") return prev;
|
||||
const next = Math.min(target, prev.percent + step);
|
||||
return { ...prev, percent: next };
|
||||
});
|
||||
}, 500);
|
||||
};
|
||||
|
||||
xhr.onload = () => {
|
||||
if (elapsedRef.current) clearInterval(elapsedRef.current);
|
||||
if (processingTimerRef.current) clearInterval(processingTimerRef.current);
|
||||
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
try {
|
||||
const result: ProcessResult = JSON.parse(xhr.responseText);
|
||||
useFileStore.getState().updateEntry(capturedIndex, {
|
||||
processedUrl: result.downloadUrl,
|
||||
processedPreviewUrl: result.previewUrl ?? null,
|
||||
processedFilename: null,
|
||||
status: "completed",
|
||||
originalSize: result.originalSize,
|
||||
processedSize: result.processedSize,
|
||||
...(result.savedFileId ? { serverFileId: result.savedFileId } : {}),
|
||||
});
|
||||
} catch {
|
||||
setError("Invalid response from server");
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const body = JSON.parse(xhr.responseText);
|
||||
const msg = body.details
|
||||
? `${body.error}: ${body.details}`
|
||||
: body.error || `Processing failed: ${xhr.status}`;
|
||||
setError(msg);
|
||||
} catch {
|
||||
setError(`Processing failed: ${xhr.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
setProcessing(false);
|
||||
setProgress(IDLE_PROGRESS);
|
||||
};
|
||||
|
||||
xhr.onerror = () => {
|
||||
if (elapsedRef.current) clearInterval(elapsedRef.current);
|
||||
if (processingTimerRef.current) clearInterval(processingTimerRef.current);
|
||||
setError("Network error - check your connection");
|
||||
setProcessing(false);
|
||||
setProgress(IDLE_PROGRESS);
|
||||
};
|
||||
|
||||
xhr.ontimeout = () => {
|
||||
if (elapsedRef.current) clearInterval(elapsedRef.current);
|
||||
if (processingTimerRef.current) clearInterval(processingTimerRef.current);
|
||||
setError("Request timed out - the server may be overloaded. Try again.");
|
||||
setProcessing(false);
|
||||
setProgress(IDLE_PROGRESS);
|
||||
};
|
||||
|
||||
xhr.open("POST", "/api/v1/pipeline/execute");
|
||||
formatHeaders().forEach((value, key) => {
|
||||
xhr.setRequestHeader(key, value);
|
||||
});
|
||||
xhr.send(formData);
|
||||
},
|
||||
[setProcessing, setError],
|
||||
);
|
||||
|
||||
const processAll = useCallback(
|
||||
async (files: File[], steps: PipelineStep[]) => {
|
||||
if (files.length === 0) {
|
||||
setError("No files selected");
|
||||
return;
|
||||
}
|
||||
if (files.length === 1) {
|
||||
processSingle(files[0], steps);
|
||||
return;
|
||||
}
|
||||
|
||||
const { updateEntry, setBatchZip } = useFileStore.getState();
|
||||
|
||||
setError(null);
|
||||
setProcessing(true);
|
||||
setProgress({ phase: "uploading", percent: 0, elapsed: 0 });
|
||||
|
||||
const startTime = Date.now();
|
||||
elapsedRef.current = setInterval(() => {
|
||||
setProgress((prev) => ({ ...prev, elapsed: Math.floor((Date.now() - startTime) / 1000) }));
|
||||
}, 1000);
|
||||
|
||||
const clientJobId = generateId();
|
||||
|
||||
// Open SSE before upload for real-time progress
|
||||
try {
|
||||
const es = new EventSource(`/api/v1/jobs/${clientJobId}/progress`);
|
||||
eventSourceRef.current = es;
|
||||
es.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.type === "batch") {
|
||||
const pct =
|
||||
data.totalFiles > 0 ? 15 + (data.completedFiles / data.totalFiles) * 85 : 15;
|
||||
setProgress((prev) => ({
|
||||
...prev,
|
||||
phase: "processing",
|
||||
percent: pct,
|
||||
stage: data.currentFile
|
||||
? `Processing ${data.currentFile} (${data.completedFiles}/${data.totalFiles})`
|
||||
: `Processing ${data.completedFiles}/${data.totalFiles}`,
|
||||
}));
|
||||
}
|
||||
} catch {
|
||||
/* ignore malformed SSE */
|
||||
}
|
||||
};
|
||||
es.onerror = () => {
|
||||
es.close();
|
||||
eventSourceRef.current = null;
|
||||
};
|
||||
} catch {
|
||||
/* SSE failed, proceed without */
|
||||
}
|
||||
|
||||
const pipeline = {
|
||||
steps: steps.map((s) => ({ toolId: s.toolId, settings: s.settings })),
|
||||
};
|
||||
|
||||
const formData = new FormData();
|
||||
for (const file of files) formData.append("file", file);
|
||||
formData.append("pipeline", JSON.stringify(pipeline));
|
||||
formData.append("clientJobId", clientJobId);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/v1/pipeline/batch", {
|
||||
method: "POST",
|
||||
headers: formatHeaders(),
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (elapsedRef.current) clearInterval(elapsedRef.current);
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close();
|
||||
eventSourceRef.current = null;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
let errorMsg: string;
|
||||
try {
|
||||
const body = JSON.parse(text);
|
||||
errorMsg = body.details
|
||||
? `${body.error}: ${body.details}`
|
||||
: body.error || `Batch processing failed: ${response.status}`;
|
||||
} catch {
|
||||
errorMsg = `Batch processing failed: ${response.status}`;
|
||||
}
|
||||
setError(errorMsg);
|
||||
setProcessing(false);
|
||||
setProgress(IDLE_PROGRESS);
|
||||
return;
|
||||
}
|
||||
|
||||
const zipBlob = await response.blob();
|
||||
setBatchZip(zipBlob, "batch-pipeline.zip");
|
||||
|
||||
// Extract files from ZIP using fflate
|
||||
const { unzipSync } = await import("fflate");
|
||||
const zipBuffer = new Uint8Array((await zipBlob.arrayBuffer()) as ArrayBuffer);
|
||||
const extracted = unzipSync(zipBuffer);
|
||||
|
||||
const entries = useFileStore.getState().entries;
|
||||
let fileResults: Record<string, string> = {};
|
||||
try {
|
||||
fileResults = JSON.parse(response.headers.get("X-File-Results") ?? "{}");
|
||||
} catch {
|
||||
// Malformed header - fall back to empty mapping, all entries marked failed
|
||||
}
|
||||
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
const processedName = fileResults[String(i)];
|
||||
if (processedName && extracted[processedName]) {
|
||||
const blob = new Blob([extracted[processedName] as BlobPart]);
|
||||
updateEntry(i, {
|
||||
processedUrl: URL.createObjectURL(blob),
|
||||
processedFilename: processedName,
|
||||
processedSize: blob.size,
|
||||
status: "completed",
|
||||
error: null,
|
||||
});
|
||||
} else {
|
||||
updateEntry(i, { status: "failed", error: "File not found in batch results" });
|
||||
}
|
||||
}
|
||||
|
||||
setProcessing(false);
|
||||
setProgress(IDLE_PROGRESS);
|
||||
} catch (err) {
|
||||
if (elapsedRef.current) clearInterval(elapsedRef.current);
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close();
|
||||
eventSourceRef.current = null;
|
||||
}
|
||||
setError(err instanceof Error ? err.message : "Batch processing failed");
|
||||
setProcessing(false);
|
||||
setProgress(IDLE_PROGRESS);
|
||||
}
|
||||
},
|
||||
[processSingle, setProcessing, setError],
|
||||
);
|
||||
|
||||
return {
|
||||
processSingle,
|
||||
processAll,
|
||||
processing,
|
||||
error,
|
||||
downloadUrl: processedUrl,
|
||||
originalSize,
|
||||
processedSize,
|
||||
progress,
|
||||
};
|
||||
}
|
||||
@@ -1,226 +1,542 @@
|
||||
import { Play, Trash2, Workflow } from "lucide-react";
|
||||
import {
|
||||
CheckCircle2,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Download,
|
||||
Play,
|
||||
Save,
|
||||
Trash2,
|
||||
Workflow,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { BeforeAfterSlider } from "@/components/common/before-after-slider";
|
||||
import { Dropzone } from "@/components/common/dropzone";
|
||||
import { ImageViewer } from "@/components/common/image-viewer";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { ThumbnailStrip } from "@/components/common/thumbnail-strip";
|
||||
import { AppLayout } from "@/components/layout/app-layout";
|
||||
import { PipelineBuilder, type PipelineStep } from "@/components/tools/pipeline-builder";
|
||||
import { PipelineBuilder } from "@/components/tools/pipeline-builder";
|
||||
import { usePipelineProcessor } from "@/hooks/use-pipeline-processor";
|
||||
import { formatHeaders } from "@/lib/api";
|
||||
import { generateId } from "@/lib/utils";
|
||||
import { formatFileSize } from "@/lib/download";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
import { type SavedPipeline, usePipelineStore } from "@/stores/pipeline-store";
|
||||
|
||||
interface SavedPipeline {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
steps: Array<{ toolId: string; settings: Record<string, unknown> }>;
|
||||
createdAt: string;
|
||||
}
|
||||
export function AutomatePage() {
|
||||
const [steps, setSteps] = useState<PipelineStep[]>([]);
|
||||
const [savedPipelines, setSavedPipelines] = useState<SavedPipeline[]>([]);
|
||||
const {
|
||||
files,
|
||||
entries,
|
||||
setFiles,
|
||||
addFiles,
|
||||
reset: resetFiles,
|
||||
processedUrl,
|
||||
originalBlobUrl,
|
||||
originalSize,
|
||||
processedSize,
|
||||
selectedFileName,
|
||||
selectedFileSize,
|
||||
batchZipBlob,
|
||||
batchZipFilename,
|
||||
selectedIndex,
|
||||
setSelectedIndex,
|
||||
navigateNext,
|
||||
navigatePrev,
|
||||
currentEntry,
|
||||
} = useFileStore();
|
||||
|
||||
const {
|
||||
steps,
|
||||
expandedStepId,
|
||||
savedPipelines,
|
||||
addStep,
|
||||
removeStep,
|
||||
reorderSteps,
|
||||
updateStepSettings,
|
||||
setExpandedStep,
|
||||
loadSteps,
|
||||
setSavedPipelines,
|
||||
} = usePipelineStore();
|
||||
|
||||
const { processSingle, processAll, processing, error, progress } = usePipelineProcessor();
|
||||
|
||||
// Local state
|
||||
const [saveName, setSaveName] = useState("");
|
||||
const [saveDescription, setSaveDescription] = useState("");
|
||||
const [showSaveForm, setShowSaveForm] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [executing, setExecuting] = useState(false);
|
||||
const [executionResult, setExecutionResult] = useState<{
|
||||
downloadUrl: string;
|
||||
originalSize: number;
|
||||
processedSize: number;
|
||||
stepsCompleted: number;
|
||||
} | null>(null);
|
||||
const [executionError, setExecutionError] = useState<string | null>(null);
|
||||
const [showAllSaved, setShowAllSaved] = useState(false);
|
||||
|
||||
// Load saved pipelines
|
||||
const loadPipelines = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/v1/pipeline/list", {
|
||||
headers: formatHeaders(),
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setSavedPipelines(data.pipelines || []);
|
||||
}
|
||||
} catch {
|
||||
// Silently fail — the list just won't show
|
||||
}
|
||||
}, []);
|
||||
const hasFile = files.length > 0;
|
||||
const hasProcessed = !!processedUrl;
|
||||
const hasMultiple = entries.length > 1;
|
||||
const hasPrev = selectedIndex > 0;
|
||||
const hasNext = selectedIndex < entries.length - 1;
|
||||
|
||||
// Load saved pipelines on mount
|
||||
useEffect(() => {
|
||||
loadPipelines();
|
||||
}, [loadPipelines]);
|
||||
|
||||
// Save pipeline
|
||||
const handleSave = useCallback(
|
||||
async (name: string, description: string) => {
|
||||
setSaving(true);
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/v1/pipeline/save", {
|
||||
method: "POST",
|
||||
headers: formatHeaders({ "Content-Type": "application/json" }),
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
description: description || undefined,
|
||||
steps: steps.map((s) => ({ toolId: s.toolId, settings: s.settings })),
|
||||
}),
|
||||
const res = await fetch("/api/v1/pipeline/list", {
|
||||
headers: formatHeaders(),
|
||||
});
|
||||
if (res.ok) {
|
||||
await loadPipelines();
|
||||
} else {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
setExecutionError(data.error || "Failed to save pipeline");
|
||||
const data = await res.json();
|
||||
setSavedPipelines(data.pipelines || []);
|
||||
}
|
||||
} catch {
|
||||
setExecutionError("Connection error while saving pipeline.");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
// Silently fail
|
||||
}
|
||||
})();
|
||||
}, [setSavedPipelines]);
|
||||
|
||||
const handleFiles = useCallback(
|
||||
(newFiles: File[]) => {
|
||||
resetFiles();
|
||||
setFiles(newFiles);
|
||||
},
|
||||
[steps, loadPipelines],
|
||||
[setFiles, resetFiles],
|
||||
);
|
||||
|
||||
// Delete pipeline
|
||||
const handleDelete = useCallback(
|
||||
const handleAddMore = useCallback(() => {
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.multiple = true;
|
||||
input.accept = "image/*,.heic,.heif,.hif";
|
||||
input.onchange = (e) => {
|
||||
const picked = Array.from((e.target as HTMLInputElement).files || []);
|
||||
if (picked.length > 0) addFiles(picked);
|
||||
};
|
||||
input.click();
|
||||
}, [addFiles]);
|
||||
|
||||
const handleProcess = useCallback(() => {
|
||||
if (files.length === 0 || steps.length === 0) return;
|
||||
if (files.length === 1) {
|
||||
processSingle(files[0], steps);
|
||||
} else {
|
||||
processAll(files, steps);
|
||||
}
|
||||
}, [files, steps, processSingle, processAll]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!saveName.trim() || steps.length === 0) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await fetch("/api/v1/pipeline/save", {
|
||||
method: "POST",
|
||||
headers: formatHeaders({ "Content-Type": "application/json" }),
|
||||
body: JSON.stringify({
|
||||
name: saveName.trim(),
|
||||
description: saveDescription.trim() || undefined,
|
||||
steps: steps.map((s) => ({ toolId: s.toolId, settings: s.settings })),
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
// Refresh saved pipelines
|
||||
const listRes = await fetch("/api/v1/pipeline/list", {
|
||||
headers: formatHeaders(),
|
||||
});
|
||||
if (listRes.ok) {
|
||||
const data = await listRes.json();
|
||||
setSavedPipelines(data.pipelines || []);
|
||||
}
|
||||
setSaveName("");
|
||||
setSaveDescription("");
|
||||
setShowSaveForm(false);
|
||||
}
|
||||
} catch {
|
||||
// Save failed silently
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [saveName, saveDescription, steps, setSavedPipelines]);
|
||||
|
||||
const handleDeletePipeline = useCallback(
|
||||
async (id: string) => {
|
||||
try {
|
||||
await fetch(`/api/v1/pipeline/${id}`, {
|
||||
method: "DELETE",
|
||||
headers: formatHeaders(),
|
||||
});
|
||||
await loadPipelines();
|
||||
const listRes = await fetch("/api/v1/pipeline/list", {
|
||||
headers: formatHeaders(),
|
||||
});
|
||||
if (listRes.ok) {
|
||||
const data = await listRes.json();
|
||||
setSavedPipelines(data.pipelines || []);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
},
|
||||
[loadPipelines],
|
||||
[setSavedPipelines],
|
||||
);
|
||||
|
||||
// Execute pipeline
|
||||
const handleExecute = useCallback(
|
||||
async (file: File) => {
|
||||
setExecuting(true);
|
||||
setExecutionResult(null);
|
||||
setExecutionError(null);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
formData.append(
|
||||
"pipeline",
|
||||
JSON.stringify({
|
||||
steps: steps.map((s) => ({
|
||||
toolId: s.toolId,
|
||||
settings: s.settings,
|
||||
})),
|
||||
}),
|
||||
);
|
||||
const handleLoadPipeline = useCallback(
|
||||
(pipeline: SavedPipeline) => {
|
||||
loadSteps(pipeline.steps);
|
||||
},
|
||||
[loadSteps],
|
||||
);
|
||||
|
||||
const res = await fetch("/api/v1/pipeline/execute", {
|
||||
method: "POST",
|
||||
headers: formatHeaders(),
|
||||
body: formData,
|
||||
});
|
||||
const handleDownloadAll = useCallback(() => {
|
||||
if (!batchZipBlob) return;
|
||||
const url = URL.createObjectURL(batchZipBlob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = batchZipFilename ?? "batch-pipeline.zip";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}, [batchZipBlob, batchZipFilename]);
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setExecutionResult({
|
||||
downloadUrl: data.downloadUrl,
|
||||
originalSize: data.originalSize,
|
||||
processedSize: data.processedSize,
|
||||
stepsCompleted: data.stepsCompleted,
|
||||
});
|
||||
} else {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
setExecutionError(data.error || `Pipeline failed with status ${res.status}`);
|
||||
}
|
||||
} catch {
|
||||
setExecutionError("Connection error. Please try again.");
|
||||
} finally {
|
||||
setExecuting(false);
|
||||
const handleImageKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "ArrowLeft") {
|
||||
e.preventDefault();
|
||||
navigatePrev();
|
||||
} else if (e.key === "ArrowRight") {
|
||||
e.preventDefault();
|
||||
navigateNext();
|
||||
}
|
||||
},
|
||||
[steps],
|
||||
[navigateNext, navigatePrev],
|
||||
);
|
||||
|
||||
// Load saved pipeline into builder
|
||||
const loadSaved = useCallback((pipeline: SavedPipeline) => {
|
||||
const newSteps: PipelineStep[] = pipeline.steps.map((s) => ({
|
||||
id: generateId(),
|
||||
toolId: s.toolId,
|
||||
settings: { ...s.settings },
|
||||
}));
|
||||
setSteps(newSteps);
|
||||
setExecutionResult(null);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AppLayout showToolPanel={false}>
|
||||
<div className="flex h-full w-full overflow-hidden">
|
||||
{/* Left sidebar: saved automations */}
|
||||
{savedPipelines.length > 0 && (
|
||||
<div className="w-72 border-r border-border overflow-y-auto p-4 space-y-6 shrink-0 hidden md:block">
|
||||
{/* LEFT PANEL */}
|
||||
<div className="w-72 border-r border-border flex flex-col shrink-0 hidden md:flex">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 p-4 border-b border-border shrink-0">
|
||||
<div className="p-2 rounded-lg bg-primary text-primary-foreground">
|
||||
<Workflow className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold uppercase text-muted-foreground tracking-wider mb-3">
|
||||
Saved Automations
|
||||
<h1 className="text-lg font-semibold text-foreground">Automate</h1>
|
||||
<p className="text-xs text-muted-foreground">Chain tools into a pipeline</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Saved pipelines strip */}
|
||||
{savedPipelines.length > 0 && (
|
||||
<div className="px-4 pt-3 pb-2 border-b border-border shrink-0">
|
||||
<h3 className="text-xs font-semibold uppercase text-muted-foreground tracking-wider mb-2">
|
||||
Saved Pipelines
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{savedPipelines.map((pipeline) => (
|
||||
<div
|
||||
key={pipeline.id}
|
||||
className="p-3 rounded-lg border border-border hover:bg-muted/50 transition-colors group"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
{showAllSaved ? (
|
||||
<div className="space-y-1.5 max-h-48 overflow-y-auto">
|
||||
{savedPipelines.map((p) => (
|
||||
<div key={p.id} className="flex items-center gap-1.5 group">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => loadSaved(pipeline)}
|
||||
className="text-sm font-medium text-foreground hover:text-primary flex items-center gap-1.5"
|
||||
onClick={() => handleLoadPipeline(p)}
|
||||
className="flex-1 text-left text-xs text-foreground hover:text-primary truncate py-1 px-2 rounded hover:bg-muted"
|
||||
>
|
||||
<Play className="h-3 w-3" />
|
||||
{pipeline.name}
|
||||
{p.name}
|
||||
<span className="text-muted-foreground ml-1">
|
||||
({p.steps.length} step{p.steps.length !== 1 ? "s" : ""})
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDelete(pipeline.id)}
|
||||
className="opacity-0 group-hover:opacity-100 p-1 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-all"
|
||||
onClick={() => handleDeletePipeline(p.id)}
|
||||
className="opacity-0 group-hover:opacity-100 p-1 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-all shrink-0"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
{pipeline.description && (
|
||||
<p className="text-xs text-muted-foreground line-clamp-2">
|
||||
{pipeline.description}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{pipeline.steps.length} step{pipeline.steps.length !== 1 ? "s" : ""}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAllSaved(false)}
|
||||
className="text-xs text-muted-foreground hover:text-foreground mt-1"
|
||||
>
|
||||
Show less
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{savedPipelines.slice(0, 3).map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
onClick={() => handleLoadPipeline(p)}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 rounded-full bg-muted text-xs text-foreground hover:bg-primary/10 hover:text-primary transition-colors truncate max-w-[120px]"
|
||||
>
|
||||
<Play className="h-3 w-3 shrink-0" />
|
||||
<span className="truncate">{p.name}</span>
|
||||
</button>
|
||||
))}
|
||||
{savedPipelines.length > 3 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAllSaved(true)}
|
||||
className="text-xs text-muted-foreground hover:text-foreground px-2 py-1"
|
||||
>
|
||||
+{savedPipelines.length - 3} more
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* File info */}
|
||||
<div className="px-4 pt-3 pb-2 border-b border-border shrink-0">
|
||||
<h3 className="text-xs font-semibold uppercase text-muted-foreground tracking-wider mb-2">
|
||||
Files
|
||||
</h3>
|
||||
{files.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
Drop or upload images to get started
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-foreground">
|
||||
{files.length} file{files.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddMore}
|
||||
className="text-xs text-primary hover:text-primary/80"
|
||||
>
|
||||
+ Add more
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-xs text-foreground bg-muted rounded px-2 py-1.5">
|
||||
<CheckCircle2 className="h-3.5 w-3.5 text-green-500 shrink-0" />
|
||||
<span className="truncate flex-1">{selectedFileName ?? files[0].name}</span>
|
||||
<span className="text-muted-foreground shrink-0 ml-1">
|
||||
{formatFileSize(selectedFileSize ?? files[0].size)}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => resetFiles()}
|
||||
className="text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main builder area */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="p-2 rounded-lg bg-primary text-primary-foreground">
|
||||
<Workflow className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-foreground">Automation Pipeline</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Chain multiple tools into a single workflow
|
||||
</p>
|
||||
{/* Error display */}
|
||||
{error && (
|
||||
<div className="px-4 pt-2 shrink-0">
|
||||
<div className="text-xs text-red-500 bg-red-50 dark:bg-red-950/30 rounded px-2 py-1.5 flex items-start gap-1.5">
|
||||
<X className="h-3.5 w-3.5 shrink-0 mt-0.5" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pipeline steps (scrollable) */}
|
||||
<div className="flex-1 overflow-y-auto px-4 pt-3 pb-2">
|
||||
<h3 className="text-xs font-semibold uppercase text-muted-foreground tracking-wider mb-2">
|
||||
Pipeline Steps
|
||||
</h3>
|
||||
<PipelineBuilder
|
||||
steps={steps}
|
||||
onStepsChange={setSteps}
|
||||
onSave={handleSave}
|
||||
onExecute={handleExecute}
|
||||
saving={saving}
|
||||
executing={executing}
|
||||
executionResult={executionResult}
|
||||
executionError={executionError}
|
||||
expandedStepId={expandedStepId}
|
||||
onAddStep={addStep}
|
||||
onRemoveStep={removeStep}
|
||||
onReorderSteps={reorderSteps}
|
||||
onUpdateSettings={updateStepSettings}
|
||||
onToggleStep={setExpandedStep}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Progress card */}
|
||||
{processing && (
|
||||
<div className="px-4 pb-2 shrink-0">
|
||||
<ProgressCard
|
||||
active={progress.phase !== "idle"}
|
||||
phase={progress.phase === "idle" ? "processing" : progress.phase}
|
||||
label={
|
||||
files.length > 1
|
||||
? `Processing ${files.length} files...`
|
||||
: "Processing pipeline..."
|
||||
}
|
||||
stage={progress.stage}
|
||||
percent={progress.percent}
|
||||
elapsed={progress.elapsed}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action buttons (sticky bottom) */}
|
||||
<div className="p-4 border-t border-border space-y-2 shrink-0">
|
||||
{/* Process button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleProcess}
|
||||
disabled={!hasFile || steps.length === 0 || processing}
|
||||
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium flex items-center justify-center gap-2 hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Play className="h-4 w-4" />
|
||||
{files.length <= 1 ? "Process" : `Process All (${files.length} files)`}
|
||||
</button>
|
||||
|
||||
{/* Download All ZIP */}
|
||||
{hasProcessed && batchZipBlob && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDownloadAll}
|
||||
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
Download All (ZIP)
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Save pipeline */}
|
||||
{steps.length > 0 && !showSaveForm && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowSaveForm(true)}
|
||||
className="w-full py-2 rounded-lg border border-border text-muted-foreground font-medium flex items-center justify-center gap-2 hover:bg-muted hover:text-foreground text-sm"
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
Save Pipeline
|
||||
</button>
|
||||
)}
|
||||
|
||||
{showSaveForm && (
|
||||
<div className="space-y-2 rounded-lg border border-border p-3 bg-muted/30">
|
||||
<input
|
||||
type="text"
|
||||
value={saveName}
|
||||
onChange={(e) => setSaveName(e.target.value)}
|
||||
placeholder="Pipeline name"
|
||||
className="w-full text-sm px-2.5 py-1.5 rounded border border-border bg-background text-foreground placeholder:text-muted-foreground"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={saveDescription}
|
||||
onChange={(e) => setSaveDescription(e.target.value)}
|
||||
placeholder="Description (optional)"
|
||||
className="w-full text-sm px-2.5 py-1.5 rounded border border-border bg-background text-foreground placeholder:text-muted-foreground"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={!saveName.trim() || saving}
|
||||
className="flex-1 py-1.5 rounded bg-primary text-primary-foreground text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{saving ? "Saving..." : "Save"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowSaveForm(false);
|
||||
setSaveName("");
|
||||
setSaveDescription("");
|
||||
}}
|
||||
className="px-3 py-1.5 rounded border border-border text-sm text-muted-foreground hover:bg-muted"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* RIGHT PANEL */}
|
||||
<section
|
||||
aria-label="Image area"
|
||||
className="flex-1 flex flex-col overflow-hidden min-h-0"
|
||||
onKeyDown={hasMultiple ? handleImageKeyDown : undefined}
|
||||
tabIndex={hasMultiple ? 0 : undefined}
|
||||
>
|
||||
<div className="flex-1 relative flex items-center justify-center p-6 min-h-0">
|
||||
{/* Nav arrows */}
|
||||
{hasMultiple && hasPrev && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={navigatePrev}
|
||||
className="absolute left-3 z-10 w-8 h-8 rounded-full bg-background/80 border border-border shadow-sm flex items-center justify-center hover:bg-background transition-colors"
|
||||
aria-label="Previous image"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
{hasMultiple && hasNext && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={navigateNext}
|
||||
className="absolute right-3 z-10 w-8 h-8 rounded-full bg-background/80 border border-border shadow-sm flex items-center justify-center hover:bg-background transition-colors"
|
||||
aria-label="Next image"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
{hasMultiple && (
|
||||
<div className="absolute top-3 right-3 z-10 bg-background/80 border border-border px-2 py-0.5 rounded-full text-xs text-muted-foreground tabular-nums">
|
||||
{selectedIndex + 1} / {entries.length}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Image display area */}
|
||||
{!hasFile && (
|
||||
<Dropzone onFiles={handleFiles} accept="image/*" multiple currentFiles={files} />
|
||||
)}
|
||||
|
||||
{hasFile && !hasProcessed && currentEntry?.status === "failed" && (
|
||||
<div className="flex flex-col items-center justify-center gap-3 h-full text-center px-4">
|
||||
<p className="text-sm text-red-500">
|
||||
{currentEntry.error ?? "Processing failed for this file"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasFile && hasProcessed && originalBlobUrl && (
|
||||
<BeforeAfterSlider
|
||||
beforeSrc={originalBlobUrl}
|
||||
afterSrc={processedUrl as string}
|
||||
beforeSize={originalSize ?? undefined}
|
||||
afterSize={processedSize ?? undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
{hasFile && !hasProcessed && originalBlobUrl && currentEntry?.status !== "failed" && (
|
||||
<ImageViewer
|
||||
src={originalBlobUrl}
|
||||
filename={selectedFileName ?? files[0].name}
|
||||
fileSize={selectedFileSize ?? files[0].size}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Info bar */}
|
||||
{hasFile && (
|
||||
<div className="flex items-center justify-between px-3 py-1.5 border-t border-border text-xs text-muted-foreground shrink-0">
|
||||
<span className="truncate mr-2">{selectedFileName ?? files[0].name}</span>
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
{hasProcessed && processedSize != null && (
|
||||
<span>
|
||||
{formatFileSize(originalSize ?? 0)} → {formatFileSize(processedSize)}
|
||||
</span>
|
||||
)}
|
||||
{!hasProcessed && <span>{formatFileSize(selectedFileSize ?? files[0].size)}</span>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Thumbnail strip for multi-file */}
|
||||
{hasMultiple && (
|
||||
<ThumbnailStrip
|
||||
entries={entries}
|
||||
selectedIndex={selectedIndex}
|
||||
onSelect={setSelectedIndex}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</AppLayout>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { create } from "zustand";
|
||||
import { generateId } from "@/lib/utils";
|
||||
|
||||
export interface PipelineStep {
|
||||
id: string;
|
||||
toolId: string;
|
||||
settings: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface SavedPipeline {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
steps: Array<{ toolId: string; settings: Record<string, unknown> }>;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface PipelineState {
|
||||
steps: PipelineStep[];
|
||||
expandedStepId: string | null;
|
||||
savedPipelines: SavedPipeline[];
|
||||
|
||||
addStep: (toolId: string) => void;
|
||||
removeStep: (id: string) => void;
|
||||
reorderSteps: (activeId: string, overId: string) => void;
|
||||
updateStepSettings: (id: string, settings: Record<string, unknown>) => void;
|
||||
setExpandedStep: (id: string | null) => void;
|
||||
loadSteps: (steps: Array<{ toolId: string; settings: Record<string, unknown> }>) => void;
|
||||
setSavedPipelines: (pipelines: SavedPipeline[]) => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export const usePipelineStore = create<PipelineState>((set, get) => ({
|
||||
steps: [],
|
||||
expandedStepId: null,
|
||||
savedPipelines: [],
|
||||
|
||||
addStep: (toolId) => {
|
||||
const step: PipelineStep = { id: generateId(), toolId, settings: {} };
|
||||
set({ steps: [...get().steps, step], expandedStepId: step.id });
|
||||
},
|
||||
|
||||
removeStep: (id) => {
|
||||
const { steps, expandedStepId } = get();
|
||||
set({
|
||||
steps: steps.filter((s) => s.id !== id),
|
||||
expandedStepId: expandedStepId === id ? null : expandedStepId,
|
||||
});
|
||||
},
|
||||
|
||||
reorderSteps: (activeId, overId) => {
|
||||
const { steps } = get();
|
||||
const oldIndex = steps.findIndex((s) => s.id === activeId);
|
||||
const newIndex = steps.findIndex((s) => s.id === overId);
|
||||
if (oldIndex < 0 || newIndex < 0) return;
|
||||
const reordered = [...steps];
|
||||
const [moved] = reordered.splice(oldIndex, 1);
|
||||
reordered.splice(newIndex, 0, moved);
|
||||
set({ steps: reordered });
|
||||
},
|
||||
|
||||
updateStepSettings: (id, settings) => {
|
||||
set({ steps: get().steps.map((s) => (s.id === id ? { ...s, settings } : s)) });
|
||||
},
|
||||
|
||||
setExpandedStep: (id) => set({ expandedStepId: id }),
|
||||
|
||||
loadSteps: (rawSteps) => {
|
||||
const steps = rawSteps.map((s) => ({
|
||||
id: generateId(),
|
||||
toolId: s.toolId,
|
||||
settings: { ...s.settings },
|
||||
}));
|
||||
set({ steps, expandedStepId: null });
|
||||
},
|
||||
|
||||
setSavedPipelines: (pipelines) => set({ savedPipelines: pipelines }),
|
||||
|
||||
reset: () => set({ steps: [], expandedStepId: null, savedPipelines: [] }),
|
||||
}));
|
||||
+6
-1
@@ -53,5 +53,10 @@
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^3.0.0"
|
||||
},
|
||||
"license": "AGPL-3.0"
|
||||
"license": "AGPL-3.0",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+66
@@ -7,6 +7,16 @@ settings:
|
||||
importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@dnd-kit/core':
|
||||
specifier: ^6.3.1
|
||||
version: 6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@dnd-kit/sortable':
|
||||
specifier: ^10.0.0
|
||||
version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)
|
||||
'@dnd-kit/utilities':
|
||||
specifier: ^3.2.2
|
||||
version: 3.2.2(react@19.2.4)
|
||||
devDependencies:
|
||||
'@biomejs/biome':
|
||||
specifier: ^2.4.8
|
||||
@@ -195,6 +205,15 @@ importers:
|
||||
|
||||
apps/web:
|
||||
dependencies:
|
||||
'@dnd-kit/core':
|
||||
specifier: ^6.3.1
|
||||
version: 6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@dnd-kit/sortable':
|
||||
specifier: ^10.0.0
|
||||
version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)
|
||||
'@dnd-kit/utilities':
|
||||
specifier: ^3.2.2
|
||||
version: 3.2.2(react@19.2.4)
|
||||
'@stirling-image/shared':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/shared
|
||||
@@ -600,6 +619,28 @@ packages:
|
||||
resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==}
|
||||
engines: {node: '>=20.19.0'}
|
||||
|
||||
'@dnd-kit/accessibility@3.1.1':
|
||||
resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==}
|
||||
peerDependencies:
|
||||
react: '>=16.8.0'
|
||||
|
||||
'@dnd-kit/core@6.3.1':
|
||||
resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==}
|
||||
peerDependencies:
|
||||
react: '>=16.8.0'
|
||||
react-dom: '>=16.8.0'
|
||||
|
||||
'@dnd-kit/sortable@10.0.0':
|
||||
resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==}
|
||||
peerDependencies:
|
||||
'@dnd-kit/core': ^6.3.0
|
||||
react: '>=16.8.0'
|
||||
|
||||
'@dnd-kit/utilities@3.2.2':
|
||||
resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==}
|
||||
peerDependencies:
|
||||
react: '>=16.8.0'
|
||||
|
||||
'@docsearch/css@3.8.2':
|
||||
resolution: {integrity: sha512-y05ayQFyUmCXze79+56v/4HpycYF3uFqB78pLPrSV5ZKAlDuIAAJNhaRi8tTdRNXh05yxX/TyNnzD6LwSM89vQ==}
|
||||
|
||||
@@ -6328,6 +6369,31 @@ snapshots:
|
||||
|
||||
'@csstools/css-tokenizer@4.0.0': {}
|
||||
|
||||
'@dnd-kit/accessibility@3.1.1(react@19.2.4)':
|
||||
dependencies:
|
||||
react: 19.2.4
|
||||
tslib: 2.8.1
|
||||
|
||||
'@dnd-kit/core@6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@dnd-kit/accessibility': 3.1.1(react@19.2.4)
|
||||
'@dnd-kit/utilities': 3.2.2(react@19.2.4)
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
tslib: 2.8.1
|
||||
|
||||
'@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@dnd-kit/core': 6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@dnd-kit/utilities': 3.2.2(react@19.2.4)
|
||||
react: 19.2.4
|
||||
tslib: 2.8.1
|
||||
|
||||
'@dnd-kit/utilities@3.2.2(react@19.2.4)':
|
||||
dependencies:
|
||||
react: 19.2.4
|
||||
tslib: 2.8.1
|
||||
|
||||
'@docsearch/css@3.8.2': {}
|
||||
|
||||
'@docsearch/js@3.8.2(@algolia/client-search@5.49.2)(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(search-insights@2.17.3)':
|
||||
|
||||
+29
-171
@@ -10,7 +10,7 @@ test.describe("Automate Page", () => {
|
||||
*/
|
||||
async function gotoAutomate(page: import("@playwright/test").Page) {
|
||||
const heading = page.getByRole("heading", {
|
||||
name: /automation pipeline/i,
|
||||
name: /automate/i,
|
||||
});
|
||||
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
@@ -60,10 +60,11 @@ test.describe("Automate Page", () => {
|
||||
|
||||
const testImagePath = getTestImagePath();
|
||||
|
||||
/** Upload the test image via file chooser. */
|
||||
/** Upload the test image via the Dropzone file chooser in the right panel. */
|
||||
async function uploadTestFile(page: import("@playwright/test").Page) {
|
||||
const fileChooserPromise = page.waitForEvent("filechooser");
|
||||
await page.getByRole("button", { name: /upload image to process/i }).click();
|
||||
// The Dropzone renders a button labelled "Upload from computer"
|
||||
await page.getByRole("button", { name: /upload from computer/i }).click();
|
||||
const fileChooser = await fileChooserPromise;
|
||||
await fileChooser.setFiles(testImagePath);
|
||||
await page.waitForTimeout(500);
|
||||
@@ -73,17 +74,19 @@ test.describe("Automate Page", () => {
|
||||
|
||||
test("automate page renders pipeline builder", async ({ loggedInPage: page }) => {
|
||||
await gotoAutomate(page);
|
||||
await expect(page.getByText(/chain multiple tools/i).first()).toBeVisible();
|
||||
await expect(page.getByText(/chain tools into a pipeline/i).first()).toBeVisible();
|
||||
});
|
||||
|
||||
test("shows empty state message when no steps", async ({ loggedInPage: page }) => {
|
||||
await gotoAutomate(page);
|
||||
await expect(page.getByText(/add steps to build your automation pipeline/i)).toBeVisible();
|
||||
await expect(page.getByText(/add steps to build your pipeline/i)).toBeVisible();
|
||||
});
|
||||
|
||||
test("shows upload image button", async ({ loggedInPage: page }) => {
|
||||
test("shows dropzone when no file uploaded", async ({ loggedInPage: page }) => {
|
||||
await gotoAutomate(page);
|
||||
await expect(page.getByRole("button", { name: /upload image to process/i })).toBeVisible();
|
||||
// The Dropzone section should be visible with its upload button
|
||||
await expect(page.locator("section[aria-label='File drop zone']")).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: /upload from computer/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test("has Add Step button", async ({ loggedInPage: page }) => {
|
||||
@@ -103,9 +106,12 @@ test.describe("Automate Page", () => {
|
||||
|
||||
test("has Save Pipeline button (disabled when no steps)", async ({ loggedInPage: page }) => {
|
||||
await gotoAutomate(page);
|
||||
const saveBtn = page.getByRole("button", { name: "Save Pipeline" });
|
||||
await expect(saveBtn).toBeVisible();
|
||||
await expect(saveBtn).toBeDisabled();
|
||||
// Save Pipeline button is only rendered when steps > 0, so it should not exist yet
|
||||
await expect(page.getByRole("button", { name: "Save Pipeline" })).not.toBeVisible();
|
||||
|
||||
// Add a step so the button appears
|
||||
await addToolStep(page, "Resize", 1);
|
||||
await expect(page.getByRole("button", { name: "Save Pipeline" })).toBeVisible();
|
||||
});
|
||||
|
||||
// --- Add Step ---
|
||||
@@ -116,19 +122,11 @@ test.describe("Automate Page", () => {
|
||||
await expect(page.getByText("Add a step")).toBeVisible();
|
||||
});
|
||||
|
||||
test("tool picker shows available tools", async ({ loggedInPage: page }) => {
|
||||
await gotoAutomate(page);
|
||||
await page.getByRole("button", { name: /add step/i }).click();
|
||||
const pickerArea = page.locator(".max-h-80.overflow-y-auto");
|
||||
await expect(pickerArea.getByText("Resize").first()).toBeVisible();
|
||||
await expect(pickerArea.getByText("Convert").first()).toBeVisible();
|
||||
});
|
||||
|
||||
test("selecting a tool from picker adds a step", async ({ loggedInPage: page }) => {
|
||||
await gotoAutomate(page);
|
||||
await addToolStep(page, "Resize", 1);
|
||||
// Verify empty state is gone
|
||||
await expect(page.getByText(/add steps to build your automation pipeline/i)).not.toBeVisible();
|
||||
await expect(page.getByText(/add steps to build your pipeline/i)).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("can add multiple steps", async ({ loggedInPage: page }) => {
|
||||
@@ -157,56 +155,14 @@ test.describe("Automate Page", () => {
|
||||
await waitForSteps(page, 1);
|
||||
});
|
||||
|
||||
test("can expand step settings", async ({ loggedInPage: page }) => {
|
||||
await gotoAutomate(page);
|
||||
await addToolStep(page, "Resize", 1);
|
||||
// Adding a second step collapses the first (only one expanded at a time)
|
||||
await addToolStep(page, "Compress", 2);
|
||||
|
||||
// Expand the first step's settings
|
||||
await page.getByTitle("Settings").first().click();
|
||||
await expect(page.getByText("Custom Size").first()).toBeVisible();
|
||||
});
|
||||
|
||||
test("move up button disabled on first step", async ({ loggedInPage: page }) => {
|
||||
await gotoAutomate(page);
|
||||
await addToolStep(page, "Resize", 1);
|
||||
await addToolStep(page, "Compress", 2);
|
||||
|
||||
await expect(page.getByTitle("Move up").first()).toBeDisabled();
|
||||
});
|
||||
|
||||
test("move down button disabled on last step", async ({ loggedInPage: page }) => {
|
||||
await gotoAutomate(page);
|
||||
await addToolStep(page, "Resize", 1);
|
||||
await addToolStep(page, "Compress", 2);
|
||||
|
||||
await expect(page.getByTitle("Move down").last()).toBeDisabled();
|
||||
});
|
||||
|
||||
// --- File Upload ---
|
||||
|
||||
test("can upload a file via file chooser", async ({ loggedInPage: page }) => {
|
||||
test("can upload a file via dropzone", async ({ loggedInPage: page }) => {
|
||||
await gotoAutomate(page);
|
||||
await uploadTestFile(page);
|
||||
|
||||
// File name and size should be visible in the upload area
|
||||
// File name should be visible in the left panel file info section
|
||||
await expect(page.getByText("test-image.png")).toBeVisible();
|
||||
// The file size text is inside the dashed border area
|
||||
const uploadArea = page.locator("[class*='border-dashed']").first();
|
||||
await expect(uploadArea.getByText(/KB\)/)).toBeVisible();
|
||||
});
|
||||
|
||||
test("can remove uploaded file", async ({ loggedInPage: page }) => {
|
||||
await gotoAutomate(page);
|
||||
await uploadTestFile(page);
|
||||
await expect(page.getByText("test-image.png")).toBeVisible();
|
||||
|
||||
// Remove file - the X button inside the dashed upload area
|
||||
const uploadArea = page.locator("[class*='border-dashed']").first();
|
||||
await uploadArea.locator("button").click();
|
||||
|
||||
await expect(page.getByRole("button", { name: /upload image to process/i })).toBeVisible();
|
||||
});
|
||||
|
||||
// --- Save Pipeline ---
|
||||
@@ -215,7 +171,7 @@ test.describe("Automate Page", () => {
|
||||
await gotoAutomate(page);
|
||||
await addToolStep(page, "Resize", 1);
|
||||
|
||||
await expect(page.getByRole("button", { name: "Save Pipeline" })).toBeEnabled();
|
||||
await expect(page.getByRole("button", { name: "Save Pipeline" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("clicking Save Pipeline shows name input form", async ({ loggedInPage: page }) => {
|
||||
@@ -226,19 +182,7 @@ test.describe("Automate Page", () => {
|
||||
await expect(page.getByPlaceholder("Pipeline name")).toBeVisible();
|
||||
});
|
||||
|
||||
test("Save button disabled when name is empty", async ({ loggedInPage: page }) => {
|
||||
await gotoAutomate(page);
|
||||
await addToolStep(page, "Resize", 1);
|
||||
|
||||
await page.getByRole("button", { name: "Save Pipeline" }).click();
|
||||
const saveSubmitBtn = page.getByRole("button", {
|
||||
name: "Save",
|
||||
exact: true,
|
||||
});
|
||||
await expect(saveSubmitBtn).toBeDisabled();
|
||||
});
|
||||
|
||||
test("can save a pipeline with name and see it in sidebar", async ({ loggedInPage: page }) => {
|
||||
test("can save a pipeline and see it as a chip", async ({ loggedInPage: page }) => {
|
||||
await gotoAutomate(page);
|
||||
await addToolStep(page, "Resize", 1);
|
||||
await addToolStep(page, "Compress", 2);
|
||||
@@ -248,26 +192,12 @@ test.describe("Automate Page", () => {
|
||||
await page.getByPlaceholder("Pipeline name").fill(uniqueName);
|
||||
await page.getByRole("button", { name: "Save", exact: true }).click();
|
||||
|
||||
// Wait for the pipeline to appear in sidebar
|
||||
// The saved pipeline should appear as a chip in the saved pipelines strip
|
||||
await expect(page.getByText(uniqueName).first()).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("can close save form without saving", async ({ loggedInPage: page }) => {
|
||||
await gotoAutomate(page);
|
||||
await addToolStep(page, "Resize", 1);
|
||||
|
||||
await page.getByRole("button", { name: "Save Pipeline" }).click();
|
||||
await expect(page.getByPlaceholder("Pipeline name")).toBeVisible();
|
||||
|
||||
// Close the form - the last button in the save form row
|
||||
const formRow = page.locator(".flex.items-center.gap-2.flex-1");
|
||||
await formRow.locator("button").last().click();
|
||||
|
||||
await expect(page.getByRole("button", { name: "Save Pipeline" })).toBeVisible();
|
||||
});
|
||||
|
||||
// --- Pipeline Execution ---
|
||||
|
||||
test("Process button enables when steps and file are set", async ({ loggedInPage: page }) => {
|
||||
@@ -278,7 +208,7 @@ test.describe("Automate Page", () => {
|
||||
await expect(page.getByRole("button", { name: "Process", exact: true })).toBeEnabled();
|
||||
});
|
||||
|
||||
test("executing pipeline shows success result", async ({ loggedInPage: page }) => {
|
||||
test("executing pipeline shows before/after result", async ({ loggedInPage: page }) => {
|
||||
await gotoAutomate(page);
|
||||
await addToolStep(page, "Strip Metadata", 1);
|
||||
await addToolStep(page, "Compress", 2);
|
||||
@@ -286,84 +216,12 @@ test.describe("Automate Page", () => {
|
||||
|
||||
await page.getByRole("button", { name: "Process", exact: true }).click();
|
||||
|
||||
// Wait for result (pipeline completed text)
|
||||
await expect(page.getByText(/pipeline completed/i)).toBeVisible({ timeout: 30_000 });
|
||||
// Wait for the before/after slider to appear (indicates processing completed)
|
||||
const slider = page.locator("[aria-label='Before/after comparison slider']");
|
||||
await expect(slider).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// Should show original/processed sizes
|
||||
await expect(page.getByText(/original/i)).toBeVisible();
|
||||
await expect(page.getByText(/processed/i)).toBeVisible();
|
||||
|
||||
// Should show download button
|
||||
await expect(page.getByRole("link", { name: /download result/i })).toBeVisible();
|
||||
});
|
||||
|
||||
// --- Saved Pipeline Interactions ---
|
||||
|
||||
test("can load a saved pipeline into builder", async ({ loggedInPage: page }) => {
|
||||
await gotoAutomate(page);
|
||||
|
||||
const uniqueName = `Load Pipeline ${Date.now()}`;
|
||||
|
||||
// Build and save a 2-step pipeline
|
||||
await addToolStep(page, "Resize", 1);
|
||||
await addToolStep(page, "Compress", 2);
|
||||
|
||||
await page.getByRole("button", { name: "Save Pipeline" }).click();
|
||||
await page.getByPlaceholder("Pipeline name").fill(uniqueName);
|
||||
await page.getByRole("button", { name: "Save", exact: true }).click();
|
||||
await expect(page.getByText(uniqueName).first()).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
|
||||
// Remove a step so we can tell loading worked
|
||||
await page.getByTitle("Remove").first().click();
|
||||
await waitForSteps(page, 1);
|
||||
|
||||
// Click on the saved pipeline to load it
|
||||
await page.getByRole("button", { name: uniqueName }).first().click();
|
||||
await waitForSteps(page, 2);
|
||||
});
|
||||
|
||||
test("can delete a saved pipeline", async ({ loggedInPage: page }) => {
|
||||
await gotoAutomate(page);
|
||||
|
||||
const uniqueName = `Delete Pipeline ${Date.now()}`;
|
||||
|
||||
// Build and save a pipeline
|
||||
await addToolStep(page, "Resize", 1);
|
||||
|
||||
await page.getByRole("button", { name: "Save Pipeline" }).click();
|
||||
await page.getByPlaceholder("Pipeline name").fill(uniqueName);
|
||||
await page.getByRole("button", { name: "Save", exact: true }).click();
|
||||
await expect(page.getByText(uniqueName).first()).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
|
||||
// Hover to reveal delete, then click
|
||||
const pipelineEntry = page.locator(".group").filter({ hasText: uniqueName }).first();
|
||||
await pipelineEntry.hover();
|
||||
await pipelineEntry
|
||||
.locator("button")
|
||||
.filter({ has: page.locator("svg") })
|
||||
.last()
|
||||
.click();
|
||||
|
||||
await expect(pipelineEntry).not.toBeVisible({ timeout: 5_000 });
|
||||
});
|
||||
|
||||
// --- Sidebar ---
|
||||
|
||||
test("sidebar shows Saved Automations when pipelines exist", async ({ loggedInPage: page }) => {
|
||||
await gotoAutomate(page);
|
||||
await addToolStep(page, "Resize", 1);
|
||||
|
||||
const uniqueName = `Sidebar Pipeline ${Date.now()}`;
|
||||
await page.getByRole("button", { name: "Save Pipeline" }).click();
|
||||
await page.getByPlaceholder("Pipeline name").fill(uniqueName);
|
||||
await page.getByRole("button", { name: "Save", exact: true }).click();
|
||||
|
||||
await expect(page.getByText("Saved Automations")).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
// Should show Original and Processed labels inside the slider
|
||||
await expect(page.getByText("Original").first()).toBeVisible();
|
||||
await expect(page.getByText("Processed").first()).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getSettingsSummary } from "../../apps/web/src/components/tools/pipeline-step-summary";
|
||||
|
||||
describe("getSettingsSummary", () => {
|
||||
it("returns dimensions for resize with width and height", () => {
|
||||
expect(getSettingsSummary("resize", { width: 1920, height: 1080 })).toBe("1920 x 1080");
|
||||
});
|
||||
|
||||
it("returns percentage for resize with scale", () => {
|
||||
expect(getSettingsSummary("resize", { percentage: 50 })).toBe("50%");
|
||||
});
|
||||
|
||||
it("returns width only for resize with just width", () => {
|
||||
expect(getSettingsSummary("resize", { width: 800 })).toBe("800px wide");
|
||||
});
|
||||
|
||||
it("returns quality for compress", () => {
|
||||
expect(getSettingsSummary("compress", { quality: 80 })).toBe("Quality 80");
|
||||
});
|
||||
|
||||
it("returns target size for compress targetSize mode", () => {
|
||||
expect(getSettingsSummary("compress", { mode: "targetSize", targetSizeKb: 200 })).toBe(
|
||||
"Target 200 KB",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns format for convert", () => {
|
||||
expect(getSettingsSummary("convert", { format: "webp" })).toBe("WEBP");
|
||||
});
|
||||
|
||||
it("returns angle for rotate", () => {
|
||||
expect(getSettingsSummary("rotate", { angle: 90 })).toBe("90°");
|
||||
});
|
||||
|
||||
it("returns text preview for watermark-text", () => {
|
||||
expect(getSettingsSummary("watermark-text", { text: "Copyright 2026" })).toBe("Copyright 2026");
|
||||
});
|
||||
|
||||
it("truncates long watermark text", () => {
|
||||
expect(
|
||||
getSettingsSummary("watermark-text", {
|
||||
text: "A very long watermark text that should be truncated",
|
||||
}),
|
||||
).toBe("A very long watermark te...");
|
||||
});
|
||||
|
||||
it("returns dimensions for crop", () => {
|
||||
expect(getSettingsSummary("crop", { width: 800, height: 600 })).toBe("800 x 600");
|
||||
});
|
||||
|
||||
it("returns empty string for unknown tool with empty settings", () => {
|
||||
expect(getSettingsSummary("unknown-tool", {})).toBe("");
|
||||
});
|
||||
|
||||
it("returns empty string for tool with no relevant settings", () => {
|
||||
expect(getSettingsSummary("resize", {})).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { usePipelineStore } from "../../apps/web/src/stores/pipeline-store";
|
||||
|
||||
describe("usePipelineStore", () => {
|
||||
afterEach(() => {
|
||||
usePipelineStore.getState().reset();
|
||||
});
|
||||
|
||||
it("starts with empty steps", () => {
|
||||
expect(usePipelineStore.getState().steps).toEqual([]);
|
||||
});
|
||||
|
||||
it("addStep appends a step with generated id", () => {
|
||||
usePipelineStore.getState().addStep("resize");
|
||||
const steps = usePipelineStore.getState().steps;
|
||||
expect(steps).toHaveLength(1);
|
||||
expect(steps[0].toolId).toBe("resize");
|
||||
expect(steps[0].settings).toEqual({});
|
||||
expect(steps[0].id).toBeTruthy();
|
||||
});
|
||||
|
||||
it("addStep auto-expands the new step", () => {
|
||||
usePipelineStore.getState().addStep("resize");
|
||||
const { steps, expandedStepId } = usePipelineStore.getState();
|
||||
expect(expandedStepId).toBe(steps[0].id);
|
||||
});
|
||||
|
||||
it("removeStep removes by id", () => {
|
||||
usePipelineStore.getState().addStep("resize");
|
||||
usePipelineStore.getState().addStep("compress");
|
||||
const id = usePipelineStore.getState().steps[0].id;
|
||||
usePipelineStore.getState().removeStep(id);
|
||||
expect(usePipelineStore.getState().steps).toHaveLength(1);
|
||||
expect(usePipelineStore.getState().steps[0].toolId).toBe("compress");
|
||||
});
|
||||
|
||||
it("removeStep clears expandedStepId if removing expanded step", () => {
|
||||
usePipelineStore.getState().addStep("resize");
|
||||
const id = usePipelineStore.getState().steps[0].id;
|
||||
expect(usePipelineStore.getState().expandedStepId).toBe(id);
|
||||
usePipelineStore.getState().removeStep(id);
|
||||
expect(usePipelineStore.getState().expandedStepId).toBeNull();
|
||||
});
|
||||
|
||||
it("reorderSteps swaps two step positions", () => {
|
||||
usePipelineStore.getState().addStep("resize");
|
||||
usePipelineStore.getState().addStep("compress");
|
||||
usePipelineStore.getState().addStep("convert");
|
||||
const steps = usePipelineStore.getState().steps;
|
||||
usePipelineStore.getState().reorderSteps(steps[0].id, steps[2].id);
|
||||
const reordered = usePipelineStore.getState().steps;
|
||||
expect(reordered.map((s) => s.toolId)).toEqual(["compress", "convert", "resize"]);
|
||||
});
|
||||
|
||||
it("updateStepSettings merges settings for the target step", () => {
|
||||
usePipelineStore.getState().addStep("resize");
|
||||
const id = usePipelineStore.getState().steps[0].id;
|
||||
usePipelineStore.getState().updateStepSettings(id, { width: 800, height: 600 });
|
||||
expect(usePipelineStore.getState().steps[0].settings).toEqual({ width: 800, height: 600 });
|
||||
});
|
||||
|
||||
it("loadSteps replaces all steps and generates new ids", () => {
|
||||
usePipelineStore.getState().addStep("resize");
|
||||
usePipelineStore.getState().loadSteps([
|
||||
{ toolId: "compress", settings: { quality: 80 } },
|
||||
{ toolId: "convert", settings: { format: "webp" } },
|
||||
]);
|
||||
const steps = usePipelineStore.getState().steps;
|
||||
expect(steps).toHaveLength(2);
|
||||
expect(steps[0].toolId).toBe("compress");
|
||||
expect(steps[0].settings).toEqual({ quality: 80 });
|
||||
expect(steps[1].toolId).toBe("convert");
|
||||
});
|
||||
|
||||
it("reset clears all state", () => {
|
||||
usePipelineStore.getState().addStep("resize");
|
||||
usePipelineStore.getState().reset();
|
||||
expect(usePipelineStore.getState().steps).toEqual([]);
|
||||
expect(usePipelineStore.getState().expandedStepId).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user