← back to ClawCoder
src/app/marketplace/page.tsx
395 lines
'use client'
import { useState, useEffect, useRef, useCallback } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import type { MarketplaceListing } from '@/lib/marketplace/data'
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
type CategoryFilter = 'all' | 'skill' | 'subagent' | 'mcp-server' | 'hook' | 'template'
const CATEGORY_TABS: { id: CategoryFilter; label: string; color: string }[] = [
{ id: 'all', label: 'All', color: 'bg-slate-100 text-slate-700' },
{ id: 'skill', label: 'Skills', color: 'bg-violet-100 text-violet-700' },
{ id: 'subagent', label: 'Subagents', color: 'bg-amber-100 text-amber-700' },
{ id: 'mcp-server', label: 'MCP Servers', color: 'bg-sky-100 text-sky-700' },
{ id: 'hook', label: 'Hooks', color: 'bg-rose-100 text-rose-700' },
{ id: 'template', label: 'Templates', color: 'bg-emerald-100 text-emerald-700' },
]
const CATEGORY_COLORS: Record<string, string> = {
skill: 'bg-violet-100 text-violet-700',
subagent: 'bg-amber-100 text-amber-700',
'mcp-server': 'bg-sky-100 text-sky-700',
hook: 'bg-rose-100 text-rose-700',
template: 'bg-emerald-100 text-emerald-700',
}
const TRUST_BADGES: Record<string, { label: string; className: string }> = {
official: { label: 'Official', className: 'bg-green-100 text-green-700 ring-1 ring-green-300' },
verified: { label: 'Verified', className: 'bg-blue-100 text-blue-700 ring-1 ring-blue-300' },
community: { label: 'Community', className: 'bg-slate-100 text-slate-600 ring-1 ring-slate-300' },
}
const PROFILE_OPTIONS = [
{ value: '', label: 'All profiles' },
{ value: 'teen-beginner', label: 'Teen Beginner' },
{ value: 'teen-intermediate', label: 'Teen Intermediate' },
{ value: 'teen-expert', label: 'Teen Expert' },
{ value: 'young-adult-beginner', label: 'Young Adult Beginner' },
{ value: 'young-adult-intermediate', label: 'Young Adult Intermediate' },
{ value: 'young-adult-expert', label: 'Young Adult Expert' },
{ value: 'professional-beginner', label: 'Professional Beginner' },
{ value: 'professional-intermediate', label: 'Professional Intermediate' },
{ value: 'professional-expert', label: 'Professional Expert' },
{ value: 'pre-senior-beginner', label: 'Pre-Senior Beginner' },
{ value: 'pre-senior-intermediate', label: 'Pre-Senior Intermediate' },
{ value: 'pre-senior-expert', label: 'Pre-Senior Expert' },
{ value: 'senior-beginner', label: 'Senior Beginner' },
{ value: 'senior-intermediate', label: 'Senior Intermediate' },
{ value: 'senior-expert', label: 'Senior Expert' },
]
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function formatStars(n?: number): string {
if (!n) return ''
if (n >= 1000) return `${(n / 1000).toFixed(1)}k`
return String(n)
}
// ---------------------------------------------------------------------------
// Listing Card
// ---------------------------------------------------------------------------
function ListingCard({ listing }: { listing: MarketplaceListing }) {
const [copied, setCopied] = useState(false)
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(listing.installCmd)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch {
// Fallback for insecure contexts
const textarea = document.createElement('textarea')
textarea.value = listing.installCmd
document.body.appendChild(textarea)
textarea.select()
document.execCommand('copy')
document.body.removeChild(textarea)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
}
const badge = TRUST_BADGES[listing.trustLevel]
const catColor = CATEGORY_COLORS[listing.category] || 'bg-slate-100 text-slate-600'
const maxTags = 3
return (
<motion.div
layout
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8, transition: { duration: 0.15 } }}
transition={{ duration: 0.25 }}
className="group flex flex-col rounded-xl border border-slate-200 bg-white shadow-sm transition-shadow hover:shadow-md"
>
{/* Header */}
<div className="flex items-start justify-between gap-2 p-5 pb-3">
<div className="min-w-0 flex-1">
<h3 className="truncate text-base font-semibold text-slate-900 group-hover:text-blue-600 transition-colors">
{listing.name}
</h3>
<div className="mt-1.5 flex flex-wrap items-center gap-1.5">
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide ${catColor}`}>
{listing.category.replace('-', ' ')}
</span>
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium ${badge.className}`}>
{badge.label}
</span>
</div>
</div>
{listing.stars && (
<div className="flex shrink-0 items-center gap-1 rounded-md bg-slate-50 px-2 py-1 text-xs text-slate-500">
<svg className="h-3.5 w-3.5 text-amber-400" fill="currentColor" viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
<span>{formatStars(listing.stars)}</span>
</div>
)}
</div>
{/* Description */}
<p className="px-5 text-sm leading-relaxed text-slate-600 line-clamp-2">
{listing.description}
</p>
{/* Author + Tags */}
<div className="mt-auto space-y-3 px-5 pt-3 pb-4">
<div className="flex items-center gap-2 text-xs text-slate-500">
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z" />
</svg>
<span>{listing.author}</span>
</div>
{listing.tags.length > 0 && (
<div className="flex flex-wrap gap-1">
{listing.tags.slice(0, maxTags).map((tag) => (
<span
key={tag}
className="rounded-md bg-slate-100 px-1.5 py-0.5 text-[10px] font-medium text-slate-500"
>
{tag}
</span>
))}
{listing.tags.length > maxTags && (
<span className="rounded-md bg-slate-50 px-1.5 py-0.5 text-[10px] text-slate-400">
+{listing.tags.length - maxTags}
</span>
)}
</div>
)}
</div>
{/* Actions */}
<div className="flex items-center gap-2 border-t border-slate-100 px-5 py-3">
<button
type="button"
onClick={handleCopy}
className="flex min-h-[36px] flex-1 items-center justify-center gap-1.5 rounded-lg bg-blue-600 px-3 text-xs font-medium text-white transition-colors hover:bg-blue-700 active:bg-blue-800"
>
{copied ? (
<>
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
Copied!
</>
) : (
<>
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 002.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 00-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 00.75-.75 2.25 2.25 0 00-.1-.664m-5.8 0A2.251 2.251 0 0113.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25z" />
</svg>
Install
</>
)}
</button>
<a
href={listing.source}
target="_blank"
rel="noopener noreferrer"
className="flex min-h-[36px] items-center gap-1 rounded-lg border border-slate-200 px-3 text-xs font-medium text-slate-600 transition-colors hover:bg-slate-50"
>
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
</svg>
Source
</a>
</div>
</motion.div>
)
}
// ---------------------------------------------------------------------------
// Main Page
// ---------------------------------------------------------------------------
export default function MarketplacePage() {
const [listings, setListings] = useState<MarketplaceListing[]>([])
const [loading, setLoading] = useState(true)
const [activeCategory, setActiveCategory] = useState<CategoryFilter>('all')
const [searchQuery, setSearchQuery] = useState('')
const [profileFilter, setProfileFilter] = useState('')
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const fetchListings = useCallback(async (query: string, category: CategoryFilter, profile: string) => {
setLoading(true)
try {
const params = new URLSearchParams()
if (query.trim()) params.set('q', query.trim())
if (category !== 'all') params.set('category', category)
if (profile) params.set('profile', profile)
const qs = params.toString()
const res = await fetch(`/api/marketplace${qs ? `?${qs}` : ''}`)
if (res.ok) {
const data = await res.json()
setListings(data)
}
} catch {
// silent
} finally {
setLoading(false)
}
}, [])
// Initial load
useEffect(() => {
fetchListings('', 'all', '')
}, [fetchListings])
// Debounced search
const handleSearch = (value: string) => {
setSearchQuery(value)
if (debounceRef.current) clearTimeout(debounceRef.current)
debounceRef.current = setTimeout(() => {
fetchListings(value, activeCategory, profileFilter)
}, 300)
}
// Category change
const handleCategoryChange = (cat: CategoryFilter) => {
setActiveCategory(cat)
fetchListings(searchQuery, cat, profileFilter)
}
// Profile change
const handleProfileChange = (profile: string) => {
setProfileFilter(profile)
fetchListings(searchQuery, activeCategory, profile)
}
// Count per category (from current query results, or show total without filter)
const categoryCounts: Record<string, number> = { all: listings.length }
for (const l of listings) {
categoryCounts[l.category] = (categoryCounts[l.category] || 0) + 1
}
return (
<div className="mx-auto w-full max-w-7xl px-4 py-8 sm:px-6">
{/* Header */}
<div className="mb-8">
<h1 className="text-3xl font-bold tracking-tight text-slate-900 sm:text-4xl">
Marketplace
</h1>
<p className="mt-2 text-base text-slate-600 sm:text-lg">
Discover skills, subagents, MCP servers, hooks, and templates from the open-source Claude Code ecosystem.
</p>
</div>
{/* Search + Profile filter */}
<div className="mb-6 flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="relative flex-1">
<svg
className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400"
fill="none"
viewBox="0 0 24 24"
strokeWidth={2}
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
/>
</svg>
<input
type="text"
value={searchQuery}
onChange={(e) => handleSearch(e.target.value)}
placeholder="Search by name, tag, or description..."
className="h-11 w-full rounded-xl border border-slate-300 bg-white pl-10 pr-4 text-sm text-slate-800 placeholder:text-slate-400 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20"
/>
</div>
<div className="flex items-center gap-2">
<label htmlFor="profile-filter" className="whitespace-nowrap text-xs font-medium text-slate-500">
Recommended for:
</label>
<select
id="profile-filter"
value={profileFilter}
onChange={(e) => handleProfileChange(e.target.value)}
className="h-11 rounded-xl border border-slate-300 bg-white px-3 text-sm text-slate-700 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20"
>
{PROFILE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
</div>
{/* Category Tabs */}
<div className="mb-6 -mx-4 overflow-x-auto px-4 sm:mx-0 sm:px-0">
<div className="flex gap-2">
{CATEGORY_TABS.map((tab) => {
const isActive = activeCategory === tab.id
return (
<button
key={tab.id}
type="button"
onClick={() => handleCategoryChange(tab.id)}
className={`flex shrink-0 items-center gap-1.5 rounded-full px-4 py-2 text-sm font-medium transition-all ${
isActive
? 'bg-blue-600 text-white shadow-sm'
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
}`}
>
{tab.label}
{activeCategory === 'all' && tab.id !== 'all' && categoryCounts[tab.id] !== undefined && (
<span className={`rounded-full px-1.5 py-0 text-[10px] ${
isActive ? 'bg-blue-500 text-blue-100' : 'bg-slate-200 text-slate-500'
}`}>
{categoryCounts[tab.id]}
</span>
)}
</button>
)
})}
</div>
</div>
{/* Results */}
{loading ? (
<div className="flex items-center justify-center py-20">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-slate-300 border-t-blue-600" />
</div>
) : listings.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-xl border-2 border-dashed border-slate-200 py-20">
<svg
className="mb-4 h-12 w-12 text-slate-300"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1}
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
/>
</svg>
<p className="text-base font-medium text-slate-500">No results found</p>
<p className="mt-1 text-sm text-slate-400">Try a different search or category.</p>
</div>
) : (
<AnimatePresence mode="popLayout">
<motion.div
layout
className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"
>
{listings.map((listing) => (
<ListingCard key={listing.id} listing={listing} />
))}
</motion.div>
</AnimatePresence>
)}
{/* Footer count */}
{!loading && listings.length > 0 && (
<p className="mt-6 text-center text-xs text-slate-400">
Showing {listings.length} listing{listings.length !== 1 ? 's' : ''}
</p>
)}
</div>
)
}