mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
Merge pull request #81 from ashim-hq/worktree-automate-ui-redesign
feat: redesign Automate page — tool palette + pipeline canvas
This commit is contained in:
@@ -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,54 +78,59 @@ 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
|
||||||
onClick={onToggle}
|
// biome-ignore lint/a11y/useSemanticElements: div with role=button avoids invalid nested <button> in HTML
|
||||||
className="flex items-center gap-2 p-3 w-full text-left"
|
<div
|
||||||
>
|
role="button"
|
||||||
{/* Drag handle */}
|
tabIndex={0}
|
||||||
{
|
onClick={onToggle}
|
||||||
// biome-ignore lint/a11y/noStaticElementInteractions: dnd-kit drag handle spreads its own event handlers
|
className="flex items-center gap-2 p-3 w-full text-left cursor-pointer"
|
||||||
<span
|
|
||||||
{...attributes}
|
|
||||||
{...listeners}
|
|
||||||
className="cursor-grab active:cursor-grabbing p-0.5 rounded hover:bg-muted text-muted-foreground"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
onKeyDown={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<GripVertical className="h-4 w-4" />
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
|
|
||||||
{/* Step number badge */}
|
|
||||||
<span className="w-6 h-6 rounded-full bg-primary/10 text-primary text-xs font-semibold flex items-center justify-center shrink-0">
|
|
||||||
{index + 1}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{/* Tool icon + name */}
|
|
||||||
<Icon className="h-4 w-4 text-muted-foreground shrink-0" />
|
|
||||||
<span className="text-sm font-medium text-foreground">{tool.name}</span>
|
|
||||||
|
|
||||||
{/* Settings summary when collapsed */}
|
|
||||||
{!isExpanded && summary && (
|
|
||||||
<span className="text-xs text-muted-foreground truncate ml-1">{summary}</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<span className="flex-1" />
|
|
||||||
|
|
||||||
{/* Remove button */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onRemove();
|
|
||||||
}}
|
|
||||||
title="Remove"
|
|
||||||
className="p-1 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive"
|
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
{/* Drag handle */}
|
||||||
</button>
|
{
|
||||||
</button>
|
// biome-ignore lint/a11y/noStaticElementInteractions: dnd-kit drag handle spreads its own event handlers
|
||||||
|
<span
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
className="cursor-grab active:cursor-grabbing p-0.5 rounded hover:bg-muted text-muted-foreground"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onKeyDown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<GripVertical className="h-4 w-4" />
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
{/* Step number badge */}
|
||||||
|
<span className="w-6 h-6 rounded-full bg-primary/10 text-primary text-xs font-semibold flex items-center justify-center shrink-0">
|
||||||
|
{index + 1}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Tool icon + name */}
|
||||||
|
<Icon className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||||
|
<span className="text-sm font-medium text-foreground">{tool.name}</span>
|
||||||
|
|
||||||
|
{/* Settings summary when collapsed */}
|
||||||
|
{!isExpanded && summary && (
|
||||||
|
<span className="text-xs text-muted-foreground truncate ml-1">{summary}</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<span className="flex-1" />
|
||||||
|
|
||||||
|
{/* Remove button */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onRemove();
|
||||||
|
}}
|
||||||
|
title="Remove"
|
||||||
|
className="p-1 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</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,92 +166,37 @@ 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">
|
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||||
{/* Sortable step list */}
|
<SortableContext items={steps.map((s) => s.id)} strategy={verticalListSortingStrategy}>
|
||||||
{steps.length === 0 ? (
|
<div className="space-y-2">
|
||||||
<div className="text-center py-8 text-muted-foreground text-sm">
|
{steps.map((step, idx) => (
|
||||||
Add steps to build your pipeline
|
<SortableStep
|
||||||
|
key={step.id}
|
||||||
|
step={step}
|
||||||
|
index={idx}
|
||||||
|
isExpanded={expandedStepId === step.id}
|
||||||
|
onToggle={() => onToggleStep(expandedStepId === step.id ? null : step.id)}
|
||||||
|
onRemove={() => onRemoveStep(step.id)}
|
||||||
|
onUpdateSettings={(s) => onUpdateSettings(step.id, s)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
</SortableContext>
|
||||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
</DndContext>
|
||||||
<SortableContext items={steps.map((s) => s.id)} strategy={verticalListSortingStrategy}>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{steps.map((step, idx) => (
|
|
||||||
<SortableStep
|
|
||||||
key={step.id}
|
|
||||||
step={step}
|
|
||||||
index={idx}
|
|
||||||
isExpanded={expandedStepId === step.id}
|
|
||||||
onToggle={() => onToggleStep(expandedStepId === step.id ? null : step.id)}
|
|
||||||
onRemove={() => onRemoveStep(step.id)}
|
|
||||||
onUpdateSettings={(s) => onUpdateSettings(step.id, s)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</SortableContext>
|
|
||||||
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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)} → {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,153 +495,153 @@ export function AutomatePage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* File info */}
|
{/* RIGHT PANE — Pipeline Canvas + Preview */}
|
||||||
<div className="px-4 pt-3 pb-2 border-b border-border shrink-0">
|
<div className="flex-1 flex flex-col overflow-hidden min-w-0">
|
||||||
<h3 className="text-xs font-semibold uppercase text-muted-foreground tracking-wider mb-2">
|
{/* Canvas header */}
|
||||||
Files
|
<div className="flex items-center gap-3 px-5 py-3 border-b border-border shrink-0">
|
||||||
</h3>
|
<Workflow className="h-5 w-5 text-primary" />
|
||||||
{files.length === 0 ? (
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-xs text-muted-foreground italic">
|
<h1 className="text-lg font-semibold text-foreground">Pipeline Builder</h1>
|
||||||
Drop or upload images to get started
|
<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>
|
</p>
|
||||||
) : (
|
</div>
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="flex items-center justify-between">
|
{/* File badge */}
|
||||||
<span className="text-xs font-medium text-foreground">
|
{hasFile ? (
|
||||||
{files.length} file{files.length !== 1 ? "s" : ""}
|
<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>
|
||||||
<button
|
<span className="text-muted-foreground">
|
||||||
type="button"
|
|
||||||
onClick={handleAddMore}
|
|
||||||
className="text-xs text-primary hover:text-primary/80"
|
|
||||||
>
|
|
||||||
+ Add more
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1.5 text-xs text-foreground bg-muted rounded px-2 py-1.5">
|
|
||||||
<CheckCircle2 className="h-3.5 w-3.5 text-green-500 shrink-0" />
|
|
||||||
<span className="truncate flex-1">{selectedFileName ?? files[0].name}</span>
|
|
||||||
<span className="text-muted-foreground shrink-0 ml-1">
|
|
||||||
{formatFileSize(selectedFileSize ?? files[0].size)}
|
{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 */}
|
||||||
{error && (
|
<div className="flex-1 flex flex-col overflow-hidden min-h-0">
|
||||||
<div className="px-4 pt-2 shrink-0">
|
{/* Pipeline steps area */}
|
||||||
<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">
|
<div className="flex-1 overflow-y-auto px-5 py-4 min-h-0">
|
||||||
<X className="h-3.5 w-3.5 shrink-0 mt-0.5" />
|
{error && (
|
||||||
<span>{error}</span>
|
<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>
|
<X className="h-3.5 w-3.5 shrink-0 mt-0.5" />
|
||||||
</div>
|
<span>{error}</span>
|
||||||
)}
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Pipeline steps (scrollable) */}
|
<PipelineBuilder
|
||||||
<div className="flex-1 overflow-y-auto px-4 pt-3 pb-2">
|
steps={steps}
|
||||||
<h3 className="text-xs font-semibold uppercase text-muted-foreground tracking-wider mb-2">
|
expandedStepId={expandedStepId}
|
||||||
Pipeline Steps
|
onRemoveStep={removeStep}
|
||||||
</h3>
|
onReorderSteps={reorderSteps}
|
||||||
<PipelineBuilder
|
onUpdateSettings={updateStepSettings}
|
||||||
steps={steps}
|
onToggleStep={setExpandedStep}
|
||||||
expandedStepId={expandedStepId}
|
|
||||||
onAddStep={addStep}
|
|
||||||
onRemoveStep={removeStep}
|
|
||||||
onReorderSteps={reorderSteps}
|
|
||||||
onUpdateSettings={updateStepSettings}
|
|
||||||
onToggleStep={setExpandedStep}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Progress card */}
|
|
||||||
{processing && (
|
|
||||||
<div className="px-4 pb-2 shrink-0">
|
|
||||||
<ProgressCard
|
|
||||||
active={progress.phase !== "idle"}
|
|
||||||
phase={progress.phase === "idle" ? "processing" : progress.phase}
|
|
||||||
label={
|
|
||||||
files.length > 1
|
|
||||||
? `Processing ${files.length} files...`
|
|
||||||
: "Processing pipeline..."
|
|
||||||
}
|
|
||||||
stage={progress.stage}
|
|
||||||
percent={progress.percent}
|
|
||||||
elapsed={progress.elapsed}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{processing && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<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>
|
</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
|
|
||||||
type="button"
|
|
||||||
onClick={handleProcess}
|
|
||||||
disabled={!hasFile || steps.length === 0 || processing}
|
|
||||||
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium flex items-center justify-center gap-2 hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
<Play className="h-4 w-4" />
|
|
||||||
{files.length <= 1 ? "Process" : `Process All (${files.length} files)`}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Download All ZIP */}
|
|
||||||
{hasProcessed && batchZipBlob && (
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleDownloadAll}
|
onClick={handleProcess}
|
||||||
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"
|
disabled={!hasFile || steps.length === 0 || processing}
|
||||||
|
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"
|
||||||
>
|
>
|
||||||
<Download className="h-4 w-4" />
|
<Play className="h-4 w-4" />
|
||||||
Download All (ZIP)
|
{files.length <= 1 ? "Process" : `Process All (${files.length})`}
|
||||||
</button>
|
</button>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Save pipeline */}
|
{hasProcessed && batchZipBlob && (
|
||||||
{steps.length > 0 && !showSaveForm && (
|
<button
|
||||||
<button
|
type="button"
|
||||||
type="button"
|
onClick={handleDownloadAll}
|
||||||
onClick={() => setShowSaveForm(true)}
|
className="px-4 py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center gap-2 hover:bg-primary/5"
|
||||||
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"
|
>
|
||||||
>
|
<Download className="h-4 w-4" />
|
||||||
<Save className="h-4 w-4" />
|
Download ZIP
|
||||||
Save Pipeline
|
</button>
|
||||||
</button>
|
)}
|
||||||
)}
|
|
||||||
|
|
||||||
{showSaveForm && (
|
<span className="flex-1" />
|
||||||
<div className="space-y-2 rounded-lg border border-border p-3 bg-muted/30">
|
|
||||||
<input
|
{steps.length > 0 && !showSaveForm && (
|
||||||
type="text"
|
<button
|
||||||
value={saveName}
|
type="button"
|
||||||
onChange={(e) => setSaveName(e.target.value)}
|
onClick={() => setShowSaveForm(true)}
|
||||||
placeholder="Pipeline name"
|
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"
|
||||||
className="w-full text-sm px-2.5 py-1.5 rounded border border-border bg-background text-foreground placeholder:text-muted-foreground"
|
>
|
||||||
/>
|
<Save className="h-4 w-4" />
|
||||||
<input
|
Save
|
||||||
type="text"
|
</button>
|
||||||
value={saveDescription}
|
)}
|
||||||
onChange={(e) => setSaveDescription(e.target.value)}
|
|
||||||
placeholder="Description (optional)"
|
{showSaveForm && (
|
||||||
className="w-full text-sm px-2.5 py-1.5 rounded border border-border bg-background text-foreground placeholder:text-muted-foreground"
|
<div className="flex items-center gap-2">
|
||||||
/>
|
<input
|
||||||
<div className="flex gap-2">
|
type="text"
|
||||||
|
value={saveName}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={saveDescription}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
<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,106 +650,127 @@ 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) */}
|
||||||
<section
|
<div
|
||||||
aria-label="Image area"
|
className={cn(
|
||||||
className="flex-1 flex flex-col overflow-hidden min-h-0"
|
"border-t border-border shrink-0 flex flex-col transition-all",
|
||||||
onKeyDown={hasMultiple ? handleImageKeyDown : undefined}
|
previewCollapsed ? "h-10" : "h-[38%] min-h-[200px]",
|
||||||
tabIndex={hasMultiple ? 0 : undefined}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex-1 relative flex items-center justify-center p-6 min-h-0">
|
{/* Preview header with toggle */}
|
||||||
{/* Nav arrows */}
|
|
||||||
{hasMultiple && hasPrev && (
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={navigatePrev}
|
onClick={() => setPreviewCollapsed(!previewCollapsed)}
|
||||||
className="absolute left-3 z-10 w-8 h-8 rounded-full bg-background/80 border border-border shadow-sm flex items-center justify-center hover:bg-background transition-colors"
|
className="flex items-center gap-2 px-5 py-2 shrink-0 hover:bg-muted/50 transition-colors"
|
||||||
aria-label="Previous image"
|
|
||||||
>
|
>
|
||||||
<ChevronLeft className="h-4 w-4" />
|
{previewCollapsed ? (
|
||||||
</button>
|
<ChevronUp className="h-4 w-4 text-muted-foreground" />
|
||||||
)}
|
) : (
|
||||||
{hasMultiple && hasNext && (
|
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||||
<button
|
)}
|
||||||
type="button"
|
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||||
onClick={navigateNext}
|
Preview
|
||||||
className="absolute right-3 z-10 w-8 h-8 rounded-full bg-background/80 border border-border shadow-sm flex items-center justify-center hover:bg-background transition-colors"
|
</span>
|
||||||
aria-label="Next image"
|
{hasFile && hasProcessed && processedSize != null && (
|
||||||
>
|
<span className="text-xs text-muted-foreground ml-auto">
|
||||||
<ChevronRight className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{hasMultiple && (
|
|
||||||
<div className="absolute top-3 right-3 z-10 bg-background/80 border border-border px-2 py-0.5 rounded-full text-xs text-muted-foreground tabular-nums">
|
|
||||||
{selectedIndex + 1} / {entries.length}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Image display area */}
|
|
||||||
{!hasFile && (
|
|
||||||
<Dropzone onFiles={handleFiles} accept="image/*" multiple currentFiles={files} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{hasFile && !hasProcessed && currentEntry?.status === "failed" && (
|
|
||||||
<div className="flex flex-col items-center justify-center gap-3 h-full text-center px-4">
|
|
||||||
<p className="text-sm text-red-500">
|
|
||||||
{currentEntry.error ?? "Processing failed for this file"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{hasFile && hasProcessed && originalBlobUrl && (
|
|
||||||
<BeforeAfterSlider
|
|
||||||
beforeSrc={originalBlobUrl}
|
|
||||||
afterSrc={processedUrl as string}
|
|
||||||
beforeSize={originalSize ?? undefined}
|
|
||||||
afterSize={processedSize ?? undefined}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{hasFile && !hasProcessed && originalBlobUrl && currentEntry?.status !== "failed" && (
|
|
||||||
<ImageViewer
|
|
||||||
src={originalBlobUrl}
|
|
||||||
filename={selectedFileName ?? files[0].name}
|
|
||||||
fileSize={selectedFileSize ?? files[0].size}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Info bar */}
|
|
||||||
{hasFile && (
|
|
||||||
<div className="flex items-center justify-between px-3 py-1.5 border-t border-border text-xs text-muted-foreground shrink-0">
|
|
||||||
<span className="truncate mr-2">{selectedFileName ?? files[0].name}</span>
|
|
||||||
<div className="flex items-center gap-3 shrink-0">
|
|
||||||
{hasProcessed && processedSize != null && (
|
|
||||||
<span>
|
|
||||||
{formatFileSize(originalSize ?? 0)} → {formatFileSize(processedSize)}
|
{formatFileSize(originalSize ?? 0)} → {formatFileSize(processedSize)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{!hasProcessed && <span>{formatFileSize(selectedFileSize ?? files[0].size)}</span>}
|
</button>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Thumbnail strip for multi-file */}
|
{/* Preview content */}
|
||||||
{hasMultiple && (
|
{!previewCollapsed && (
|
||||||
<ThumbnailStrip
|
<section
|
||||||
entries={entries}
|
aria-label="Image area"
|
||||||
selectedIndex={selectedIndex}
|
className="flex-1 flex flex-col overflow-hidden min-h-0"
|
||||||
onSelect={setSelectedIndex}
|
onKeyDown={hasMultiple ? handleImageKeyDown : undefined}
|
||||||
/>
|
tabIndex={hasMultiple ? 0 : undefined}
|
||||||
)}
|
>
|
||||||
</section>
|
<div className="flex-1 relative flex items-center justify-center px-6 py-2 min-h-0">
|
||||||
|
{hasMultiple && hasPrev && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={navigatePrev}
|
||||||
|
className="absolute left-3 z-10 w-8 h-8 rounded-full bg-background/80 border border-border shadow-sm flex items-center justify-center hover:bg-background transition-colors"
|
||||||
|
aria-label="Previous image"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{hasMultiple && hasNext && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={navigateNext}
|
||||||
|
className="absolute right-3 z-10 w-8 h-8 rounded-full bg-background/80 border border-border shadow-sm flex items-center justify-center hover:bg-background transition-colors"
|
||||||
|
aria-label="Next image"
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{hasMultiple && (
|
||||||
|
<div className="absolute top-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}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!hasFile && (
|
||||||
|
<Dropzone
|
||||||
|
onFiles={handleFiles}
|
||||||
|
accept="image/*"
|
||||||
|
multiple
|
||||||
|
currentFiles={files}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasFile && !hasProcessed && currentEntry?.status === "failed" && (
|
||||||
|
<div className="flex flex-col items-center justify-center gap-2 h-full text-center px-4">
|
||||||
|
<p className="text-sm text-red-500">
|
||||||
|
{currentEntry.error ?? "Processing failed for this file"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasFile && hasProcessed && originalBlobUrl && (
|
||||||
|
<BeforeAfterSlider
|
||||||
|
beforeSrc={originalBlobUrl}
|
||||||
|
afterSrc={processedUrl as string}
|
||||||
|
beforeSize={originalSize ?? undefined}
|
||||||
|
afterSize={processedSize ?? undefined}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasFile &&
|
||||||
|
!hasProcessed &&
|
||||||
|
originalBlobUrl &&
|
||||||
|
currentEntry?.status !== "failed" && (
|
||||||
|
<ImageViewer
|
||||||
|
src={originalBlobUrl}
|
||||||
|
filename={selectedFileName ?? files[0].name}
|
||||||
|
fileSize={selectedFileSize ?? files[0].size}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasMultiple && (
|
||||||
|
<ThumbnailStrip
|
||||||
|
entries={entries}
|
||||||
|
selectedIndex={selectedIndex}
|
||||||
|
onSelect={setSelectedIndex}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user