diff --git a/apps/web/src/components/tools/collage-preview.tsx b/apps/web/src/components/tools/collage-preview.tsx index 084151eb..e130f522 100644 --- a/apps/web/src/components/tools/collage-preview.tsx +++ b/apps/web/src/components/tools/collage-preview.tsx @@ -344,7 +344,15 @@ function CollageCell({ }, [isSelected, image]); const bindDrag = useDrag( - ({ movement: [mx, my], first, memo }) => { + ({ + movement: [mx, my], + first, + memo, + }: { + movement: [number, number]; + first: boolean; + memo?: { panX: number; panY: number }; + }) => { if (!image || !isSelected) return memo; if (first) memo = { panX: transform.panX, panY: transform.panY }; const rect = cellRef.current?.getBoundingClientRect(); @@ -358,7 +366,7 @@ function CollageCell({ ); const bindPinch = usePinch( - ({ offset: [scale] }) => { + ({ offset: [scale] }: { offset: [number, number] }) => { if (!image || !isSelected) return; const zoom = Math.max(1, Math.min(10, scale)); store.setCellTransform(cellIndex, { zoom }); diff --git a/apps/web/src/components/tools/pipeline-builder.tsx b/apps/web/src/components/tools/pipeline-builder.tsx index 0fd1ab6f..133af17c 100644 --- a/apps/web/src/components/tools/pipeline-builder.tsx +++ b/apps/web/src/components/tools/pipeline-builder.tsx @@ -15,25 +15,16 @@ import { verticalListSortingStrategy, } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; -import { FileImage, 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 { FileImage, GripVertical, X } from "lucide-react"; import { ICON_MAP } from "@/lib/icon-map"; 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", "compare", "find-duplicates", "collage", "compose"].includes(t.id), -); - interface PipelineBuilderProps { steps: PipelineStep[]; expandedStepId: string | null; - onAddStep: (toolId: string) => void; onRemoveStep: (id: string) => void; onReorderSteps: (activeId: string, overId: string) => void; onUpdateSettings: (id: string, settings: Record) => void; @@ -87,54 +78,59 @@ function SortableStep({ )} > {/* Header row - click to expand/collapse */} - - + {/* Drag handle */} + { + // biome-ignore lint/a11y/noStaticElementInteractions: dnd-kit drag handle spreads its own event handlers + e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + > + + + } + + {/* Step number badge */} + + {index + 1} + + + {/* Tool icon + name */} + + {tool.name} + + {/* Settings summary when collapsed */} + {!isExpanded && summary && ( + {summary} + )} + + + + {/* Remove button */} + + + }
- ; ); } @@ -154,48 +149,11 @@ function SortableStep({ export function PipelineBuilder({ steps, expandedStepId, - onAddStep, onRemoveStep, onReorderSteps, onUpdateSettings, onToggleStep, }: PipelineBuilderProps) { - const [showToolPicker, setShowToolPicker] = useState(false); - const [toolSearch, setToolSearch] = useState(""); - const [disabledTools, setDisabledTools] = useState([]); - const [experimentalEnabled, setExperimentalEnabled] = useState(false); - const [pipelineToolIds, setPipelineToolIds] = useState(null); - - /* Fetch settings + pipeline-compatible tool IDs on mount */ - useEffect(() => { - apiGet<{ settings: Record }>("/v1/settings") - .then((data) => { - setDisabledTools( - data.settings.disabledTools ? JSON.parse(data.settings.disabledTools) : [], - ); - setExperimentalEnabled(data.settings.enableExperimentalTools === "true"); - }) - .catch(() => {}); - - apiGet<{ toolIds: string[] }>("/v1/pipeline/tools") - .then((data) => setPipelineToolIds(data.toolIds)) - .catch(() => {}); - }, []); - - const PIPELINE_TOOLS = useMemo(() => { - const q = toolSearch.toLowerCase(); - return PIPELINE_TOOLS_BASE.filter((t) => { - if (disabledTools.includes(t.id)) return false; - if (t.experimental && !experimentalEnabled) return false; - if (pipelineToolIds && !pipelineToolIds.includes(t.id)) return false; - if (q && !t.name.toLowerCase().includes(q) && !t.description.toLowerCase().includes(q)) { - return false; - } - return true; - }); - }, [disabledTools, experimentalEnabled, pipelineToolIds, toolSearch]); - - /* dnd-kit sensors */ const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), @@ -208,92 +166,37 @@ export function PipelineBuilder({ } } - function handleAddStep(toolId: string) { - onAddStep(toolId); - setShowToolPicker(false); - setToolSearch(""); + if (steps.length === 0) { + return ( +
+
+ +
+

No steps yet

+

+ Click tools from the palette to build your pipeline +

+
+ ); } return ( -
- {/* 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)} + /> + ))}
- ) : ( - - 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)} - /> - ))} -
-
-
- )} - - {/* Tool picker */} - {showToolPicker ? ( -
-
- Add a step - -
- - {PIPELINE_TOOLS.length === 0 ? ( -

No tools found

- ) : ( - PIPELINE_TOOLS.map((tool) => { - const Icon = - (ICON_MAP[tool.icon] as React.ComponentType<{ className?: string }>) ?? FileImage; - return ( - - ); - }) - )} -
- ) : ( - - )} -
+ + ); } diff --git a/apps/web/src/components/tools/tool-palette.tsx b/apps/web/src/components/tools/tool-palette.tsx new file mode 100644 index 00000000..2668d984 --- /dev/null +++ b/apps/web/src/components/tools/tool-palette.tsx @@ -0,0 +1,134 @@ +import { CATEGORIES, TOOLS } from "@ashim/shared"; +import { FileImage, Plus } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; +import { SearchBar } from "@/components/common/search-bar"; +import { apiGet } from "@/lib/api"; +import { ICON_MAP } from "@/lib/icon-map"; +import { cn } from "@/lib/utils"; + +const EXCLUDED_TOOLS = new Set(["pipeline", "compare", "find-duplicates", "collage", "compose"]); + +interface ToolPaletteProps { + onAddStep: (toolId: string) => void; + className?: string; +} + +export function ToolPalette({ onAddStep, className }: ToolPaletteProps) { + const [search, setSearch] = useState(""); + const [disabledTools, setDisabledTools] = useState([]); + const [experimentalEnabled, setExperimentalEnabled] = useState(false); + const [pipelineToolIds, setPipelineToolIds] = useState(null); + + useEffect(() => { + apiGet<{ settings: Record }>("/v1/settings") + .then((data) => { + setDisabledTools( + data.settings.disabledTools ? JSON.parse(data.settings.disabledTools) : [], + ); + setExperimentalEnabled(data.settings.enableExperimentalTools === "true"); + }) + .catch(() => {}); + + apiGet<{ toolIds: string[] }>("/v1/pipeline/tools") + .then((data) => setPipelineToolIds(data.toolIds)) + .catch(() => {}); + }, []); + + const availableTools = useMemo(() => { + const q = search.toLowerCase(); + return TOOLS.filter((t) => { + if (EXCLUDED_TOOLS.has(t.id)) return false; + if (disabledTools.includes(t.id)) return false; + if (t.experimental && !experimentalEnabled) return false; + if (pipelineToolIds && !pipelineToolIds.includes(t.id)) return false; + if (q && !t.name.toLowerCase().includes(q) && !t.description.toLowerCase().includes(q)) { + return false; + } + return true; + }); + }, [disabledTools, experimentalEnabled, pipelineToolIds, search]); + + const groupedTools = useMemo(() => { + const groups: Record = {}; + for (const tool of availableTools) { + const cat = tool.category || "other"; + if (!groups[cat]) groups[cat] = []; + groups[cat].push(tool); + } + return groups; + }, [availableTools]); + + const isSearching = search.length > 0; + + return ( +
+
+ +
+ +
+ {availableTools.length === 0 ? ( +

No tools found

+ ) : isSearching ? ( +
+ {availableTools.map((tool) => ( + + ))} +
+ ) : ( +
+ {CATEGORIES.map((cat) => { + const tools = groupedTools[cat.id]; + if (!tools || tools.length === 0) return null; + const CatIcon = + (ICON_MAP[cat.icon] as React.ComponentType<{ className?: string }>) ?? FileImage; + return ( +
+
+ + + {cat.name} + +
+
+ {tools.map((tool) => ( + + ))} +
+
+ ); + })} +
+ )} +
+
+ ); +} + +interface ToolItemProps { + tool: { id: string; name: string; description: string; icon: string }; + onAdd: (toolId: string) => void; +} + +function ToolItem({ tool, onAdd }: ToolItemProps) { + const Icon = (ICON_MAP[tool.icon] as React.ComponentType<{ className?: string }>) ?? FileImage; + + return ( + + ); +} diff --git a/apps/web/src/pages/automate-page.tsx b/apps/web/src/pages/automate-page.tsx index 37c60917..98227ed3 100644 --- a/apps/web/src/pages/automate-page.tsx +++ b/apps/web/src/pages/automate-page.tsx @@ -1,9 +1,13 @@ import { CheckCircle2, + ChevronDown, ChevronLeft, ChevronRight, + ChevronUp, Download, + Layers, Play, + Plus, Save, Trash2, Workflow, @@ -17,9 +21,12 @@ 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 { useMobile } from "@/hooks/use-mobile"; import { usePipelineProcessor } from "@/hooks/use-pipeline-processor"; import { formatHeaders } from "@/lib/api"; import { formatFileSize } from "@/lib/download"; +import { cn } from "@/lib/utils"; import { useFileStore } from "@/stores/file-store"; import { type SavedPipeline, usePipelineStore } from "@/stores/pipeline-store"; @@ -59,13 +66,15 @@ export function AutomatePage() { } = usePipelineStore(); const { processSingle, processAll, processing, error, progress } = usePipelineProcessor(); + const isMobile = useMobile(); - // Local state 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 hasFile = files.length > 0; const hasProcessed = !!processedUrl; @@ -73,7 +82,6 @@ export function AutomatePage() { const hasPrev = selectedIndex > 0; const hasNext = selectedIndex < entries.length - 1; - // Load saved pipelines on mount useEffect(() => { (async () => { try { @@ -133,7 +141,6 @@ export function AutomatePage() { }), }); if (res.ok) { - // Refresh saved pipelines const listRes = await fetch("/api/v1/pipeline/list", { headers: formatHeaders(), }); @@ -203,32 +210,238 @@ export function AutomatePage() { [navigateNext, navigatePrev], ); + const handleAddStep = useCallback( + (toolId: string) => { + addStep(toolId); + if (isMobile) setMobileToolPaletteOpen(false); + }, + [addStep, isMobile], + ); + + /* ------------------------------------------------------------------ */ + /* Mobile Layout */ + /* ------------------------------------------------------------------ */ + if (isMobile) { + return ( + +
+ {/* Mobile header */} +
+ +

Automate

+ {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 ?? "Processing failed for this file"} +

+
+ )} + + {hasMultiple && ( +
+ +
+ )} + + + + {error && ( +
+ + {error} +
+ )} + + {processing && ( +
+ 1 + ? `Processing ${files.length} files...` + : "Processing pipeline..." + } + 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 PANEL */} -
- {/* Header */} + {/* LEFT PANE — Tool Palette */} +
+ {/* Palette header */}
- +
-

Automate

-

Chain tools into a pipeline

+

Tool Palette

+

Click to add to pipeline

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

- Saved Pipelines +
+

+ Saved

{showAllSaved ? ( -
+
{savedPipelines.map((p) => ( -
+
) : ( -
+
{savedPipelines.slice(0, 3).map((p) => ( @@ -282,153 +495,153 @@ export function AutomatePage() { )}
)} +
- {/* File info */} -
-

- Files -

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

- Drop or upload images to get started + {/* RIGHT PANE — Pipeline Canvas + Preview */} +

+ {/* Canvas header */} +
+ +
+

Pipeline Builder

+

+ {steps.length === 0 + ? "Add tools from the palette to get started" + : `${steps.length} step${steps.length !== 1 ? "s" : ""} configured`}

- ) : ( -
-
- - {files.length} file{files.length !== 1 ? "s" : ""} +
+ + {/* File badge */} + {hasFile ? ( +
+
+ + + {files.length > 1 + ? `${files.length} files` + : (selectedFileName ?? files[0].name)} - -
-
- - {selectedFileName ?? files[0].name} - + {formatFileSize(selectedFileSize ?? files[0].size)}
+
+ ) : ( + No files loaded )}
- {/* Error display */} - {error && ( -
-
- - {error} -
-
- )} + {/* Canvas body */} +
+ {/* Pipeline steps area */} +
+ {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} + + + {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 && ( + {/* Action bar */} +
- )} - {/* Save pipeline */} - {steps.length > 0 && !showSaveForm && ( - - )} + {hasProcessed && batchZipBlob && ( + + )} - {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" - /> -
+ + + {steps.length > 0 && !showSaveForm && ( + + )} + + {showSaveForm && ( +
+ setSaveName(e.target.value)} + placeholder="Pipeline name" + 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="Description" + className="text-sm px-2.5 py-1.5 rounded border border-border bg-background text-foreground placeholder:text-muted-foreground w-36" + />
-
- )} -
-
+ )} +
- {/* RIGHT PANEL */} -
-
- {/* Nav arrows */} - {hasMultiple && hasPrev && ( + {/* Preview panel (collapsible) */} +
+ {/* Preview header with toggle */} - )} - {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 && ( - + {previewCollapsed ? ( + + ) : ( + + )} + + Preview + + {hasFile && hasProcessed && processedSize != null && ( + {formatFileSize(originalSize ?? 0)} → {formatFileSize(processedSize)} )} - {!hasProcessed && {formatFileSize(selectedFileSize ?? files[0].size)}} -
-
- )} + - {/* Thumbnail strip for multi-file */} - {hasMultiple && ( - - )} -
+ {/* Preview content */} + {!previewCollapsed && ( +
+
+ {hasMultiple && hasPrev && ( + + )} + {hasMultiple && hasNext && ( + + )} + {hasMultiple && ( +
+ {selectedIndex + 1} / {entries.length} +
+ )} + + {!hasFile && ( + + )} + + {hasFile && !hasProcessed && currentEntry?.status === "failed" && ( +
+

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

+
+ )} + + {hasFile && hasProcessed && originalBlobUrl && ( + + )} + + {hasFile && + !hasProcessed && + originalBlobUrl && + currentEntry?.status !== "failed" && ( + + )} +
+ + {hasMultiple && ( + + )} +
+ )} +
+
+
);