import { CheckCircle2, ChevronDown, ChevronLeft, ChevronRight, ChevronUp, Download, FolderOpen, Layers, Play, Plus, Save, Trash2, Upload, Workflow, X, } from "lucide-react"; import { useCallback, useEffect, useRef, useState } from "react"; import { useLocation, useNavigate } from "react-router-dom"; import { BeforeAfterSlider } from "@/components/common/before-after-slider"; import { Dropzone } from "@/components/common/dropzone"; import { FileLibraryModal } from "@/components/common/file-library-modal"; 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 } from "@/components/tools/pipeline-builder"; import { ToolPalette } from "@/components/tools/tool-palette"; import { useTranslation } from "@/contexts/i18n-context"; import { useMobile } from "@/hooks/use-mobile"; import { usePipelineProcessor } from "@/hooks/use-pipeline-processor"; import { formatHeaders, getFileDownloadUrl } from "@/lib/api"; import { formatFileSize } from "@/lib/download"; import { format } from "@/lib/format"; import { cn } from "@/lib/utils"; import { useFileStore } from "@/stores/file-store"; import { type SavedPipeline, usePipelineStore } from "@/stores/pipeline-store"; export function AutomatePage() { const { t } = useTranslation(); 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(); const isMobile = useMobile(); const location = useLocation(); const navigate = useNavigate(); const libraryImportHandled = useRef(false); const [libraryModalOpen, setLibraryModalOpen] = useState(false); const [saveName, setSaveName] = useState(""); const [saveDescription, setSaveDescription] = useState(""); const [showSaveForm, setShowSaveForm] = useState(false); const [saving, setSaving] = useState(false); const [showAllSaved, setShowAllSaved] = useState(false); const [previewCollapsed, setPreviewCollapsed] = useState(false); const [mobileToolPaletteOpen, setMobileToolPaletteOpen] = useState(false); const [importError, setImportError] = useState(null); const hasFile = files.length > 0; const hasProcessed = !!processedUrl; const hasMultiple = entries.length > 1; const hasPrev = selectedIndex > 0; const hasNext = selectedIndex < entries.length - 1; useEffect(() => { (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 } })(); }, [setSavedPipelines]); useEffect(() => { const state = location.state as { libraryFileIds?: string[] } | null; if (!state?.libraryFileIds || libraryImportHandled.current) return; const fileIds = state.libraryFileIds; libraryImportHandled.current = true; navigate(location.pathname, { replace: true, state: null }); (async () => { const downloaded = await Promise.all( fileIds.map(async (id) => { try { const res = await fetch(getFileDownloadUrl(id), { headers: formatHeaders() }); if (!res.ok) return null; const blob = await res.blob(); const name = res.headers.get("content-disposition")?.match(/filename="?(.+?)"?$/)?.[1] ?? `file-${id}`; return new File([blob], name, { type: blob.type }); } catch { return null; } }), ); const valid = downloaded.filter((f): f is File => f !== null); if (valid.length > 0) { resetFiles(); setFiles(valid); } })(); }, [location.state, location.pathname, navigate, resetFiles, setFiles]); const handleLibraryImport = useCallback( (imported: File[]) => { if (files.length === 0) { resetFiles(); setFiles(imported); } else { addFiles(imported); } }, [files.length, resetFiles, setFiles, addFiles], ); const handleFiles = useCallback( (newFiles: File[]) => { resetFiles(); setFiles(newFiles); }, [setFiles, resetFiles], ); const handleAddMore = useCallback(() => { const input = document.createElement("input"); input.type = "file"; input.multiple = true; input.accept = "image/*,.avif,.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) { 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(), }); const listRes = await fetch("/api/v1/pipeline/list", { headers: formatHeaders(), }); if (listRes.ok) { const data = await listRes.json(); setSavedPipelines(data.pipelines || []); } } catch { // ignore } }, [setSavedPipelines], ); const handleLoadPipeline = useCallback( (pipeline: SavedPipeline) => { loadSteps(pipeline.steps); }, [loadSteps], ); const handleExportPipeline = useCallback((pipeline: SavedPipeline) => { const exportData = { format: "snapotter-pipeline" as const, version: 1, name: pipeline.name, description: pipeline.description, steps: pipeline.steps, }; const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: "application/json" }); const url = URL.createObjectURL(blob); const slug = pipeline.name .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-|-$/g, ""); const a = document.createElement("a"); a.href = url; a.download = `${slug || "pipeline"}.snapotter.json`; a.click(); URL.revokeObjectURL(url); }, []); const handleImportPipeline = useCallback(async () => { setImportError(null); const input = document.createElement("input"); input.type = "file"; input.accept = ".json"; input.onchange = async (e) => { const file = (e.target as HTMLInputElement).files?.[0]; if (!file) return; try { const text = await file.text(); const data = JSON.parse(text); if (data.format !== "snapotter-pipeline") { setImportError(t.automate.invalidPipelineFile); return; } if (typeof data.version !== "number" || data.version > 1) { setImportError(t.automate.newerVersion); return; } if (!data.name || typeof data.name !== "string") { setImportError(t.automate.missingName); return; } if (!Array.isArray(data.steps) || data.steps.length === 0) { setImportError(t.automate.noSteps); return; } const res = await fetch("/api/v1/pipeline/save", { method: "POST", headers: formatHeaders({ "Content-Type": "application/json" }), body: JSON.stringify({ name: data.name, description: data.description || undefined, steps: data.steps, }), }); if (!res.ok) { const err = await res.json().catch(() => ({ error: "Import failed" })); setImportError(err.error || "Import failed"); return; } const listRes = await fetch("/api/v1/pipeline/list", { headers: formatHeaders(), }); if (listRes.ok) { const listData = await listRes.json(); setSavedPipelines(listData.pipelines || []); } } catch { setImportError(t.automate.couldNotRead); } }; input.click(); }, [setSavedPipelines]); useEffect(() => { if (!importError) return; const timer = setTimeout(() => setImportError(null), 5000); return () => clearTimeout(timer); }, [importError]); 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]); const handleImageKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key === "ArrowLeft") { e.preventDefault(); navigatePrev(); } else if (e.key === "ArrowRight") { e.preventDefault(); navigateNext(); } }, [navigateNext, navigatePrev], ); const handleAddStep = useCallback( (toolId: string) => { addStep(toolId); if (isMobile) setMobileToolPaletteOpen(false); }, [addStep, isMobile], ); /* ------------------------------------------------------------------ */ /* Mobile Layout */ /* ------------------------------------------------------------------ */ if (isMobile) { return (
{/* Mobile header */}

{t.automate.title}

{hasFile && ( {files.length} file{files.length !== 1 ? "s" : ""} )}
{/* Mobile pipeline steps */}
{!hasFile && (
)} {hasFile && (
{selectedFileName ?? files[0].name}
)} {/* Mobile image preview / result */} {hasFile && hasProcessed && originalBlobUrl && (
{processedSize != null && (
{selectedFileName ?? files[0].name} {formatFileSize(originalSize ?? 0)} → {formatFileSize(processedSize)}
)}
)} {hasFile && !hasProcessed && originalBlobUrl && currentEntry?.status !== "failed" && (
)} {hasFile && !hasProcessed && currentEntry?.status === "failed" && (

{currentEntry.error ?? t.toolPage.processingFailed}

)} {hasMultiple && (
)} {error && (
{error}
)} {processing && (
1 ? format(t.automate.processingMultiple, { count: files.length }) : t.automate.processingPipeline } stage={progress.stage} percent={progress.percent} elapsed={progress.elapsed} />
)}
{/* Mobile action bar */}
{hasProcessed && batchZipBlob && ( )}
{/* Mobile FAB for tool palette */} {/* Mobile tool palette bottom sheet */} {mobileToolPaletteOpen && ( <> ); } /* ------------------------------------------------------------------ */ /* Desktop Layout */ /* ------------------------------------------------------------------ */ return (
{/* LEFT PANE — Tool Palette */}
{/* Palette header */}

{t.automate.toolPalette}

{t.automate.clickToAdd}

{/* Tool catalog */} {/* Saved pipelines */}

{t.automate.savedLabel}

{importError && (
{importError}
)} {savedPipelines.length === 0 ? (

{t.automate.noSavedPipelines}

) : showAllSaved ? (
{savedPipelines.map((p) => (
))}
) : (
{savedPipelines.slice(0, 3).map((p) => (
))} {savedPipelines.length > 3 && ( )}
)}
{/* RIGHT PANE — Pipeline Canvas + Preview */}
{/* Canvas header */}

{t.automate.pipelineBuilder}

{steps.length === 0 ? t.automate.addToolsPrompt : `${steps.length} ${steps.length !== 1 ? "steps" : "step"} configured`}

{/* File badge */} {hasFile ? (
{files.length > 1 ? `${files.length} files` : (selectedFileName ?? files[0].name)} {formatFileSize(selectedFileSize ?? files[0].size)}
) : (
{t.automate.noFilesLoaded}
)}
{/* Canvas body */}
{/* Pipeline steps area */}
{error && (
{error}
)} {processing && (
1 ? `Processing ${files.length} files...` : "Processing pipeline..." } stage={progress.stage} percent={progress.percent} elapsed={progress.elapsed} />
)}
{/* Action bar */}
{hasProcessed && batchZipBlob && ( )} {steps.length > 0 && !showSaveForm && ( )} {showSaveForm && (
setSaveName(e.target.value)} placeholder={t.automate.pipelineName} className="text-sm px-2.5 py-1.5 rounded border border-border bg-background text-foreground placeholder:text-muted-foreground w-36" /> setSaveDescription(e.target.value)} placeholder={t.automate.descriptionPlaceholder} className="text-sm px-2.5 py-1.5 rounded border border-border bg-background text-foreground placeholder:text-muted-foreground w-36" />
)}
{/* Preview panel (collapsible) */}
{/* Preview header with toggle */} {/* Preview content */} {!previewCollapsed && (
{hasMultiple && hasPrev && ( )} {hasMultiple && hasNext && ( )} {hasMultiple && (
{selectedIndex + 1} / {entries.length}
)} {!hasFile && (
)} {hasFile && !hasProcessed && currentEntry?.status === "failed" && (

{currentEntry.error ?? t.toolPage.processingFailed}

)} {hasFile && hasProcessed && originalBlobUrl && ( )} {hasFile && !hasProcessed && originalBlobUrl && currentEntry?.status !== "failed" && ( )}
{hasMultiple && ( )}
)}
setLibraryModalOpen(false)} onImport={handleLibraryImport} />
); }