You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

584 lines
21 KiB

"use client";
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import { WorkflowFile } from "@/store/workflowStore";
import { getAllPresets, PRESET_TEMPLATES } from "@/lib/quickstart/templates";
import { QuickstartBackButton } from "./QuickstartBackButton";
import { TemplateCard } from "./TemplateCard";
import { CommunityWorkflowMeta, TemplateCategory, TemplateMetadata } from "@/types/quickstart";
interface TemplateExplorerViewProps {
onBack: () => void;
onWorkflowSelected: (workflow: WorkflowFile) => void;
}
type CategoryFilter = "all" | TemplateCategory;
const CATEGORY_OPTIONS: { id: CategoryFilter; label: string }[] = [
{ id: "all", label: "All" },
{ id: "product", label: "Product" },
{ id: "style", label: "Style" },
{ id: "composition", label: "Composition" },
{ id: "community", label: "Community" },
];
export function TemplateExplorerView({
onBack,
onWorkflowSelected,
}: TemplateExplorerViewProps) {
const [communityWorkflows, setCommunityWorkflows] = useState<CommunityWorkflowMeta[]>([]);
const [isLoadingList, setIsLoadingList] = useState(true);
const [loadingWorkflowId, setLoadingWorkflowId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
// Filter state
const [searchQuery, setSearchQuery] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState("");
const [categoryFilter, setCategoryFilter] = useState<CategoryFilter>("all");
const [selectedTags, setSelectedTags] = useState<Set<string>>(new Set());
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const presets = getAllPresets();
// Debounce search query
useEffect(() => {
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current);
}
searchTimeoutRef.current = setTimeout(() => {
setDebouncedSearch(searchQuery);
}, 200);
return () => {
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current);
}
};
}, [searchQuery]);
// Calculate node counts for each preset
const presetMetadata = useMemo(() => {
const metadata: Record<string, TemplateMetadata> = {};
PRESET_TEMPLATES.forEach((template) => {
metadata[template.id] = {
nodeCount: template.workflow.nodes.length,
category: template.category,
tags: template.tags,
};
});
return metadata;
}, []);
// Get workflow data for each template
const presetWorkflows = useMemo(() => {
const workflows: Record<string, { nodes: typeof PRESET_TEMPLATES[0]["workflow"]["nodes"]; edges: typeof PRESET_TEMPLATES[0]["workflow"]["edges"] } | undefined> = {};
PRESET_TEMPLATES.forEach((template) => {
workflows[template.id] = {
nodes: template.workflow.nodes,
edges: template.workflow.edges,
};
});
return workflows;
}, []);
// Filter presets based on search, category, and tags
const filteredPresets = useMemo(() => {
return presets.filter((preset) => {
// Search filter: match name or description
if (debouncedSearch) {
const searchLower = debouncedSearch.toLowerCase();
const matchesSearch =
preset.name.toLowerCase().includes(searchLower) ||
preset.description.toLowerCase().includes(searchLower);
if (!matchesSearch) return false;
}
// Category filter
if (categoryFilter !== "all" && categoryFilter !== "community") {
if (preset.category !== categoryFilter) return false;
}
// If "community" is selected, hide preset templates (they're not community)
if (categoryFilter === "community") {
return false;
}
// Tags filter (OR logic - match ANY selected tag)
if (selectedTags.size > 0) {
const hasMatchingTag = preset.tags.some((tag) => selectedTags.has(tag));
if (!hasMatchingTag) return false;
}
return true;
});
}, [presets, debouncedSearch, categoryFilter, selectedTags]);
// Filter community workflows
const filteredCommunity = useMemo(() => {
// Only show community workflows if "all" or "community" category selected
if (categoryFilter !== "all" && categoryFilter !== "community") {
return [];
}
return communityWorkflows.filter((workflow) => {
// Search filter
if (debouncedSearch) {
const searchLower = debouncedSearch.toLowerCase();
const matchesSearch =
workflow.name.toLowerCase().includes(searchLower) ||
workflow.author.toLowerCase().includes(searchLower);
if (!matchesSearch) return false;
}
// Tags don't apply to community workflows (they don't have tags)
return true;
});
}, [communityWorkflows, debouncedSearch, categoryFilter]);
// Collect all unique tags from filtered presets
const availableTags = useMemo(() => {
const tags = new Set<string>();
// Get tags from ALL presets (not just filtered) so users can explore
presets.forEach((preset) => {
preset.tags.forEach((tag) => tags.add(tag));
});
return Array.from(tags).sort();
}, [presets]);
// Toggle tag selection
const toggleTag = useCallback((tag: string) => {
setSelectedTags((prev) => {
const next = new Set(prev);
if (next.has(tag)) {
next.delete(tag);
} else {
next.add(tag);
}
return next;
});
}, []);
// Clear all filters
const clearFilters = useCallback(() => {
setSearchQuery("");
setDebouncedSearch("");
setCategoryFilter("all");
setSelectedTags(new Set());
}, []);
// Check if any filters are active
const hasActiveFilters = searchQuery || categoryFilter !== "all" || selectedTags.size > 0;
// Check if results are empty
const hasNoResults =
filteredPresets.length === 0 &&
(categoryFilter === "community" ? filteredCommunity.length === 0 : true) &&
!isLoadingList;
// Fetch community workflows on mount
useEffect(() => {
async function fetchCommunityWorkflows() {
try {
const response = await fetch("/api/community-workflows");
const result = await response.json();
if (result.success) {
setCommunityWorkflows(result.workflows);
} else {
console.error("Failed to fetch community workflows:", result.error);
}
} catch (err) {
console.error("Error fetching community workflows:", err);
} finally {
setIsLoadingList(false);
}
}
fetchCommunityWorkflows();
}, []);
const handlePresetSelect = useCallback(
async (templateId: string) => {
setLoadingWorkflowId(templateId);
setError(null);
try {
const response = await fetch("/api/quickstart", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
templateId,
contentLevel: "full",
}),
});
const result = await response.json();
if (!result.success) {
throw new Error(result.error || "Failed to load template");
}
if (result.workflow) {
onWorkflowSelected(result.workflow);
}
} catch (err) {
console.error("Error loading preset:", err);
setError(err instanceof Error ? err.message : "Failed to load template");
} finally {
setLoadingWorkflowId(null);
}
},
[onWorkflowSelected]
);
const handleCommunitySelect = useCallback(
async (workflowId: string) => {
setLoadingWorkflowId(workflowId);
setError(null);
try {
const response = await fetch(`/api/community-workflows/${workflowId}`);
const result = await response.json();
if (!result.success) {
throw new Error(result.error || "Failed to load workflow");
}
if (result.workflow) {
onWorkflowSelected(result.workflow);
}
} catch (err) {
console.error("Error loading community workflow:", err);
setError(err instanceof Error ? err.message : "Failed to load workflow");
} finally {
setLoadingWorkflowId(null);
}
},
[onWorkflowSelected]
);
const isLoading = loadingWorkflowId !== null;
return (
<div className="flex flex-col h-full min-h-0">
{/* Header */}
<div className="flex-shrink-0 px-6 py-4 border-b border-neutral-700 flex items-center gap-4">
<QuickstartBackButton onClick={onBack} disabled={isLoading} />
<h2 className="text-lg font-semibold text-neutral-100">
Template Explorer
</h2>
</div>
{/* Content - Sidebar + Main Grid */}
<div className="flex-1 flex min-h-0 overflow-clip">
{/* Sidebar */}
<div className="w-48 flex-shrink-0 bg-neutral-900/80 border-r border-neutral-700 p-4 space-y-5 overflow-y-auto">
{/* Search Input */}
<div className="relative">
<svg
className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-neutral-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
/>
</svg>
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search templates..."
className="w-full pl-8 pr-3 py-2 text-sm bg-neutral-700/50 border border-neutral-600 rounded-lg text-neutral-200 placeholder-neutral-500 focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
{/* Category Filters */}
<div className="space-y-2">
<h3 className="text-xs font-medium text-neutral-500 uppercase tracking-wider">
Category
</h3>
<div className="flex flex-col gap-1">
{CATEGORY_OPTIONS.map((option) => (
<button
key={option.id}
onClick={() => setCategoryFilter(option.id)}
className={`
px-3 py-1.5 text-xs font-medium rounded-md text-left transition-colors
${
categoryFilter === option.id
? "bg-blue-500/20 border border-blue-500/50 text-blue-300"
: "bg-neutral-700/30 border border-transparent text-neutral-400 hover:bg-neutral-700/50 hover:text-neutral-300"
}
`}
>
{option.label}
</button>
))}
</div>
</div>
{/* Tags Section */}
<div className="space-y-2">
<h3 className="text-xs font-medium text-neutral-500 uppercase tracking-wider">
Tags
</h3>
<div className="flex flex-wrap gap-1">
{availableTags.map((tag) => (
<button
key={tag}
onClick={() => toggleTag(tag)}
className={`
px-2 py-1 text-[10px] font-medium rounded transition-colors
${
selectedTags.has(tag)
? "bg-blue-500/30 text-blue-300 border border-blue-500/50"
: "bg-neutral-700/30 text-neutral-500 hover:bg-neutral-700/50 hover:text-neutral-400 border border-transparent"
}
`}
>
{tag}
</button>
))}
</div>
</div>
{/* Clear Filters */}
{hasActiveFilters && (
<button
onClick={clearFilters}
className="w-full px-3 py-1.5 text-xs font-medium text-neutral-400 hover:text-neutral-300 bg-neutral-700/30 hover:bg-neutral-700/50 rounded-md transition-colors"
>
Clear filters
</button>
)}
</div>
{/* Main Content Area */}
<div className="flex-1 min-h-0 overflow-y-auto overscroll-contain p-6 space-y-6">
{/* Empty State */}
{hasNoResults && hasActiveFilters && (
<div className="flex flex-col items-center justify-center py-12 text-center">
<svg
className="w-12 h-12 text-neutral-600 mb-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
/>
</svg>
<h3 className="text-sm font-medium text-neutral-300 mb-1">
No templates match your filters
</h3>
<p className="text-xs text-neutral-500 mb-4">
Try adjusting your search or filters
</p>
<button
onClick={clearFilters}
className="px-4 py-2 text-sm font-medium text-blue-400 hover:text-blue-300 bg-blue-500/10 hover:bg-blue-500/20 rounded-lg transition-colors"
>
Clear all filters
</button>
</div>
)}
{/* Quick Start Templates */}
{filteredPresets.length > 0 && (
<div className="space-y-3">
<h3 className="text-xs font-medium text-neutral-400 uppercase tracking-wider">
Quick Start
</h3>
<div className="grid grid-cols-2 gap-4">
{filteredPresets.map((preset) => (
<TemplateCard
key={preset.id}
template={preset}
nodeCount={presetMetadata[preset.id]?.nodeCount ?? 0}
workflow={presetWorkflows[preset.id]}
isLoading={loadingWorkflowId === preset.id}
onClick={() => handlePresetSelect(preset.id)}
disabled={isLoading}
/>
))}
</div>
</div>
)}
{/* Divider */}
{filteredPresets.length > 0 && (filteredCommunity.length > 0 || (isLoadingList && categoryFilter !== "community")) && (
<div className="border-t border-neutral-700" />
)}
{/* Community Workflows */}
{(filteredCommunity.length > 0 || (isLoadingList && (categoryFilter === "all" || categoryFilter === "community"))) && (
<div className="space-y-3">
<h3 className="text-xs font-medium text-neutral-400 uppercase tracking-wider">
Community Workflows
</h3>
{isLoadingList ? (
<div className="flex items-center justify-center py-8">
<svg
className="w-5 h-5 text-neutral-500 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>
) : (
<div className="grid grid-cols-2 gap-4">
{filteredCommunity.map((workflow) => (
<button
key={workflow.id}
onClick={() => handleCommunitySelect(workflow.id)}
disabled={isLoading}
className={`
group w-full text-left rounded-lg border p-4 transition-all
${
loadingWorkflowId === workflow.id
? "bg-purple-600/20 border-purple-500/50"
: "bg-neutral-900/50 border-neutral-700 hover:border-neutral-500 hover:bg-neutral-900/70"
}
${isLoading && loadingWorkflowId !== workflow.id ? "opacity-50 cursor-not-allowed" : "cursor-pointer"}
`}
>
{/* Icon */}
<div
className={`
w-12 h-12 rounded-lg flex items-center justify-center mb-3
${
loadingWorkflowId === workflow.id
? "bg-purple-500/30"
: "bg-neutral-700/50 group-hover:bg-neutral-700"
}
`}
>
{loadingWorkflowId === workflow.id ? (
<svg
className="w-5 h-5 text-purple-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>
) : (
<svg
className="w-6 h-6 text-neutral-400 group-hover:text-neutral-300 transition-colors"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"
/>
</svg>
)}
</div>
{/* Name */}
<h3 className="text-sm font-medium text-neutral-200 mb-1">
{workflow.name}
</h3>
{/* Author */}
<p className="text-xs text-purple-400/80">
@{workflow.author}
</p>
{/* Community badge */}
<div className="mt-3">
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-amber-500/20 text-amber-300">
Community
</span>
</div>
</button>
))}
</div>
)}
{/* Discord CTA */}
<p className="text-xs text-neutral-500 mt-3">
Want to share your workflow?{" "}
<a
href="https://discord.com/invite/89Nr6EKkTf"
target="_blank"
rel="noopener noreferrer"
className="text-purple-400 hover:text-purple-300 underline"
>
Join our Discord
</a>{" "}
to submit it to the community templates.
</p>
</div>
)}
{/* Error */}
{error && (
<div className="flex items-start gap-2 p-3 rounded-lg bg-red-500/10 border border-red-500/30">
<svg
className="w-4 h-4 text-red-400 mt-0.5 flex-shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
<div className="flex-1">
<p className="text-sm text-red-400">{error}</p>
<button
onClick={() => setError(null)}
className="text-xs text-red-400/70 hover:text-red-400 mt-1"
>
Dismiss
</button>
</div>
</div>
)}
</div>
</div>
</div>
);
}