Jake Shore 96e52666c5 MCPEngine full sync — studio scaffold, factory v2, server updates, state.json — 2026-02-12
=== NEW ===
- studio/ — MCPEngine Studio scaffold (Next.js monorepo, build plan)
- docs/FACTORY-V2.md — Factory v2 architecture doc
- docs/CALENDLY_MCP_BUILD_SUMMARY.md — Calendly MCP build report

=== UPDATED SERVERS ===
- fieldedge: Added jobs-tools, UI build script, main entry update
- lightspeed: Updated main + server entry points
- squarespace: Added collection-browser + page-manager apps
- toast: Added main + server entry points

=== INFRA ===
- infra/command-center/state.json — Updated pipeline state
- infra/command-center/FACTORY-V2.md — Factory v2 operator playbook
2026-02-12 17:58:33 -05:00

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>
);
}