Merge pull request #81 from ashim-hq/worktree-automate-ui-redesign

feat: redesign Automate page — tool palette + pipeline canvas
This commit is contained in:
Ashim
2026-04-21 09:27:47 +08:00
committed by GitHub
4 changed files with 681 additions and 402 deletions
@@ -344,7 +344,15 @@ function CollageCell({
}, [isSelected, image]); }, [isSelected, image]);
const bindDrag = useDrag( 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 (!image || !isSelected) return memo;
if (first) memo = { panX: transform.panX, panY: transform.panY }; if (first) memo = { panX: transform.panX, panY: transform.panY };
const rect = cellRef.current?.getBoundingClientRect(); const rect = cellRef.current?.getBoundingClientRect();
@@ -358,7 +366,7 @@ function CollageCell({
); );
const bindPinch = usePinch( const bindPinch = usePinch(
({ offset: [scale] }) => { ({ offset: [scale] }: { offset: [number, number] }) => {
if (!image || !isSelected) return; if (!image || !isSelected) return;
const zoom = Math.max(1, Math.min(10, scale)); const zoom = Math.max(1, Math.min(10, scale));
store.setCellTransform(cellIndex, { zoom }); store.setCellTransform(cellIndex, { zoom });
@@ -15,25 +15,16 @@ import {
verticalListSortingStrategy, verticalListSortingStrategy,
} from "@dnd-kit/sortable"; } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities"; import { CSS } from "@dnd-kit/utilities";
import { FileImage, GripVertical, Plus, X } from "lucide-react"; import { FileImage, GripVertical, X } 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 { ICON_MAP } from "@/lib/icon-map";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { PipelineStep } from "@/stores/pipeline-store"; import type { PipelineStep } from "@/stores/pipeline-store";
import { PipelineStepSettings } from "./pipeline-step-settings"; import { PipelineStepSettings } from "./pipeline-step-settings";
import { getSettingsSummary } from "./pipeline-step-summary"; 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 { interface PipelineBuilderProps {
steps: PipelineStep[]; steps: PipelineStep[];
expandedStepId: string | null; expandedStepId: string | null;
onAddStep: (toolId: string) => void;
onRemoveStep: (id: string) => void; onRemoveStep: (id: string) => void;
onReorderSteps: (activeId: string, overId: string) => void; onReorderSteps: (activeId: string, overId: string) => void;
onUpdateSettings: (id: string, settings: Record<string, unknown>) => void; onUpdateSettings: (id: string, settings: Record<string, unknown>) => void;
@@ -87,10 +78,14 @@ function SortableStep({
)} )}
> >
{/* Header row - click to expand/collapse */} {/* Header row - click to expand/collapse */}
<button {
type="button" // biome-ignore lint/a11y/useKeyWithClickEvents: keyboard handled by dnd-kit attributes
// biome-ignore lint/a11y/useSemanticElements: div with role=button avoids invalid nested <button> in HTML
<div
role="button"
tabIndex={0}
onClick={onToggle} onClick={onToggle}
className="flex items-center gap-2 p-3 w-full text-left" className="flex items-center gap-2 p-3 w-full text-left cursor-pointer"
> >
{/* Drag handle */} {/* Drag handle */}
{ {
@@ -134,7 +129,8 @@ function SortableStep({
> >
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</button> </button>
</button> </div>
}
<div className={isExpanded ? "border-t border-border p-3 bg-muted/10 space-y-3" : "hidden"}> <div className={isExpanded ? "border-t border-border p-3 bg-muted/10 space-y-3" : "hidden"}>
<PipelineStepSettings <PipelineStepSettings
toolId={step.toolId} toolId={step.toolId}
@@ -142,7 +138,6 @@ function SortableStep({
onChange={onUpdateSettings} onChange={onUpdateSettings}
/> />
</div> </div>
;
</div> </div>
); );
} }
@@ -154,48 +149,11 @@ function SortableStep({
export function PipelineBuilder({ export function PipelineBuilder({
steps, steps,
expandedStepId, expandedStepId,
onAddStep,
onRemoveStep, onRemoveStep,
onReorderSteps, onReorderSteps,
onUpdateSettings, onUpdateSettings,
onToggleStep, onToggleStep,
}: PipelineBuilderProps) { }: PipelineBuilderProps) {
const [showToolPicker, setShowToolPicker] = useState(false);
const [toolSearch, setToolSearch] = useState("");
const [disabledTools, setDisabledTools] = useState<string[]>([]);
const [experimentalEnabled, setExperimentalEnabled] = useState(false);
const [pipelineToolIds, setPipelineToolIds] = useState<string[] | null>(null);
/* Fetch settings + pipeline-compatible tool IDs on mount */
useEffect(() => {
apiGet<{ settings: Record<string, string> }>("/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( const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
@@ -208,20 +166,21 @@ export function PipelineBuilder({
} }
} }
function handleAddStep(toolId: string) { if (steps.length === 0) {
onAddStep(toolId); return (
setShowToolPicker(false); <div className="flex flex-col items-center justify-center py-16 text-center">
setToolSearch(""); <div className="p-4 rounded-full bg-muted/50 mb-4">
<FileImage className="h-8 w-8 text-muted-foreground" />
</div>
<h3 className="text-sm font-medium text-foreground mb-1">No steps yet</h3>
<p className="text-sm text-muted-foreground max-w-[240px]">
Click tools from the palette to build your pipeline
</p>
</div>
);
} }
return ( return (
<div className="space-y-2">
{/* Sortable step list */}
{steps.length === 0 ? (
<div className="text-center py-8 text-muted-foreground text-sm">
Add steps to build your pipeline
</div>
) : (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}> <DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={steps.map((s) => s.id)} strategy={verticalListSortingStrategy}> <SortableContext items={steps.map((s) => s.id)} strategy={verticalListSortingStrategy}>
<div className="space-y-2"> <div className="space-y-2">
@@ -239,61 +198,5 @@ export function PipelineBuilder({
</div> </div>
</SortableContext> </SortableContext>
</DndContext> </DndContext>
)}
{/* Tool picker */}
{showToolPicker ? (
<div className="rounded-lg border border-border bg-background p-3 space-y-2 max-h-80 overflow-y-auto">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-foreground">Add a step</span>
<button
type="button"
onClick={() => {
setShowToolPicker(false);
setToolSearch("");
}}
className="p-1 rounded hover:bg-muted text-muted-foreground"
>
<X className="h-4 w-4" />
</button>
</div>
<SearchBar value={toolSearch} onChange={setToolSearch} placeholder="Search tools..." />
{PIPELINE_TOOLS.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4">No tools found</p>
) : (
PIPELINE_TOOLS.map((tool) => {
const Icon =
(ICON_MAP[tool.icon] as React.ComponentType<{ className?: string }>) ?? FileImage;
return (
<button
key={tool.id}
type="button"
onClick={() => handleAddStep(tool.id)}
className="flex items-center gap-2 w-full px-3 py-2 rounded-lg hover:bg-muted text-sm text-left transition-colors"
>
<Icon className="h-4 w-4 text-muted-foreground shrink-0" />
<div className="flex-1 min-w-0">
<div className="font-medium text-foreground">{tool.name}</div>
<div className="text-xs text-muted-foreground truncate">{tool.description}</div>
</div>
</button>
);
})
)}
</div>
) : (
<button
type="button"
onClick={() => {
setShowToolPicker(true);
setToolSearch("");
}}
className="flex items-center gap-2 w-full justify-center px-4 py-2.5 rounded-lg border border-dashed border-border text-sm text-muted-foreground hover:border-primary hover:text-primary transition-colors"
>
<Plus className="h-4 w-4" />
Add Step
</button>
)}
</div>
); );
} }
@@ -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<string[]>([]);
const [experimentalEnabled, setExperimentalEnabled] = useState(false);
const [pipelineToolIds, setPipelineToolIds] = useState<string[] | null>(null);
useEffect(() => {
apiGet<{ settings: Record<string, string> }>("/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<string, typeof availableTools> = {};
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 (
<div className={cn("flex flex-col h-full", className)}>
<div className="px-3 pt-3 pb-2 shrink-0">
<SearchBar value={search} onChange={setSearch} placeholder="Search tools..." />
</div>
<div className="flex-1 overflow-y-auto px-3 pb-3">
{availableTools.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8">No tools found</p>
) : isSearching ? (
<div className="space-y-1">
{availableTools.map((tool) => (
<ToolItem key={tool.id} tool={tool} onAdd={onAddStep} />
))}
</div>
) : (
<div className="space-y-4">
{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 (
<div key={cat.id}>
<div className="flex items-center gap-1.5 mb-1.5 px-1">
<CatIcon className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-xs font-semibold uppercase text-muted-foreground tracking-wider">
{cat.name}
</span>
</div>
<div className="space-y-0.5">
{tools.map((tool) => (
<ToolItem key={tool.id} tool={tool} onAdd={onAddStep} />
))}
</div>
</div>
);
})}
</div>
)}
</div>
</div>
);
}
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 (
<button
type="button"
onClick={() => onAdd(tool.id)}
className="flex items-center gap-2.5 w-full px-2.5 py-2 rounded-lg hover:bg-muted text-left transition-colors group"
>
<div className="p-1.5 rounded-md bg-muted group-hover:bg-primary/10 transition-colors shrink-0">
<Icon className="h-3.5 w-3.5 text-muted-foreground group-hover:text-primary transition-colors" />
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-foreground leading-tight">{tool.name}</div>
<div className="text-[11px] text-muted-foreground truncate leading-tight">
{tool.description}
</div>
</div>
<Plus className="h-3.5 w-3.5 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity shrink-0" />
</button>
);
}
+338 -104
View File
@@ -1,9 +1,13 @@
import { import {
CheckCircle2, CheckCircle2,
ChevronDown,
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
ChevronUp,
Download, Download,
Layers,
Play, Play,
Plus,
Save, Save,
Trash2, Trash2,
Workflow, Workflow,
@@ -17,9 +21,12 @@ import { ProgressCard } from "@/components/common/progress-card";
import { ThumbnailStrip } from "@/components/common/thumbnail-strip"; import { ThumbnailStrip } from "@/components/common/thumbnail-strip";
import { AppLayout } from "@/components/layout/app-layout"; import { AppLayout } from "@/components/layout/app-layout";
import { PipelineBuilder } from "@/components/tools/pipeline-builder"; 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 { usePipelineProcessor } from "@/hooks/use-pipeline-processor";
import { formatHeaders } from "@/lib/api"; import { formatHeaders } from "@/lib/api";
import { formatFileSize } from "@/lib/download"; import { formatFileSize } from "@/lib/download";
import { cn } from "@/lib/utils";
import { useFileStore } from "@/stores/file-store"; import { useFileStore } from "@/stores/file-store";
import { type SavedPipeline, usePipelineStore } from "@/stores/pipeline-store"; import { type SavedPipeline, usePipelineStore } from "@/stores/pipeline-store";
@@ -59,13 +66,15 @@ export function AutomatePage() {
} = usePipelineStore(); } = usePipelineStore();
const { processSingle, processAll, processing, error, progress } = usePipelineProcessor(); const { processSingle, processAll, processing, error, progress } = usePipelineProcessor();
const isMobile = useMobile();
// Local state
const [saveName, setSaveName] = useState(""); const [saveName, setSaveName] = useState("");
const [saveDescription, setSaveDescription] = useState(""); const [saveDescription, setSaveDescription] = useState("");
const [showSaveForm, setShowSaveForm] = useState(false); const [showSaveForm, setShowSaveForm] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [showAllSaved, setShowAllSaved] = useState(false); const [showAllSaved, setShowAllSaved] = useState(false);
const [previewCollapsed, setPreviewCollapsed] = useState(false);
const [mobileToolPaletteOpen, setMobileToolPaletteOpen] = useState(false);
const hasFile = files.length > 0; const hasFile = files.length > 0;
const hasProcessed = !!processedUrl; const hasProcessed = !!processedUrl;
@@ -73,7 +82,6 @@ export function AutomatePage() {
const hasPrev = selectedIndex > 0; const hasPrev = selectedIndex > 0;
const hasNext = selectedIndex < entries.length - 1; const hasNext = selectedIndex < entries.length - 1;
// Load saved pipelines on mount
useEffect(() => { useEffect(() => {
(async () => { (async () => {
try { try {
@@ -133,7 +141,6 @@ export function AutomatePage() {
}), }),
}); });
if (res.ok) { if (res.ok) {
// Refresh saved pipelines
const listRes = await fetch("/api/v1/pipeline/list", { const listRes = await fetch("/api/v1/pipeline/list", {
headers: formatHeaders(), headers: formatHeaders(),
}); });
@@ -203,32 +210,238 @@ export function AutomatePage() {
[navigateNext, navigatePrev], [navigateNext, navigatePrev],
); );
const handleAddStep = useCallback(
(toolId: string) => {
addStep(toolId);
if (isMobile) setMobileToolPaletteOpen(false);
},
[addStep, isMobile],
);
/* ------------------------------------------------------------------ */
/* Mobile Layout */
/* ------------------------------------------------------------------ */
if (isMobile) {
return (
<AppLayout showToolPanel={false}>
<div className="flex flex-col h-full w-full overflow-hidden">
{/* Mobile header */}
<div className="flex items-center gap-2 px-4 py-3 border-b border-border shrink-0">
<Workflow className="h-5 w-5 text-primary" />
<h1 className="text-base font-semibold text-foreground flex-1">Automate</h1>
{hasFile && (
<span className="text-xs text-muted-foreground bg-muted px-2 py-0.5 rounded-full">
{files.length} file{files.length !== 1 ? "s" : ""}
</span>
)}
</div>
{/* Mobile pipeline steps */}
<div className="flex-1 overflow-y-auto px-4 py-3">
{!hasFile && (
<div className="mb-4">
<Dropzone onFiles={handleFiles} accept="image/*" multiple currentFiles={files} />
</div>
)}
{hasFile && (
<div className="flex items-center gap-2 mb-3 p-2 rounded-lg bg-muted/50">
<CheckCircle2 className="h-4 w-4 text-green-500 shrink-0" />
<span className="text-sm text-foreground truncate flex-1">
{selectedFileName ?? files[0].name}
</span>
<button
type="button"
onClick={handleAddMore}
className="text-xs text-primary hover:text-primary/80"
>
+ Add
</button>
<button
type="button"
onClick={() => resetFiles()}
className="text-xs text-muted-foreground hover:text-foreground"
>
Clear
</button>
</div>
)}
{/* Mobile image preview / result */}
{hasFile && hasProcessed && originalBlobUrl && (
<div className="mb-3 rounded-lg border border-border overflow-hidden">
<div className="relative h-48">
<BeforeAfterSlider
beforeSrc={originalBlobUrl}
afterSrc={processedUrl as string}
beforeSize={originalSize ?? undefined}
afterSize={processedSize ?? undefined}
/>
</div>
{processedSize != null && (
<div className="flex items-center justify-between px-3 py-1.5 border-t border-border text-xs text-muted-foreground">
<span className="truncate">{selectedFileName ?? files[0].name}</span>
<span>
{formatFileSize(originalSize ?? 0)} &rarr; {formatFileSize(processedSize)}
</span>
</div>
)}
</div>
)}
{hasFile && !hasProcessed && originalBlobUrl && currentEntry?.status !== "failed" && (
<div className="mb-3 rounded-lg border border-border overflow-hidden h-40 flex items-center justify-center bg-muted/20">
<ImageViewer
src={originalBlobUrl}
filename={selectedFileName ?? files[0].name}
fileSize={selectedFileSize ?? files[0].size}
/>
</div>
)}
{hasFile && !hasProcessed && currentEntry?.status === "failed" && (
<div className="mb-3 rounded-lg border border-border p-3 text-center">
<p className="text-sm text-red-500">
{currentEntry.error ?? "Processing failed for this file"}
</p>
</div>
)}
{hasMultiple && (
<div className="mb-3">
<ThumbnailStrip
entries={entries}
selectedIndex={selectedIndex}
onSelect={setSelectedIndex}
/>
</div>
)}
<PipelineBuilder
steps={steps}
expandedStepId={expandedStepId}
onRemoveStep={removeStep}
onReorderSteps={reorderSteps}
onUpdateSettings={updateStepSettings}
onToggleStep={setExpandedStep}
/>
{error && (
<div className="mt-3 text-xs text-red-500 bg-red-50 dark:bg-red-950/30 rounded px-2 py-1.5 flex items-start gap-1.5">
<X className="h-3.5 w-3.5 shrink-0 mt-0.5" />
<span>{error}</span>
</div>
)}
{processing && (
<div className="mt-3">
<ProgressCard
active={progress.phase !== "idle"}
phase={progress.phase === "idle" ? "processing" : progress.phase}
label={
files.length > 1
? `Processing ${files.length} files...`
: "Processing pipeline..."
}
stage={progress.stage}
percent={progress.percent}
elapsed={progress.elapsed}
/>
</div>
)}
</div>
{/* Mobile action bar */}
<div className="p-3 border-t border-border shrink-0 flex gap-2">
<button
type="button"
onClick={handleProcess}
disabled={!hasFile || steps.length === 0 || processing}
className="flex-1 py-2.5 rounded-lg bg-primary text-primary-foreground font-medium flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
>
<Play className="h-4 w-4" />
{files.length <= 1 ? "Process" : `Process (${files.length})`}
</button>
{hasProcessed && batchZipBlob && (
<button
type="button"
onClick={handleDownloadAll}
className="px-4 py-2.5 rounded-lg border border-primary text-primary"
>
<Download className="h-4 w-4" />
</button>
)}
</div>
{/* Mobile FAB for tool palette */}
<button
type="button"
onClick={() => setMobileToolPaletteOpen(true)}
className="fixed bottom-24 right-4 z-20 w-14 h-14 rounded-full bg-primary text-primary-foreground shadow-lg flex items-center justify-center hover:bg-primary/90 active:scale-95 transition-transform"
>
<Plus className="h-6 w-6" />
</button>
{/* Mobile tool palette bottom sheet */}
{mobileToolPaletteOpen && (
<>
<div
aria-hidden="true"
className="fixed inset-0 z-40 bg-black/50 backdrop-blur-sm"
onClick={() => setMobileToolPaletteOpen(false)}
/>
<div className="fixed inset-x-0 bottom-0 z-50 bg-background border-t border-border rounded-t-2xl shadow-xl max-h-[70vh] flex flex-col animate-in slide-in-from-bottom">
<div className="flex items-center justify-between px-4 py-3 border-b border-border shrink-0">
<h2 className="text-sm font-semibold text-foreground">Add Tool</h2>
<button
type="button"
onClick={() => setMobileToolPaletteOpen(false)}
className="p-1.5 rounded-lg hover:bg-muted"
>
<X className="h-4 w-4" />
</button>
</div>
<ToolPalette onAddStep={handleAddStep} className="flex-1 min-h-0" />
</div>
</>
)}
</div>
</AppLayout>
);
}
/* ------------------------------------------------------------------ */
/* Desktop Layout */
/* ------------------------------------------------------------------ */
return ( return (
<AppLayout showToolPanel={false}> <AppLayout showToolPanel={false}>
<div className="flex h-full w-full overflow-hidden"> <div className="flex h-full w-full overflow-hidden">
{/* LEFT PANEL */} {/* LEFT PANE — Tool Palette */}
<div className="w-72 border-r border-border flex flex-col shrink-0 hidden md:flex"> <div className="w-72 border-r border-border flex flex-col shrink-0">
{/* Header */} {/* Palette header */}
<div className="flex items-center gap-3 p-4 border-b border-border shrink-0"> <div className="flex items-center gap-3 p-4 border-b border-border shrink-0">
<div className="p-2 rounded-lg bg-primary text-primary-foreground"> <div className="p-2 rounded-lg bg-primary text-primary-foreground">
<Workflow className="h-5 w-5" /> <Layers className="h-5 w-5" />
</div> </div>
<div> <div>
<h1 className="text-lg font-semibold text-foreground">Automate</h1> <h2 className="text-sm font-semibold text-foreground">Tool Palette</h2>
<p className="text-xs text-muted-foreground">Chain tools into a pipeline</p> <p className="text-xs text-muted-foreground">Click to add to pipeline</p>
</div> </div>
</div> </div>
{/* Saved pipelines strip */} {/* Tool catalog */}
<ToolPalette onAddStep={handleAddStep} className="flex-1 min-h-0" />
{/* Saved pipelines */}
{savedPipelines.length > 0 && ( {savedPipelines.length > 0 && (
<div className="px-4 pt-3 pb-2 border-b border-border shrink-0"> <div className="px-3 py-2 border-t border-border shrink-0">
<h3 className="text-xs font-semibold uppercase text-muted-foreground tracking-wider mb-2"> <h3 className="text-xs font-semibold uppercase text-muted-foreground tracking-wider mb-1.5">
Saved Pipelines Saved
</h3> </h3>
{showAllSaved ? ( {showAllSaved ? (
<div className="space-y-1.5 max-h-48 overflow-y-auto"> <div className="space-y-1 max-h-36 overflow-y-auto">
{savedPipelines.map((p) => ( {savedPipelines.map((p) => (
<div key={p.id} className="flex items-center gap-1.5 group"> <div key={p.id} className="flex items-center gap-1 group">
<button <button
type="button" type="button"
onClick={() => handleLoadPipeline(p)} onClick={() => handleLoadPipeline(p)}
@@ -257,13 +470,13 @@ export function AutomatePage() {
</button> </button>
</div> </div>
) : ( ) : (
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1">
{savedPipelines.slice(0, 3).map((p) => ( {savedPipelines.slice(0, 3).map((p) => (
<button <button
key={p.id} key={p.id}
type="button" type="button"
onClick={() => handleLoadPipeline(p)} onClick={() => handleLoadPipeline(p)}
className="inline-flex items-center gap-1 px-2 py-1 rounded-full bg-muted text-xs text-foreground hover:bg-primary/10 hover:text-primary transition-colors truncate max-w-[120px]" className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-muted text-xs text-foreground hover:bg-primary/10 hover:text-primary transition-colors truncate max-w-[110px]"
> >
<Play className="h-3 w-3 shrink-0" /> <Play className="h-3 w-3 shrink-0" />
<span className="truncate">{p.name}</span> <span className="truncate">{p.name}</span>
@@ -273,7 +486,7 @@ export function AutomatePage() {
<button <button
type="button" type="button"
onClick={() => setShowAllSaved(true)} onClick={() => setShowAllSaved(true)}
className="text-xs text-muted-foreground hover:text-foreground px-2 py-1" className="text-xs text-muted-foreground hover:text-foreground px-2 py-0.5"
> >
+{savedPipelines.length - 3} more +{savedPipelines.length - 3} more
</button> </button>
@@ -282,77 +495,78 @@ export function AutomatePage() {
)} )}
</div> </div>
)} )}
{/* File info */}
<div className="px-4 pt-3 pb-2 border-b border-border shrink-0">
<h3 className="text-xs font-semibold uppercase text-muted-foreground tracking-wider mb-2">
Files
</h3>
{files.length === 0 ? (
<p className="text-xs text-muted-foreground italic">
Drop or upload images to get started
</p>
) : (
<div className="space-y-1">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-foreground">
{files.length} file{files.length !== 1 ? "s" : ""}
</span>
<button
type="button"
onClick={handleAddMore}
className="text-xs text-primary hover:text-primary/80"
>
+ Add more
</button>
</div> </div>
<div className="flex items-center gap-1.5 text-xs text-foreground bg-muted rounded px-2 py-1.5">
<CheckCircle2 className="h-3.5 w-3.5 text-green-500 shrink-0" /> {/* RIGHT PANE — Pipeline Canvas + Preview */}
<span className="truncate flex-1">{selectedFileName ?? files[0].name}</span> <div className="flex-1 flex flex-col overflow-hidden min-w-0">
<span className="text-muted-foreground shrink-0 ml-1"> {/* Canvas header */}
<div className="flex items-center gap-3 px-5 py-3 border-b border-border shrink-0">
<Workflow className="h-5 w-5 text-primary" />
<div className="flex-1 min-w-0">
<h1 className="text-lg font-semibold text-foreground">Pipeline Builder</h1>
<p className="text-xs text-muted-foreground">
{steps.length === 0
? "Add tools from the palette to get started"
: `${steps.length} step${steps.length !== 1 ? "s" : ""} configured`}
</p>
</div>
{/* File badge */}
{hasFile ? (
<div className="flex items-center gap-2 shrink-0">
<div className="flex items-center gap-1.5 text-xs bg-muted rounded-full px-3 py-1">
<CheckCircle2 className="h-3.5 w-3.5 text-green-500" />
<span className="text-foreground max-w-[120px] truncate">
{files.length > 1
? `${files.length} files`
: (selectedFileName ?? files[0].name)}
</span>
<span className="text-muted-foreground">
{formatFileSize(selectedFileSize ?? files[0].size)} {formatFileSize(selectedFileSize ?? files[0].size)}
</span> </span>
</div> </div>
<button
type="button"
onClick={handleAddMore}
className="text-xs text-primary hover:text-primary/80"
>
+ Add
</button>
<button <button
type="button" type="button"
onClick={() => resetFiles()} onClick={() => resetFiles()}
className="text-xs text-muted-foreground hover:text-foreground" className="text-xs text-muted-foreground hover:text-foreground"
> >
Clear all Clear
</button> </button>
</div> </div>
) : (
<span className="text-xs text-muted-foreground italic shrink-0">No files loaded</span>
)} )}
</div> </div>
{/* Error display */} {/* Canvas body */}
<div className="flex-1 flex flex-col overflow-hidden min-h-0">
{/* Pipeline steps area */}
<div className="flex-1 overflow-y-auto px-5 py-4 min-h-0">
{error && ( {error && (
<div className="px-4 pt-2 shrink-0"> <div className="mb-3 text-xs text-red-500 bg-red-50 dark:bg-red-950/30 rounded-lg px-3 py-2 flex items-start gap-1.5">
<div className="text-xs text-red-500 bg-red-50 dark:bg-red-950/30 rounded px-2 py-1.5 flex items-start gap-1.5">
<X className="h-3.5 w-3.5 shrink-0 mt-0.5" /> <X className="h-3.5 w-3.5 shrink-0 mt-0.5" />
<span>{error}</span> <span>{error}</span>
</div> </div>
</div>
)} )}
{/* Pipeline steps (scrollable) */}
<div className="flex-1 overflow-y-auto px-4 pt-3 pb-2">
<h3 className="text-xs font-semibold uppercase text-muted-foreground tracking-wider mb-2">
Pipeline Steps
</h3>
<PipelineBuilder <PipelineBuilder
steps={steps} steps={steps}
expandedStepId={expandedStepId} expandedStepId={expandedStepId}
onAddStep={addStep}
onRemoveStep={removeStep} onRemoveStep={removeStep}
onReorderSteps={reorderSteps} onReorderSteps={reorderSteps}
onUpdateSettings={updateStepSettings} onUpdateSettings={updateStepSettings}
onToggleStep={setExpandedStep} onToggleStep={setExpandedStep}
/> />
</div>
{/* Progress card */}
{processing && ( {processing && (
<div className="px-4 pb-2 shrink-0"> <div className="mt-4">
<ProgressCard <ProgressCard
active={progress.phase !== "idle"} active={progress.phase !== "idle"}
phase={progress.phase === "idle" ? "processing" : progress.phase} phase={progress.phase === "idle" ? "processing" : progress.phase}
@@ -367,68 +581,67 @@ export function AutomatePage() {
/> />
</div> </div>
)} )}
</div>
{/* Action buttons (sticky bottom) */} {/* Action bar */}
<div className="p-4 border-t border-border space-y-2 shrink-0"> <div className="px-5 py-3 border-t border-border shrink-0 flex items-center gap-2">
{/* Process button */}
<button <button
type="button" type="button"
onClick={handleProcess} onClick={handleProcess}
disabled={!hasFile || steps.length === 0 || processing} disabled={!hasFile || steps.length === 0 || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium flex items-center justify-center gap-2 hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed" className="px-5 py-2.5 rounded-lg bg-primary text-primary-foreground font-medium flex items-center gap-2 hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed"
> >
<Play className="h-4 w-4" /> <Play className="h-4 w-4" />
{files.length <= 1 ? "Process" : `Process All (${files.length} files)`} {files.length <= 1 ? "Process" : `Process All (${files.length})`}
</button> </button>
{/* Download All ZIP */}
{hasProcessed && batchZipBlob && ( {hasProcessed && batchZipBlob && (
<button <button
type="button" type="button"
onClick={handleDownloadAll} onClick={handleDownloadAll}
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5" className="px-4 py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center gap-2 hover:bg-primary/5"
> >
<Download className="h-4 w-4" /> <Download className="h-4 w-4" />
Download All (ZIP) Download ZIP
</button> </button>
)} )}
{/* Save pipeline */} <span className="flex-1" />
{steps.length > 0 && !showSaveForm && ( {steps.length > 0 && !showSaveForm && (
<button <button
type="button" type="button"
onClick={() => setShowSaveForm(true)} onClick={() => setShowSaveForm(true)}
className="w-full py-2 rounded-lg border border-border text-muted-foreground font-medium flex items-center justify-center gap-2 hover:bg-muted hover:text-foreground text-sm" className="px-4 py-2 rounded-lg border border-border text-muted-foreground font-medium flex items-center gap-2 hover:bg-muted hover:text-foreground text-sm"
> >
<Save className="h-4 w-4" /> <Save className="h-4 w-4" />
Save Pipeline Save
</button> </button>
)} )}
{showSaveForm && ( {showSaveForm && (
<div className="space-y-2 rounded-lg border border-border p-3 bg-muted/30"> <div className="flex items-center gap-2">
<input <input
type="text" type="text"
value={saveName} value={saveName}
onChange={(e) => setSaveName(e.target.value)} onChange={(e) => setSaveName(e.target.value)}
placeholder="Pipeline name" 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" className="text-sm px-2.5 py-1.5 rounded border border-border bg-background text-foreground placeholder:text-muted-foreground w-36"
/> />
<input <input
type="text" type="text"
value={saveDescription} value={saveDescription}
onChange={(e) => setSaveDescription(e.target.value)} onChange={(e) => setSaveDescription(e.target.value)}
placeholder="Description (optional)" placeholder="Description"
className="w-full text-sm px-2.5 py-1.5 rounded border border-border bg-background text-foreground placeholder:text-muted-foreground" className="text-sm px-2.5 py-1.5 rounded border border-border bg-background text-foreground placeholder:text-muted-foreground w-36"
/> />
<div className="flex gap-2">
<button <button
type="button" type="button"
onClick={handleSave} onClick={handleSave}
disabled={!saveName.trim() || saving} disabled={!saveName.trim() || saving}
className="flex-1 py-1.5 rounded bg-primary text-primary-foreground text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed" className="px-3 py-1.5 rounded bg-primary text-primary-foreground text-sm font-medium disabled:opacity-50"
> >
{saving ? "Saving..." : "Save"} {saving ? "..." : "Save"}
</button> </button>
<button <button
type="button" type="button"
@@ -437,25 +650,51 @@ export function AutomatePage() {
setSaveName(""); setSaveName("");
setSaveDescription(""); setSaveDescription("");
}} }}
className="px-3 py-1.5 rounded border border-border text-sm text-muted-foreground hover:bg-muted" className="px-2 py-1.5 rounded text-sm text-muted-foreground hover:bg-muted"
> >
Cancel Cancel
</button> </button>
</div> </div>
</div>
)} )}
</div> </div>
</div>
{/* RIGHT PANEL */} {/* Preview panel (collapsible) */}
<div
className={cn(
"border-t border-border shrink-0 flex flex-col transition-all",
previewCollapsed ? "h-10" : "h-[38%] min-h-[200px]",
)}
>
{/* Preview header with toggle */}
<button
type="button"
onClick={() => setPreviewCollapsed(!previewCollapsed)}
className="flex items-center gap-2 px-5 py-2 shrink-0 hover:bg-muted/50 transition-colors"
>
{previewCollapsed ? (
<ChevronUp className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
)}
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Preview
</span>
{hasFile && hasProcessed && processedSize != null && (
<span className="text-xs text-muted-foreground ml-auto">
{formatFileSize(originalSize ?? 0)} &rarr; {formatFileSize(processedSize)}
</span>
)}
</button>
{/* Preview content */}
{!previewCollapsed && (
<section <section
aria-label="Image area" aria-label="Image area"
className="flex-1 flex flex-col overflow-hidden min-h-0" className="flex-1 flex flex-col overflow-hidden min-h-0"
onKeyDown={hasMultiple ? handleImageKeyDown : undefined} onKeyDown={hasMultiple ? handleImageKeyDown : undefined}
tabIndex={hasMultiple ? 0 : undefined} tabIndex={hasMultiple ? 0 : undefined}
> >
<div className="flex-1 relative flex items-center justify-center p-6 min-h-0"> <div className="flex-1 relative flex items-center justify-center px-6 py-2 min-h-0">
{/* Nav arrows */}
{hasMultiple && hasPrev && ( {hasMultiple && hasPrev && (
<button <button
type="button" type="button"
@@ -477,18 +716,22 @@ export function AutomatePage() {
</button> </button>
)} )}
{hasMultiple && ( {hasMultiple && (
<div className="absolute top-3 right-3 z-10 bg-background/80 border border-border px-2 py-0.5 rounded-full text-xs text-muted-foreground tabular-nums"> <div className="absolute top-2 right-3 z-10 bg-background/80 border border-border px-2 py-0.5 rounded-full text-xs text-muted-foreground tabular-nums">
{selectedIndex + 1} / {entries.length} {selectedIndex + 1} / {entries.length}
</div> </div>
)} )}
{/* Image display area */}
{!hasFile && ( {!hasFile && (
<Dropzone onFiles={handleFiles} accept="image/*" multiple currentFiles={files} /> <Dropzone
onFiles={handleFiles}
accept="image/*"
multiple
currentFiles={files}
/>
)} )}
{hasFile && !hasProcessed && currentEntry?.status === "failed" && ( {hasFile && !hasProcessed && currentEntry?.status === "failed" && (
<div className="flex flex-col items-center justify-center gap-3 h-full text-center px-4"> <div className="flex flex-col items-center justify-center gap-2 h-full text-center px-4">
<p className="text-sm text-red-500"> <p className="text-sm text-red-500">
{currentEntry.error ?? "Processing failed for this file"} {currentEntry.error ?? "Processing failed for this file"}
</p> </p>
@@ -504,7 +747,10 @@ export function AutomatePage() {
/> />
)} )}
{hasFile && !hasProcessed && originalBlobUrl && currentEntry?.status !== "failed" && ( {hasFile &&
!hasProcessed &&
originalBlobUrl &&
currentEntry?.status !== "failed" && (
<ImageViewer <ImageViewer
src={originalBlobUrl} src={originalBlobUrl}
filename={selectedFileName ?? files[0].name} filename={selectedFileName ?? files[0].name}
@@ -513,22 +759,6 @@ export function AutomatePage() {
)} )}
</div> </div>
{/* Info bar */}
{hasFile && (
<div className="flex items-center justify-between px-3 py-1.5 border-t border-border text-xs text-muted-foreground shrink-0">
<span className="truncate mr-2">{selectedFileName ?? files[0].name}</span>
<div className="flex items-center gap-3 shrink-0">
{hasProcessed && processedSize != null && (
<span>
{formatFileSize(originalSize ?? 0)} &rarr; {formatFileSize(processedSize)}
</span>
)}
{!hasProcessed && <span>{formatFileSize(selectedFileSize ?? files[0].size)}</span>}
</div>
</div>
)}
{/* Thumbnail strip for multi-file */}
{hasMultiple && ( {hasMultiple && (
<ThumbnailStrip <ThumbnailStrip
entries={entries} entries={entries}
@@ -537,6 +767,10 @@ export function AutomatePage() {
/> />
)} )}
</section> </section>
)}
</div>
</div>
</div>
</div> </div>
</AppLayout> </AppLayout>
); );