mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-04-28 09:56:23 +02:00
Community cards and prebuilt cards (#258)
* Add community sharing feature and merge with pre-built templates * Add warning before publishing * [Untested] Add delete flow for community cards * Fix bug with sorting by likes count and update design of cards * Fix community assistant parsing errors * Remove all as a type filter * Remove default assistant name for publishing to community * Update DB calls to be standardized paginated
This commit is contained in:
parent
62c1230cff
commit
be4e17b5a5
20 changed files with 2144 additions and 264 deletions
307
apps/rowboat/components/common/AssistantCard.tsx
Normal file
307
apps/rowboat/components/common/AssistantCard.tsx
Normal file
|
|
@ -0,0 +1,307 @@
|
|||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
import { PictureImg } from '@/components/ui/picture-img';
|
||||
import { Heart, Share2, Calendar } from 'lucide-react';
|
||||
|
||||
// Helper function to get relative time
|
||||
const getRelativeTime = (dateString: string): string => {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000);
|
||||
|
||||
if (diffInSeconds < 60) {
|
||||
return 'just now';
|
||||
}
|
||||
|
||||
const diffInMinutes = Math.floor(diffInSeconds / 60);
|
||||
if (diffInMinutes < 60) {
|
||||
return `${diffInMinutes} minute${diffInMinutes === 1 ? '' : 's'} ago`;
|
||||
}
|
||||
|
||||
const diffInHours = Math.floor(diffInMinutes / 60);
|
||||
if (diffInHours < 24) {
|
||||
return `${diffInHours} hour${diffInHours === 1 ? '' : 's'} ago`;
|
||||
}
|
||||
|
||||
const diffInDays = Math.floor(diffInHours / 24);
|
||||
if (diffInDays < 7) {
|
||||
return `${diffInDays} day${diffInDays === 1 ? '' : 's'} ago`;
|
||||
}
|
||||
|
||||
const diffInWeeks = Math.floor(diffInDays / 7);
|
||||
if (diffInWeeks < 4) {
|
||||
return `${diffInWeeks} week${diffInWeeks === 1 ? '' : 's'} ago`;
|
||||
}
|
||||
|
||||
const diffInMonths = Math.floor(diffInDays / 30);
|
||||
if (diffInMonths < 12) {
|
||||
return `${diffInMonths} month${diffInMonths === 1 ? '' : 's'} ago`;
|
||||
}
|
||||
|
||||
const diffInYears = Math.floor(diffInDays / 365);
|
||||
return `${diffInYears} year${diffInYears === 1 ? '' : 's'} ago`;
|
||||
};
|
||||
|
||||
interface AssistantCardProps {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
category: string;
|
||||
tools?: Array<{
|
||||
name: string;
|
||||
logo?: string;
|
||||
}>;
|
||||
// Community-specific props
|
||||
authorName?: string;
|
||||
isAnonymous?: boolean;
|
||||
likeCount?: number;
|
||||
createdAt?: string;
|
||||
onLike?: () => void;
|
||||
onShare?: () => void;
|
||||
onDelete?: () => void;
|
||||
isLiked?: boolean;
|
||||
// Template type indicator
|
||||
templateType?: 'prebuilt' | 'community';
|
||||
// Common props
|
||||
onClick?: () => void;
|
||||
loading?: boolean;
|
||||
disabled?: boolean;
|
||||
getUniqueTools?: (item: any) => Array<{ name: string; logo?: string }>;
|
||||
}
|
||||
|
||||
export function AssistantCard({
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
category,
|
||||
tools = [],
|
||||
authorName,
|
||||
isAnonymous = false,
|
||||
likeCount = 0,
|
||||
createdAt,
|
||||
onLike,
|
||||
onShare,
|
||||
isLiked = false,
|
||||
onDelete,
|
||||
templateType,
|
||||
onClick,
|
||||
loading = false,
|
||||
disabled = false,
|
||||
getUniqueTools
|
||||
}: AssistantCardProps) {
|
||||
const displayTools = getUniqueTools ? getUniqueTools({ tools }) : tools;
|
||||
const [isDescriptionExpanded, setIsDescriptionExpanded] = React.useState(false);
|
||||
const [showDescriptionToggle, setShowDescriptionToggle] = React.useState(false);
|
||||
const descriptionRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
React.useEffect(() => {
|
||||
let t: any;
|
||||
if (copied) {
|
||||
t = setTimeout(() => setCopied(false), 1500);
|
||||
}
|
||||
return () => t && clearTimeout(t);
|
||||
}, [copied]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const el = descriptionRef.current;
|
||||
if (!el) return;
|
||||
// Measure if truncated (only when collapsed)
|
||||
if (!isDescriptionExpanded) {
|
||||
setShowDescriptionToggle(el.scrollHeight > el.clientHeight + 1);
|
||||
} else {
|
||||
setShowDescriptionToggle(true);
|
||||
}
|
||||
}, [description, isDescriptionExpanded]);
|
||||
|
||||
const getCategoryColor = (category: string) => {
|
||||
const lowerCategory = category.toLowerCase();
|
||||
if (lowerCategory.includes('work productivity')) {
|
||||
return 'bg-amber-50 text-amber-700 dark:bg-amber-400/10 dark:text-amber-300';
|
||||
} else if (lowerCategory.includes('developer productivity')) {
|
||||
return 'bg-indigo-50 text-indigo-700 dark:bg-indigo-400/10 dark:text-indigo-300';
|
||||
} else if (lowerCategory.includes('news') || lowerCategory.includes('social')) {
|
||||
return 'bg-green-50 text-green-700 dark:bg-green-400/10 dark:text-green-300';
|
||||
} else if (lowerCategory.includes('customer support')) {
|
||||
return 'bg-red-50 text-red-700 dark:bg-red-400/10 dark:text-red-300';
|
||||
} else if (lowerCategory.includes('education')) {
|
||||
return 'bg-blue-50 text-blue-700 dark:bg-blue-400/10 dark:text-blue-300';
|
||||
} else if (lowerCategory.includes('entertainment')) {
|
||||
return 'bg-purple-50 text-purple-700 dark:bg-purple-400/10 dark:text-purple-300';
|
||||
} else {
|
||||
return 'bg-gray-50 text-gray-700 dark:bg-gray-400/10 dark:text-gray-300';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className={clsx(
|
||||
"relative block p-4 border border-gray-200 dark:border-gray-700 rounded-xl transition-all group text-left cursor-pointer",
|
||||
"hover:border-blue-300 dark:hover:border-blue-600 hover:bg-gray-50 dark:hover:bg-gray-700/50 hover:shadow-md",
|
||||
loading && "opacity-90 cursor-not-allowed",
|
||||
disabled && "opacity-50 cursor-not-allowed"
|
||||
)}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{/* Title and Description */}
|
||||
<div>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="font-medium text-gray-900 dark:text-gray-100 group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors line-clamp-1 flex-1">
|
||||
{name}
|
||||
</div>
|
||||
{/* Template Type Badge */}
|
||||
{templateType && (
|
||||
<span className={clsx(
|
||||
"inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium flex-shrink-0",
|
||||
templateType === 'prebuilt'
|
||||
? "bg-blue-50 text-blue-700 dark:bg-blue-400/10 dark:text-blue-300"
|
||||
: "bg-rose-50 text-rose-700 dark:bg-rose-400/10 dark:text-rose-300"
|
||||
)}>
|
||||
{templateType === 'prebuilt' ? 'Library' : 'Community'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 relative">
|
||||
<div
|
||||
ref={descriptionRef}
|
||||
className={clsx(
|
||||
"text-sm leading-5 text-gray-600 dark:text-gray-400 relative min-h-[2.5rem]",
|
||||
(!isDescriptionExpanded && showDescriptionToggle) && "pr-20",
|
||||
!isDescriptionExpanded && "line-clamp-2"
|
||||
)}
|
||||
>
|
||||
{description}
|
||||
</div>
|
||||
{showDescriptionToggle && (
|
||||
!isDescriptionExpanded ? (
|
||||
<div className="pointer-events-none absolute inset-0">
|
||||
<div className="absolute bottom-0 right-0 h-5 w-24 pl-2 flex items-center justify-end bg-gradient-to-l from-white dark:from-gray-800/95 to-transparent">
|
||||
<button
|
||||
onClick={(e) => { e.preventDefault(); e.stopPropagation(); setIsDescriptionExpanded(true); }}
|
||||
className="pointer-events-auto text-xs font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300 px-1"
|
||||
aria-label="Read more"
|
||||
>
|
||||
Read more
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={(e) => { e.preventDefault(); e.stopPropagation(); setIsDescriptionExpanded(false); }}
|
||||
className="mt-1 text-xs font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
aria-label="Show less"
|
||||
>
|
||||
Show less
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tools (reserve row height even when absent to align cards) */}
|
||||
<div className="flex items-center gap-2 min-h-[20px] -mt-1">
|
||||
{displayTools.length > 0 && (
|
||||
<>
|
||||
<div className="text-xs text-gray-400 dark:text-gray-500">
|
||||
Tools:
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{displayTools.slice(0, 4).map((tool) => (
|
||||
tool.logo && (
|
||||
<PictureImg
|
||||
key={tool.name}
|
||||
src={tool.logo}
|
||||
alt={`${tool.name} logo`}
|
||||
className="w-4 h-4 rounded-sm object-cover flex-shrink-0"
|
||||
title={tool.name}
|
||||
/>
|
||||
)
|
||||
))}
|
||||
{displayTools.length > 4 && (
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500">
|
||||
+{displayTools.length - 4}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Category Badge */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className={clsx(
|
||||
"inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium",
|
||||
getCategoryColor(category)
|
||||
)}>
|
||||
{category}
|
||||
</span>
|
||||
{loading && (
|
||||
<div className="text-blue-600 dark:text-blue-400">
|
||||
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-current"></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Author and interaction info */}
|
||||
<div className="flex items-center justify-between text-xs text-gray-500 dark:text-gray-400">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>
|
||||
{isAnonymous ? 'Anonymous' : (authorName ? (authorName.split(' ')[0] || 'Rowboat') : 'Rowboat')}
|
||||
</span>
|
||||
{onDelete && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
className="ml-1 inline-flex items-center justify-center text-gray-400 hover:text-red-600 transition-colors"
|
||||
aria-label="Delete template"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"></path><path d="M10 11v6"></path><path d="M14 11v6"></path><path d="M9 6V4a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v2"></path></svg>
|
||||
</button>
|
||||
)}
|
||||
{createdAt && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Calendar size={12} />
|
||||
<span>{getRelativeTime(createdAt)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onLike?.();
|
||||
}}
|
||||
className={clsx(
|
||||
"flex items-center gap-1 hover:text-red-500 transition-colors",
|
||||
isLiked && "text-red-500"
|
||||
)}
|
||||
>
|
||||
<Heart size={14} className={isLiked ? "fill-current" : ""} />
|
||||
<span>{likeCount || 0}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setCopied(true);
|
||||
onShare?.();
|
||||
}}
|
||||
className="flex items-center gap-1 hover:text-blue-500 transition-colors"
|
||||
aria-label="Copy share URL"
|
||||
>
|
||||
<Share2 size={14} className={copied ? "text-blue-600" : undefined} />
|
||||
{copied && <span className="text-[10px] text-blue-600">Copied</span>}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
238
apps/rowboat/components/common/AssistantSection.tsx
Normal file
238
apps/rowboat/components/common/AssistantSection.tsx
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Input } from "@heroui/react";
|
||||
import { Search } from 'lucide-react';
|
||||
import { AssistantCard } from './AssistantCard';
|
||||
|
||||
interface AssistantItem {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
category: string;
|
||||
tools?: Array<{
|
||||
name: string;
|
||||
logo?: string;
|
||||
}>;
|
||||
// Community-specific
|
||||
authorName?: string;
|
||||
isAnonymous?: boolean;
|
||||
likeCount?: number;
|
||||
createdAt?: string;
|
||||
isLiked?: boolean;
|
||||
}
|
||||
|
||||
interface AssistantSectionProps {
|
||||
title: string;
|
||||
description: string;
|
||||
items: AssistantItem[];
|
||||
loading?: boolean;
|
||||
error?: string | null;
|
||||
onItemClick?: (item: AssistantItem) => void;
|
||||
onRetry?: () => void;
|
||||
loadingItemId?: string | null;
|
||||
emptyMessage?: string;
|
||||
// Community-specific callbacks
|
||||
onLike?: (item: AssistantItem) => void;
|
||||
onShare?: (item: AssistantItem) => void;
|
||||
// Pre-built specific
|
||||
getUniqueTools?: (item: AssistantItem) => Array<{ name: string; logo?: string }>;
|
||||
// Filter state
|
||||
initialSearchQuery?: string;
|
||||
initialSelectedCategory?: string;
|
||||
onFiltersChange?: (filters: {
|
||||
searchQuery: string;
|
||||
selectedCategory: string;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
|
||||
export function AssistantSection({
|
||||
title,
|
||||
description,
|
||||
items,
|
||||
loading = false,
|
||||
error = null,
|
||||
onItemClick,
|
||||
onRetry,
|
||||
loadingItemId = null,
|
||||
emptyMessage = "No assistants available",
|
||||
onLike,
|
||||
onShare,
|
||||
getUniqueTools,
|
||||
initialSearchQuery = '',
|
||||
initialSelectedCategory = '',
|
||||
onFiltersChange
|
||||
}: AssistantSectionProps) {
|
||||
const [searchQuery, setSearchQuery] = useState(initialSearchQuery);
|
||||
const [selectedCategory, setSelectedCategory] = useState(initialSelectedCategory);
|
||||
|
||||
// Notify parent of filter changes if callback provided
|
||||
useEffect(() => {
|
||||
if (onFiltersChange) {
|
||||
onFiltersChange({
|
||||
searchQuery,
|
||||
selectedCategory
|
||||
});
|
||||
}
|
||||
}, [searchQuery, selectedCategory, onFiltersChange]);
|
||||
|
||||
// Get available categories from items
|
||||
const availableCategories = React.useMemo(() => {
|
||||
const categories = new Set(items.map(item => item.category));
|
||||
return Array.from(categories).sort();
|
||||
}, [items]);
|
||||
|
||||
// Filter items
|
||||
const filteredItems = React.useMemo(() => {
|
||||
let filtered = [...items];
|
||||
|
||||
// Apply search filter
|
||||
if (searchQuery) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
filtered = filtered.filter(item =>
|
||||
item.name.toLowerCase().includes(query) ||
|
||||
item.description.toLowerCase().includes(query) ||
|
||||
item.category.toLowerCase().includes(query)
|
||||
);
|
||||
}
|
||||
|
||||
// Apply category filter
|
||||
if (selectedCategory) {
|
||||
filtered = filtered.filter(item => item.category === selectedCategory);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}, [items, searchQuery, selectedCategory]);
|
||||
|
||||
const isCommunity = items.length > 0 && items[0].authorName !== undefined;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl p-6 shadow-lg border border-gray-200 dark:border-gray-700">
|
||||
<div className="text-left mb-6">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">
|
||||
{title}
|
||||
</h2>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
|
||||
<p className="text-gray-500 dark:text-gray-400 mt-2">Loading assistants...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl p-6 shadow-lg border border-gray-200 dark:border-gray-700">
|
||||
<div className="text-left mb-6">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">
|
||||
{title}
|
||||
</h2>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-center py-12">
|
||||
<p className="text-red-500 dark:text-red-400">{error}</p>
|
||||
{onRetry && (
|
||||
<button
|
||||
onClick={onRetry}
|
||||
className="mt-4 px-4 py-2 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-md hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl p-6 shadow-lg border border-gray-200 dark:border-gray-700">
|
||||
<div className="text-left mb-6">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">
|
||||
{title}
|
||||
</h2>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-col sm:flex-row gap-4 mb-6">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
placeholder="Search assistants..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
startContent={<Search size={16} className="text-gray-400" />}
|
||||
className="max-w-md"
|
||||
classNames={{
|
||||
input: "focus:outline-none focus:ring-0 focus:border-gray-300 dark:focus:border-gray-600",
|
||||
inputWrapper: "focus-within:ring-0 focus-within:ring-offset-0 focus-within:border-gray-300 dark:focus-within:border-gray-600"
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<div className="relative">
|
||||
<select
|
||||
value={selectedCategory}
|
||||
onChange={(e) => setSelectedCategory(e.target.value)}
|
||||
className="w-48 px-3 py-2 pr-10 border border-gray-200 dark:border-gray-700 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 appearance-none text-sm"
|
||||
>
|
||||
<option value="">All Categories</option>
|
||||
{availableCategories.map((category) => (
|
||||
<option key={category} value={category}>
|
||||
{category}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none">
|
||||
<svg className="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grid */}
|
||||
{filteredItems.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-500 dark:text-gray-400">{emptyMessage}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{filteredItems.map((item) => (
|
||||
<AssistantCard
|
||||
key={item.id}
|
||||
id={item.id}
|
||||
name={item.name}
|
||||
description={item.description}
|
||||
category={item.category}
|
||||
tools={item.tools}
|
||||
authorName={item.authorName}
|
||||
isAnonymous={item.isAnonymous}
|
||||
likeCount={item.likeCount}
|
||||
createdAt={item.createdAt}
|
||||
onClick={() => onItemClick?.(item)}
|
||||
loading={loadingItemId === item.id}
|
||||
getUniqueTools={getUniqueTools}
|
||||
onLike={onLike ? () => onLike(item) : undefined}
|
||||
onShare={onShare ? () => onShare(item) : undefined}
|
||||
isLiked={item.isLiked}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
468
apps/rowboat/components/common/UnifiedTemplatesSection.tsx
Normal file
468
apps/rowboat/components/common/UnifiedTemplatesSection.tsx
Normal file
|
|
@ -0,0 +1,468 @@
|
|||
'use client';
|
||||
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { Input } from "@heroui/react";
|
||||
import { Search, Filter } from 'lucide-react';
|
||||
import { AssistantCard } from './AssistantCard';
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface TemplateItem {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
category: string;
|
||||
authorId?: string;
|
||||
source?: 'library' | 'community';
|
||||
tools?: Array<{
|
||||
name: string;
|
||||
logo?: string;
|
||||
}>;
|
||||
// Community-specific
|
||||
authorName?: string;
|
||||
isAnonymous?: boolean;
|
||||
likeCount?: number;
|
||||
createdAt?: string;
|
||||
isLiked?: boolean;
|
||||
// Template type indicator
|
||||
type: 'prebuilt' | 'community';
|
||||
}
|
||||
|
||||
interface UnifiedTemplatesSectionProps {
|
||||
prebuiltTemplates: TemplateItem[];
|
||||
communityTemplates: TemplateItem[];
|
||||
loading?: boolean;
|
||||
error?: string | null;
|
||||
onTemplateClick?: (item: TemplateItem) => void;
|
||||
onRetry?: () => void;
|
||||
loadingItemId?: string | null;
|
||||
onLike?: (item: TemplateItem) => void;
|
||||
onShare?: (item: TemplateItem) => void;
|
||||
onDelete?: (item: TemplateItem) => void;
|
||||
getUniqueTools?: (item: TemplateItem) => Array<{ name: string; logo?: string }>;
|
||||
onLoadMore?: (type: 'prebuilt' | 'community', targetCount: number) => Promise<void> | void;
|
||||
onTypeChange?: (type: 'prebuilt' | 'community', targetCount: number) => Promise<void> | void;
|
||||
}
|
||||
|
||||
export function UnifiedTemplatesSection({
|
||||
prebuiltTemplates,
|
||||
communityTemplates,
|
||||
loading = false,
|
||||
error = null,
|
||||
onTemplateClick,
|
||||
onRetry,
|
||||
loadingItemId = null,
|
||||
onLike,
|
||||
onShare,
|
||||
onDelete,
|
||||
getUniqueTools,
|
||||
onLoadMore,
|
||||
onTypeChange,
|
||||
}: UnifiedTemplatesSectionProps) {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedType, setSelectedType] = useState<'prebuilt' | 'community'>('prebuilt');
|
||||
const [selectedCategories, setSelectedCategories] = useState<Set<string>>(new Set());
|
||||
const [sortBy, setSortBy] = useState<'popular' | 'newest' | 'alphabetical'>('popular');
|
||||
const [currentUserId, setCurrentUserId] = useState<string | null>(null);
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [pendingDeleteItem, setPendingDeleteItem] = useState<TemplateItem | null>(null);
|
||||
|
||||
// Row-based pagination state
|
||||
const [columns, setColumns] = useState<number>(1);
|
||||
const [rowsShown, setRowsShown] = useState<number>(4);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
(async () => {
|
||||
try {
|
||||
const resp = await fetch('/api/me', { cache: 'no-store' });
|
||||
if (!resp.ok) return;
|
||||
const data = await resp.json();
|
||||
if (isMounted) setCurrentUserId(data.id || null);
|
||||
} catch (_e) {}
|
||||
})();
|
||||
return () => { isMounted = false; };
|
||||
}, []);
|
||||
|
||||
// Combine all templates
|
||||
const allTemplates = useMemo(() => {
|
||||
const combined = [
|
||||
...prebuiltTemplates.map(t => ({ ...t, type: 'prebuilt' as const })),
|
||||
...communityTemplates.map(t => ({ ...t, type: 'community' as const }))
|
||||
];
|
||||
return combined;
|
||||
}, [prebuiltTemplates, communityTemplates]);
|
||||
|
||||
// Get available categories
|
||||
const availableCategories = useMemo(() => {
|
||||
const categories = new Set(allTemplates.map(item => item.category));
|
||||
return Array.from(categories).sort();
|
||||
}, [allTemplates]);
|
||||
|
||||
// Filter and sort templates
|
||||
const filteredTemplates = useMemo(() => {
|
||||
let filtered = [...allTemplates];
|
||||
|
||||
// Apply search filter
|
||||
if (searchQuery) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
filtered = filtered.filter(item =>
|
||||
item.name.toLowerCase().includes(query) ||
|
||||
item.description.toLowerCase().includes(query) ||
|
||||
item.category.toLowerCase().includes(query)
|
||||
);
|
||||
}
|
||||
|
||||
// Apply type filter (default to 'prebuilt' / Library)
|
||||
filtered = filtered.filter(item => item.type === selectedType);
|
||||
|
||||
// Apply category filter
|
||||
if (selectedCategories.size > 0) {
|
||||
filtered = filtered.filter(item => selectedCategories.has(item.category));
|
||||
}
|
||||
|
||||
// Apply sorting
|
||||
filtered.sort((a, b) => {
|
||||
switch (sortBy) {
|
||||
case 'newest':
|
||||
if (a.createdAt && b.createdAt) {
|
||||
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
|
||||
}
|
||||
return 0;
|
||||
case 'alphabetical':
|
||||
return a.name.localeCompare(b.name);
|
||||
case 'popular':
|
||||
default:
|
||||
// Sort across both types by like count desc; tie-break by createdAt desc, then name
|
||||
{
|
||||
const aLikes = a.likeCount || 0;
|
||||
const bLikes = b.likeCount || 0;
|
||||
if (bLikes !== aLikes) return bLikes - aLikes;
|
||||
const aTime = a.createdAt ? new Date(a.createdAt).getTime() : 0;
|
||||
const bTime = b.createdAt ? new Date(b.createdAt).getTime() : 0;
|
||||
if (bTime !== aTime) return bTime - aTime;
|
||||
return a.name.localeCompare(b.name);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return filtered;
|
||||
}, [allTemplates, searchQuery, selectedType, selectedCategories, sortBy]);
|
||||
|
||||
// Determine columns based on Tailwind breakpoints used by the grid
|
||||
useEffect(() => {
|
||||
const computeColumns = () => {
|
||||
if (typeof window === 'undefined') return 1;
|
||||
// Tailwind: grid-cols-1 sm:grid-cols-2 lg:grid-cols-3
|
||||
const isLg = window.matchMedia('(min-width: 1024px)').matches;
|
||||
const isSm = window.matchMedia('(min-width: 640px)').matches;
|
||||
return isLg ? 3 : (isSm ? 2 : 1);
|
||||
};
|
||||
const update = () => setColumns(computeColumns());
|
||||
update();
|
||||
window.addEventListener('resize', update);
|
||||
return () => window.removeEventListener('resize', update);
|
||||
}, []);
|
||||
|
||||
// Reset rowsShown when filters/sort change
|
||||
useEffect(() => {
|
||||
setRowsShown(4);
|
||||
}, [searchQuery, selectedType, selectedCategories, sortBy]);
|
||||
|
||||
const itemsPerRow = Math.max(columns, 1);
|
||||
const visibleCount = rowsShown * itemsPerRow;
|
||||
const hasMore = filteredTemplates.length > visibleCount;
|
||||
const remainingItems = Math.max(filteredTemplates.length - visibleCount, 0);
|
||||
const remainingRows = Math.ceil(remainingItems / itemsPerRow);
|
||||
|
||||
const visibleTemplates = filteredTemplates.slice(0, visibleCount);
|
||||
|
||||
// Handle category toggle
|
||||
const toggleCategory = (category: string) => {
|
||||
setSelectedCategories(prev => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(category)) {
|
||||
newSet.delete(category);
|
||||
} else {
|
||||
newSet.add(category);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
// Clear all filters (default type back to 'prebuilt' / Library)
|
||||
const clearFilters = () => {
|
||||
setSearchQuery('');
|
||||
setSelectedType('prebuilt');
|
||||
setSelectedCategories(new Set());
|
||||
setSortBy('popular');
|
||||
};
|
||||
|
||||
// Check if any filters are active
|
||||
const hasActiveFilters = useMemo(() => {
|
||||
return !!searchQuery || selectedType !== 'prebuilt' || selectedCategories.size > 0;
|
||||
}, [searchQuery, selectedType, selectedCategories]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl p-6 shadow-lg border border-gray-200 dark:border-gray-700">
|
||||
<div className="text-left mb-6">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">
|
||||
Templates
|
||||
</h2>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Discover and use pre-built and community templates.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
|
||||
<p className="text-gray-500 dark:text-gray-400 mt-2">Loading templates...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl p-6 shadow-lg border border-gray-200 dark:border-gray-700">
|
||||
<div className="text-left mb-6">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">
|
||||
Templates
|
||||
</h2>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Discover and use pre-built and community templates.
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-center py-12">
|
||||
<p className="text-red-500 dark:text-red-400">{error}</p>
|
||||
{onRetry && (
|
||||
<button
|
||||
onClick={onRetry}
|
||||
className="mt-4 px-4 py-2 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-md hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl p-6 shadow-lg border border-gray-200 dark:border-gray-700">
|
||||
<div className="text-left mb-6">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">
|
||||
Templates
|
||||
</h2>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Discover and use pre-built and community templates.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="space-y-4 mb-6">
|
||||
{/* Search and Type Filters */}
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
placeholder="Search templates..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
startContent={<Search size={16} className="text-gray-400" />}
|
||||
className="max-w-md"
|
||||
classNames={{
|
||||
input: "focus:outline-none focus:ring-0 focus:border-gray-300 dark:focus:border-gray-600",
|
||||
inputWrapper: "focus-within:ring-0 focus-within:ring-offset-0 focus-within:border-gray-300 dark:focus-within:border-gray-600"
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{/* Type Filter Segmented Control (Library | Community) */}
|
||||
<div className="flex gap-0.5 items-center h-8 rounded-full border border-gray-200 dark:border-gray-700 p-0 bg-white dark:bg-gray-800 shadow-sm overflow-hidden">
|
||||
{[
|
||||
{ key: 'prebuilt', label: 'Library' },
|
||||
{ key: 'community', label: 'Community' }
|
||||
].map(({ key, label }) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={async () => {
|
||||
const newType = key as 'prebuilt' | 'community';
|
||||
const target = rowsShown * itemsPerRow;
|
||||
if (onTypeChange) {
|
||||
await onTypeChange(newType, target);
|
||||
}
|
||||
setSelectedType(newType);
|
||||
}}
|
||||
aria-pressed={selectedType === key}
|
||||
className={`inline-flex items-center h-8 px-2.5 rounded-full text-[13px] font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-400 ${
|
||||
selectedType === key
|
||||
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300'
|
||||
: 'bg-transparent text-gray-700 hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Sort Dropdown */}
|
||||
<div className="relative">
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={(e) => setSortBy(e.target.value as any)}
|
||||
className="w-44 h-8 px-4 pr-10 border border-gray-300 dark:border-gray-700 rounded-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 appearance-none text-sm hover:bg-gray-50 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-400"
|
||||
>
|
||||
<option value="popular">Most Popular</option>
|
||||
<option value="newest">Newest First</option>
|
||||
<option value="alphabetical">A-Z</option>
|
||||
</select>
|
||||
<div className="pointer-events-none absolute inset-y-0 right-3 flex items-center">
|
||||
<svg className="w-4 h-4 text-gray-400 -translate-y-[2px]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category Filters */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<Filter size={14} />
|
||||
<span>Categories:</span>
|
||||
</div>
|
||||
{availableCategories.map((category) => (
|
||||
<button
|
||||
key={category}
|
||||
onClick={() => toggleCategory(category)}
|
||||
className={`px-3 py-1 rounded-full text-sm font-medium transition-colors border shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-400 ${
|
||||
selectedCategories.has(category)
|
||||
? 'bg-blue-50 text-blue-700 border-blue-300 dark:bg-blue-900/20 dark:text-blue-300 dark:border-blue-700'
|
||||
: 'bg-gray-50 text-gray-700 border-gray-300 hover:bg-gray-100 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-600 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
{category}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Clear Filters Button */}
|
||||
{hasActiveFilters && (
|
||||
<div className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<button
|
||||
onClick={clearFilters}
|
||||
className="text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300"
|
||||
>
|
||||
Clear all filters
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
<div className="space-y-4">
|
||||
{filteredTemplates.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
{searchQuery || selectedType !== 'prebuilt' || selectedCategories.size > 0
|
||||
? 'No templates found matching your filters'
|
||||
: 'No templates available'
|
||||
}
|
||||
</p>
|
||||
{(searchQuery || selectedType !== 'prebuilt' || selectedCategories.size > 0) && (
|
||||
<button
|
||||
onClick={clearFilters}
|
||||
className="mt-2 text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 text-sm"
|
||||
>
|
||||
Clear filters
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Showing {Math.min(visibleCount, filteredTemplates.length)} of {filteredTemplates.length} template{filteredTemplates.length !== 1 ? 's' : ''} ({rowsShown} row{rowsShown !== 1 ? 's' : ''})
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{visibleTemplates.map((item) => (
|
||||
<AssistantCard
|
||||
key={`${item.type}-${item.id}`}
|
||||
id={item.id}
|
||||
name={item.name}
|
||||
description={item.description}
|
||||
category={item.category}
|
||||
tools={item.tools}
|
||||
authorName={item.authorName}
|
||||
isAnonymous={item.isAnonymous}
|
||||
likeCount={item.likeCount}
|
||||
createdAt={item.createdAt}
|
||||
onClick={() => onTemplateClick?.(item)}
|
||||
loading={loadingItemId === item.id}
|
||||
getUniqueTools={getUniqueTools}
|
||||
onLike={() => onLike?.(item)}
|
||||
onShare={() => onShare?.(item)}
|
||||
onDelete={onDelete && currentUserId && item.type === 'community' && item.authorId === currentUserId ? () => {
|
||||
setPendingDeleteItem(item);
|
||||
setConfirmOpen(true);
|
||||
} : undefined}
|
||||
isLiked={item.isLiked}
|
||||
templateType={item.type}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{hasMore && (
|
||||
<div className="flex items-center justify-center pt-2">
|
||||
<button
|
||||
onClick={async () => {
|
||||
const target = (rowsShown + 4) * itemsPerRow;
|
||||
if (onLoadMore) {
|
||||
await onLoadMore(selectedType, target);
|
||||
}
|
||||
setRowsShown(prev => prev + 4);
|
||||
}}
|
||||
className="text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300 text-sm font-medium underline-offset-2 hover:underline"
|
||||
>
|
||||
View more
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Delete confirmation modal */}
|
||||
{confirmOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="bg-white dark:bg-gray-900 rounded-xl shadow-xl border border-gray-200 dark:border-gray-700 max-w-sm w-full p-5">
|
||||
<div className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">Delete template?</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
This will permanently remove "{pendingDeleteItem?.name}" from the community templates. This action cannot be undone.
|
||||
</div>
|
||||
<div className="mt-5 flex justify-end gap-2">
|
||||
<button
|
||||
onClick={() => { setConfirmOpen(false); setPendingDeleteItem(null); }}
|
||||
className="px-4 py-2 text-sm rounded-md bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (pendingDeleteItem && onDelete) {
|
||||
await onDelete(pendingDeleteItem);
|
||||
}
|
||||
setConfirmOpen(false);
|
||||
setPendingDeleteItem(null);
|
||||
}}
|
||||
className="px-4 py-2 text-sm rounded-md bg-red-600 hover:bg-red-700 text-white"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue