From fb33a46a6457a4f5d393a4a7264d9d21a8ed1075 Mon Sep 17 00:00:00 2001 From: stirling-image Date: Mon, 13 Apr 2026 16:26:38 +0800 Subject: [PATCH] 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 --- apps/api/src/routes/pipeline.ts | 271 ++++++++ apps/web/package.json | 3 + .../components/tools/blur-faces-settings.tsx | 12 +- .../src/components/tools/border-settings.tsx | 27 +- .../src/components/tools/color-settings.tsx | 26 +- .../components/tools/compress-settings.tsx | 12 +- .../src/components/tools/convert-settings.tsx | 11 +- .../src/components/tools/crop-settings.tsx | 13 +- .../components/tools/gif-tools-settings.tsx | 14 +- .../src/components/tools/pipeline-builder.tsx | 544 ++++++--------- .../tools/pipeline-step-settings.tsx | 35 +- .../components/tools/pipeline-step-summary.ts | 59 ++ .../tools/replace-color-settings.tsx | 17 +- .../src/components/tools/resize-settings.tsx | 26 +- .../src/components/tools/rotate-settings.tsx | 17 +- .../components/tools/smart-crop-settings.tsx | 23 +- .../tools/strip-metadata-settings.tsx | 13 + .../tools/text-overlay-settings.tsx | 22 +- .../src/components/tools/upscale-settings.tsx | 16 +- .../tools/watermark-text-settings.tsx | 18 +- apps/web/src/hooks/use-pipeline-processor.ts | 338 +++++++++ apps/web/src/pages/automate-page.tsx | 640 +++++++++++++----- apps/web/src/stores/pipeline-store.ts | 80 +++ package.json | 7 +- pnpm-lock.yaml | 66 ++ tests/e2e/automate.spec.ts | 200 +----- tests/unit/pipeline-step-summary.test.ts | 58 ++ tests/unit/pipeline-store.test.ts | 81 +++ 28 files changed, 1935 insertions(+), 714 deletions(-) create mode 100644 apps/web/src/components/tools/pipeline-step-summary.ts create mode 100644 apps/web/src/hooks/use-pipeline-processor.ts create mode 100644 apps/web/src/stores/pipeline-store.ts create mode 100644 tests/unit/pipeline-step-summary.test.ts create mode 100644 tests/unit/pipeline-store.test.ts diff --git a/apps/api/src/routes/pipeline.ts b/apps/api/src/routes/pipeline.ts index 2e7da0e1..173905e3 100644 --- a/apps/api/src/routes/pipeline.ts +++ b/apps/api/src/routes/pipeline.ts @@ -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 { + // ── 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; + 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(); + 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 = {}; + 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"); } diff --git a/apps/web/package.json b/apps/web/package.json index 6cdca2da..bcce48c5 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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", diff --git a/apps/web/src/components/tools/blur-faces-settings.tsx b/apps/web/src/components/tools/blur-faces-settings.tsx index 4465bfb5..5fad33bb 100644 --- a/apps/web/src/components/tools/blur-faces-settings.tsx +++ b/apps/web/src/components/tools/blur-faces-settings.tsx @@ -5,13 +5,23 @@ import { useToolProcessor } from "@/hooks/use-tool-processor"; import { useFileStore } from "@/stores/file-store"; export interface BlurFacesControlsProps { + settings?: Record; onChange?: (settings: Record) => 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; diff --git a/apps/web/src/components/tools/border-settings.tsx b/apps/web/src/components/tools/border-settings.tsx index a709678b..a4cf942f 100644 --- a/apps/web/src/components/tools/border-settings.tsx +++ b/apps/web/src/components/tools/border-settings.tsx @@ -215,11 +215,16 @@ function buildPreviewStyle(s: { // ── Controls ───────────────────────────────────────────────────────── export interface BorderControlsProps { + settings?: Record; onChange?: (settings: Record) => 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(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; diff --git a/apps/web/src/components/tools/color-settings.tsx b/apps/web/src/components/tools/color-settings.tsx index d65f078d..d5219c5f 100644 --- a/apps/web/src/components/tools/color-settings.tsx +++ b/apps/web/src/components/tools/color-settings.tsx @@ -8,11 +8,17 @@ type Effect = "none" | "grayscale" | "sepia" | "invert"; interface ColorControlsProps { toolId: string; + settings?: Record; onChange?: (settings: Record) => 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("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; diff --git a/apps/web/src/components/tools/compress-settings.tsx b/apps/web/src/components/tools/compress-settings.tsx index 983ee2e4..7a9620c7 100644 --- a/apps/web/src/components/tools/compress-settings.tsx +++ b/apps/web/src/components/tools/compress-settings.tsx @@ -7,14 +7,24 @@ import { useFileStore } from "@/stores/file-store"; type CompressMode = "quality" | "targetSize"; export interface CompressControlsProps { + settings?: Record; onChange?: (settings: Record) => void; } -export function CompressControls({ onChange }: CompressControlsProps) { +export function CompressControls({ settings: initialSettings, onChange }: CompressControlsProps) { const [mode, setMode] = useState("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; diff --git a/apps/web/src/components/tools/convert-settings.tsx b/apps/web/src/components/tools/convert-settings.tsx index 16b0b09b..849748ae 100644 --- a/apps/web/src/components/tools/convert-settings.tsx +++ b/apps/web/src/components/tools/convert-settings.tsx @@ -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; onChange?: (settings: Record) => void; } -export function ConvertControls({ onChange }: ConvertControlsProps) { +export function ConvertControls({ settings: initialSettings, onChange }: ConvertControlsProps) { const [format, setFormat] = useState("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); diff --git a/apps/web/src/components/tools/crop-settings.tsx b/apps/web/src/components/tools/crop-settings.tsx index 7e4a5280..8f787e71 100644 --- a/apps/web/src/components/tools/crop-settings.tsx +++ b/apps/web/src/components/tools/crop-settings.tsx @@ -402,15 +402,26 @@ export function CropSettings({ // ── Pipeline-only crop controls (numeric inputs, no canvas) ────────── export interface CropControlsProps { + settings?: Record; onChange?: (settings: Record) => 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; diff --git a/apps/web/src/components/tools/gif-tools-settings.tsx b/apps/web/src/components/tools/gif-tools-settings.tsx index 89656665..7ee0eeab 100644 --- a/apps/web/src/components/tools/gif-tools-settings.tsx +++ b/apps/web/src/components/tools/gif-tools-settings.tsx @@ -20,10 +20,11 @@ const MODES: { id: GifMode; label: string; requiresAnimation: boolean }[] = [ ]; export interface GifToolsControlsProps { + settings?: Record; onChange?: (settings: Record) => 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("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) { diff --git a/apps/web/src/components/tools/pipeline-builder.tsx b/apps/web/src/components/tools/pipeline-builder.tsx index 7a99e86c..71317229 100644 --- a/apps/web/src/components/tools/pipeline-builder.tsx +++ b/apps/web/src/components/tools/pipeline-builder.tsx @@ -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; -} +const iconsMap = icons as unknown as Record>; interface PipelineBuilderProps { steps: PipelineStep[]; - onStepsChange: (action: SetStateAction) => 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) => void; + onToggleStep: (id: string | null) => void; } +/* ------------------------------------------------------------------ */ +/* SortableStep */ +/* ------------------------------------------------------------------ */ + +interface SortableStepProps { + step: PipelineStep; + index: number; + isExpanded: boolean; + onToggle: () => void; + onRemove: () => void; + onUpdateSettings: (settings: Record) => 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 ( +
+ {/* Header row - click to expand/collapse */} + + + {/* Inline settings panel */} +
+ +
+
+ ); +} + +/* ------------------------------------------------------------------ */ +/* 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(null); - const [saveName, setSaveName] = useState(""); - const [saveDescription, setSaveDescription] = useState(""); - const [showSaveForm, setShowSaveForm] = useState(false); - const [file, setFile] = useState(null); + const [toolSearch, setToolSearch] = useState(""); const [disabledTools, setDisabledTools] = useState([]); const [experimentalEnabled, setExperimentalEnabled] = useState(false); const [pipelineToolIds, setPipelineToolIds] = useState(null); - const [toolSearch, setToolSearch] = useState(""); + /* Fetch settings + pipeline-compatible tool IDs on mount */ useEffect(() => { apiGet<{ settings: Record }>("/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) => { - 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>; + function handleAddStep(toolId: string) { + onAddStep(toolId); + setShowToolPicker(false); + setToolSearch(""); + } return ( -
- {/* File Upload Area */} -
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 ? ( -
- -
- {file.name} - - ({(file.size / 1024).toFixed(0)} KB) - +
+ {/* Sortable step list */} + {steps.length === 0 ? ( +
+ Add steps to build your pipeline +
+ ) : ( + + s.id)} strategy={verticalListSortingStrategy}> +
+ {steps.map((step, idx) => ( + onToggleStep(expandedStepId === step.id ? null : step.id)} + onRemove={() => onRemoveStep(step.id)} + onUpdateSettings={(s) => onUpdateSettings(step.id, s)} + /> + ))}
- -
- ) : ( - - )} -
+ + + )} - {/* Pipeline Steps */} -
- {steps.length === 0 ? ( -
- Add steps to build your automation pipeline -
- ) : ( - 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 ( -
-
- {/* Step number */} - - {idx + 1} - - - {/* Tool icon + name */} - - {tool.name} - - {/* Controls */} -
- - - - -
-
- - {/* Settings panel - hidden when collapsed, never unmounted so state persists */} -
-

{tool.description}

- updateStepSettings(step.id, s)} - /> -
-
- ); - }) - )} -
- - {/* Add Step */} + {/* Tool picker */} {showToolPicker ? (
@@ -332,7 +274,7 @@ export function PipelineBuilder({ )} - - {/* Execution error */} - {executionError && ( -
-
- - {executionError} -
-
- )} - - {/* Execution result */} - {executionResult && ( -
-
- - - Pipeline completed ({executionResult.stepsCompleted} steps) - -
-
- Original: {(executionResult.originalSize / 1024).toFixed(0)} KB - Processed: {(executionResult.processedSize / 1024).toFixed(0)} KB -
- - - Download Result - -
- )} - - {/* Action Buttons */} -
- - - {!showSaveForm ? ( - - ) : ( -
- 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" - /> - 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" - /> - - -
- )} -
); } diff --git a/apps/web/src/components/tools/pipeline-step-settings.tsx b/apps/web/src/components/tools/pipeline-step-settings.tsx index e4b369fe..12bb1e71 100644 --- a/apps/web/src/components/tools/pipeline-step-settings.tsx +++ b/apps/web/src/components/tools/pipeline-step-settings.tsx @@ -24,23 +24,28 @@ interface PipelineStepSettingsProps { } export function PipelineStepSettings({ toolId, settings, onChange }: PipelineStepSettingsProps) { - if (toolId === "resize") return ; - if (toolId === "crop") return ; - if (toolId === "rotate") return ; - if (toolId === "convert") return ; - if (toolId === "compress") return ; - if (toolId === "strip-metadata") return ; - if (toolId === "border") return ; - if (toolId === "watermark-text") return ; - if (toolId === "text-overlay") return ; - if (toolId === "replace-color") return ; - if (toolId === "smart-crop") return ; - if (toolId === "gif-tools") return ; - if (toolId === "upscale") return ; - if (toolId === "blur-faces") return ; + if (toolId === "resize") return ; + if (toolId === "crop") return ; + if (toolId === "rotate") return ; + if (toolId === "convert") return ; + if (toolId === "compress") return ; + if (toolId === "strip-metadata") + return ; + if (toolId === "border") return ; + if (toolId === "watermark-text") + return ; + if (toolId === "text-overlay") + return ; + if (toolId === "replace-color") + return ; + if (toolId === "smart-crop") return ; + if (toolId === "gif-tools") return ; + if (toolId === "upscale") return ; + if (toolId === "blur-faces") return ; if (toolId === "remove-background") return ; - if (COLOR_TOOL_IDS.has(toolId)) return ; + if (COLOR_TOOL_IDS.has(toolId)) + return ; return (

diff --git a/apps/web/src/components/tools/pipeline-step-summary.ts b/apps/web/src/components/tools/pipeline-step-summary.ts new file mode 100644 index 00000000..d092f1b8 --- /dev/null +++ b/apps/web/src/components/tools/pipeline-step-summary.ts @@ -0,0 +1,59 @@ +export function getSettingsSummary(toolId: string, settings: Record): 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 ""; + } +} diff --git a/apps/web/src/components/tools/replace-color-settings.tsx b/apps/web/src/components/tools/replace-color-settings.tsx index 6d9ff506..b681c032 100644 --- a/apps/web/src/components/tools/replace-color-settings.tsx +++ b/apps/web/src/components/tools/replace-color-settings.tsx @@ -5,15 +5,30 @@ import { useToolProcessor } from "@/hooks/use-tool-processor"; import { useFileStore } from "@/stores/file-store"; export interface ReplaceColorControlsProps { + settings?: Record; onChange?: (settings: Record) => 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; diff --git a/apps/web/src/components/tools/resize-settings.tsx b/apps/web/src/components/tools/resize-settings.tsx index ae105591..87dcdc51 100644 --- a/apps/web/src/components/tools/resize-settings.tsx +++ b/apps/web/src/components/tools/resize-settings.tsx @@ -29,10 +29,11 @@ function HintIcon({ text }: { text: string }) { } export interface ResizeControlsProps { + settings?: Record; onChange?: (settings: Record) => void; } -export function ResizeControls({ onChange }: ResizeControlsProps) { +export function ResizeControls({ settings: initialSettings, onChange }: ResizeControlsProps) { const [tab, setTab] = useState("custom"); const [selectedPreset, setSelectedPreset] = useState(null); const [width, setWidth] = useState(""); @@ -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; diff --git a/apps/web/src/components/tools/rotate-settings.tsx b/apps/web/src/components/tools/rotate-settings.tsx index 84e0f23f..99a28423 100644 --- a/apps/web/src/components/tools/rotate-settings.tsx +++ b/apps/web/src/components/tools/rotate-settings.tsx @@ -11,12 +11,18 @@ export interface PreviewTransform { } export interface RotateControlsProps { + settings?: Record; onChange?: (settings: Record) => 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); diff --git a/apps/web/src/components/tools/smart-crop-settings.tsx b/apps/web/src/components/tools/smart-crop-settings.tsx index 7983c17c..2dae81d5 100644 --- a/apps/web/src/components/tools/smart-crop-settings.tsx +++ b/apps/web/src/components/tools/smart-crop-settings.tsx @@ -31,10 +31,11 @@ function HintIcon({ text }: { text: string }) { } export interface SmartCropControlsProps { + settings?: Record; onChange?: (settings: Record) => void; } -export function SmartCropControls({ onChange }: SmartCropControlsProps) { +export function SmartCropControls({ settings: initialSettings, onChange }: SmartCropControlsProps) { const [mode, setMode] = useState("subject"); const [subjectTab, setSubjectTab] = useState("custom"); const [selectedPreset, setSelectedPreset] = useState(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; diff --git a/apps/web/src/components/tools/strip-metadata-settings.tsx b/apps/web/src/components/tools/strip-metadata-settings.tsx index 0132bac5..06f47883 100644 --- a/apps/web/src/components/tools/strip-metadata-settings.tsx +++ b/apps/web/src/components/tools/strip-metadata-settings.tsx @@ -62,6 +62,7 @@ interface MetadataResult { } interface StripMetadataControlsProps { + settings?: Record; onChange?: (settings: Record) => 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; diff --git a/apps/web/src/components/tools/text-overlay-settings.tsx b/apps/web/src/components/tools/text-overlay-settings.tsx index a9363ed9..f7cffad5 100644 --- a/apps/web/src/components/tools/text-overlay-settings.tsx +++ b/apps/web/src/components/tools/text-overlay-settings.tsx @@ -5,10 +5,14 @@ import { useToolProcessor } from "@/hooks/use-tool-processor"; import { useFileStore } from "@/stores/file-store"; export interface TextOverlayControlsProps { + settings?: Record; onChange?: (settings: Record) => 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; diff --git a/apps/web/src/components/tools/upscale-settings.tsx b/apps/web/src/components/tools/upscale-settings.tsx index 5ce19cf4..afade430 100644 --- a/apps/web/src/components/tools/upscale-settings.tsx +++ b/apps/web/src/components/tools/upscale-settings.tsx @@ -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; onChange?: (settings: Record) => 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("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; diff --git a/apps/web/src/components/tools/watermark-text-settings.tsx b/apps/web/src/components/tools/watermark-text-settings.tsx index 4aa76a72..c475f5ce 100644 --- a/apps/web/src/components/tools/watermark-text-settings.tsx +++ b/apps/web/src/components/tools/watermark-text-settings.tsx @@ -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; onChange?: (settings: Record) => 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("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; diff --git a/apps/web/src/hooks/use-pipeline-processor.ts b/apps/web/src/hooks/use-pipeline-processor.ts new file mode 100644 index 00000000..7230f317 --- /dev/null +++ b/apps/web/src/hooks/use-pipeline-processor.ts @@ -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(IDLE_PROGRESS); + const elapsedRef = useRef | null>(null); + const xhrRef = useRef(null); + const eventSourceRef = useRef(null); + const processingTimerRef = useRef | 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 = {}; + 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, + }; +} diff --git a/apps/web/src/pages/automate-page.tsx b/apps/web/src/pages/automate-page.tsx index 2abf30e8..37c60917 100644 --- a/apps/web/src/pages/automate-page.tsx +++ b/apps/web/src/pages/automate-page.tsx @@ -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 }>; - createdAt: string; -} export function AutomatePage() { - const [steps, setSteps] = useState([]); - const [savedPipelines, setSavedPipelines] = useState([]); + 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(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 (

- {/* Left sidebar: saved automations */} - {savedPipelines.length > 0 && ( -
+ {/* LEFT PANEL */} +
+ {/* Header */} +
+
+ +
-

- Saved Automations +

Automate

+

Chain tools into a pipeline

+
+
+ + {/* Saved pipelines strip */} + {savedPipelines.length > 0 && ( +
+

+ Saved Pipelines

-
- {savedPipelines.map((pipeline) => ( -
-
+ {showAllSaved ? ( +
+ {savedPipelines.map((p) => ( +
- {pipeline.description && ( -

- {pipeline.description} -

- )} -

- {pipeline.steps.length} step{pipeline.steps.length !== 1 ? "s" : ""} -

-
- ))} -
+ ))} + +
+ ) : ( +
+ {savedPipelines.slice(0, 3).map((p) => ( + + ))} + {savedPipelines.length > 3 && ( + + )} +
+ )}
+ )} + + {/* File info */} +
+

+ Files +

+ {files.length === 0 ? ( +

+ Drop or upload images to get started +

+ ) : ( +
+
+ + {files.length} file{files.length !== 1 ? "s" : ""} + + +
+
+ + {selectedFileName ?? files[0].name} + + {formatFileSize(selectedFileSize ?? files[0].size)} + +
+ +
+ )}
- )} - {/* Main builder area */} -
-
-
-
- -
-
-

Automation Pipeline

-

- Chain multiple tools into a single workflow -

+ {/* Error display */} + {error && ( +
+
+ + {error}
+ )} + {/* Pipeline steps (scrollable) */} +
+

+ Pipeline Steps +

+ + {/* Progress card */} + {processing && ( +
+ 1 + ? `Processing ${files.length} files...` + : "Processing pipeline..." + } + stage={progress.stage} + percent={progress.percent} + elapsed={progress.elapsed} + /> +
+ )} + + {/* Action buttons (sticky bottom) */} +
+ {/* Process button */} + + + {/* Download All ZIP */} + {hasProcessed && batchZipBlob && ( + + )} + + {/* Save pipeline */} + {steps.length > 0 && !showSaveForm && ( + + )} + + {showSaveForm && ( +
+ 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" + /> + 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" + /> +
+ + +
+
+ )} +
+ + {/* RIGHT PANEL */} +
+
+ {/* Nav arrows */} + {hasMultiple && hasPrev && ( + + )} + {hasMultiple && hasNext && ( + + )} + {hasMultiple && ( +
+ {selectedIndex + 1} / {entries.length} +
+ )} + + {/* Image display area */} + {!hasFile && ( + + )} + + {hasFile && !hasProcessed && currentEntry?.status === "failed" && ( +
+

+ {currentEntry.error ?? "Processing failed for this file"} +

+
+ )} + + {hasFile && hasProcessed && originalBlobUrl && ( + + )} + + {hasFile && !hasProcessed && originalBlobUrl && currentEntry?.status !== "failed" && ( + + )} +
+ + {/* Info bar */} + {hasFile && ( +
+ {selectedFileName ?? files[0].name} +
+ {hasProcessed && processedSize != null && ( + + {formatFileSize(originalSize ?? 0)} → {formatFileSize(processedSize)} + + )} + {!hasProcessed && {formatFileSize(selectedFileSize ?? files[0].size)}} +
+
+ )} + + {/* Thumbnail strip for multi-file */} + {hasMultiple && ( + + )} +
); diff --git a/apps/web/src/stores/pipeline-store.ts b/apps/web/src/stores/pipeline-store.ts new file mode 100644 index 00000000..7f9879e8 --- /dev/null +++ b/apps/web/src/stores/pipeline-store.ts @@ -0,0 +1,80 @@ +import { create } from "zustand"; +import { generateId } from "@/lib/utils"; + +export interface PipelineStep { + id: string; + toolId: string; + settings: Record; +} + +export interface SavedPipeline { + id: string; + name: string; + description: string | null; + steps: Array<{ toolId: string; settings: Record }>; + 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) => void; + setExpandedStep: (id: string | null) => void; + loadSteps: (steps: Array<{ toolId: string; settings: Record }>) => void; + setSavedPipelines: (pipelines: SavedPipeline[]) => void; + reset: () => void; +} + +export const usePipelineStore = create((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: [] }), +})); diff --git a/package.json b/package.json index ea9fbf52..56396e34 100644 --- a/package.json +++ b/package.json @@ -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" + } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c8cd9265..92d4b07c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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)': diff --git a/tests/e2e/automate.spec.ts b/tests/e2e/automate.spec.ts index 5c676ae6..9f5ec834 100644 --- a/tests/e2e/automate.spec.ts +++ b/tests/e2e/automate.spec.ts @@ -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(); }); }); diff --git a/tests/unit/pipeline-step-summary.test.ts b/tests/unit/pipeline-step-summary.test.ts new file mode 100644 index 00000000..b8cf53c6 --- /dev/null +++ b/tests/unit/pipeline-step-summary.test.ts @@ -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(""); + }); +}); diff --git a/tests/unit/pipeline-store.test.ts b/tests/unit/pipeline-store.test.ts new file mode 100644 index 00000000..c9938aa9 --- /dev/null +++ b/tests/unit/pipeline-store.test.ts @@ -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(); + }); +});