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]);
|
||||
|
||||
const bindDrag = useDrag(
|
||||
({ movement: [mx, my], first, memo }) => {
|
||||
({
|
||||
movement: [mx, my],
|
||||
first,
|
||||
memo,
|
||||
}: {
|
||||
movement: [number, number];
|
||||
first: boolean;
|
||||
memo?: { panX: number; panY: number };
|
||||
}) => {
|
||||
if (!image || !isSelected) return memo;
|
||||
if (first) memo = { panX: transform.panX, panY: transform.panY };
|
||||
const rect = cellRef.current?.getBoundingClientRect();
|
||||
@@ -358,7 +366,7 @@ function CollageCell({
|
||||
);
|
||||
|
||||
const bindPinch = usePinch(
|
||||
({ offset: [scale] }) => {
|
||||
({ offset: [scale] }: { offset: [number, number] }) => {
|
||||
if (!image || !isSelected) return;
|
||||
const zoom = Math.max(1, Math.min(10, scale));
|
||||
store.setCellTransform(cellIndex, { zoom });
|
||||
|
||||
@@ -15,25 +15,16 @@ import {
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { FileImage, GripVertical, Plus, X } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { SearchBar } from "@/components/common/search-bar";
|
||||
import { apiGet } from "@/lib/api";
|
||||
import { FileImage, GripVertical, X } from "lucide-react";
|
||||
import { ICON_MAP } from "@/lib/icon-map";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { PipelineStep } from "@/stores/pipeline-store";
|
||||
import { PipelineStepSettings } from "./pipeline-step-settings";
|
||||
import { getSettingsSummary } from "./pipeline-step-summary";
|
||||
|
||||
/** Tools that can be used as pipeline steps (excludes pipeline/batch/multi-file tools). */
|
||||
const PIPELINE_TOOLS_BASE = TOOLS.filter(
|
||||
(t) => !["pipeline", "compare", "find-duplicates", "collage", "compose"].includes(t.id),
|
||||
);
|
||||
|
||||
interface PipelineBuilderProps {
|
||||
steps: PipelineStep[];
|
||||
expandedStepId: string | null;
|
||||
onAddStep: (toolId: string) => void;
|
||||
onRemoveStep: (id: string) => void;
|
||||
onReorderSteps: (activeId: string, overId: string) => void;
|
||||
onUpdateSettings: (id: string, settings: Record<string, unknown>) => void;
|
||||
@@ -87,54 +78,59 @@ function SortableStep({
|
||||
)}
|
||||
>
|
||||
{/* Header row - click to expand/collapse */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className="flex items-center gap-2 p-3 w-full text-left"
|
||||
>
|
||||
{/* Drag handle */}
|
||||
{
|
||||
// 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"
|
||||
{
|
||||
// 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}
|
||||
className="flex items-center gap-2 p-3 w-full text-left cursor-pointer"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</button>
|
||||
{/* Drag handle */}
|
||||
{
|
||||
// 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"}>
|
||||
<PipelineStepSettings
|
||||
toolId={step.toolId}
|
||||
@@ -142,7 +138,6 @@ function SortableStep({
|
||||
onChange={onUpdateSettings}
|
||||
/>
|
||||
</div>
|
||||
;
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -154,48 +149,11 @@ function SortableStep({
|
||||
export function PipelineBuilder({
|
||||
steps,
|
||||
expandedStepId,
|
||||
onAddStep,
|
||||
onRemoveStep,
|
||||
onReorderSteps,
|
||||
onUpdateSettings,
|
||||
onToggleStep,
|
||||
}: PipelineBuilderProps) {
|
||||
const [showToolPicker, setShowToolPicker] = useState(false);
|
||||
const [toolSearch, setToolSearch] = useState("");
|
||||
const [disabledTools, setDisabledTools] = useState<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(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
||||
@@ -208,92 +166,37 @@ export function PipelineBuilder({
|
||||
}
|
||||
}
|
||||
|
||||
function handleAddStep(toolId: string) {
|
||||
onAddStep(toolId);
|
||||
setShowToolPicker(false);
|
||||
setToolSearch("");
|
||||
if (steps.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<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 (
|
||||
<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
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<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>
|
||||
) : (
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<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>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user