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.
 
 

370 lines
13 KiB

"use client";
import { useState, useEffect, useCallback } from "react";
import { WorkflowFile } from "@/store/workflowStore";
import { getAllPresets } from "@/lib/quickstart/templates";
import { QuickstartBackButton } from "./QuickstartBackButton";
import { CommunityWorkflowMeta } from "@/types/quickstart";
interface QuickstartTemplatesViewProps {
onBack: () => void;
onWorkflowSelected: (workflow: WorkflowFile) => void;
}
export function QuickstartTemplatesView({
onBack,
onWorkflowSelected,
}: QuickstartTemplatesViewProps) {
const [communityWorkflows, setCommunityWorkflows] = useState<CommunityWorkflowMeta[]>([]);
const [isLoadingList, setIsLoadingList] = useState(true);
const [loadingWorkflowId, setLoadingWorkflowId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const presets = getAllPresets();
// 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 {
// Step 1: Get presigned download URL from API
const response = await fetch(`/api/community-workflows/${workflowId}`);
const result = await response.json();
if (!result.success || !result.downloadUrl) {
throw new Error(result.error || "Failed to get download URL");
}
// Step 2: Download workflow directly from R2
const workflowResponse = await fetch(result.downloadUrl);
if (!workflowResponse.ok) {
throw new Error("Failed to download workflow");
}
const workflow = await workflowResponse.json();
onWorkflowSelected(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">
{/* Header */}
<div className="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">
Workflow Templates
</h2>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-6 space-y-6">
{/* Description */}
<p className="text-sm text-neutral-400">
Pre-built workflows to help you get started quickly. Select a template to load it into the canvas.
</p>
{/* Quick Start Templates */}
<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 sm:grid-cols-3 gap-2">
{presets.map((preset) => (
<button
key={preset.id}
onClick={() => handlePresetSelect(preset.id)}
disabled={isLoading}
className={`
group flex items-center gap-2.5 px-3 py-2.5 rounded-lg border transition-all text-left
${
loadingWorkflowId === preset.id
? "bg-blue-600/20 border-blue-500/50"
: "bg-neutral-800/50 border-neutral-700 hover:border-neutral-600 hover:bg-neutral-800"
}
${isLoading && loadingWorkflowId !== preset.id ? "opacity-50" : ""}
${isLoading ? "cursor-not-allowed" : "cursor-pointer"}
`}
>
<div
className={`
w-8 h-8 rounded-md flex items-center justify-center flex-shrink-0
${
loadingWorkflowId === preset.id
? "bg-blue-500/30"
: "bg-neutral-700/50 group-hover:bg-neutral-700"
}
`}
>
{loadingWorkflowId === preset.id ? (
<svg
className="w-4 h-4 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>
) : (
<svg
className="w-4 h-4 text-neutral-400 group-hover:text-neutral-300"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d={preset.icon}
/>
</svg>
)}
</div>
<div className="min-w-0">
<div className="text-sm font-medium text-neutral-200 truncate">
{preset.name}
</div>
<div className="text-[10px] text-neutral-500 truncate">
{preset.description}
</div>
</div>
</button>
))}
</div>
</div>
{/* Divider */}
<div className="border-t border-neutral-700" />
{/* Community Workflows */}
<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>
) : communityWorkflows.length === 0 ? (
<p className="text-sm text-neutral-500 py-4">
No community workflows available
</p>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
{communityWorkflows.map((workflow) => (
<button
key={workflow.id}
onClick={() => handleCommunitySelect(workflow.id)}
disabled={isLoading}
className={`
group flex items-center gap-2.5 px-3 py-2.5 rounded-lg border transition-all text-left
${
loadingWorkflowId === workflow.id
? "bg-purple-600/20 border-purple-500/50"
: "bg-neutral-800/50 border-neutral-700 hover:border-neutral-600 hover:bg-neutral-800"
}
${isLoading && loadingWorkflowId !== workflow.id ? "opacity-50" : ""}
${isLoading ? "cursor-not-allowed" : "cursor-pointer"}
`}
>
<div
className={`
w-8 h-8 rounded-md flex items-center justify-center flex-shrink-0
${
loadingWorkflowId === workflow.id
? "bg-purple-500/30"
: "bg-neutral-700/50 group-hover:bg-neutral-700"
}
`}
>
{loadingWorkflowId === workflow.id ? (
<svg
className="w-4 h-4 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-4 h-4 text-neutral-400 group-hover:text-neutral-300"
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>
<div className="min-w-0">
<div className="text-sm font-medium text-neutral-200 truncate">
{workflow.name}
</div>
<div className="text-[10px] text-purple-400/80">
@{workflow.author}
</div>
</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>
);
}