Browse Source

refactor(26-01): move workflow preview to modal with icon trigger

- Restore image thumbnails as main preview in TemplateCard
- Add small icon button in top right to trigger workflow preview
- Create WorkflowPreviewModal for larger preview display
- Fix WorkflowPreview to properly show all nodes with fitView
- Add legend showing node type colors in modal
handoff-20260429-1057
shrimbly 6 months ago
parent
commit
a93b41f95a
  1. 49
      src/components/quickstart/TemplateCard.tsx
  2. 29
      src/components/quickstart/TemplateExplorerView.tsx
  3. 50
      src/components/quickstart/WorkflowPreview.tsx
  4. 117
      src/components/quickstart/WorkflowPreviewModal.tsx

49
src/components/quickstart/TemplateCard.tsx

@ -2,7 +2,6 @@
import { Node, Edge } from "@xyflow/react"; import { Node, Edge } from "@xyflow/react";
import { TemplateCategory } from "@/types/quickstart"; import { TemplateCategory } from "@/types/quickstart";
import { WorkflowPreview } from "./WorkflowPreview";
interface TemplateCardProps { interface TemplateCardProps {
template: { template: {
@ -14,9 +13,11 @@ interface TemplateCardProps {
tags: string[]; tags: string[];
}; };
nodeCount: number; nodeCount: number;
previewImage?: string;
workflow?: { nodes: Node[]; edges: Edge[] }; workflow?: { nodes: Node[]; edges: Edge[] };
isLoading: boolean; isLoading: boolean;
onClick: () => void; onClick: () => void;
onPreviewClick?: () => void;
disabled: boolean; disabled: boolean;
} }
@ -37,9 +38,11 @@ const CATEGORY_COLORS: Record<TemplateCategory, string> = {
export function TemplateCard({ export function TemplateCard({
template, template,
nodeCount, nodeCount,
previewImage,
workflow, workflow,
isLoading, isLoading,
onClick, onClick,
onPreviewClick,
disabled, disabled,
}: TemplateCardProps) { }: TemplateCardProps) {
return ( return (
@ -89,8 +92,13 @@ export function TemplateCard({
/> />
</svg> </svg>
</div> </div>
) : workflow ? ( ) : previewImage ? (
<WorkflowPreview workflow={workflow} /> /* eslint-disable-next-line @next/next/no-img-element */
<img
src={previewImage}
alt={`${template.name} preview`}
className="w-full h-full object-cover"
/>
) : ( ) : (
<div className="absolute inset-0 flex items-center justify-center"> <div className="absolute inset-0 flex items-center justify-center">
<svg <svg
@ -104,6 +112,41 @@ export function TemplateCard({
</svg> </svg>
</div> </div>
)} )}
{/* Workflow Preview Button - show in top right when workflow is available */}
{workflow && !isLoading && (
<div
role="button"
tabIndex={0}
onClick={(e) => {
e.stopPropagation();
onPreviewClick?.();
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.stopPropagation();
onPreviewClick?.();
}
}}
className="absolute top-2 right-2 p-1.5 rounded-md bg-neutral-900/80 hover:bg-neutral-800 border border-neutral-600 hover:border-neutral-500 transition-colors cursor-pointer"
title="Preview workflow"
>
{/* Node graph icon */}
<svg
className="w-4 h-4 text-neutral-400 hover:text-neutral-300"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M7.5 14.25v2.25m3-4.5v4.5m3-6.75v6.75m3-9v9M6 20.25h12A2.25 2.25 0 0020.25 18V6A2.25 2.25 0 0018 3.75H6A2.25 2.25 0 003.75 6v12A2.25 2.25 0 006 20.25z"
/>
</svg>
</div>
)}
</div> </div>
{/* Name */} {/* Name */}

29
src/components/quickstart/TemplateExplorerView.tsx

@ -2,9 +2,10 @@
import { useState, useEffect, useCallback, useMemo, useRef } from "react"; import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import { WorkflowFile } from "@/store/workflowStore"; import { WorkflowFile } from "@/store/workflowStore";
import { getAllPresets, PRESET_TEMPLATES } from "@/lib/quickstart/templates"; import { getAllPresets, PRESET_TEMPLATES, getTemplateContent } from "@/lib/quickstart/templates";
import { QuickstartBackButton } from "./QuickstartBackButton"; import { QuickstartBackButton } from "./QuickstartBackButton";
import { TemplateCard } from "./TemplateCard"; import { TemplateCard } from "./TemplateCard";
import { WorkflowPreviewModal } from "./WorkflowPreviewModal";
import { CommunityWorkflowMeta, TemplateCategory, TemplateMetadata } from "@/types/quickstart"; import { CommunityWorkflowMeta, TemplateCategory, TemplateMetadata } from "@/types/quickstart";
interface TemplateExplorerViewProps { interface TemplateExplorerViewProps {
@ -30,6 +31,7 @@ export function TemplateExplorerView({
const [isLoadingList, setIsLoadingList] = useState(true); const [isLoadingList, setIsLoadingList] = useState(true);
const [loadingWorkflowId, setLoadingWorkflowId] = useState<string | null>(null); const [loadingWorkflowId, setLoadingWorkflowId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [previewTemplateId, setPreviewTemplateId] = useState<string | null>(null);
// Filter state // Filter state
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
@ -81,6 +83,19 @@ export function TemplateExplorerView({
return workflows; return workflows;
}, []); }, []);
// Get preview image for each template (first image from "full" content level)
const previewImages = useMemo(() => {
const images: Record<string, string | undefined> = {};
PRESET_TEMPLATES.forEach((template) => {
const content = getTemplateContent(template.id, "full");
if (content?.images) {
const imageUrls = Object.values(content.images).map((img) => img.url);
images[template.id] = imageUrls[0];
}
});
return images;
}, []);
// Filter presets based on search, category, and tags // Filter presets based on search, category, and tags
const filteredPresets = useMemo(() => { const filteredPresets = useMemo(() => {
return presets.filter((preset) => { return presets.filter((preset) => {
@ -404,9 +419,11 @@ export function TemplateExplorerView({
key={preset.id} key={preset.id}
template={preset} template={preset}
nodeCount={presetMetadata[preset.id]?.nodeCount ?? 0} nodeCount={presetMetadata[preset.id]?.nodeCount ?? 0}
previewImage={previewImages[preset.id]}
workflow={presetWorkflows[preset.id]} workflow={presetWorkflows[preset.id]}
isLoading={loadingWorkflowId === preset.id} isLoading={loadingWorkflowId === preset.id}
onClick={() => handlePresetSelect(preset.id)} onClick={() => handlePresetSelect(preset.id)}
onPreviewClick={() => setPreviewTemplateId(preset.id)}
disabled={isLoading} disabled={isLoading}
/> />
))} ))}
@ -579,6 +596,16 @@ export function TemplateExplorerView({
)} )}
</div> </div>
</div> </div>
{/* Workflow Preview Modal */}
{previewTemplateId && presetWorkflows[previewTemplateId] && (
<WorkflowPreviewModal
isOpen={true}
onClose={() => setPreviewTemplateId(null)}
templateName={presets.find((p) => p.id === previewTemplateId)?.name || "Template"}
workflow={presetWorkflows[previewTemplateId]}
/>
)}
</div> </div>
); );
} }

50
src/components/quickstart/WorkflowPreview.tsx

@ -1,15 +1,17 @@
"use client"; "use client";
import { useMemo } from "react"; import { useMemo, useCallback } from "react";
import { import {
ReactFlow, ReactFlow,
ReactFlowProvider, ReactFlowProvider,
Node, Node,
Edge, Edge,
useReactFlow,
NodeProps,
} from "@xyflow/react"; } from "@xyflow/react";
import "@xyflow/react/dist/style.css"; import "@xyflow/react/dist/style.css";
// Node type to color mapping - using minimap colors from WorkflowCanvas // Node type to color mapping - using similar colors from WorkflowCanvas minimap
const NODE_COLORS: Record<string, string> = { const NODE_COLORS: Record<string, string> = {
imageInput: "#22c55e", // green-500 imageInput: "#22c55e", // green-500
annotation: "#eab308", // yellow-500 annotation: "#eab308", // yellow-500
@ -22,8 +24,9 @@ const NODE_COLORS: Record<string, string> = {
}; };
// Simple preview node component - just a colored rectangle // Simple preview node component - just a colored rectangle
function PreviewNode({ data }: { data: { nodeType: string } }) { function PreviewNode({ data }: NodeProps) {
const color = NODE_COLORS[data.nodeType] || "#6b7280"; const nodeType = data?.nodeType as string || "unknown";
const color = NODE_COLORS[nodeType] || "#6b7280";
return ( return (
<div <div
@ -31,8 +34,9 @@ function PreviewNode({ data }: { data: { nodeType: string } }) {
width: "100%", width: "100%",
height: "100%", height: "100%",
backgroundColor: color, backgroundColor: color,
borderRadius: 4, borderRadius: 6,
opacity: 0.8, border: "2px solid rgba(255,255,255,0.2)",
boxShadow: "0 2px 4px rgba(0,0,0,0.3)",
}} }}
/> />
); );
@ -50,40 +54,51 @@ interface WorkflowPreviewProps {
className?: string; className?: string;
} }
// Inner component that can use useReactFlow
function WorkflowPreviewInner({ workflow, className = "" }: WorkflowPreviewProps) { function WorkflowPreviewInner({ workflow, className = "" }: WorkflowPreviewProps) {
// Transform workflow nodes to preview nodes const { fitView } = useReactFlow();
// Transform workflow nodes to preview nodes - keep original sizes/positions for better layout
const previewNodes = useMemo(() => { const previewNodes = useMemo(() => {
return workflow.nodes.map((node) => ({ return workflow.nodes.map((node) => ({
id: node.id, id: node.id,
type: "preview", type: "preview",
position: node.position, position: node.position,
data: { nodeType: node.type || "unknown" }, data: { nodeType: node.type || "unknown" },
// Scale down the node sizes for preview // Use scaled-down versions of the original dimensions
style: { style: {
width: ((node.style?.width as number) || 300) * 0.15, width: ((node.style?.width as number) || 300) * 0.3,
height: ((node.style?.height as number) || 280) * 0.15, height: ((node.style?.height as number) || 280) * 0.3,
}, },
})); }));
}, [workflow.nodes]); }, [workflow.nodes]);
// Use the workflow edges as-is (simplified rendering) // Use the workflow edges with simplified styling
const previewEdges = useMemo(() => { const previewEdges = useMemo(() => {
return workflow.edges.map((edge) => ({ return workflow.edges.map((edge) => ({
id: edge.id, id: edge.id,
source: edge.source, source: edge.source,
target: edge.target, target: edge.target,
// Simple edge style style: { stroke: "#525252", strokeWidth: 2 },
style: { stroke: "#525252", strokeWidth: 1 },
type: "default", type: "default",
})); }));
}, [workflow.edges]); }, [workflow.edges]);
// Fit view when nodes load
const onInit = useCallback(() => {
// Small delay to ensure nodes are rendered
setTimeout(() => {
fitView({ padding: 0.3, duration: 0 });
}, 50);
}, [fitView]);
return ( return (
<div className={`w-full h-full ${className}`}> <div className={`w-full h-full ${className}`}>
<ReactFlow <ReactFlow
nodes={previewNodes} nodes={previewNodes}
edges={previewEdges} edges={previewEdges}
nodeTypes={previewNodeTypes} nodeTypes={previewNodeTypes}
onInit={onInit}
// Non-interactive mode // Non-interactive mode
nodesDraggable={false} nodesDraggable={false}
nodesConnectable={false} nodesConnectable={false}
@ -92,15 +107,14 @@ function WorkflowPreviewInner({ workflow, className = "" }: WorkflowPreviewProps
zoomOnScroll={false} zoomOnScroll={false}
zoomOnPinch={false} zoomOnPinch={false}
zoomOnDoubleClick={false} zoomOnDoubleClick={false}
preventScrolling={false} preventScrolling={true}
// Fit the preview to container // Fit the preview to container
fitView={true} fitView={true}
fitViewOptions={{ padding: 0.2 }} fitViewOptions={{ padding: 0.3 }}
// Hide attribution and controls // Hide attribution
proOptions={{ hideAttribution: true }} proOptions={{ hideAttribution: true }}
// Minimal styling // Styling
className="bg-transparent" className="bg-transparent"
// No default edge options needed
defaultEdgeOptions={{ defaultEdgeOptions={{
type: "default", type: "default",
animated: false, animated: false,

117
src/components/quickstart/WorkflowPreviewModal.tsx

@ -0,0 +1,117 @@
"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-3xl 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">
<h3 className="text-base font-semibold text-neutral-100">
{templateName} Workflow Preview
</h3>
<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-[60vh] bg-neutral-800/50 rounded-lg border border-neutral-700 overflow-hidden">
<WorkflowPreview workflow={workflow} />
</div>
</div>
{/* Legend */}
<div className="px-5 py-3 border-t border-neutral-700">
<div className="flex flex-wrap items-center gap-4 text-xs text-neutral-400">
<span className="font-medium text-neutral-300">Node Types:</span>
<div className="flex items-center gap-1.5">
<div className="w-3 h-3 rounded bg-[#22c55e]" />
<span>Image Input</span>
</div>
<div className="flex items-center gap-1.5">
<div className="w-3 h-3 rounded bg-[#3b82f6]" />
<span>Prompt</span>
</div>
<div className="flex items-center gap-1.5">
<div className="w-3 h-3 rounded bg-[#f97316]" />
<span>Generate Image</span>
</div>
<div className="flex items-center gap-1.5">
<div className="w-3 h-3 rounded bg-[#a855f7]" />
<span>Generate Video</span>
</div>
<div className="flex items-center gap-1.5">
<div className="w-3 h-3 rounded bg-[#06b6d4]" />
<span>LLM</span>
</div>
<div className="flex items-center gap-1.5">
<div className="w-3 h-3 rounded bg-[#6b7280]" />
<span>Output</span>
</div>
</div>
</div>
</div>
</div>
);
}
Loading…
Cancel
Save