232 lines
8.2 KiB
TypeScript
232 lines
8.2 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect, useCallback } from 'react';
|
|
import { ChevronDown, Package } from 'lucide-react';
|
|
import { TemplateSearch } from './TemplateSearch';
|
|
import { CategoryFilter } from './CategoryFilter';
|
|
import { TemplateCard } from './TemplateCard';
|
|
import type { MarketplaceTemplate } from '@mcpengine/ai-pipeline/types';
|
|
|
|
type SortOption = 'popular' | 'newest' | 'most_forked';
|
|
|
|
interface MarketplacePageProps {
|
|
initialData?: MarketplaceTemplate[];
|
|
initialMeta?: {
|
|
page: number;
|
|
limit: number;
|
|
total: number;
|
|
totalPages: number;
|
|
categories: string[];
|
|
};
|
|
}
|
|
|
|
export function MarketplacePage({ initialData, initialMeta }: MarketplacePageProps) {
|
|
const [templates, setTemplates] = useState<MarketplaceTemplate[]>(initialData || []);
|
|
const [search, setSearch] = useState('');
|
|
const [category, setCategory] = useState('');
|
|
const [sort, setSort] = useState<SortOption>('popular');
|
|
const [page, setPage] = useState(1);
|
|
const [totalPages, setTotalPages] = useState(initialMeta?.totalPages || 1);
|
|
const [total, setTotal] = useState(initialMeta?.total || 0);
|
|
const [categories, setCategories] = useState<string[]>(initialMeta?.categories || []);
|
|
const [loading, setLoading] = useState(false);
|
|
const [sortOpen, setSortOpen] = useState(false);
|
|
|
|
const fetchTemplates = useCallback(async () => {
|
|
setLoading(true);
|
|
try {
|
|
const params = new URLSearchParams();
|
|
if (search) params.set('q', search);
|
|
if (category) params.set('category', category);
|
|
params.set('page', String(page));
|
|
params.set('limit', '24');
|
|
// Sort is handled client-side via the API's default ordering
|
|
// but we pass a hint for future server support
|
|
params.set('sort', sort);
|
|
|
|
const res = await fetch(`/api/marketplace?${params.toString()}`);
|
|
if (!res.ok) throw new Error('Failed to fetch');
|
|
|
|
const json = await res.json();
|
|
setTemplates(json.data || []);
|
|
setTotalPages(json.meta?.totalPages || 1);
|
|
setTotal(json.meta?.total || 0);
|
|
if (json.meta?.categories) {
|
|
setCategories(json.meta.categories);
|
|
}
|
|
} catch (err) {
|
|
console.error('[MarketplacePage] fetch error:', err);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [search, category, sort, page]);
|
|
|
|
useEffect(() => {
|
|
fetchTemplates();
|
|
}, [fetchTemplates]);
|
|
|
|
// Reset page on filter change
|
|
useEffect(() => {
|
|
setPage(1);
|
|
}, [search, category, sort]);
|
|
|
|
const sortLabel: Record<SortOption, string> = {
|
|
popular: 'Popular',
|
|
newest: 'Newest',
|
|
most_forked: 'Most Forked',
|
|
};
|
|
|
|
// Build category counts (we don't have per-category counts from API, show available categories)
|
|
const categoryCounts = categories.reduce(
|
|
(acc, cat) => ({ ...acc, [cat]: 0 }),
|
|
{} as Record<string, number>,
|
|
);
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-white">Marketplace</h1>
|
|
<p className="text-sm text-gray-400 mt-1">
|
|
Browse {total > 0 ? total.toLocaleString() : ''} community templates
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Search bar */}
|
|
<TemplateSearch onSearch={setSearch} />
|
|
|
|
{/* Category filter + sort */}
|
|
<div className="flex items-center justify-between gap-4">
|
|
<div className="flex-1 overflow-hidden">
|
|
<CategoryFilter
|
|
selected={category}
|
|
onSelect={setCategory}
|
|
categoryCounts={Object.keys(categoryCounts).length > 0 ? categoryCounts : undefined}
|
|
/>
|
|
</div>
|
|
|
|
{/* Sort dropdown */}
|
|
<div className="relative flex-shrink-0">
|
|
<button
|
|
onClick={() => setSortOpen(!sortOpen)}
|
|
className="flex items-center gap-2 px-4 py-1.5 rounded-lg
|
|
bg-gray-800 border border-gray-700 text-sm text-gray-300
|
|
hover:bg-gray-700 transition-colors"
|
|
>
|
|
{sortLabel[sort]}
|
|
<ChevronDown className={`h-4 w-4 transition-transform ${sortOpen ? 'rotate-180' : ''}`} />
|
|
</button>
|
|
{sortOpen && (
|
|
<>
|
|
<div className="fixed inset-0 z-10" onClick={() => setSortOpen(false)} />
|
|
<div className="absolute right-0 top-full mt-1 z-20 w-40 rounded-lg
|
|
bg-gray-800 border border-gray-700 shadow-xl py-1">
|
|
{(Object.keys(sortLabel) as SortOption[]).map((opt) => (
|
|
<button
|
|
key={opt}
|
|
onClick={() => { setSort(opt); setSortOpen(false); }}
|
|
className={`
|
|
w-full text-left px-4 py-2 text-sm transition-colors
|
|
${sort === opt
|
|
? 'text-indigo-400 bg-indigo-500/10'
|
|
: 'text-gray-300 hover:bg-gray-700'
|
|
}
|
|
`}
|
|
>
|
|
{sortLabel[opt]}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Template grid */}
|
|
{loading ? (
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
|
{Array.from({ length: 8 }).map((_, i) => (
|
|
<div key={i} className="h-52 rounded-xl bg-gray-800/50 animate-pulse" />
|
|
))}
|
|
</div>
|
|
) : templates.length === 0 ? (
|
|
/* Empty state */
|
|
<div className="flex flex-col items-center justify-center py-20 text-center">
|
|
<div className="w-16 h-16 rounded-2xl bg-gray-800 flex items-center justify-center mb-4">
|
|
<Package className="h-8 w-8 text-gray-600" />
|
|
</div>
|
|
<h3 className="text-lg font-semibold text-gray-300 mb-2">
|
|
No templates found
|
|
</h3>
|
|
<p className="text-sm text-gray-500 max-w-sm">
|
|
{search
|
|
? `No results for "${search}". Try a different search term or category.`
|
|
: 'No templates available in this category yet.'}
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
|
{templates.map((t) => (
|
|
<TemplateCard key={t.id} template={t} />
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Pagination */}
|
|
{totalPages > 1 && !loading && (
|
|
<div className="flex items-center justify-center gap-2 pt-4">
|
|
<button
|
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
|
disabled={page <= 1}
|
|
className="px-3 py-1.5 rounded-lg text-sm font-medium
|
|
bg-gray-800 text-gray-300 border border-gray-700
|
|
hover:bg-gray-700 disabled:opacity-40 disabled:cursor-not-allowed
|
|
transition-colors"
|
|
>
|
|
Previous
|
|
</button>
|
|
{Array.from({ length: Math.min(totalPages, 7) }, (_, i) => {
|
|
let pageNum: number;
|
|
if (totalPages <= 7) {
|
|
pageNum = i + 1;
|
|
} else if (page <= 4) {
|
|
pageNum = i + 1;
|
|
} else if (page >= totalPages - 3) {
|
|
pageNum = totalPages - 6 + i;
|
|
} else {
|
|
pageNum = page - 3 + i;
|
|
}
|
|
return (
|
|
<button
|
|
key={pageNum}
|
|
onClick={() => setPage(pageNum)}
|
|
className={`
|
|
w-9 h-9 rounded-lg text-sm font-medium transition-colors
|
|
${page === pageNum
|
|
? 'bg-indigo-600 text-white'
|
|
: 'bg-gray-800 text-gray-400 hover:bg-gray-700 hover:text-gray-200'
|
|
}
|
|
`}
|
|
>
|
|
{pageNum}
|
|
</button>
|
|
);
|
|
})}
|
|
<button
|
|
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
|
disabled={page >= totalPages}
|
|
className="px-3 py-1.5 rounded-lg text-sm font-medium
|
|
bg-gray-800 text-gray-300 border border-gray-700
|
|
hover:bg-gray-700 disabled:opacity-40 disabled:cursor-not-allowed
|
|
transition-colors"
|
|
>
|
|
Next
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|