Browse Source

refactor(26-01): simplify to horizontal cards with direct workflow loading

- TemplateCard: horizontal layout with image left, details right,
  "Use workflow" button bottom right
- Remove detail modal - clicking button loads workflow directly
- Delete unused TemplateDetailModal, WorkflowPreview, WorkflowPreviewModal
- Change grid to single column for horizontal card layout

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
handoff-20260429-1057
shrimbly 6 months ago
parent
commit
d037668b5b
  1. 147
      src/components/quickstart/TemplateCard.tsx
  2. 231
      src/components/quickstart/TemplateDetailModal.tsx
  3. 23
      src/components/quickstart/TemplateExplorerView.tsx
  4. 239
      src/components/quickstart/WorkflowPreview.tsx
  5. 98
      src/components/quickstart/WorkflowPreviewModal.tsx

147
src/components/quickstart/TemplateCard.tsx

@ -14,7 +14,7 @@ interface TemplateCardProps {
nodeCount: number;
previewImage?: string;
isLoading?: boolean;
onClick: () => void;
onUseWorkflow: () => void;
disabled?: boolean;
}
@ -37,57 +37,33 @@ export function TemplateCard({
nodeCount,
previewImage,
isLoading = false,
onClick,
onUseWorkflow,
disabled = false,
}: TemplateCardProps) {
return (
<button
onClick={onClick}
disabled={disabled || isLoading}
<div
className={`
group w-full text-left rounded-lg border p-4 transition-all
group w-full rounded-lg border p-4 transition-all flex gap-4
${
isLoading
? "bg-blue-600/20 border-blue-500/50"
: "bg-neutral-900/50 border-neutral-700 hover:border-neutral-500 hover:bg-neutral-900/70"
: "bg-neutral-900/50 border-neutral-700"
}
${disabled && !isLoading ? "opacity-50 cursor-not-allowed" : "cursor-pointer"}
${disabled && !isLoading ? "opacity-50" : ""}
`}
>
{/* Thumbnail Area */}
{/* Thumbnail - Left side */}
<div
className={`
w-full aspect-[4/3] rounded-lg mb-3 overflow-hidden relative
w-40 h-28 flex-shrink-0 rounded-lg overflow-hidden relative
${
isLoading
? "bg-blue-500/20"
: "bg-neutral-800 group-hover:bg-neutral-700/80"
: "bg-neutral-800"
}
`}
>
{isLoading ? (
<div className="absolute inset-0 flex items-center justify-center">
<svg
className="w-6 h-6 text-blue-400 animate-spin"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
</div>
) : previewImage ? (
{previewImage ? (
/* eslint-disable-next-line @next/next/no-img-element */
<img
src={previewImage}
@ -97,7 +73,7 @@ export function TemplateCard({
) : (
<div className="absolute inset-0 flex items-center justify-center">
<svg
className="w-8 h-8 text-neutral-600 group-hover:text-neutral-500 transition-colors"
className="w-8 h-8 text-neutral-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
@ -109,33 +85,84 @@ export function TemplateCard({
)}
</div>
{/* Name */}
<h3 className="text-sm font-medium text-neutral-200 mb-1">
{template.name}
</h3>
{/* Description */}
<p className="text-xs text-neutral-400 line-clamp-2 mb-3">
{template.description}
</p>
{/* Content - Right side */}
<div className="flex-1 flex flex-col min-w-0">
{/* Header row */}
<div className="flex items-start justify-between gap-2 mb-1">
<h3 className="text-sm font-medium text-neutral-200 truncate">
{template.name}
</h3>
<div className="flex items-center gap-1.5 flex-shrink-0">
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-neutral-700/50 text-neutral-400">
{nodeCount} nodes
</span>
<span
className={`
inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium
${CATEGORY_COLORS[template.category]}
`}
>
{CATEGORY_LABELS[template.category]}
</span>
</div>
</div>
{/* Metadata row */}
<div className="flex items-center gap-2">
{/* Node count badge */}
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-neutral-700/50 text-neutral-400">
{nodeCount} nodes
</span>
{/* Description */}
<p className="text-xs text-neutral-400 line-clamp-2 flex-1">
{template.description}
</p>
{/* Category badge */}
<span
className={`
inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium
${CATEGORY_COLORS[template.category]}
`}
>
{CATEGORY_LABELS[template.category]}
</span>
{/* Action row */}
<div className="flex justify-end mt-2">
<button
onClick={onUseWorkflow}
disabled={disabled || isLoading}
className="px-3 py-1.5 text-xs font-medium bg-blue-600 hover:bg-blue-500 text-white rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1.5"
>
{isLoading ? (
<>
<svg
className="w-3 h-3 animate-spin"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
Loading...
</>
) : (
<>
Use workflow
<svg
className="w-3 h-3"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3"
/>
</svg>
</>
)}
</button>
</div>
</div>
</button>
</div>
);
}

231
src/components/quickstart/TemplateDetailModal.tsx

@ -1,231 +0,0 @@
"use client";
import { useEffect, useCallback } from "react";
import { TemplateCategory } from "@/types/quickstart";
interface TemplateDetailModalProps {
isOpen: boolean;
onClose: () => void;
onUseWorkflow: () => void;
isLoading: boolean;
template: {
id: string;
name: string;
description: string;
category: TemplateCategory;
tags: string[];
};
nodeCount: number;
previewImage?: string;
}
const CATEGORY_LABELS: Record<TemplateCategory, string> = {
product: "Product",
style: "Style",
composition: "Composition",
community: "Community",
};
const CATEGORY_COLORS: Record<TemplateCategory, string> = {
product: "bg-blue-500/20 text-blue-300 border-blue-500/30",
style: "bg-purple-500/20 text-purple-300 border-purple-500/30",
composition: "bg-green-500/20 text-green-300 border-green-500/30",
community: "bg-amber-500/20 text-amber-300 border-amber-500/30",
};
export function TemplateDetailModal({
isOpen,
onClose,
onUseWorkflow,
isLoading,
template,
nodeCount,
previewImage,
}: TemplateDetailModalProps) {
// Handle escape key to close
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === "Escape" && !isLoading) {
onClose();
}
},
[onClose, isLoading]
);
useEffect(() => {
if (isOpen) {
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}
}, [isOpen, handleKeyDown]);
if (!isOpen) return null;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4"
onClick={isLoading ? undefined : onClose}
>
{/* Backdrop */}
<div className="absolute inset-0 bg-black/70" />
{/* Modal */}
<div
className="relative bg-neutral-900 border border-neutral-700 rounded-xl shadow-2xl w-full max-w-4xl max-h-[90vh] flex flex-col overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-start justify-between px-6 py-5 border-b border-neutral-700">
<div className="flex-1 pr-4">
<div className="flex items-center gap-3 mb-2">
<h2 className="text-xl font-semibold text-neutral-100">
{template.name}
</h2>
<span
className={`
inline-flex items-center px-2 py-0.5 rounded text-xs font-medium border
${CATEGORY_COLORS[template.category]}
`}
>
{CATEGORY_LABELS[template.category]}
</span>
</div>
<p className="text-sm text-neutral-400 leading-relaxed">
{template.description}
</p>
</div>
<button
onClick={onClose}
disabled={isLoading}
className="p-1.5 rounded-md text-neutral-400 hover:text-neutral-200 hover:bg-neutral-800 transition-colors disabled:opacity-50"
>
<svg
className="w-5 h-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
{/* Workflow Screenshot */}
<div className="flex-1 min-h-0 p-6 bg-neutral-950/50">
<div className="w-full h-[50vh] bg-neutral-900 rounded-lg border border-neutral-800 overflow-hidden flex items-center justify-center">
{previewImage ? (
/* eslint-disable-next-line @next/next/no-img-element */
<img
src={previewImage}
alt={`${template.name} workflow screenshot`}
className="max-w-full max-h-full object-contain"
/>
) : (
<div className="text-neutral-500 text-sm">No preview available</div>
)}
</div>
</div>
{/* Footer */}
<div className="px-6 py-4 border-t border-neutral-700 bg-neutral-900">
<div className="flex items-center justify-between">
{/* Metadata */}
<div className="flex items-center gap-4">
<div className="flex items-center gap-2 text-sm text-neutral-400">
<svg
className="w-4 h-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-2.25A2.25 2.25 0 0113.5 18v-2.25z"
/>
</svg>
<span>{nodeCount} nodes</span>
</div>
{/* Tags */}
<div className="flex items-center gap-1.5">
{template.tags.map((tag) => (
<span
key={tag}
className="px-2 py-0.5 rounded text-xs bg-neutral-800 text-neutral-500"
>
{tag}
</span>
))}
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-3">
<button
onClick={onClose}
disabled={isLoading}
className="px-4 py-2 text-sm font-medium text-neutral-400 hover:text-neutral-200 transition-colors disabled:opacity-50"
>
Cancel
</button>
<button
onClick={onUseWorkflow}
disabled={isLoading}
className="px-5 py-2 text-sm font-medium bg-blue-600 hover:bg-blue-500 text-white rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
{isLoading ? (
<>
<svg
className="w-4 h-4 animate-spin"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
Loading...
</>
) : (
<>
Use this workflow
<svg
className="w-4 h-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3"
/>
</svg>
</>
)}
</button>
</div>
</div>
</div>
</div>
</div>
);
}

23
src/components/quickstart/TemplateExplorerView.tsx

@ -5,7 +5,6 @@ import { WorkflowFile } from "@/store/workflowStore";
import { getAllPresets, PRESET_TEMPLATES, getTemplateContent } from "@/lib/quickstart/templates";
import { QuickstartBackButton } from "./QuickstartBackButton";
import { TemplateCard } from "./TemplateCard";
import { TemplateDetailModal } from "./TemplateDetailModal";
import { CommunityWorkflowMeta, TemplateCategory, TemplateMetadata } from "@/types/quickstart";
interface TemplateExplorerViewProps {
@ -31,7 +30,6 @@ export function TemplateExplorerView({
const [isLoadingList, setIsLoadingList] = useState(true);
const [loadingWorkflowId, setLoadingWorkflowId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [selectedTemplateId, setSelectedTemplateId] = useState<string | null>(null);
// Filter state
const [searchQuery, setSearchQuery] = useState("");
@ -401,15 +399,16 @@ export function TemplateExplorerView({
<h3 className="text-xs font-medium text-neutral-400 uppercase tracking-wider">
Quick Start
</h3>
<div className="grid grid-cols-2 gap-4">
<div className="flex flex-col gap-3">
{filteredPresets.map((preset) => (
<TemplateCard
key={preset.id}
template={preset}
nodeCount={presetMetadata[preset.id]?.nodeCount ?? 0}
previewImage={previewImages[preset.id]}
onClick={() => setSelectedTemplateId(preset.id)}
disabled={isLoading}
isLoading={loadingWorkflowId === preset.id}
onUseWorkflow={() => handlePresetSelect(preset.id)}
disabled={isLoading && loadingWorkflowId !== preset.id}
/>
))}
</div>
@ -582,20 +581,6 @@ export function TemplateExplorerView({
</div>
</div>
{/* Template Detail Modal */}
{selectedTemplateId && (
<TemplateDetailModal
isOpen={true}
onClose={() => setSelectedTemplateId(null)}
onUseWorkflow={() => {
handlePresetSelect(selectedTemplateId);
}}
isLoading={loadingWorkflowId === selectedTemplateId}
template={presets.find((p) => p.id === selectedTemplateId)!}
nodeCount={presetMetadata[selectedTemplateId]?.nodeCount ?? 0}
previewImage={previewImages[selectedTemplateId]}
/>
)}
</div>
);
}

239
src/components/quickstart/WorkflowPreview.tsx

@ -1,239 +0,0 @@
"use client";
import { useMemo, useCallback } from "react";
import {
ReactFlow,
ReactFlowProvider,
Node,
Edge,
useReactFlow,
NodeProps,
Handle,
Position,
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
// Node type to color mapping
const NODE_COLORS: Record<string, string> = {
imageInput: "#22c55e", // green-500
annotation: "#eab308", // yellow-500
prompt: "#3b82f6", // blue-500
nanoBanana: "#f97316", // orange-500
generateVideo: "#a855f7", // purple-500
llmGenerate: "#06b6d4", // cyan-500
splitGrid: "#ec4899", // pink-500
output: "#6b7280", // gray-500
};
// Node type to display label mapping
const NODE_LABELS: Record<string, string> = {
imageInput: "Image Input",
annotation: "Annotation",
prompt: "Prompt",
nanoBanana: "Generate Image",
generateVideo: "Generate Video",
llmGenerate: "LLM",
splitGrid: "Split Grid",
output: "Output",
};
interface PreviewNodeData extends Record<string, unknown> {
nodeType: string;
label?: string;
model?: string;
}
// Preview node component with outline style and labels
function PreviewNode({ data }: NodeProps) {
const nodeData = data as PreviewNodeData;
const nodeType = nodeData?.nodeType || "unknown";
const color = NODE_COLORS[nodeType] || "#6b7280";
const label = nodeData?.label || NODE_LABELS[nodeType] || nodeType;
const model = nodeData?.model;
// Determine if this is a generate node that should show model
const isGenerateNode = nodeType === "nanoBanana" || nodeType === "generateVideo";
return (
<div
style={{
width: "100%",
height: "100%",
backgroundColor: "rgba(23, 23, 23, 0.95)",
border: `2px solid ${color}`,
borderRadius: 8,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
padding: "8px 12px",
gap: 4,
}}
>
{/* Node label */}
<div
style={{
fontSize: 11,
fontWeight: 600,
color: color,
textAlign: "center",
lineHeight: 1.2,
}}
>
{label}
</div>
{/* Model name for generate nodes */}
{isGenerateNode && model && (
<div
style={{
fontSize: 9,
color: "rgba(255,255,255,0.6)",
textAlign: "center",
lineHeight: 1.2,
maxWidth: "100%",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{model}
</div>
)}
{/* Invisible handles for edge connections */}
<Handle
type="target"
position={Position.Left}
style={{ opacity: 0, pointerEvents: "none" }}
/>
<Handle
type="source"
position={Position.Right}
style={{ opacity: 0, pointerEvents: "none" }}
/>
</div>
);
}
const previewNodeTypes = {
preview: PreviewNode,
};
interface WorkflowPreviewProps {
workflow: {
nodes: Node[];
edges: Edge[];
};
className?: string;
}
// Helper to extract model name from node data
function getModelName(nodeData: Record<string, unknown>): string | undefined {
// Check for model field (common in generate nodes)
if (typeof nodeData?.model === "string") {
// Format model name to be more readable
const model = nodeData.model as string;
// Map internal names to display names
if (model === "nano-banana") return "Gemini Flash";
if (model === "nano-banana-pro") return "Gemini Pro";
// For other models, show shortened version
if (model.includes("/")) {
const parts = model.split("/");
return parts[parts.length - 1];
}
return model;
}
return undefined;
}
// Inner component that can use useReactFlow
function WorkflowPreviewInner({ workflow, className = "" }: WorkflowPreviewProps) {
const { fitView } = useReactFlow();
// Transform workflow nodes to preview nodes with labels
const previewNodes = useMemo(() => {
return workflow.nodes.map((node) => {
const nodeType = node.type || "unknown";
const nodeData = node.data as Record<string, unknown>;
return {
id: node.id,
type: "preview",
position: node.position,
data: {
nodeType,
label: NODE_LABELS[nodeType] || nodeType,
model: getModelName(nodeData),
} as PreviewNodeData,
// Use scaled-down versions of the original dimensions
style: {
width: ((node.style?.width as number) || 300) * 0.35,
height: ((node.style?.height as number) || 280) * 0.3,
},
};
});
}, [workflow.nodes]);
// Create visible edges with better styling
const previewEdges = useMemo(() => {
return workflow.edges.map((edge) => ({
id: edge.id,
source: edge.source,
target: edge.target,
type: "smoothstep",
style: {
stroke: "#525252",
strokeWidth: 2,
},
animated: false,
}));
}, [workflow.edges]);
// Fit view when nodes load
const onInit = useCallback(() => {
setTimeout(() => {
fitView({ padding: 0.2, duration: 0 });
}, 50);
}, [fitView]);
return (
<div className={`w-full h-full ${className}`}>
<ReactFlow
nodes={previewNodes}
edges={previewEdges}
nodeTypes={previewNodeTypes}
onInit={onInit}
// Non-interactive mode
nodesDraggable={false}
nodesConnectable={false}
elementsSelectable={false}
panOnDrag={false}
zoomOnScroll={false}
zoomOnPinch={false}
zoomOnDoubleClick={false}
preventScrolling={true}
// Fit the preview to container
fitView={true}
fitViewOptions={{ padding: 0.2 }}
// Hide attribution
proOptions={{ hideAttribution: true }}
// Styling
className="bg-transparent"
defaultEdgeOptions={{
type: "smoothstep",
animated: false,
}}
/>
</div>
);
}
// Wrap with ReactFlowProvider for standalone usage
export function WorkflowPreview(props: WorkflowPreviewProps) {
return (
<ReactFlowProvider>
<WorkflowPreviewInner {...props} />
</ReactFlowProvider>
);
}

98
src/components/quickstart/WorkflowPreviewModal.tsx

@ -1,98 +0,0 @@
"use client";
import { useEffect, useCallback } from "react";
import { Node, Edge } from "@xyflow/react";
import { WorkflowPreview } from "./WorkflowPreview";
interface WorkflowPreviewModalProps {
isOpen: boolean;
onClose: () => void;
templateName: string;
workflow: { nodes: Node[]; edges: Edge[] };
}
export function WorkflowPreviewModal({
isOpen,
onClose,
templateName,
workflow,
}: WorkflowPreviewModalProps) {
// Handle escape key to close
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === "Escape") {
onClose();
}
},
[onClose]
);
useEffect(() => {
if (isOpen) {
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}
}, [isOpen, handleKeyDown]);
if (!isOpen) return null;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center"
onClick={onClose}
>
{/* Backdrop */}
<div className="absolute inset-0 bg-black/70" />
{/* Modal */}
<div
className="relative bg-neutral-900 border border-neutral-700 rounded-xl shadow-2xl w-[90vw] max-w-4xl max-h-[85vh] flex flex-col"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-neutral-700">
<div>
<h3 className="text-base font-semibold text-neutral-100">
{templateName}
</h3>
<p className="text-xs text-neutral-500 mt-0.5">
Workflow structure preview
</p>
</div>
<button
onClick={onClose}
className="p-1.5 rounded-md text-neutral-400 hover:text-neutral-200 hover:bg-neutral-800 transition-colors"
>
<svg
className="w-5 h-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
{/* Preview Content */}
<div className="flex-1 min-h-0 p-4">
<div className="w-full h-[65vh] bg-neutral-950 rounded-lg border border-neutral-800 overflow-hidden">
<WorkflowPreview workflow={workflow} />
</div>
</div>
{/* Footer hint */}
<div className="px-5 py-3 border-t border-neutral-700 text-center">
<p className="text-xs text-neutral-500">
Press <kbd className="px-1.5 py-0.5 rounded bg-neutral-800 text-neutral-400 font-mono text-[10px]">Esc</kbd> or click outside to close
</p>
</div>
</div>
</div>
);
}
Loading…
Cancel
Save