Browse Source

feat(25-02): improve template explorer layout and visuals

- Change to 2-column grid layout for better visual balance
- Add animated thumbnail cycling through preview images (2.5s interval, fade transition)
- Add indicator dots for multi-image templates
- Add darker sidebar background (bg-neutral-900/80) for visual separation
- Update card backgrounds to neutral-900/50 for better contrast
- Pass preview images from template content to TemplateCard
handoff-20260429-1057
shrimbly 6 months ago
parent
commit
f25826b3b4
  1. 117
      src/components/quickstart/TemplateCard.tsx
  2. 25
      src/components/quickstart/TemplateExplorerView.tsx

117
src/components/quickstart/TemplateCard.tsx

@ -1,5 +1,6 @@
"use client"; "use client";
import { useState, useEffect } from "react";
import { TemplateCategory } from "@/types/quickstart"; import { TemplateCategory } from "@/types/quickstart";
interface TemplateCardProps { interface TemplateCardProps {
@ -12,6 +13,7 @@ interface TemplateCardProps {
tags: string[]; tags: string[];
}; };
nodeCount: number; nodeCount: number;
previewImages?: string[];
isLoading: boolean; isLoading: boolean;
onClick: () => void; onClick: () => void;
disabled: boolean; disabled: boolean;
@ -34,10 +36,31 @@ const CATEGORY_COLORS: Record<TemplateCategory, string> = {
export function TemplateCard({ export function TemplateCard({
template, template,
nodeCount, nodeCount,
previewImages = [],
isLoading, isLoading,
onClick, onClick,
disabled, disabled,
}: TemplateCardProps) { }: TemplateCardProps) {
const [currentImageIndex, setCurrentImageIndex] = useState(0);
const [isTransitioning, setIsTransitioning] = useState(false);
// Cycle through preview images
useEffect(() => {
if (previewImages.length <= 1) return;
const interval = setInterval(() => {
setIsTransitioning(true);
setTimeout(() => {
setCurrentImageIndex((prev) => (prev + 1) % previewImages.length);
setIsTransitioning(false);
}, 300); // Fade out duration
}, 2500); // Time between transitions
return () => clearInterval(interval);
}, [previewImages.length]);
const hasImages = previewImages.length > 0;
return ( return (
<button <button
onClick={onClick} onClick={onClick}
@ -47,52 +70,82 @@ export function TemplateCard({
${ ${
isLoading isLoading
? "bg-blue-600/20 border-blue-500/50" ? "bg-blue-600/20 border-blue-500/50"
: "bg-neutral-800/50 border-neutral-700 hover:border-neutral-600 hover:bg-neutral-800/70" : "bg-neutral-900/50 border-neutral-700 hover:border-neutral-500 hover:bg-neutral-900/70"
} }
${disabled && !isLoading ? "opacity-50 cursor-not-allowed" : "cursor-pointer"} ${disabled && !isLoading ? "opacity-50 cursor-not-allowed" : "cursor-pointer"}
`} `}
> >
{/* Icon */} {/* Thumbnail Area */}
<div <div
className={` className={`
w-12 h-12 rounded-lg flex items-center justify-center mb-3 w-full aspect-[4/3] rounded-lg mb-3 overflow-hidden relative
${ ${
isLoading isLoading
? "bg-blue-500/30" ? "bg-blue-500/20"
: "bg-neutral-700/50 group-hover:bg-neutral-700" : "bg-neutral-800 group-hover:bg-neutral-700/80"
} }
`} `}
> >
{isLoading ? ( {isLoading ? (
<svg <div className="absolute inset-0 flex items-center justify-center">
className="w-5 h-5 text-blue-400 animate-spin" <svg
fill="none" className="w-6 h-6 text-blue-400 animate-spin"
viewBox="0 0 24 24" fill="none"
> viewBox="0 0 24 24"
<circle >
className="opacity-25" <circle
cx="12" className="opacity-25"
cy="12" cx="12"
r="10" cy="12"
stroke="currentColor" r="10"
strokeWidth="4" stroke="currentColor"
/> strokeWidth="4"
<path />
className="opacity-75" <path
fill="currentColor" className="opacity-75"
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" 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>
) : hasImages ? (
<>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={previewImages[currentImageIndex]}
alt={`${template.name} preview`}
className={`
w-full h-full object-cover transition-opacity duration-300
${isTransitioning ? "opacity-0" : "opacity-100"}
`}
/> />
</svg> {/* Image indicator dots */}
{previewImages.length > 1 && (
<div className="absolute bottom-2 left-1/2 -translate-x-1/2 flex gap-1">
{previewImages.map((_, idx) => (
<div
key={idx}
className={`
w-1.5 h-1.5 rounded-full transition-colors
${idx === currentImageIndex ? "bg-white" : "bg-white/40"}
`}
/>
))}
</div>
)}
</>
) : ( ) : (
<svg <div className="absolute inset-0 flex items-center justify-center">
className="w-6 h-6 text-neutral-400 group-hover:text-neutral-300 transition-colors" <svg
fill="none" className="w-8 h-8 text-neutral-600 group-hover:text-neutral-500 transition-colors"
viewBox="0 0 24 24" fill="none"
stroke="currentColor" viewBox="0 0 24 24"
strokeWidth={1.5} stroke="currentColor"
> strokeWidth={1.5}
<path strokeLinecap="round" strokeLinejoin="round" d={template.icon} /> >
</svg> <path strokeLinecap="round" strokeLinejoin="round" d={template.icon} />
</svg>
</div>
)} )}
</div> </div>

25
src/components/quickstart/TemplateExplorerView.tsx

@ -2,7 +2,7 @@
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 { CommunityWorkflowMeta, TemplateCategory, TemplateMetadata } from "@/types/quickstart"; import { CommunityWorkflowMeta, TemplateCategory, TemplateMetadata } from "@/types/quickstart";
@ -69,6 +69,20 @@ export function TemplateExplorerView({
return metadata; return metadata;
}, []); }, []);
// Get preview images for each template (from the "full" content level)
const previewImages = useMemo(() => {
const images: Record<string, string[]> = {};
PRESET_TEMPLATES.forEach((template) => {
const content = getTemplateContent(template.id, "full");
if (content?.images) {
images[template.id] = Object.values(content.images).map((img) => img.url);
} else {
images[template.id] = [];
}
});
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) => {
@ -261,7 +275,7 @@ export function TemplateExplorerView({
{/* Content - Sidebar + Main Grid */} {/* Content - Sidebar + Main Grid */}
<div className="flex-1 flex overflow-hidden"> <div className="flex-1 flex overflow-hidden">
{/* Sidebar */} {/* Sidebar */}
<div className="w-48 flex-shrink-0 border-r border-neutral-700 p-4 space-y-5 overflow-y-auto"> <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 */} {/* Search Input */}
<div className="relative"> <div className="relative">
<svg <svg
@ -386,12 +400,13 @@ export function TemplateExplorerView({
<h3 className="text-xs font-medium text-neutral-400 uppercase tracking-wider"> <h3 className="text-xs font-medium text-neutral-400 uppercase tracking-wider">
Quick Start Quick Start
</h3> </h3>
<div className="grid grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-2 gap-4">
{filteredPresets.map((preset) => ( {filteredPresets.map((preset) => (
<TemplateCard <TemplateCard
key={preset.id} key={preset.id}
template={preset} template={preset}
nodeCount={presetMetadata[preset.id]?.nodeCount ?? 0} nodeCount={presetMetadata[preset.id]?.nodeCount ?? 0}
previewImages={previewImages[preset.id]}
isLoading={loadingWorkflowId === preset.id} isLoading={loadingWorkflowId === preset.id}
onClick={() => handlePresetSelect(preset.id)} onClick={() => handlePresetSelect(preset.id)}
disabled={isLoading} disabled={isLoading}
@ -436,7 +451,7 @@ export function TemplateExplorerView({
</svg> </svg>
</div> </div>
) : ( ) : (
<div className="grid grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-2 gap-4">
{filteredCommunity.map((workflow) => ( {filteredCommunity.map((workflow) => (
<button <button
key={workflow.id} key={workflow.id}
@ -447,7 +462,7 @@ export function TemplateExplorerView({
${ ${
loadingWorkflowId === workflow.id loadingWorkflowId === workflow.id
? "bg-purple-600/20 border-purple-500/50" ? "bg-purple-600/20 border-purple-500/50"
: "bg-neutral-800/50 border-neutral-700 hover:border-neutral-600 hover:bg-neutral-800/70" : "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"} ${isLoading && loadingWorkflowId !== workflow.id ? "opacity-50 cursor-not-allowed" : "cursor-pointer"}
`} `}

Loading…
Cancel
Save