Browse Source

feat(26-01): create detail modal flow for template selection

- TemplateDetailModal: full details view with description, workflow preview,
  metadata, and "Use this workflow" button
- TemplateCard: simplified - removed preview icon and tags row,
  card click now opens detail modal
- TemplateExplorerView: wired up new flow where card opens detail modal

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
handoff-20260429-1057
shrimbly 6 months ago
parent
commit
2fdf771d4b
  1. 64
      src/components/quickstart/TemplateCard.tsx
  2. 224
      src/components/quickstart/TemplateDetailModal.tsx
  3. 26
      src/components/quickstart/TemplateExplorerView.tsx

64
src/components/quickstart/TemplateCard.tsx

@ -1,6 +1,5 @@
"use client";
import { Node, Edge } from "@xyflow/react";
import { TemplateCategory } from "@/types/quickstart";
interface TemplateCardProps {
@ -14,11 +13,9 @@ interface TemplateCardProps {
};
nodeCount: number;
previewImage?: string;
workflow?: { nodes: Node[]; edges: Edge[] };
isLoading: boolean;
isLoading?: boolean;
onClick: () => void;
onPreviewClick?: () => void;
disabled: boolean;
disabled?: boolean;
}
const CATEGORY_LABELS: Record<TemplateCategory, string> = {
@ -39,16 +36,14 @@ export function TemplateCard({
template,
nodeCount,
previewImage,
workflow,
isLoading,
isLoading = false,
onClick,
onPreviewClick,
disabled,
disabled = false,
}: TemplateCardProps) {
return (
<button
onClick={onClick}
disabled={disabled}
disabled={disabled || isLoading}
className={`
group w-full text-left rounded-lg border p-4 transition-all
${
@ -112,41 +107,6 @@ export function TemplateCard({
</svg>
</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>
{/* Name */}
@ -160,7 +120,7 @@ export function TemplateCard({
</p>
{/* Metadata row */}
<div className="flex items-center gap-2 mb-2">
<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
@ -176,18 +136,6 @@ export function TemplateCard({
{CATEGORY_LABELS[template.category]}
</span>
</div>
{/* Tags */}
<div className="flex flex-wrap gap-1">
{template.tags.slice(0, 4).map((tag) => (
<span
key={tag}
className="px-1.5 py-0.5 rounded text-[10px] bg-neutral-700/30 text-neutral-500"
>
{tag}
</span>
))}
</div>
</button>
);
}

224
src/components/quickstart/TemplateDetailModal.tsx

@ -0,0 +1,224 @@
"use client";
import { useEffect, useCallback } from "react";
import { Node, Edge } from "@xyflow/react";
import { TemplateCategory } from "@/types/quickstart";
import { WorkflowPreview } from "./WorkflowPreview";
interface TemplateDetailModalProps {
isOpen: boolean;
onClose: () => void;
onUseWorkflow: () => void;
isLoading: boolean;
template: {
id: string;
name: string;
description: string;
category: TemplateCategory;
tags: string[];
};
nodeCount: number;
workflow: { nodes: Node[]; edges: Edge[] };
}
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,
workflow,
}: 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 Preview */}
<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">
<WorkflowPreview workflow={workflow} />
</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>
);
}

26
src/components/quickstart/TemplateExplorerView.tsx

@ -5,7 +5,7 @@ import { WorkflowFile } from "@/store/workflowStore";
import { getAllPresets, PRESET_TEMPLATES, getTemplateContent } from "@/lib/quickstart/templates";
import { QuickstartBackButton } from "./QuickstartBackButton";
import { TemplateCard } from "./TemplateCard";
import { WorkflowPreviewModal } from "./WorkflowPreviewModal";
import { TemplateDetailModal } from "./TemplateDetailModal";
import { CommunityWorkflowMeta, TemplateCategory, TemplateMetadata } from "@/types/quickstart";
interface TemplateExplorerViewProps {
@ -31,7 +31,7 @@ export function TemplateExplorerView({
const [isLoadingList, setIsLoadingList] = useState(true);
const [loadingWorkflowId, setLoadingWorkflowId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [previewTemplateId, setPreviewTemplateId] = useState<string | null>(null);
const [selectedTemplateId, setSelectedTemplateId] = useState<string | null>(null);
// Filter state
const [searchQuery, setSearchQuery] = useState("");
@ -420,10 +420,7 @@ export function TemplateExplorerView({
template={preset}
nodeCount={presetMetadata[preset.id]?.nodeCount ?? 0}
previewImage={previewImages[preset.id]}
workflow={presetWorkflows[preset.id]}
isLoading={loadingWorkflowId === preset.id}
onClick={() => handlePresetSelect(preset.id)}
onPreviewClick={() => setPreviewTemplateId(preset.id)}
onClick={() => setSelectedTemplateId(preset.id)}
disabled={isLoading}
/>
))}
@ -597,13 +594,18 @@ export function TemplateExplorerView({
</div>
</div>
{/* Workflow Preview Modal */}
{previewTemplateId && presetWorkflows[previewTemplateId] && (
<WorkflowPreviewModal
{/* Template Detail Modal */}
{selectedTemplateId && presetWorkflows[selectedTemplateId] && (
<TemplateDetailModal
isOpen={true}
onClose={() => setPreviewTemplateId(null)}
templateName={presets.find((p) => p.id === previewTemplateId)?.name || "Template"}
workflow={presetWorkflows[previewTemplateId]}
onClose={() => setSelectedTemplateId(null)}
onUseWorkflow={() => {
handlePresetSelect(selectedTemplateId);
}}
isLoading={loadingWorkflowId === selectedTemplateId}
template={presets.find((p) => p.id === selectedTemplateId)!}
nodeCount={presetMetadata[selectedTemplateId]?.nodeCount ?? 0}
workflow={presetWorkflows[selectedTemplateId]}
/>
)}
</div>

Loading…
Cancel
Save