chore: import upstream snapshot with attribution
Component Security Validation / Security Audit (push) Has been cancelled
Deploy to Cloudflare Pages / deploy (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:38:58 +08:00
commit bb5c75ce05
8824 changed files with 1946442 additions and 0 deletions
+228
View File
@@ -0,0 +1,228 @@
import { useState, useEffect, useRef } from 'react';
interface ClerkUser {
fullName?: string | null;
firstName?: string | null;
imageUrl?: string;
email?: string;
}
// Mock user data for local testing
const MOCK_USER: ClerkUser = {
fullName: 'Bitreon',
firstName:'Bitreon',
imageUrl: 'https://avatars.githubusercontent.com/u/207326426?v=4'
};
// Enable mock mode via console: window.enableAuthMock()
// Disable mock mode via console: window.disableAuthMock()
declare global {
interface Window {
enableAuthMock: () => void;
disableAuthMock: () => void;
__authMockEnabled?: boolean;
}
}
function useGlobalAuth() {
const [state, setState] = useState<{ isSignedIn: boolean; isLoaded: boolean; user: ClerkUser | null }>({
isSignedIn: false, isLoaded: false, user: null,
});
const [mockEnabled, setMockEnabled] = useState(false);
useEffect(() => {
// Setup console commands for mock mode
window.enableAuthMock = () => {
window.__authMockEnabled = true;
setMockEnabled(true);
console.log('✅ Auth mock mode enabled! Showing mock user:', MOCK_USER.fullName);
};
window.disableAuthMock = () => {
window.__authMockEnabled = false;
setMockEnabled(false);
console.log('❌ Auth mock mode disabled');
};
// Check if mock was previously enabled
if (window.__authMockEnabled) {
setMockEnabled(true);
}
function check() {
// If mock is enabled and not on Vercel, use mock data
const isVercel = window.location.hostname.includes('vercel.app') ||
window.location.hostname.includes('claude.ai');
if (window.__authMockEnabled && !isVercel) {
setState({
isSignedIn: true,
isLoaded: true,
user: MOCK_USER,
});
return;
}
const clerk = (window as any).Clerk;
if (clerk?.loaded) {
setState({
isSignedIn: !!clerk.user,
isLoaded: true,
user: clerk.user ? {
fullName: clerk.user.fullName,
firstName: clerk.user.firstName,
imageUrl: clerk.user.imageUrl,
email: clerk.user.primaryEmailAddress?.emailAddress || '',
} : null,
});
}
}
check();
const interval = setInterval(check, 500);
const handleChange = () => check();
window.addEventListener('clerk:session', handleChange);
return () => { clearInterval(interval); window.removeEventListener('clerk:session', handleChange); };
}, [mockEnabled]);
return state;
}
function UserMenu({ user }: { user: ClerkUser }) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
}
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, [open]);
const displayName = user.fullName || user.firstName || 'User';
return (
<div ref={ref} className="relative">
<button
onClick={() => setOpen(!open)}
className="flex items-center gap-2.5 px-2.5 py-[6px] rounded-md text-[13px] text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-surface-2)] transition-colors w-full"
>
{user.imageUrl ? (
<img
src={user.imageUrl}
alt={displayName}
className="w-6 h-6 rounded-full shrink-0 ring-2 ring-orange-500"
/>
) : (
<div className="w-6 h-6 rounded-full bg-orange-500 flex items-center justify-center shrink-0 text-[11px] font-bold text-white ring-2 ring-orange-500">
{displayName.charAt(0).toUpperCase()}
</div>
)}
<span className="truncate flex-1 text-left">{displayName}</span>
</button>
{open && (
<div className="fixed bottom-4 left-4 w-56 bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded-lg shadow-xl z-[9999] py-1">
{/* User Info Header */}
<div className="px-3 py-2 border-b border-[var(--color-border)]">
<div className="text-[13px] font-medium text-[var(--color-text-primary)] truncate">
{displayName}
</div>
<div className="text-[11px] text-[var(--color-text-secondary)] truncate">
{user.email || ''}
</div>
</div>
{/* Menu Items */}
<div className="py-1">
<button
onClick={() => {
console.log('Navigate to My Components');
window.location.href = '/my-components';
setOpen(false);
}}
className="flex items-center gap-2 w-full px-3 py-1.5 text-[12px] text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-surface-3)] transition-colors"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="1.5">
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
</svg>
My Components
</button>
<button
onClick={() => {
console.log('Navigate to Settings');
window.location.href = '/settings';
setOpen(false);
}}
className="flex items-center gap-2 w-full px-3 py-1.5 text-[12px] text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-surface-3)] transition-colors"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="1.5">
<path strokeLinecap="round" strokeLinejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
Settings
</button>
</div>
{/* Divider */}
<div className="border-t border-[var(--color-border)] my-1"></div>
{/* Sign Out */}
<div className="py-1">
<button
onClick={() => {
const isVercel = window.location.hostname.includes('vercel.app') ||
window.location.hostname.includes('claude.ai');
if (window.__authMockEnabled && !isVercel) {
console.log('🔓 Mock sign out - disabling mock mode');
window.disableAuthMock?.();
} else {
(window as any).Clerk?.signOut?.();
}
setOpen(false);
}}
className="flex items-center gap-2 w-full px-3 py-1.5 text-[12px] text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-surface-3)] transition-colors"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="1.5">
<path strokeLinecap="round" strokeLinejoin="round" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
Sign Out
</button>
</div>
</div>
)}
</div>
);
}
export default function AuthButton() {
const { isSignedIn, isLoaded, user } = useGlobalAuth();
if (!isLoaded) {
return (
<div className="flex items-center gap-2.5 px-2.5 py-[6px]">
<div className="w-6 h-6 rounded-full bg-[var(--color-surface-3)] animate-pulse" />
<div className="h-3 w-16 bg-[var(--color-surface-3)] rounded animate-pulse" />
</div>
);
}
if (isSignedIn && user) {
return <UserMenu user={user} />;
}
return (
<button
onClick={() => (window as any).Clerk?.openSignIn?.()}
className="flex items-center gap-2.5 px-2.5 py-[6px] rounded-md text-[13px] text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-surface-2)] transition-colors w-full"
>
<svg className="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
<span>Sign In</span>
</button>
);
}
+295
View File
@@ -0,0 +1,295 @@
import { useState, useEffect } from 'react';
import type { Cart } from '../lib/types';
import { TYPE_CONFIG } from '../lib/icons';
const EMPTY_CART: Cart = {
agents: [], commands: [], settings: [], hooks: [], mcps: [], skills: [], templates: [],
};
const TYPE_FLAGS: Record<string, string> = {
agents: '--agent', commands: '--command', settings: '--setting',
hooks: '--hook', mcps: '--mcp', skills: '--skill', templates: '--template',
};
function cleanPath(path: string): string {
return path?.replace(/\.(md|json)$/, '') ?? '';
}
function formatName(name: string): string {
if (!name) return '';
return name.replace(/\.(md|json)$/, '').replace(/[-_]/g, ' ')
.split(' ').map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
}
export default function CartSidebar() {
const [open, setOpen] = useState(false);
const [cart, setCart] = useState<Cart>(EMPTY_CART);
const [copied, setCopied] = useState(false);
// Load cart
useEffect(() => {
function loadCart() {
try {
const saved = localStorage.getItem('claudeCodeCart');
if (saved) setCart({ ...EMPTY_CART, ...JSON.parse(saved) });
} catch {}
}
loadCart();
window.addEventListener('cart-updated', ((e: CustomEvent) => {
setCart({ ...EMPTY_CART, ...e.detail });
}) as EventListener);
window.addEventListener('storage', loadCart);
return () => window.removeEventListener('storage', loadCart);
}, []);
const totalItems = Object.values(cart).reduce((sum, arr) => sum + (arr?.length ?? 0), 0);
// Generate command
function generateCommand(): string {
let cmd = 'npx claude-code-templates@latest';
for (const [type, items] of Object.entries(cart)) {
if (items?.length > 0) {
const flag = TYPE_FLAGS[type];
if (flag) {
const paths = items.map((i: any) => cleanPath(i.path)).join(',');
cmd += ` ${flag} ${paths}`;
}
}
}
return cmd;
}
function removeItem(path: string, type: string) {
setCart((prev) => {
const next = { ...prev, [type]: (prev as any)[type]?.filter((i: any) => i.path !== path) ?? [] };
localStorage.setItem('claudeCodeCart', JSON.stringify(next));
window.dispatchEvent(new CustomEvent('cart-updated', { detail: next }));
return next;
});
}
function clearAll() {
if (!confirm('Clear your entire stack?')) return;
const empty = { ...EMPTY_CART };
setCart(empty);
localStorage.setItem('claudeCodeCart', JSON.stringify(empty));
window.dispatchEvent(new CustomEvent('cart-updated', { detail: empty }));
}
function copyCommand() {
navigator.clipboard.writeText(generateCommand());
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
function shareTwitter() {
const cmd = generateCommand();
const text = `Check out my Claude Code stack!\n\n${cmd}\n\nBuild yours at https://aitmpl.com`;
window.open(`https://twitter.com/intent/tweet?text=${encodeURIComponent(text)}`, '_blank');
}
return (
<>
{/* Floating button */}
{totalItems > 0 && !open && (
<button
onClick={() => setOpen(true)}
className="fixed bottom-6 right-6 z-40 flex items-center gap-2.5 pl-4 pr-3 py-3 bg-gradient-to-br from-[var(--color-accent)] to-[var(--color-accent)]/80 hover:from-[var(--color-accent)]/90 hover:to-[var(--color-accent)]/70 text-white rounded-full shadow-lg shadow-[var(--color-accent)]/30 transition-all duration-200 hover:scale-105 hover:shadow-xl hover:shadow-[var(--color-accent)]/40 active:scale-95"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
<span className="text-[13px] font-semibold">Stack</span>
<span className="min-w-5 h-5 px-1.5 rounded-full bg-white/25 backdrop-blur-sm text-white text-[11px] font-bold flex items-center justify-center border border-white/20">
{totalItems}
</span>
</button>
)}
{/* Overlay */}
{open && (
<div
className="fixed inset-0 bg-black/60 backdrop-blur-sm z-40 animate-in fade-in duration-200"
onClick={() => setOpen(false)}
/>
)}
{/* Sidebar panel */}
<div
className={`fixed top-0 right-0 h-full w-96 max-w-[90vw] bg-[var(--color-surface-0)] border-l border-[var(--color-border)] shadow-2xl z-50 transform transition-transform duration-300 ease-out flex flex-col ${
open ? 'translate-x-0' : 'translate-x-full'
}`}
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-4 border-b border-[var(--color-border)] bg-gradient-to-b from-[var(--color-surface-1)] to-[var(--color-surface-0)] shrink-0">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-lg bg-gradient-to-br from-[var(--color-accent)] to-[var(--color-accent)]/70 flex items-center justify-center shadow-md shadow-[var(--color-accent)]/20">
<svg className="w-4.5 h-4.5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
</div>
<div className="flex flex-col">
<h2 className="text-[15px] font-semibold text-[var(--color-text-primary)] leading-none">Stack Builder</h2>
<span className="text-[11px] text-[var(--color-text-tertiary)] mt-1">
{totalItems} {totalItems === 1 ? 'component' : 'components'}
</span>
</div>
</div>
<div className="flex items-center gap-1.5">
{totalItems > 0 && (
<button
onClick={clearAll}
className="text-xs text-[var(--color-text-tertiary)] hover:text-red-400 hover:bg-red-400/10 px-3 py-1.5 rounded-lg transition-all font-medium"
>
Clear All
</button>
)}
<button
onClick={() => setOpen(false)}
className="p-2 rounded-lg hover:bg-[var(--color-surface-3)] text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] transition-colors"
aria-label="Close sidebar"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto scrollbar-thin scrollbar-thumb-[var(--color-surface-3)] scrollbar-track-transparent min-h-0">
{totalItems === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-center px-6">
<div className="relative w-24 h-24 rounded-2xl bg-gradient-to-br from-[var(--color-surface-2)] to-[var(--color-surface-3)] border border-[var(--color-border)] flex items-center justify-center mb-5 shadow-sm">
<svg className="w-12 h-12 text-[var(--color-text-tertiary)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="1.5">
<path strokeLinecap="round" strokeLinejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
<div className="absolute -top-1.5 -right-1.5 w-7 h-7 rounded-full bg-[var(--color-accent)]/10 border-2 border-[var(--color-accent)]/30 flex items-center justify-center backdrop-blur-sm">
<svg className="w-3.5 h-3.5 text-[var(--color-accent)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2.5">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
</svg>
</div>
</div>
<p className="text-base font-semibold text-[var(--color-text-primary)] mb-2">Your stack is empty</p>
<p className="text-[13px] text-[var(--color-text-secondary)] max-w-xs leading-relaxed">
Browse components and click the <span className="inline-flex items-center justify-center w-4 h-4 rounded bg-[var(--color-accent)]/10 text-[var(--color-accent)] text-[10px] font-bold mx-0.5">+</span> button to add them to your stack for easy installation.
</p>
</div>
) : (
<div className="px-3 py-4">
{Object.entries(cart).filter(([, items]) => items?.length > 0).map(([type, items], idx, arr) => {
const config = TYPE_CONFIG[type];
const isLast = idx === arr.length - 1;
return (
<div key={type} className={!isLast ? 'mb-4 pb-3 border-b border-[var(--color-border)]' : 'mb-2'}>
{/* Folder row */}
<div className="flex items-center gap-2.5 py-2 px-3 rounded-lg bg-[var(--color-surface-1)] border border-[var(--color-border)] mb-2 shadow-sm">
<svg className="w-4 h-4 shrink-0" style={{ color: config?.color ?? 'var(--color-text-tertiary)' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z" />
</svg>
<span className="text-[13px] font-semibold text-[var(--color-text-primary)]">
{config?.label ?? type}
</span>
<span className="ml-auto text-[10px] px-2 py-0.5 rounded-full bg-[var(--color-surface-3)] text-[var(--color-text-tertiary)] font-semibold tabular-nums border border-[var(--color-border)]">
{items.length}
</span>
</div>
{/* File rows with tree lines */}
{items.map((item: any, i: number) => {
const isLastItem = i === items.length - 1;
return (
<div
key={item.path}
className="flex items-center group pl-2 pr-1.5 py-0.5 hover:bg-[var(--color-surface-1)] rounded-md transition-all duration-150"
>
{/* Tree connector */}
<span className="text-[var(--color-border)] text-[13px] font-mono w-7 shrink-0 select-none">
{isLastItem ? '└─' : '├─'}
</span>
{/* File icon */}
<svg className="w-3.5 h-3.5 shrink-0 mr-2.5 text-[var(--color-text-tertiary)] group-hover:text-[var(--color-text-secondary)] transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" />
<polyline points="14 2 14 8 20 8" />
</svg>
{/* Name */}
<span className="text-[12px] text-[var(--color-text-primary)] flex-1 truncate py-2 font-medium">
{formatName(item.name)}
</span>
{/* Remove */}
<button
onClick={() => removeItem(item.path, type)}
className="opacity-0 group-hover:opacity-100 p-1.5 rounded-md hover:bg-red-400/10 text-[var(--color-text-tertiary)] hover:text-red-400 transition-all shrink-0"
title="Remove from stack"
aria-label={`Remove ${formatName(item.name)}`}
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
);
})}
</div>
);
})}
</div>
)}
</div>
{/* Footer */}
{totalItems > 0 && (
<div className="border-t border-[var(--color-border)] bg-gradient-to-t from-[var(--color-surface-1)] to-[var(--color-surface-0)] p-4 space-y-3 shadow-[0_-8px_16px_rgba(0,0,0,0.08)] shrink-0">
{/* Command preview */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<svg className="w-3.5 h-3.5 text-[var(--color-text-tertiary)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<span className="text-[11px] text-[var(--color-text-tertiary)] font-semibold uppercase tracking-wide">Installation Command</span>
</div>
<div className="bg-[var(--color-surface-2)] rounded-lg p-3 text-[11px] font-mono text-[var(--color-text-secondary)] break-all max-h-20 overflow-y-auto border border-[var(--color-border)] shadow-inner hover:border-[var(--color-accent)]/30 transition-colors">
{generateCommand()}
</div>
</div>
{/* Actions */}
<div className="flex gap-2">
<button
onClick={copyCommand}
className="flex-1 flex items-center justify-center gap-2 px-4 py-2.5 bg-gradient-to-br from-[var(--color-accent)] to-[var(--color-accent)]/80 hover:from-[var(--color-accent)]/90 hover:to-[var(--color-accent)]/70 text-white rounded-lg text-sm font-semibold transition-all duration-200 shadow-md shadow-[var(--color-accent)]/20 hover:shadow-lg hover:shadow-[var(--color-accent)]/30 active:scale-95"
>
{copied ? (
<>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2.5">
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
Copied!
</>
) : (
<>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3" />
</svg>
Copy Command
</>
)}
</button>
<button
onClick={shareTwitter}
className="px-3.5 py-2.5 bg-[var(--color-surface-3)] hover:bg-[var(--color-surface-4)] text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] rounded-lg transition-all duration-200 border border-[var(--color-border)] hover:border-[var(--color-accent)]/30 hover:shadow-sm active:scale-95"
title="Share on Twitter"
aria-label="Share on Twitter"
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
</svg>
</button>
</div>
</div>
)}
</div>
</>
);
}
+12
View File
@@ -0,0 +1,12 @@
import { ClerkProvider } from '@clerk/clerk-react';
const clerkKey = import.meta.env.PUBLIC_CLERK_PUBLISHABLE_KEY;
export default function ClerkIsland() {
if (!clerkKey) return null;
return (
<ClerkProvider publishableKey={clerkKey}>
<span />
</ClerkProvider>
);
}
+736
View File
@@ -0,0 +1,736 @@
import { useState, useEffect, useMemo, useCallback } from 'react';
import type { Component } from '../lib/types';
import { TYPE_CONFIG } from '../lib/icons';
import { ITEMS_PER_PAGE } from '../lib/constants';
import { fetchComponentsByType } from '../lib/data';
import SaveToCollectionButton from './SaveToCollectionButton';
interface Props {
initialType: string;
}
interface CartState {
[key: string]: { name: string; path: string; category: string; description: string; icon: string }[];
}
function cleanPath(path: string): string {
return path?.replace(/\.(md|json)$/, '') ?? '';
}
function formatName(name: string): string {
if (!name) return '';
return name
.replace(/\.(md|json)$/, '')
.replace(/[-_]/g, ' ')
.split(' ')
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(' ');
}
import TypeIcon from './TypeIcon';
export default function ComponentGrid({ initialType }: Props) {
// Only the active type's components are loaded, from /components/{type}.json,
// instead of downloading the whole ~1.9MB index up front.
const [typeComponents, setTypeComponents] = useState<Component[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [activeType, setActiveType] = useState<string>(initialType);
const [category, setCategory] = useState('all');
const [search, setSearch] = useState('');
const [sortBy, setSortBy] = useState<'downloads' | 'name'>('downloads');
const [page, setPage] = useState(1);
const [cart, setCart] = useState<CartState>({});
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
const [showFilters, setShowFilters] = useState(false);
const [minDownloads, setMinDownloads] = useState(0);
const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
// Sync activeType when initialType changes (e.g. sidebar navigation)
useEffect(() => {
setActiveType(initialType);
setCategory('all');
setPage(1);
}, [initialType]);
useEffect(() => {
let cancelled = false;
async function load() {
try {
setLoading(true);
setError(null);
const components = await fetchComponentsByType(activeType);
if (!cancelled) { setTypeComponents(components); setLoading(false); }
} catch {
if (!cancelled) { setError('Failed to load components'); setLoading(false); }
}
}
load();
return () => { cancelled = true; };
}, [activeType]);
useEffect(() => {
try {
const saved = localStorage.getItem('claudeCodeCart');
if (saved) setCart(JSON.parse(saved));
} catch {}
}, []);
const categories = useMemo(() => {
const cats = new Set<string>();
for (const c of typeComponents) { if (c.category) cats.add(c.category); }
return Array.from(cats).sort();
}, [typeComponents]);
const filtered = useMemo(() => {
let items = typeComponents;
// Apply either single-category OR multi-category filter, not both
if (selectedCategories.length > 0) {
// Multi-category filter takes precedence
items = items.filter((c) => selectedCategories.includes(c.category ?? ''));
} else if (category !== 'all') {
// Single-category filter
items = items.filter((c) => c.category === category);
}
// Search filter
if (search.trim()) {
const q = search.toLowerCase();
items = items.filter((c) =>
c.name.toLowerCase().includes(q) ||
c.description?.toLowerCase().includes(q) ||
c.category?.toLowerCase().includes(q) ||
c.content?.toLowerCase().includes(q)
);
}
// Downloads filter
if (minDownloads > 0) {
items = items.filter((c) => (c.downloads ?? 0) >= minDownloads);
}
// Sort
const sorted = [...items];
if (sortBy === 'downloads') sorted.sort((a, b) => (b.downloads ?? 0) - (a.downloads ?? 0));
else if (sortBy === 'name') sorted.sort((a, b) => a.name.localeCompare(b.name));
return sorted;
}, [typeComponents, category, selectedCategories, search, sortBy, minDownloads]);
const totalPages = Math.max(1, Math.ceil(filtered.length / ITEMS_PER_PAGE));
const paged = filtered.slice((page - 1) * ITEMS_PER_PAGE, page * ITEMS_PER_PAGE);
useEffect(() => { setPage(1); }, [category, search, activeType, selectedCategories, minDownloads]);
// Sidebar counts come from /counts.json (see Sidebar.astro); the grid no
// longer loads every type, so it can't (and doesn't need to) emit them.
const toggleCategoryFilter = useCallback((cat: string) => {
setSelectedCategories(prev =>
prev.includes(cat) ? prev.filter(c => c !== cat) : [...prev, cat]
);
}, []);
const clearAllFilters = useCallback(() => {
setSearch('');
setCategory('all');
setSelectedCategories([]);
setMinDownloads(0);
}, []);
const hasActiveFilters = search || category !== 'all' || selectedCategories.length > 0 || minDownloads > 0;
const isInCart = useCallback(
(path: string, type: string) => {
const typePlural = type.endsWith('s') ? type : type + 's';
return cart[typePlural]?.some((item) => item.path === path) ?? false;
},
[cart]
);
const toggleCart = useCallback((component: Component) => {
const typePlural = component.type.endsWith('s') ? component.type : component.type + 's';
setCart((prev) => {
const items = prev[typePlural] ?? [];
const exists = items.some((i) => i.path === component.path);
let newItems: CartState;
if (exists) {
newItems = { ...prev, [typePlural]: items.filter((i) => i.path !== component.path) };
} else {
newItems = { ...prev, [typePlural]: [...items, {
name: component.name, path: component.path, category: component.category ?? '',
description: component.description ?? '', icon: typePlural,
}] };
}
localStorage.setItem('claudeCodeCart', JSON.stringify(newItems));
window.dispatchEvent(new CustomEvent('cart-updated', { detail: newItems }));
return newItems;
});
}, []);
if (loading) {
return (
<div className="px-6 py-32">
<div className="flex flex-col items-center gap-6">
<div className="relative w-16 h-16">
{/* Outer ring */}
<div className="absolute inset-0 border-[3px] border-[var(--color-border)] rounded-full opacity-20" />
{/* Spinning gradient ring */}
<div className="absolute inset-0 rounded-full bg-gradient-to-tr from-[var(--color-primary-500)] via-[var(--color-accent-400)] to-[var(--color-primary-500)] opacity-80 animate-spin" style={{ clipPath: 'polygon(50% 50%, 50% 0%, 100% 0%, 100% 100%, 0% 100%, 0% 0%, 50% 0%)' }} />
<div className="absolute inset-[3px] bg-[var(--color-surface-0)] rounded-full" />
{/* Inner pulse */}
<div className="absolute inset-[6px] bg-gradient-to-br from-[var(--color-primary-500)]/20 to-[var(--color-accent-400)]/20 rounded-full animate-pulse" />
</div>
<div className="flex flex-col items-center gap-2">
<span className="text-[14px] font-semibold text-[var(--color-text-primary)] animate-pulse">Loading components</span>
<span className="text-[12px] text-[var(--color-text-tertiary)]">Preparing your experience...</span>
</div>
{/* Skeleton cards preview */}
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 w-full max-w-6xl mt-8">
{[1, 2, 3, 4, 5, 6].map((i) => (
<div key={i} className="p-5 rounded-xl bg-[var(--color-card-bg)] border border-[var(--color-border)] animate-pulse" style={{ animationDelay: `${i * 100}ms` }}>
<div className="flex items-start gap-3.5">
<div className="w-11 h-11 rounded-xl bg-[var(--color-surface-3)] skeleton" />
<div className="flex-1 space-y-3">
<div className="h-4 bg-[var(--color-surface-3)] rounded skeleton w-3/4" />
<div className="space-y-2">
<div className="h-3 bg-[var(--color-surface-3)] rounded skeleton" />
<div className="h-3 bg-[var(--color-surface-3)] rounded skeleton w-5/6" />
</div>
<div className="flex gap-2">
<div className="h-5 w-16 bg-[var(--color-surface-3)] rounded-full skeleton" />
<div className="h-5 w-20 bg-[var(--color-surface-3)] rounded-full skeleton" />
</div>
</div>
</div>
</div>
))}
</div>
</div>
</div>
);
}
if (error) {
return (
<div className="px-6 py-24">
<div className="flex flex-col items-center gap-4 max-w-md mx-auto text-center">
<div className="w-12 h-12 rounded-full bg-red-500/10 border border-red-500/20 flex items-center justify-center">
<svg className="w-6 h-6 text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<div>
<h3 className="text-[14px] font-semibold text-[var(--color-text-primary)] mb-1">Failed to load components</h3>
<p className="text-[13px] text-[var(--color-text-secondary)]">{error}</p>
</div>
<button
onClick={() => window.location.reload()}
className="px-4 py-2 text-[13px] font-medium rounded-lg bg-[var(--color-primary-500)] text-white hover:bg-[var(--color-primary-600)] transition-all active:scale-95"
>
Try again
</button>
</div>
</div>
);
}
return (
<div>
{/* Enhanced Filter bar with view modes */}
<div className="border-b border-[var(--color-border)] bg-[var(--color-surface-0)] backdrop-blur-sm sticky top-14 z-10">
<div className="flex flex-col sm:flex-row sm:flex-wrap sm:items-center gap-2.5 px-6 py-4">
{/* Search */}
<div className="relative w-full sm:w-auto sm:flex-1 sm:min-w-[140px] sm:max-w-md">
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--color-text-tertiary)] pointer-events-none transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
type="text"
placeholder="Search components..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded-lg text-[13px] text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] pl-10 pr-9 py-2.5 outline-none focus:bg-[var(--color-surface-3)] focus:border-[var(--color-primary-500)] focus:ring-2 focus:ring-[var(--color-primary-500)]/20 transition-all hover:border-[var(--color-border-hover)]"
/>
{search && (
<button
onClick={() => setSearch('')}
className="absolute right-2.5 top-1/2 -translate-y-1/2 w-5 h-5 rounded-md flex items-center justify-center text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-surface-3)] transition-all active:scale-95"
aria-label="Clear search"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2.5">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
{/* Category select + advanced filters toggle: share a row on mobile,
flow individually as flex items from sm: up (display:contents
unwraps this grouping div so it doesn't affect desktop layout) */}
<div className="flex items-center gap-2.5 sm:contents">
<select
value={category}
onChange={(e) => setCategory(e.target.value)}
className="flex-1 sm:flex-none min-w-0 bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded-lg text-[13px] text-[var(--color-text-secondary)] font-medium px-3.5 py-2.5 pr-9 outline-none focus:bg-[var(--color-surface-3)] focus:border-[var(--color-primary-500)] focus:ring-2 focus:ring-[var(--color-primary-500)]/20 cursor-pointer transition-all appearance-none hover:border-[var(--color-border-hover)] hover:text-[var(--color-text-primary)] bg-[url('data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20fill%3D%22none%22%20viewBox%3D%220%200%2020%2020%22%3E%3Cpath%20stroke%3D%22%23737373%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%20stroke-width%3D%221.5%22%20d%3D%22m6%208%204%204%204-4%22%2F%3E%3C%2Fsvg%3E')] bg-[length:1.25rem] bg-[right_0.5rem_center] bg-no-repeat"
>
<option value="all">All categories</option>
{categories.map((cat) => (
<option key={cat} value={cat}>{cat}</option>
))}
</select>
{/* Advanced Filters Toggle */}
<button
onClick={() => setShowFilters(!showFilters)}
className={`shrink-0 flex items-center gap-2 px-3 py-2 text-[13px] font-mono font-semibold rounded border transition-all ${
showFilters || hasActiveFilters
? 'bg-[var(--color-accent)] text-white border-[var(--color-accent)]'
: 'bg-[var(--color-surface-2)] text-[var(--color-text-secondary)] border-[var(--color-border)] hover:border-[var(--color-accent)] hover:text-[var(--color-text-primary)]'
}`}
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4" />
</svg>
<span className="hidden sm:inline">Filters</span>
{hasActiveFilters && (
<span className="w-2 h-2 rounded-full bg-white animate-pulse"></span>
)}
</button>
</div>
{/* View Mode Toggle */}
<div className="hidden md:flex items-center gap-1 bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded-lg p-1">
<button
onClick={() => setViewMode('grid')}
className={`p-2 rounded-md transition-all ${
viewMode === 'grid'
? 'bg-[var(--color-surface-4)] text-[var(--color-text-primary)] shadow-sm'
: 'text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)]'
}`}
title="Grid view"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
</svg>
</button>
<button
onClick={() => setViewMode('list')}
className={`p-2 rounded-md transition-all ${
viewMode === 'list'
? 'bg-[var(--color-surface-4)] text-[var(--color-text-primary)] shadow-sm'
: 'text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)]'
}`}
title="List view"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
</div>
{/* Sort */}
<div className="flex items-center gap-2 w-full sm:w-auto sm:ml-auto">
<span className="text-[11px] text-[var(--color-text-tertiary)] font-medium hidden sm:inline">Sort by</span>
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as 'downloads' | 'name')}
className="flex-1 sm:flex-none font-mono bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded text-[13px] text-[var(--color-text-secondary)] font-medium px-3 py-2 pr-9 outline-none focus:border-[var(--color-accent)] cursor-pointer transition-all appearance-none hover:border-[var(--color-accent)] hover:text-[var(--color-text-primary)] bg-[url('data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20fill%3D%22none%22%20viewBox%3D%220%200%2020%2020%22%3E%3Cpath%20stroke%3D%22%237d8590%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%20stroke-width%3D%221.5%22%20d%3D%22m6%208%204%204%204-4%22%2F%3E%3C%2Fsvg%3E')] bg-[length:1.25rem] bg-[right_0.5rem_center] bg-no-repeat"
>
<option value="downloads">Most Popular</option>
<option value="name">Alphabetical</option>
</select>
</div>
</div>
{/* Advanced Filters Panel */}
{showFilters && (
<div className="px-6 py-5 border-t border-[var(--color-border)] bg-[var(--color-surface-1)] animate-fade-in-up">
<div className="flex items-start gap-6">
{/* Multi-category filter */}
<div className="flex-1">
<label className="text-[11px] font-bold text-[var(--color-text-tertiary)] uppercase tracking-wider mb-3 block">
Categories
</label>
<div className="flex flex-wrap gap-2">
{categories.slice(0, 8).map((cat) => (
<button
key={cat}
onClick={() => toggleCategoryFilter(cat)}
className={`px-3 py-1 text-[11px] font-mono font-semibold rounded border transition-all ${
selectedCategories.includes(cat)
? 'bg-[var(--color-accent)] text-white border-[var(--color-accent)]'
: 'bg-[var(--color-surface-2)] text-[var(--color-text-secondary)] border-[var(--color-border)] hover:border-[var(--color-accent)] hover:text-[var(--color-text-primary)]'
}`}
>
{cat}
</button>
))}
</div>
</div>
{/* Downloads filter */}
<div className="w-64">
<label className="text-[11px] font-bold text-[var(--color-text-tertiary)] uppercase tracking-wider mb-3 block">
Min Downloads: {minDownloads}+
</label>
<input
type="range"
min="0"
max="1000"
step="50"
value={minDownloads}
onChange={(e) => setMinDownloads(Number(e.target.value))}
className="w-full h-2 bg-[var(--color-surface-3)] rounded-lg appearance-none cursor-pointer accent-[var(--color-primary-500)]"
/>
<div className="flex justify-between text-[10px] text-[var(--color-text-tertiary)] mt-1">
<span>0</span>
<span>500</span>
<span>1000+</span>
</div>
</div>
{/* Clear filters */}
{hasActiveFilters && (
<button
onClick={clearAllFilters}
className="px-4 py-2 text-[12px] font-semibold text-red-400 hover:text-red-300 bg-red-500/10 hover:bg-red-500/20 border border-red-500/30 rounded-lg transition-all active:scale-95"
>
Clear All
</button>
)}
</div>
</div>
)}
</div>
{/* Results count with quick actions */}
<div className="px-6 pt-5 pb-3 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<span className="text-[13px] text-[var(--color-text-secondary)] font-medium">
{filtered.length} {filtered.length !== 1 ? 'components' : 'component'}
</span>
{search && (
<span className="text-[12px] text-[var(--color-text-tertiary)]">
matching <span className="font-medium text-[var(--color-text-secondary)]">"{search}"</span>
</span>
)}
{category !== 'all' && (
<span className="text-[11px] px-2 py-0.5 rounded-md bg-[var(--color-surface-3)] text-[var(--color-text-tertiary)] border border-[var(--color-border)]">
{category}
</span>
)}
</div>
{/* Quick Actions */}
{filtered.length > 0 && (
<div className="flex items-center gap-2 ml-4">
<button
onClick={() => {
paged.forEach(comp => {
if (!isInCart(comp.path, comp.type)) {
toggleCart(comp);
}
});
}}
className="text-[11px] font-semibold px-3 py-1.5 rounded-lg bg-[var(--color-surface-2)] text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-surface-3)] border border-[var(--color-border)] hover:border-[var(--color-border-hover)] transition-all active:scale-95"
title="Add all visible components to stack"
>
+ Add Page to Stack
</button>
</div>
)}
</div>
{/* Keyboard shortcuts hint */}
<div className="hidden lg:flex items-center gap-2 text-[11px] text-[var(--color-text-tertiary)]">
<kbd className="px-2 py-1 rounded bg-[var(--color-surface-2)] border border-[var(--color-border)] font-mono">K</kbd>
<span>to search</span>
</div>
</div>
{/* Grid or List View */}
<div className={viewMode === 'grid'
? "grid grid-cols-1 md:grid-cols-2 2xl:grid-cols-3 gap-5 px-6 pb-6"
: "flex flex-col gap-4 px-6 pb-6"
}>
{paged.map((component, idx) => {
const inCart = isInCart(component.path, component.type);
const config = TYPE_CONFIG[activeType];
if (viewMode === 'list') {
// List View
return (
<div
key={component.path ?? component.name}
className="group relative flex items-center gap-5 p-4 rounded-md bg-[var(--color-card-bg)] border border-[var(--color-border)] hover:bg-[var(--color-card-hover)] hover:border-[var(--color-accent)] transition-all duration-150 cursor-pointer animate-fade-in-up"
style={{
animationDelay: `${idx * 30}ms`,
animationFillMode: 'both'
}}
onClick={() => {
window.location.href = `/component/${component.type}/${cleanPath(component.path ?? component.name)}`;
}}
>
{/* Icon */}
<div
className="w-11 h-11 rounded flex items-center justify-center shrink-0"
style={{
backgroundColor: `${config?.color ?? '#a1a1a1'}15`,
color: config?.color ?? '#a1a1a1',
border: `1px solid ${config?.color ?? '#a1a1a1'}30`,
}}
>
<TypeIcon type={activeType} size={22} className="[&>svg]:w-[22px] [&>svg]:h-[22px]" />
</div>
{/* Info */}
<div className="flex-1 min-w-0">
<h3 className="font-mono text-[14px] font-bold text-[var(--color-text-primary)] group-hover:text-[var(--color-accent)] transition-colors mb-1">
{formatName(component.name)}
</h3>
<p className="text-[12px] text-[var(--color-text-secondary)] line-clamp-1 leading-relaxed">
{component.description || component.content?.slice(0, 150) || 'No description'}
</p>
</div>
{/* Badges */}
<div className="flex items-center gap-2 shrink-0">
{component.category && (
<span className="font-mono text-[10px] font-semibold px-2 py-0.5 rounded border border-[var(--color-border)] bg-[var(--color-surface-2)] text-[var(--color-text-tertiary)]">
{component.category}
</span>
)}
{(component.downloads ?? 0) > 0 && (
<span className="badge-download text-[10px]">
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2.5">
<path strokeLinecap="round" strokeLinejoin="round" d="M19 14l-7 7m0 0l-7-7m7 7V3" />
</svg>
{component.downloads?.toLocaleString()}
</span>
)}
</div>
{/* Actions */}
<div className="flex items-center gap-2 shrink-0">
<SaveToCollectionButton
componentType={component.type}
componentPath={component.path}
componentName={component.name}
componentCategory={component.category}
/>
<button
onClick={(e) => { e.stopPropagation(); toggleCart(component); }}
className={`w-8 h-8 rounded flex items-center justify-center transition-all duration-150 ${
inCart
? 'bg-[var(--color-accent)] text-white border border-[var(--color-accent)]'
: 'text-[var(--color-text-tertiary)] opacity-0 group-hover:opacity-100 hover:text-[var(--color-accent)] hover:bg-[var(--color-surface-2)] border border-transparent hover:border-[var(--color-border)]'
}`}
title={inCart ? 'Remove from stack' : 'Add to stack'}
>
{inCart ? (
<svg className="w-4.5 h-4.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="3">
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
) : (
<svg className="w-4.5 h-4.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
</svg>
)}
</button>
</div>
</div>
);
}
// Grid View — terminal style
return (
<div
key={component.path ?? component.name}
className="group relative flex items-start gap-4 p-5 rounded-md bg-[var(--color-card-bg)] border border-[var(--color-border)] hover:bg-[var(--color-card-hover)] hover:border-[var(--color-accent)] transition-all duration-150 cursor-pointer animate-fade-in-up"
style={{
animationDelay: `${idx * 50}ms`,
animationFillMode: 'both'
}}
onClick={() => {
window.location.href = `/component/${component.type}/${cleanPath(component.path ?? component.name)}`;
}}
>
{/* Icon — flat, no glow */}
<div
className="w-10 h-10 rounded flex items-center justify-center shrink-0"
style={{
backgroundColor: `${config?.color ?? '#a1a1a1'}15`,
color: config?.color ?? '#a1a1a1',
border: `1px solid ${config?.color ?? '#a1a1a1'}30`,
}}
>
<TypeIcon type={activeType} size={20} className="[&>svg]:w-[20px] [&>svg]:h-[20px]" />
</div>
{/* Info */}
<div className="min-w-0 flex-1">
<h3 className="font-mono text-[14px] font-bold text-[var(--color-text-primary)] group-hover:text-[var(--color-accent)] transition-colors leading-tight mb-1.5">
{formatName(component.name)}
</h3>
<p className="text-[12px] text-[var(--color-text-secondary)] line-clamp-2 leading-[1.6]">
{component.description || component.content?.slice(0, 120) || 'No description'}
</p>
<div className="flex items-center gap-2 mt-3">
{component.category && (
<span className="font-mono text-[10px] font-semibold px-2 py-0.5 rounded border border-[var(--color-border)] bg-[var(--color-surface-2)] text-[var(--color-text-tertiary)]">
{component.category}
</span>
)}
{(component.downloads ?? 0) > 0 && (
<span className="badge-download text-[10px]">
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2.5">
<path strokeLinecap="round" strokeLinejoin="round" d="M19 14l-7 7m0 0l-7-7m7 7V3" />
</svg>
{component.downloads?.toLocaleString()}
</span>
)}
</div>
</div>
{/* Action buttons */}
<div className="flex items-center gap-1.5 shrink-0">
<SaveToCollectionButton
componentType={component.type}
componentPath={component.path}
componentName={component.name}
componentCategory={component.category}
/>
<button
onClick={(e) => { e.stopPropagation(); toggleCart(component); }}
className={`w-8 h-8 rounded flex items-center justify-center transition-all duration-150 ${
inCart
? 'bg-[var(--color-accent)] text-white border border-[var(--color-accent)]'
: 'text-[var(--color-text-tertiary)] opacity-0 group-hover:opacity-100 hover:text-[var(--color-accent)] hover:bg-[var(--color-surface-2)] border border-transparent hover:border-[var(--color-border)]'
}`}
title={inCart ? 'Remove from stack' : 'Add to stack'}
aria-label={inCart ? 'Remove from stack' : 'Add to stack'}
>
{inCart ? (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="3">
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
) : (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
</svg>
)}
</button>
</div>
</div>
);
})}
</div>
{/* Empty state */}
{paged.length === 0 && !loading && (
<div className="flex flex-col items-center justify-center px-6 py-32">
{/* Animated illustration */}
<div className="relative mb-8 animate-float">
<div className="w-24 h-24 rounded-3xl bg-gradient-to-br from-[var(--color-surface-2)] to-[var(--color-surface-3)] border border-[var(--color-border)] flex items-center justify-center shadow-xl">
<svg className="w-12 h-12 text-[var(--color-text-tertiary)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="1.5">
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
{/* Decorative animated elements */}
<div className="absolute -top-2 -right-2 w-4 h-4 rounded-full bg-gradient-to-br from-[var(--color-accent-400)] to-[var(--color-accent-500)] opacity-80 animate-ping" style={{ animationDuration: '2s' }}></div>
<div className="absolute -top-2 -right-2 w-4 h-4 rounded-full bg-[var(--color-accent-400)] opacity-80"></div>
<div className="absolute -bottom-2 -left-2 w-3 h-3 rounded-full bg-gradient-to-br from-[var(--color-primary-400)] to-[var(--color-primary-500)] opacity-80 animate-ping" style={{ animationDuration: '2.5s', animationDelay: '0.5s' }}></div>
<div className="absolute -bottom-2 -left-2 w-3 h-3 rounded-full bg-[var(--color-primary-400)] opacity-80"></div>
{/* Glow effect */}
<div className="absolute inset-0 bg-gradient-to-br from-[var(--color-primary-500)]/10 to-[var(--color-accent-400)]/10 rounded-3xl blur-2xl -z-10"></div>
</div>
{/* Message */}
<h3 className="text-[17px] font-bold text-[var(--color-text-primary)] mb-3">
{search ? `No results for "${search}"` : 'No components found'}
</h3>
<p className="text-[14px] text-[var(--color-text-secondary)] max-w-md text-center mb-8 leading-relaxed">
{search
? "We couldn't find any components matching your search. Try different keywords or browse all categories."
: category !== 'all'
? "No components in this category yet. Try selecting a different category or browse all."
: "No components available at the moment. Check back soon!"}
</p>
{/* Actions */}
<div className="flex items-center gap-3">
{search && (
<button
onClick={() => setSearch('')}
className="px-5 py-2.5 text-[13px] font-semibold rounded-xl bg-[var(--color-surface-2)] text-[var(--color-text-primary)] hover:bg-[var(--color-surface-3)] border border-[var(--color-border)] hover:border-[var(--color-border-hover)] hover:shadow-lg transition-all active:scale-95"
>
Clear search
</button>
)}
{category !== 'all' && (
<button
onClick={() => setCategory('all')}
className="px-5 py-2.5 text-[13px] font-semibold rounded-xl bg-[var(--color-surface-2)] text-[var(--color-text-primary)] hover:bg-[var(--color-surface-3)] border border-[var(--color-border)] hover:border-[var(--color-border-hover)] hover:shadow-lg transition-all active:scale-95"
>
View all categories
</button>
)}
{!search && category === 'all' && (
<a
href="/"
className="px-5 py-2.5 text-[13px] font-semibold rounded-xl bg-gradient-to-r from-[var(--color-primary-500)] to-[var(--color-primary-600)] text-white hover:from-[var(--color-primary-600)] hover:to-[var(--color-primary-700)] shadow-lg hover:shadow-xl transition-all active:scale-95"
>
Browse all components
</a>
)}
</div>
</div>
)}
{/* Pagination - Premium style */}
{totalPages > 1 && (
<div className="flex items-center justify-center gap-3 px-6 pb-10 pt-4">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
className="group flex items-center gap-2 px-4 py-2.5 text-[13px] font-semibold rounded-xl text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-surface-2)] border border-[var(--color-border)] hover:border-[var(--color-border-hover)] disabled:opacity-30 disabled:cursor-not-allowed hover:shadow-md transition-all active:scale-95 disabled:active:scale-100 disabled:hover:shadow-none"
aria-label="Previous page"
>
<svg className="w-4 h-4 transition-transform group-hover:-translate-x-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2.5">
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg>
<span className="hidden sm:inline">Previous</span>
</button>
<div className="flex items-center gap-2 px-5 py-2.5 rounded-xl bg-[var(--color-surface-2)] border border-[var(--color-border)] shadow-sm">
<span className="text-[13px] font-bold text-[var(--color-text-primary)] tabular-nums">
{page}
</span>
<span className="text-[13px] text-[var(--color-text-tertiary)] font-medium">/</span>
<span className="text-[13px] font-semibold text-[var(--color-text-secondary)] tabular-nums">
{totalPages}
</span>
</div>
<button
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
className="group flex items-center gap-2 px-4 py-2.5 text-[13px] font-semibold rounded-xl text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-surface-2)] border border-[var(--color-border)] hover:border-[var(--color-border-hover)] disabled:opacity-30 disabled:cursor-not-allowed hover:shadow-md transition-all active:scale-95 disabled:active:scale-100 disabled:hover:shadow-none"
aria-label="Next page"
>
<span className="hidden sm:inline">Next</span>
<svg className="w-4 h-4 transition-transform group-hover:translate-x-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2.5">
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
)}
</div>
);
}
+188
View File
@@ -0,0 +1,188 @@
import { useId } from 'react';
interface CountryFlagProps {
code: string;
className?: string;
}
// SVG flag path data for top countries (mask applied via unique ID per instance)
const FLAG_DATA: Record<string, (maskId: string) => React.ReactNode> = {
US: (m) => (
<>
<mask id={m}><circle cx="256" cy="256" r="256" fill="#fff"/></mask>
<g mask={`url(#${m})`}>
<path fill="#eee" d="M0 0h512v512H0z"/>
<path fill="#d80027" d="M0 0h512v39.4H0zm0 78.8h512v39.4H0zm0 78.7h512v39.4H0zm0 78.8h512v39.4H0zm0 78.8h512v39.3H0zm0 78.7h512v39.4H0zm0 78.8h512V512H0z"/>
<path fill="#0052b4" d="M0 0h256v275.8H0z"/>
<path fill="#eee" d="m99.8 66.3 4.4 13.5H118l-11.5 8.4 4.4 13.5-11.4-8.3-11.5 8.3 4.4-13.5-11.4-8.4h14.1zm-55 0 4.3 13.5h14.2l-11.5 8.4 4.4 13.5-11.4-8.3-11.5 8.3 4.4-13.5-11.4-8.4h14.1zm110 0 4.4 13.5h14.1l-11.4 8.4 4.4 13.5-11.5-8.3-11.4 8.3 4.4-13.5-11.5-8.4h14.2zm-55 44.5 4.4 13.5h14.1l-11.4 8.3 4.4 13.5-11.5-8.3-11.4 8.3 4.4-13.5-11.5-8.3h14.2zm-55 0 4.3 13.5h14.2l-11.5 8.3 4.4 13.5-11.4-8.3-11.5 8.3 4.4-13.5-11.4-8.3h14.1zm110 0 4.4 13.5h14.1l-11.4 8.3 4.4 13.5-11.5-8.3-11.4 8.3 4.4-13.5-11.5-8.3h14.2zm-55 44.4 4.4 13.5h14.1l-11.4 8.4 4.4 13.5-11.5-8.3-11.4 8.3 4.4-13.5-11.5-8.4h14.2zm-55 0 4.3 13.5h14.2l-11.5 8.4 4.4 13.5-11.4-8.3-11.5 8.3 4.4-13.5-11.4-8.4h14.1zm110 0 4.4 13.5h14.1l-11.4 8.4 4.4 13.5-11.5-8.3-11.4 8.3 4.4-13.5-11.5-8.4h14.2zm-55 44.5 4.4 13.5h14.1l-11.4 8.3 4.4 13.5-11.5-8.3-11.4 8.3 4.4-13.5-11.5-8.3h14.2zm-55 0 4.3 13.5h14.2l-11.5 8.3 4.4 13.5-11.4-8.3-11.5 8.3 4.4-13.5-11.4-8.3h14.1zm110 0 4.4 13.5h14.1l-11.4 8.3 4.4 13.5-11.5-8.3-11.4 8.3 4.4-13.5-11.5-8.3h14.2zm-55 44.5 4.4 13.5h14.1l-11.4 8.3 4.4 13.5-11.5-8.3-11.4 8.3 4.4-13.5-11.5-8.3h14.2zm-55 0 4.3 13.5h14.2l-11.5 8.3 4.4 13.5-11.4-8.3-11.5 8.3 4.4-13.5-11.4-8.3h14.1zm110 0 4.4 13.5h14.1l-11.4 8.3 4.4 13.5-11.5-8.3-11.4 8.3 4.4-13.5-11.5-8.3h14.2z"/>
</g>
</>
),
KR: (m) => (
<>
<mask id={m}><circle cx="256" cy="256" r="256" fill="#fff"/></mask>
<g mask={`url(#${m})`}>
<path fill="#eee" d="M0 0h512v512H0z"/>
<circle cx="256" cy="256" r="111.3" fill="#d80027"/>
<path fill="#0052b4" d="M345.3 256a89.3 89.3 0 0 1-89.3 89.3 111.3 111.3 0 0 0 0-178.6 89.3 89.3 0 0 1 89.3 89.3z"/>
<path fill="#333" d="m350.4 334.7 23.4 17-8.9 28.8 23.4-17 23.4 17-9-28.8 23.5-17h-29l-8.9-28.8-8.9 28.8zm-34.3-22.3-8.9 28.8 23.4-17 23.4 17-8.9-28.8 23.4-17h-29l-8.9-28.8-8.9 28.8h-28.9zm22.3 133.9 8.9-28.8h29l-23.4-17 8.9-28.8-23.4 17-23.4-17 8.9 28.8-23.4 17h29zm-34.3-22.3 23.4-17-8.9-28.8 23.4 17 23.4-17-8.9 28.8 23.4 17h-29l-8.9 28.8-8.9-28.8zM127.7 88.8l23.4 17-8.9 28.8 23.4-17 23.4 17-8.9-28.8 23.4-17h-29l-8.9-28.8-8.9 28.8zm-34.3-22.3-8.9 28.8 23.4-17 23.4 17-8.9-28.8 23.4-17h-29l-8.9-28.8-8.9 28.8H70.1zm22.3 133.9 8.9-28.8h29l-23.4-17 8.9-28.8-23.4 17-23.4-17 8.9 28.8-23.4 17h29zm-34.3-22.3 23.4-17-8.9-28.8 23.4 17 23.4-17-8.9 28.8 23.4 17h-29l-8.9 28.8-8.9-28.8z"/>
</g>
</>
),
BR: (m) => (
<>
<mask id={m}><circle cx="256" cy="256" r="256" fill="#fff"/></mask>
<g mask={`url(#${m})`}>
<path fill="#6da544" d="M0 0h512v512H0z"/>
<path fill="#ffda44" d="M256 100.2 467.5 256 256 411.8 44.5 256z"/>
<circle cx="256" cy="256" r="89" fill="#0052b4"/>
<path fill="#eee" d="M211.2 250.1c-5.4 0-10.8.4-16 1.3 0-2.7-.1-5.4-.1-8.2 0-53.2 18.9-101.3 50.2-138.6-79.6 4.4-143 71-143 152.6 0 84.4 68.5 152.9 152.9 152.9 39.5 0 75.6-15 102.8-39.7-27.8 22.8-63.3 36.5-101.9 36.5-88.4 0-160.2-71.7-160.2-160.1 0-41.2 15.6-78.8 41.3-107.3z"/>
</g>
</>
),
CN: (m) => (
<>
<mask id={m}><circle cx="256" cy="256" r="256" fill="#fff"/></mask>
<g mask={`url(#${m})`}>
<path fill="#d80027" d="M0 0h512v512H0z"/>
<path fill="#ffda44" d="m226.8 157.2 12 37h38.9l-31.5 22.9 12 37-31.4-22.9-31.5 22.9 12-37-31.4-22.9h38.9zm87.1 38.5 3.9 11.9 12.5.1-10.1 7.4 3.8 11.9-10.1-7.3-10.1 7.3 3.9-11.9-10.1-7.4 12.4-.1zm13.6 53.3 11.8 4.3 7.6-9.7-.5 12.5 11.9 4.2-12.3 2-.5 12.4-4.9-11.4-12.4 2 7.7-9.7zm-44.8 52.7 9.4 8.1 10.2-6.5-5.1 11.3 9.4 8-12.4-.9-5.2 11.2-1.1-12.4-12.4-.9 10.2-6.5zm-46.3-29.3 4.3 11.8 9.7 7.6-12.5-.5-4.2 11.9-2-12.3-12.4-.5 11.4-4.9-2-12.4 9.7 7.7z"/>
</g>
</>
),
ES: (m) => (
<>
<mask id={m}><circle cx="256" cy="256" r="256" fill="#fff"/></mask>
<g mask={`url(#${m})`}>
<path fill="#d80027" d="M0 0h512v128l-39.8 130.3L512 384v128H0V384l37.1-124L0 128z"/>
<path fill="#ffda44" d="M0 128h512v256H0z"/>
</g>
</>
),
GB: (m) => (
<>
<mask id={m}><circle cx="256" cy="256" r="256" fill="#fff"/></mask>
<g mask={`url(#${m})`}>
<path fill="#0052b4" d="M0 0h512v512H0z"/>
<path fill="#eee" d="M512 0v64L322 256l190 192v64h-64L258 320 68 512H0v-64l190-192L0 64V0h64l190 192L444 0z"/>
<path fill="#d80027" d="M0 0v32l160 160h32L0 0zm352 192 160 160v-32L384 192h-32zM0 512h32l160-160v-32L0 512zm352-192 160 160h-32L320 320v32z"/>
<path fill="#eee" d="M176 0v512h160V0H176zM0 176v160h512V176H0z"/>
<path fill="#d80027" d="M0 208v96h512v-96H0zM208 0v512h96V0h-96z"/>
</g>
</>
),
DE: (m) => (
<>
<mask id={m}><circle cx="256" cy="256" r="256" fill="#fff"/></mask>
<g mask={`url(#${m})`}>
<path fill="#333" d="M0 0h512v170.7H0z"/>
<path fill="#d80027" d="M0 170.7h512v170.6H0z"/>
<path fill="#ffda44" d="M0 341.3h512V512H0z"/>
</g>
</>
),
FR: (m) => (
<>
<mask id={m}><circle cx="256" cy="256" r="256" fill="#fff"/></mask>
<g mask={`url(#${m})`}>
<path fill="#eee" d="M167 0h178l25.9 252.3L345 512H167l-29.8-253.4z"/>
<path fill="#0052b4" d="M0 0h167v512H0z"/>
<path fill="#d80027" d="M345 0h167v512H345z"/>
</g>
</>
),
IN: (m) => (
<>
<mask id={m}><circle cx="256" cy="256" r="256" fill="#fff"/></mask>
<g mask={`url(#${m})`}>
<path fill="#eee" d="M0 167l253.8-19.3L512 167v178l-254.9 32.3L0 345z"/>
<path fill="#ff9811" d="M0 0h512v167H0z"/>
<path fill="#6da544" d="M0 345h512v167H0z"/>
<circle cx="256" cy="256" r="89" fill="#0052b4"/>
<circle cx="256" cy="256" r="55.7" fill="#eee"/>
<circle cx="256" cy="256" r="22.3" fill="#0052b4"/>
</g>
</>
),
JP: (m) => (
<>
<mask id={m}><circle cx="256" cy="256" r="256" fill="#fff"/></mask>
<g mask={`url(#${m})`}>
<path fill="#eee" d="M0 0h512v512H0z"/>
<circle cx="256" cy="256" r="111.3" fill="#d80027"/>
</g>
</>
),
CA: (m) => (
<>
<mask id={m}><circle cx="256" cy="256" r="256" fill="#fff"/></mask>
<g mask={`url(#${m})`}>
<path fill="#eee" d="M144.7 0h222.6v512H144.7z"/>
<path fill="#d80027" d="M0 0h144.7v512H0zm367.3 0H512v512H367.3z"/>
<path fill="#d80027" d="m300.5 289.4-16.7-4.9-16.6 5 6.2-16.4-10.8-13.6 17.6-.4 6.9-16.1 6.9 16 17.6.5-10.8 13.6zm-89 0 16.7-4.9 16.6 5-6.2-16.4 10.8-13.6-17.6-.4-6.9-16.1-6.9 16-17.6.5 10.8 13.6zm44.5-50.5 4.9 16.7-5 16.6 16.4-6.2 13.6 10.8.4-17.6 16.1-6.9-16-6.9-.5-17.6-13.6 10.8zm0 122.2-4.9-16.7 5-16.6-16.4 6.2-13.6-10.8-.4 17.6-16.1 6.9 16 6.9.5 17.6 13.6-10.8zm-44.5-27.8 16.7 5 16.6-5-6.2 16.4 10.8 13.6-17.6.4-6.9 16.1-6.9-16-17.6-.5 10.8-13.6zm89 0-16.7 5-16.6-5 6.2 16.4-10.8 13.6 17.6.4 6.9 16.1 6.9-16 17.6-.5-10.8-13.6z"/>
</g>
</>
),
AU: (m) => (
<>
<mask id={m}><circle cx="256" cy="256" r="256" fill="#fff"/></mask>
<g mask={`url(#${m})`}>
<path fill="#0052b4" d="M0 0h512v512H0z"/>
<path fill="#eee" d="m154 300.5 8.3 25.5h26.8l-21.7 15.7 8.3 25.5-21.7-15.8-21.7 15.8 8.3-25.5-21.7-15.7h26.8zm206.6-89 4.2 12.7h13.4L367.4 232l4.1 12.8-10.8-7.9-10.9 7.9 4.2-12.8-10.9-7.9h13.4zm0 155.8 4.2 12.8h13.4l-10.8 7.8 4.1 12.8-10.8-7.9-10.9 7.9 4.2-12.8-10.9-7.8h13.4zm-44.5-66.9 4.2 12.8h13.4l-10.8 7.8 4.1 12.8-10.8-7.9-10.9 7.9 4.2-12.8-10.9-7.8h13.4zm88.9 0 4.2 12.8H423l-10.8 7.8 4.1 12.8-10.8-7.9-10.9 7.9 4.2-12.8-10.9-7.8h13.4z"/>
<path fill="#0052b4" d="M256 0v32l-45.3 32H256v64h-46.9L256 154.7V192h-22.1L160 134v58H96v-58l-73.9 58H0v-37.3L54.9 128H0V64h54.9L0 37.3V0h22.1L96 58V0h64v58l73.9-58H256z"/>
<path fill="#eee" d="M144 0v69.3L32 0H0v32l112 80v80h32v-80l112-80V0h-32l-112 69.3V0h-32z"/>
<path fill="#d80027" d="M0 0v15.1L138.7 96H160v-15.1L21.3 0H0zM160 0v15.1L21.3 96H0v-15.1L138.7 0H160z"/>
</g>
</>
),
IT: (m) => (
<>
<mask id={m}><circle cx="256" cy="256" r="256" fill="#fff"/></mask>
<g mask={`url(#${m})`}>
<path fill="#eee" d="M167 0h178l25.9 252.3L345 512H167l-29.8-253.4z"/>
<path fill="#6da544" d="M0 0h167v512H0z"/>
<path fill="#d80027" d="M345 0h167v512H345z"/>
</g>
</>
),
TR: (m) => (
<>
<mask id={m}><circle cx="256" cy="256" r="256" fill="#fff"/></mask>
<g mask={`url(#${m})`}>
<path fill="#d80027" d="M0 0h512v512H0z"/>
<path fill="#eee" d="M199.2 155.4a100.6 100.6 0 1 0 21.6 198.3 122.8 122.8 0 1 1 0-197 100.3 100.3 0 0 0-21.6-1.3z"/>
<path fill="#eee" d="m294.3 202.5 8 34.7 34.8-3.9-27.9 21.8 17.9 29.8-32.2-13.9-13 33 -4.9-35-34.8 4.9 27.9-22.7-17.9-28.8 32.2 12.9z"/>
</g>
</>
),
MX: (m) => (
<>
<mask id={m}><circle cx="256" cy="256" r="256" fill="#fff"/></mask>
<g mask={`url(#${m})`}>
<path fill="#eee" d="M167 0h178l25.9 252.3L345 512H167l-29.8-253.4z"/>
<path fill="#6da544" d="M0 0h167v512H0z"/>
<path fill="#d80027" d="M345 0h167v512H345z"/>
<path fill="#6da544" d="M208 256c0-26.5 21.5-48 48-48s48 21.5 48 48-21.5 48-48 48-48-21.5-48-48z"/>
</g>
</>
),
};
export default function CountryFlag({ code, className }: CountryFlagProps) {
const id = useId();
const maskId = `flag-${code}-${id}`;
const renderFlag = FLAG_DATA[code];
if (!renderFlag) {
return (
<svg className={className || "w-6 h-6"} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
);
}
return (
<svg className={className || "w-6 h-6"} viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
{renderFlag(maskId)}
</svg>
);
}
@@ -0,0 +1,28 @@
---
import type { FeaturedItem } from '../lib/types';
interface Props {
item: FeaturedItem;
}
const { item } = Astro.props;
---
<a
href={item.url}
class="group flex items-center gap-3 p-4 bg-[var(--color-card-bg)] rounded-md border border-[var(--color-border)] hover:bg-[var(--color-card-hover)] hover:border-[var(--color-accent)] transition-all duration-150"
>
<div class="w-11 h-11 rounded-sm bg-[var(--color-surface-2)] border border-[var(--color-border)] flex items-center justify-center shrink-0 p-2">
<img src={item.logo} alt={item.name} class="w-full h-full object-contain" loading="lazy" onerror="this.style.display='none'" />
</div>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span class="font-ui-mono text-[13px] font-bold text-[var(--color-text-primary)] group-hover:text-[var(--color-accent)] transition-colors">{item.name}</span>
<span class="font-ui-mono text-[10px] font-medium px-2 py-0.5 rounded border border-[var(--color-border)] text-[var(--color-text-tertiary)] bg-[var(--color-surface-2)]">
{item.tag}
</span>
</div>
<p class="text-[12px] text-[var(--color-text-secondary)] mt-1 leading-[1.5]">{item.description}</p>
</div>
<span class="terminal-prompt text-[12px] opacity-0 group-hover:opacity-100 transition-opacity shrink-0"></span>
</a>
@@ -0,0 +1,32 @@
---
import FeaturedCard from './FeaturedCard.astro';
import { FEATURED_ITEMS } from '../lib/constants';
const gridColsClass = FEATURED_ITEMS.length % 2 === 0
? 'sm:grid-cols-2'
: 'sm:grid-cols-2 lg:grid-cols-3';
---
<section class="px-6 py-4 mb-2">
<div class="bg-[var(--color-surface-1)] rounded-md p-5 border border-[var(--color-border)]">
<div class="flex flex-wrap items-center justify-between gap-2 mb-4">
<div class="flex items-center gap-2">
<span class="terminal-prompt text-[13px]">★</span>
<h2 class="font-ui-mono text-[13px] font-bold text-[var(--color-text-primary)] tracking-wide uppercase">Featured Integrations</h2>
</div>
<a
href="https://docs.google.com/forms/d/e/1FAIpQLSd7uyNot_YG4vMrt8jJuWiCfb2HNHjGcCZEoVS07SU8OCc-yA/viewform"
target="_blank"
rel="noopener noreferrer"
class="w-full sm:w-auto text-center shrink-0 font-ui-mono text-[12px] font-bold text-[var(--color-accent)] border border-[var(--color-accent)] rounded-md px-3 py-1.5 hover:bg-[var(--color-accent)] hover:text-[var(--color-surface-1)] transition-colors"
>
Promote your component →
</a>
</div>
<div class={`grid grid-cols-1 ${gridColsClass} gap-3`}>
{FEATURED_ITEMS.map((item) => (
<FeaturedCard item={item} />
))}
</div>
</div>
</section>
@@ -0,0 +1,200 @@
import { useState, useMemo } from 'react';
interface FileTreeSidebarProps {
references: string[];
skillName: string;
}
interface TreeNode {
name: string;
path: string;
isFile: boolean;
children: TreeNode[];
ext?: string;
}
const EXT_COLORS: Record<string, string> = {
md: '#60a5fa', js: '#facc15', ts: '#60a5fa', tsx: '#60a5fa', jsx: '#facc15',
py: '#4ade80', html: '#fb923c', json: '#fde047', txt: 'var(--color-text-tertiary)',
sh: '#86efac', yml: '#f472b6', yaml: '#f472b6', css: '#c084fc', toml: '#f472b6',
};
function buildTree(paths: string[]): TreeNode[] {
const root: TreeNode[] = [];
for (const p of paths) {
const parts = p.split('/');
let current = root;
for (let i = 0; i < parts.length; i++) {
const name = parts[i];
const isFile = i === parts.length - 1;
const fullPath = parts.slice(0, i + 1).join('/');
let existing = current.find((n) => n.name === name && n.isFile === isFile);
if (!existing) {
const ext = isFile ? name.split('.').pop()?.toLowerCase() : undefined;
existing = { name, path: fullPath, isFile, children: [], ext };
current.push(existing);
}
current = existing.children;
}
}
// Sort: folders first, then files, alphabetically
function sortNodes(nodes: TreeNode[]) {
nodes.sort((a, b) => {
if (a.isFile !== b.isFile) return a.isFile ? 1 : -1;
return a.name.localeCompare(b.name);
});
for (const n of nodes) {
if (!n.isFile) sortNodes(n.children);
}
}
sortNodes(root);
return root;
}
function countFiles(nodes: TreeNode[]): number {
let count = 0;
for (const n of nodes) {
if (n.isFile) count++;
else count += countFiles(n.children);
}
return count;
}
export default function FileTreeSidebar({ references, skillName }: FileTreeSidebarProps) {
const tree = useMemo(() => buildTree(references), [references]);
const [search, setSearch] = useState('');
const filteredRefs = useMemo(() => {
if (!search.trim()) return null;
const q = search.toLowerCase();
return references.filter((r) => r.toLowerCase().includes(q));
}, [search, references]);
const filteredTree = useMemo(() => {
if (!filteredRefs) return tree;
return buildTree(filteredRefs);
}, [filteredRefs, tree]);
return (
<div className="w-64 shrink-0 bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded-lg overflow-hidden flex flex-col" style={{ maxHeight: 'calc(100vh - 8rem)' }}>
{/* Header */}
<div className="px-3 py-2.5 border-b border-[var(--color-border)] flex items-center justify-between">
<div className="flex items-center gap-2 min-w-0">
<svg className="w-4 h-4 text-[var(--color-text-tertiary)] shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 12.75V12A2.25 2.25 0 014.5 9.75h15A2.25 2.25 0 0121.75 12v.75m-8.69-6.44l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-5.379a1.5 1.5 0 01-1.06-.44z" />
</svg>
<span className="text-xs font-semibold text-[var(--color-text-primary)] truncate">{skillName}</span>
</div>
<span className="text-[10px] text-[var(--color-text-tertiary)] shrink-0">{references.length} files</span>
</div>
{/* Search */}
<div className="px-2 py-1.5 border-b border-[var(--color-border)]">
<div className="relative">
<svg className="absolute left-2 top-1/2 -translate-y-1/2 w-3 h-3 text-[var(--color-text-tertiary)] pointer-events-none" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<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={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Filter files..."
className="w-full bg-[var(--color-surface-3)] border-none rounded pl-7 pr-2 py-1 text-[11px] text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] focus:outline-none focus:ring-1 focus:ring-accent-400/30"
/>
{search && (
<button
onClick={() => setSearch('')}
className="absolute right-1.5 top-1/2 -translate-y-1/2 text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)]"
>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
</div>
{/* Tree */}
<div className="flex-1 overflow-y-auto py-1" style={{ scrollbarWidth: 'thin' }}>
{filteredTree.length === 0 && search ? (
<div className="px-3 py-4 text-center text-[11px] text-[var(--color-text-tertiary)]">No matching files</div>
) : (
<TreeNodes nodes={filteredTree} depth={0} forceOpen={!!search} />
)}
</div>
</div>
);
}
function TreeNodes({ nodes, depth, forceOpen }: { nodes: TreeNode[]; depth: number; forceOpen?: boolean }) {
return (
<>
{nodes.map((node) =>
node.isFile ? (
<FileNode key={node.path} node={node} depth={depth} />
) : (
<FolderNode key={node.path} node={node} depth={depth} forceOpen={forceOpen} />
)
)}
</>
);
}
function FolderNode({ node, depth, forceOpen }: { node: TreeNode; depth: number; forceOpen?: boolean }) {
const [open, setOpen] = useState(forceOpen || depth < 1);
const fileCount = useMemo(() => countFiles(node.children), [node.children]);
// If forceOpen changes to true, open the folder
const isOpen = forceOpen || open;
return (
<div>
<button
onClick={() => setOpen(!open)}
className="w-full flex items-center gap-1 py-[3px] pr-2 hover:bg-white/[0.04] transition-colors group/folder"
style={{ paddingLeft: `${depth * 12 + 8}px` }}
>
<svg
className={`w-3 h-3 text-[var(--color-text-tertiary)] shrink-0 transition-transform duration-100 ${isOpen ? 'rotate-90' : ''}`}
fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
<svg className="w-3.5 h-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke={isOpen ? '#facc15' : 'var(--color-text-tertiary)'} strokeWidth={1.5}>
{isOpen ? (
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 9.776c.112-.017.227-.026.344-.026h15.812c.117 0 .232.009.344.026m-16.5 0a2.25 2.25 0 00-1.883 2.542l.857 6a2.25 2.25 0 002.227 1.932H19.05a2.25 2.25 0 002.227-1.932l.857-6a2.25 2.25 0 00-1.883-2.542m-16.5 0V6A2.25 2.25 0 016 3.75h3.879a1.5 1.5 0 011.06.44l2.122 2.12a1.5 1.5 0 001.06.44H18A2.25 2.25 0 0120.25 9v.776" />
) : (
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 12.75V12A2.25 2.25 0 014.5 9.75h15A2.25 2.25 0 0121.75 12v.75m-8.69-6.44l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-5.379a1.5 1.5 0 01-1.06-.44z" />
)}
</svg>
<span className="text-[11px] text-[var(--color-text-secondary)] truncate">{node.name}</span>
<span className="text-[9px] text-[var(--color-text-tertiary)] ml-auto opacity-0 group-hover/folder:opacity-100 transition-opacity">{fileCount}</span>
</button>
{isOpen && <TreeNodes nodes={node.children} depth={depth + 1} forceOpen={forceOpen} />}
</div>
);
}
function FileNode({ node, depth }: { node: TreeNode; depth: number }) {
const color = EXT_COLORS[node.ext ?? ''] ?? 'var(--color-text-tertiary)';
return (
<div
className="flex items-center gap-1.5 py-[3px] pr-2 hover:bg-white/[0.04] cursor-default transition-colors"
style={{ paddingLeft: `${depth * 12 + 20}px` }}
title={node.path}
>
<svg className="w-3.5 h-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke={color} strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
</svg>
<span className="text-[11px] text-[var(--color-text-primary)] truncate font-mono">{node.name}</span>
{node.ext && (
<span className="text-[8px] ml-auto shrink-0 font-mono uppercase" style={{ color }}>{node.ext}</span>
)}
</div>
);
}
+264
View File
@@ -0,0 +1,264 @@
import { useState, useEffect } from 'react';
interface Job {
id: string;
company: string;
position: string;
location: string;
remote: boolean;
salary: string;
applyUrl: string;
source: string;
tags: string[];
companyIcon: string;
}
interface JobsData {
totalJobs: number;
jobs: Job[];
}
const SOURCE_COLORS: Record<string, string> = {
HackerNews: 'bg-orange-500/15 text-orange-400 border-orange-500/20',
RemoteOK: 'bg-blue-500/15 text-blue-400 border-blue-500/20',
WeWorkRemotely: 'bg-emerald-500/15 text-emerald-400 border-emerald-500/20',
Anthropic: 'bg-purple-500/15 text-purple-400 border-purple-500/20',
};
function useGlobalAuth() {
const [state, setState] = useState<{ isSignedIn: boolean; isLoaded: boolean }>({
isSignedIn: false, isLoaded: false,
});
useEffect(() => {
function check() {
const clerk = (window as any).Clerk;
if (clerk?.loaded) {
const signedIn = !!clerk.user;
setState((prev) => {
if (prev.isLoaded && prev.isSignedIn === signedIn) return prev;
return { isSignedIn: signedIn, isLoaded: true };
});
}
}
check();
const interval = setInterval(check, 500);
const handleChange = () => check();
window.addEventListener('clerk:session', handleChange);
return () => { clearInterval(interval); window.removeEventListener('clerk:session', handleChange); };
}, []);
return state;
}
function safeUrl(url: string): string {
return /^https?:\/\//i.test(url) ? url : '#';
}
function getCompanyLogoUrl(companyName: string, existingIcon?: string): string {
// If icon exists in data, use it
if (existingIcon) return existingIcon;
// Otherwise, try to fetch from Clearbit Logo API
const domain = companyName.toLowerCase()
.replace(/\s+/g, '')
.replace(/[^a-z0-9]/g, '');
return `https://logo.clearbit.com/${domain}.com`;
}
interface Props {
variant?: 'preview' | 'sidebar';
}
export default function JobsPreview({ variant = 'preview' }: Props) {
const [data, setData] = useState<JobsData | null>(null);
const { isSignedIn, isLoaded } = useGlobalAuth();
useEffect(() => {
fetch('/claude-jobs.json')
.then((r) => r.json())
.then((d) => setData(d))
.catch(() => {});
}, []);
if (!data || data.jobs.length === 0) return null;
const jobs = variant === 'sidebar' ? data.jobs.slice(0, 12) : data.jobs.slice(0, 5);
const showAuthGate = isLoaded && !isSignedIn;
function handleJobClick(e: React.MouseEvent) {
if (!isLoaded) { e.preventDefault(); return; }
if (!isSignedIn) {
e.preventDefault();
(window as any).Clerk?.openSignIn?.();
}
}
if (variant === 'sidebar') {
return (
<div className="flex flex-col h-full">
{/* Sticky header */}
<div className="px-4 py-3 border-b border-[var(--color-border)] bg-[var(--color-surface-0)] sticky top-0 z-10">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="font-mono text-[11px] font-bold text-[var(--color-text-primary)] uppercase tracking-wider">Jobs</span>
<span className="font-mono text-[9px] font-medium bg-orange-500/15 text-orange-400 px-1.5 py-0.5 rounded border border-orange-500/20">
{data.totalJobs}
</span>
</div>
<a
href="/jobs"
className="font-mono text-[10px] text-[var(--color-text-tertiary)] hover:text-[var(--color-accent)] transition-colors"
>
view all
</a>
</div>
<p className="font-mono text-[9px] text-[var(--color-text-tertiary)] mt-0.5">Using Claude Code</p>
</div>
{/* Vertical job list */}
<div className="flex-1 overflow-y-auto px-3 py-2 space-y-1.5">
{jobs.map((job) => (
<a
key={job.id}
href={isSignedIn ? safeUrl(job.applyUrl) : '#'}
target={isSignedIn ? '_blank' : undefined}
rel={isSignedIn ? 'noopener noreferrer' : undefined}
onClick={showAuthGate ? handleJobClick : undefined}
className="flex items-start gap-2.5 p-2.5 rounded border border-[var(--color-border)] hover:border-[var(--color-accent)] bg-[var(--color-card-bg)] hover:bg-[var(--color-card-hover)] transition-all group relative overflow-hidden block"
>
{showAuthGate && (
<div className="absolute inset-0 bg-[var(--color-surface-0)]/80 backdrop-blur-[1px] flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
<span className="font-mono text-[9px] font-medium text-[var(--color-text-secondary)] bg-[var(--color-surface-2)] px-2 py-1 rounded border border-[var(--color-border)]">
Sign in to view
</span>
</div>
)}
{/* Logo */}
<div className="w-6 h-6 rounded shrink-0 bg-[var(--color-surface-2)] border border-[var(--color-border)] flex items-center justify-center overflow-hidden mt-0.5">
<img
src={getCompanyLogoUrl(job.company, job.companyIcon)}
alt={job.company}
className="w-3.5 h-3.5 object-contain"
onError={(e) => {
const t = e.target as HTMLImageElement;
t.style.display = 'none';
const fb = t.nextElementSibling as HTMLElement;
if (fb) fb.style.display = 'flex';
}}
/>
<span className="text-[9px] font-bold text-[var(--color-text-tertiary)]" style={{ display: 'none' }}>
{job.company.charAt(0).toUpperCase()}
</span>
</div>
{/* Info */}
<div className="min-w-0 flex-1">
<p className="font-mono text-[10px] font-bold text-[var(--color-text-primary)] group-hover:text-[var(--color-accent)] transition-colors line-clamp-2 leading-tight mb-1">
{job.position}
</p>
<p className="font-mono text-[9px] text-[var(--color-text-tertiary)] truncate mb-1.5">{job.company}</p>
<div className="flex items-center gap-1 flex-wrap">
{job.remote && (
<span className="text-[8px] font-medium bg-blue-500/15 text-blue-400 px-1 py-0.5 rounded border border-blue-500/20">Remote</span>
)}
{job.salary && (
<span className="text-[8px] font-medium bg-emerald-500/15 text-emerald-400 px-1 py-0.5 rounded border border-emerald-500/20">{job.salary}</span>
)}
<span className={`text-[8px] font-medium px-1 py-0.5 rounded border ${SOURCE_COLORS[job.source] || 'bg-[var(--color-surface-3)] text-[var(--color-text-secondary)] border-[var(--color-border)]'}`}>
{job.source}
</span>
</div>
</div>
</a>
))}
</div>
</div>
);
}
return (
<section className="px-6 py-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<h2 className="text-sm font-semibold text-[var(--color-text-primary)]">Jobs Using Claude Code</h2>
<span className="text-[10px] font-medium bg-orange-500/15 text-orange-400 px-1.5 py-0.5 rounded">
{data.totalJobs} jobs
</span>
</div>
<a
href="/jobs"
className="text-[12px] text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)] transition-colors"
>
View all &rarr;
</a>
</div>
<div className="grid grid-cols-1 md:grid-cols-5 gap-3">
{jobs.map((job) => (
<a
key={job.id}
href={isSignedIn ? safeUrl(job.applyUrl) : '#'}
target={isSignedIn ? '_blank' : undefined}
rel={isSignedIn ? 'noopener noreferrer' : undefined}
onClick={showAuthGate ? handleJobClick : undefined}
className="bg-[var(--color-card-bg)] border border-[var(--color-border)] rounded-lg p-3.5 hover:border-[var(--color-border-hover)] hover:shadow-md transition-all group relative overflow-hidden"
>
{showAuthGate && (
<div className="absolute inset-0 bg-gradient-to-t from-[var(--color-surface-0)]/80 to-transparent backdrop-blur-[1px] flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
<div className="flex items-center gap-1.5 text-[11px] font-medium text-[var(--color-text-primary)] bg-[var(--color-surface-2)] px-3 py-1.5 rounded-full border border-[var(--color-border)]">
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0110 0v4" />
</svg>
Sign in to view
</div>
</div>
)}
<div className="flex items-center gap-2 mb-2.5">
<div className="w-7 h-7 rounded bg-[var(--color-surface-2)] border border-[var(--color-border)] flex items-center justify-center shrink-0 overflow-hidden">
<img
src={getCompanyLogoUrl(job.company, job.companyIcon)}
alt={job.company}
className="w-4 h-4 object-contain"
onError={(e) => {
const target = e.target as HTMLImageElement;
target.style.display = 'none';
const fallback = target.nextElementSibling as HTMLElement;
if (fallback) fallback.style.display = 'flex';
}}
/>
<span className="text-[10px] font-bold text-[var(--color-text-tertiary)]" style={{ display: 'none' }}>
{job.company.charAt(0).toUpperCase()}
</span>
</div>
<span className="text-[11px] font-medium text-[var(--color-text-tertiary)] truncate">{job.company}</span>
</div>
<h3 className="text-[12px] font-medium text-[var(--color-text-primary)] group-hover:text-[var(--color-text-primary)] transition-colors line-clamp-2 leading-tight mb-2.5 min-h-[2.5rem]">
{job.position}
</h3>
<div className="flex items-center gap-1.5 flex-wrap">
{job.remote && (
<span className="text-[9px] font-medium bg-blue-500/15 text-blue-400 px-1.5 py-0.5 rounded border border-blue-500/20">
Remote
</span>
)}
{job.salary && (
<span className="text-[9px] font-medium bg-emerald-500/15 text-emerald-400 px-1.5 py-0.5 rounded border border-emerald-500/20">
{job.salary}
</span>
)}
<span className={`text-[9px] font-medium px-1.5 py-0.5 rounded border ${SOURCE_COLORS[job.source] || 'bg-[var(--color-surface-3)] text-[var(--color-text-secondary)] border-[var(--color-border)]'}`}>
{job.source}
</span>
</div>
</a>
))}
</div>
</section>
);
}
+750
View File
@@ -0,0 +1,750 @@
import { useState, useEffect, useMemo } from 'react';
interface Job {
id: string;
company: string;
position: string;
location: string;
remote: boolean;
salary: string;
description: string;
applyUrl: string;
source: string;
sourceUrl: string;
postedAt: string;
tags: string[];
companyIcon: string;
}
interface JobsData {
lastUpdated: string;
totalJobs: number;
sources: string[];
jobs: Job[];
}
type LocationFilter = 'all' | 'remote' | 'onsite';
type SortOption = 'recent' | 'company' | 'salary';
const SOURCE_COLORS: Record<string, string> = {
HackerNews: 'bg-orange-500/15 text-orange-400 border-orange-500/20',
RemoteOK: 'bg-blue-500/15 text-blue-400 border-blue-500/20',
WeWorkRemotely: 'bg-emerald-500/15 text-emerald-400 border-emerald-500/20',
Anthropic: 'bg-purple-500/15 text-purple-400 border-purple-500/20',
};
function useGlobalAuth() {
const [state, setState] = useState<{ isSignedIn: boolean; isLoaded: boolean }>({
isSignedIn: false, isLoaded: false,
});
useEffect(() => {
function check() {
const clerk = (window as any).Clerk;
if (clerk?.loaded) {
const signedIn = !!clerk.user;
setState((prev) => {
if (prev.isLoaded && prev.isSignedIn === signedIn) return prev;
return { isSignedIn: signedIn, isLoaded: true };
});
}
}
check();
const interval = setInterval(check, 500);
const handleChange = () => check();
window.addEventListener('clerk:session', handleChange);
return () => { clearInterval(interval); window.removeEventListener('clerk:session', handleChange); };
}, []);
return state;
}
function timeAgo(dateStr: string): string {
if (!dateStr) return '';
try {
const date = new Date(dateStr);
if (isNaN(date.getTime())) return '';
const now = new Date();
const days = Math.floor((now.getTime() - date.getTime()) / 86400000);
if (days < 0 || isNaN(days)) return '';
if (days === 0) return 'Today';
if (days === 1) return '1d ago';
if (days < 30) return `${days}d ago`;
if (days < 60) return '1mo ago';
return `${Math.floor(days / 30)}mo ago`;
} catch {
return '';
}
}
function safeUrl(url: string): string {
return /^https?:\/\//i.test(url) ? url : '#';
}
export default function JobsView() {
const [data, setData] = useState<JobsData | null>(null);
const [loading, setLoading] = useState(true);
const [locationFilter, setLocationFilter] = useState<LocationFilter>('all');
const [sourceFilter, setSourceFilter] = useState<string>('all');
const [companyFilter, setCompanyFilter] = useState<string>('all');
const [selectedTag, setSelectedTag] = useState<string>('all');
const [sortBy, setSortBy] = useState<SortOption>('recent');
const [searchQuery, setSearchQuery] = useState('');
const { isSignedIn, isLoaded } = useGlobalAuth();
useEffect(() => {
fetch('/claude-jobs.json')
.then((r) => r.json())
.then((d) => { setData(d); setLoading(false); })
.catch(() => setLoading(false));
}, []);
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
const target = e.target as HTMLElement;
if (!target.closest('.company-dropdown')) {
setIsCompanyDropdownOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const filteredJobs = useMemo(() => {
if (!data) return [];
let filtered = data.jobs.filter((job) => {
if (locationFilter === 'remote' && !job.remote) return false;
if (locationFilter === 'onsite' && job.remote) return false;
if (sourceFilter !== 'all' && job.source !== sourceFilter) return false;
if (companyFilter !== 'all' && job.company !== companyFilter) return false;
if (selectedTag !== 'all' && !job.tags.includes(selectedTag)) return false;
if (searchQuery) {
const q = searchQuery.toLowerCase();
const text = `${job.company} ${job.position} ${job.location} ${job.tags.join(' ')}`.toLowerCase();
if (!text.includes(q)) return false;
}
return true;
});
// Sort jobs
filtered.sort((a, b) => {
if (sortBy === 'recent') {
return new Date(b.postedAt || 0).getTime() - new Date(a.postedAt || 0).getTime();
} else if (sortBy === 'company') {
return a.company.localeCompare(b.company);
} else if (sortBy === 'salary') {
// Sort by salary (jobs with salary first)
if (a.salary && !b.salary) return -1;
if (!a.salary && b.salary) return 1;
return 0;
}
return 0;
});
return filtered;
}, [data, locationFilter, sourceFilter, companyFilter, selectedTag, sortBy, searchQuery]);
// Get unique companies and tags for filters
const companies = useMemo(() => {
if (!data) return [];
const uniqueCompanies = Array.from(new Set(data.jobs.map(j => j.company))).sort();
return uniqueCompanies;
}, [data]);
// Get company logo for a given company name
const getCompanyLogo = (companyName: string): string => {
if (!data) return '';
const job = data.jobs.find(j => j.company === companyName);
// If companyIcon exists in data, use it
if (job?.companyIcon) return job.companyIcon;
// Otherwise, try to fetch from Clearbit Logo API
// Format: https://logo.clearbit.com/{domain}
const domain = companyName.toLowerCase()
.replace(/\s+/g, '')
.replace(/[^a-z0-9]/g, '');
return `https://logo.clearbit.com/${domain}.com`;
};
const allTags = useMemo(() => {
if (!data) return [];
const tagSet = new Set<string>();
data.jobs.forEach(job => job.tags.forEach(tag => tagSet.add(tag)));
return Array.from(tagSet).sort();
}, [data]);
// Custom dropdown state
const [isCompanyDropdownOpen, setIsCompanyDropdownOpen] = useState(false);
function handleJobClick(e: React.MouseEvent, job: Job) {
if (!isLoaded) {
e.preventDefault();
return;
}
if (!isSignedIn) {
e.preventDefault();
(window as any).Clerk?.openSignIn?.();
}
// If signed in, let the <a> navigate normally
}
if (loading) {
return (
<div className="px-6 py-20 flex flex-col items-center gap-3">
<div className="w-5 h-5 border-2 border-[var(--color-text-tertiary)] border-t-transparent rounded-full animate-spin" />
<span className="text-[13px] text-[var(--color-text-tertiary)]">Loading jobs...</span>
</div>
);
}
if (!data || data.jobs.length === 0) {
return (
<div className="flex flex-col items-center justify-center px-6 py-24">
{/* Icon */}
<div className="w-20 h-20 rounded-2xl bg-[var(--color-surface-2)] border border-[var(--color-border)] flex items-center justify-center mb-6">
<svg className="w-10 h-10 text-[var(--color-text-tertiary)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="1.5">
<rect x="2" y="7" width="20" height="14" rx="2" ry="2" />
<path d="M16 7V5a2 2 0 00-2-2h-4a2 2 0 00-2 2v2" />
<line x1="12" y1="12" x2="12" y2="12.01" />
</svg>
</div>
{/* Message */}
<h3 className="text-[15px] font-semibold text-[var(--color-text-primary)] mb-2">No jobs available yet</h3>
<p className="text-[13px] text-[var(--color-text-secondary)] text-center max-w-md leading-relaxed">
We're currently curating exciting opportunities for AI developers. Check back soon for new job postings!
</p>
</div>
);
}
const remoteCount = data.jobs.filter((j) => j.remote).length;
const onsiteCount = data.jobs.length - remoteCount;
const showAuthGate = isLoaded && !isSignedIn;
return (
<div>
{/* Header */}
<div className="px-6 pt-5 pb-2">
<h1 className="text-lg font-semibold text-[var(--color-text-primary)] flex items-center gap-2">
Jobs Requiring Claude Code
<picture>
<source srcSet="/claude-code-logo.webp" type="image/webp" />
<img src="/claude-code-logo.png" alt="Claude Code" className="w-6 h-6 inline-block" loading="lazy" />
</picture>
</h1>
<p className="text-[13px] text-[var(--color-text-tertiary)] mt-1">
Companies actively hiring developers who use Claude Code in their workflow.
Updated daily from HackerNews "Who is Hiring", RemoteOK, and more.
</p>
</div>
{/* Auth banner for non-signed-in users */}
{showAuthGate && (
<div className="mx-6 mt-2 mb-1 bg-gradient-to-r from-purple-500/10 via-blue-500/10 to-emerald-500/10 border border-[var(--color-border)] rounded-lg px-4 py-3 flex items-center gap-3">
<svg className="w-5 h-5 text-purple-400 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="1.5">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0110 0v4" />
</svg>
<div className="flex-1">
<p className="text-[13px] text-[var(--color-text-primary)]">
<span className="font-medium">Sign in</span>
<span className="text-[var(--color-text-secondary)]"> to view job details and apply to positions</span>
</p>
</div>
<button
onClick={() => (window as any).Clerk?.openSignIn?.()}
className="px-3 py-1.5 bg-[var(--color-surface-3)] hover:bg-[var(--color-surface-4)] text-[12px] font-medium text-[var(--color-text-primary)] rounded-md transition-colors"
>
Sign In
</button>
</div>
)}
{/* Stats cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 px-6 py-4">
{[
{ label: 'Total Jobs', value: String(data.totalJobs) },
{ label: 'Remote', value: String(remoteCount) },
{ label: 'On-site', value: String(onsiteCount) },
{ label: 'Sources', value: String(data.sources.length) },
].map((s) => (
<div key={s.label} className="bg-[var(--color-card-bg)] border border-[var(--color-border)] rounded-lg px-4 py-3">
<div className="text-[18px] font-semibold text-[var(--color-text-primary)] tabular-nums">{s.value}</div>
<div className="text-[11px] text-[var(--color-text-tertiary)] mt-0.5">{s.label}</div>
</div>
))}
</div>
{/* Divider */}
<div className="border-t border-[var(--color-border)]" />
{/* Filter bar - Enhanced */}
<div className="px-6 py-4 space-y-3">
{/* Top row: Location, Source, Company, Sort */}
<div className="flex flex-wrap items-center gap-2">
{/* Location filter */}
<div className="flex items-center gap-1 bg-[var(--color-surface-2)] rounded-lg p-0.5">
{(['all', 'remote', 'onsite'] as LocationFilter[]).map((loc) => (
<button
key={loc}
onClick={() => setLocationFilter(loc)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-[12px] font-medium transition-all ${
locationFilter === loc
? 'bg-[var(--color-surface-3)] text-[var(--color-text-primary)] shadow-sm'
: 'text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)]'
}`}
>
{loc === 'all' ? (
<>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
All Locations
</>
) : loc === 'remote' ? (
<>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="10" />
<path strokeLinecap="round" strokeLinejoin="round" d="M2 12h20M12 2a15.3 15.3 0 014 10 15.3 15.3 0 01-4 10 15.3 15.3 0 01-4-10 15.3 15.3 0 014-10z" />
</svg>
Remote
</>
) : (
<>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
</svg>
On-site
</>
)}
</button>
))}
</div>
{/* Source filter dropdown */}
<div className="relative">
<select
value={sourceFilter}
onChange={(e) => setSourceFilter(e.target.value)}
className="appearance-none bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded-lg text-[12px] font-medium text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] px-3 py-1.5 pr-8 outline-none cursor-pointer transition-colors"
>
<option value="all">All Sources</option>
{data.sources.map((src) => (
<option key={src} value={src}>{src}</option>
))}
</select>
<svg className="absolute right-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-[var(--color-text-tertiary)] pointer-events-none" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</div>
{/* Company filter dropdown - Custom with logos */}
<div className="relative company-dropdown">
<button
onClick={() => setIsCompanyDropdownOpen(!isCompanyDropdownOpen)}
className="flex items-center gap-2 bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded-lg text-[12px] font-medium text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] pl-3 pr-8 py-1.5 outline-none cursor-pointer transition-colors min-w-[180px]"
>
{companyFilter === 'all' ? (
<>
<svg className="w-3.5 h-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
</svg>
<span>All Companies ({companies.length})</span>
</>
) : (
<>
<div className="w-4 h-4 rounded bg-[var(--color-surface-3)] border border-[var(--color-border)] flex items-center justify-center shrink-0 overflow-hidden">
<img
src={getCompanyLogo(companyFilter)}
alt=""
className="w-3 h-3 object-contain"
onError={(e) => {
const target = e.target as HTMLImageElement;
target.style.display = 'none';
const fallback = target.nextElementSibling as HTMLElement;
if (fallback) fallback.style.display = 'flex';
}}
/>
<span className="text-[8px] font-bold text-[var(--color-text-tertiary)]" style={{ display: 'none' }}>
{companyFilter.charAt(0).toUpperCase()}
</span>
</div>
<span className="truncate">{companyFilter}</span>
</>
)}
<svg className="absolute right-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-[var(--color-text-tertiary)] pointer-events-none" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
{/* Dropdown menu */}
{isCompanyDropdownOpen && (
<div className="absolute top-full left-0 mt-1 w-72 max-h-80 overflow-y-auto bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded-lg shadow-lg z-50">
<button
onClick={() => {
setCompanyFilter('all');
setIsCompanyDropdownOpen(false);
}}
className={`w-full flex items-center gap-2.5 px-3 py-2 text-[12px] text-left transition-colors ${
companyFilter === 'all'
? 'bg-[var(--color-surface-3)] text-[var(--color-text-primary)] font-medium'
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-primary)]'
}`}
>
<svg className="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
</svg>
<span>All Companies ({companies.length})</span>
</button>
<div className="border-t border-[var(--color-border)] my-1" />
{companies.map((company) => (
<button
key={company}
onClick={() => {
setCompanyFilter(company);
setIsCompanyDropdownOpen(false);
}}
className={`w-full flex items-center gap-2.5 px-3 py-2 text-[12px] text-left transition-colors ${
companyFilter === company
? 'bg-[var(--color-surface-3)] text-[var(--color-text-primary)] font-medium'
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-primary)]'
}`}
>
<div className="w-5 h-5 rounded bg-[var(--color-surface-3)] border border-[var(--color-border)] flex items-center justify-center shrink-0 overflow-hidden">
<img
src={getCompanyLogo(company)}
alt=""
className="w-4 h-4 object-contain"
onError={(e) => {
const target = e.target as HTMLImageElement;
target.style.display = 'none';
const fallback = target.nextElementSibling as HTMLElement;
if (fallback) fallback.style.display = 'flex';
}}
/>
<span className="text-[9px] font-bold text-[var(--color-text-tertiary)]" style={{ display: 'none' }}>
{company.charAt(0).toUpperCase()}
</span>
</div>
<span className="truncate">{company}</span>
</button>
))}
</div>
)}
</div>
{/* Sort dropdown */}
<div className="relative">
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as SortOption)}
className="appearance-none bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded-lg text-[12px] font-medium text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] pl-9 pr-8 py-1.5 outline-none cursor-pointer transition-colors"
>
<option value="recent">Most Recent</option>
<option value="company">Company A-Z</option>
<option value="salary">With Salary</option>
</select>
<svg className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-[var(--color-text-tertiary)] pointer-events-none" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
{sortBy === 'recent' ? (
<path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
) : sortBy === 'company' ? (
<path strokeLinecap="round" strokeLinejoin="round" d="M3 4h13M3 8h9m-9 4h6m4 0l4-4m0 0l4 4m-4-4v12" />
) : (
<path strokeLinecap="round" strokeLinejoin="round" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
)}
</svg>
<svg className="absolute right-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-[var(--color-text-tertiary)] pointer-events-none" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</div>
{/* Search */}
<div className="ml-auto relative">
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-[var(--color-text-tertiary)] pointer-events-none" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
<circle cx="11" cy="11" r="8" />
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-4.35-4.35" />
</svg>
<input
type="text"
placeholder="Search jobs..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded-lg text-[12px] text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] pl-9 pr-3 py-1.5 w-56 outline-none focus:border-[var(--color-border-hover)] transition-colors"
/>
{searchQuery && (
<button
onClick={() => setSearchQuery('')}
className="absolute right-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)] transition-colors"
>
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
</div>
{/* Bottom row: Tags filter */}
{allTags.length > 0 && (
<div className="flex items-center gap-2">
<span className="text-[11px] font-medium text-[var(--color-text-tertiary)] uppercase tracking-wide">Tags:</span>
<div className="flex items-center gap-1.5 flex-wrap">
<button
onClick={() => setSelectedTag('all')}
className={`px-2.5 py-1 rounded-md text-[11px] font-medium transition-all ${
selectedTag === 'all'
? 'bg-[var(--color-surface-3)] text-[var(--color-text-primary)] border border-[var(--color-border)]'
: 'bg-[var(--color-surface-2)] text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-3)]'
}`}
>
All
</button>
{allTags.slice(0, 12).map((tag) => (
<button
key={tag}
onClick={() => setSelectedTag(tag)}
className={`px-2.5 py-1 rounded-md text-[11px] font-medium transition-all ${
selectedTag === tag
? 'bg-blue-500/15 text-blue-400 border border-blue-500/30'
: 'bg-[var(--color-surface-2)] text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-3)]'
}`}
>
{tag}
</button>
))}
{allTags.length > 12 && (
<span className="text-[11px] text-[var(--color-text-tertiary)] px-2">
+{allTags.length - 12} more
</span>
)}
</div>
</div>
)}
{/* Active filters indicator */}
{(locationFilter !== 'all' || sourceFilter !== 'all' || companyFilter !== 'all' || selectedTag !== 'all' || searchQuery) && (
<div className="flex items-center gap-2 pt-1">
<span className="text-[11px] text-[var(--color-text-tertiary)]">Active filters:</span>
<div className="flex items-center gap-1.5 flex-wrap">
{locationFilter !== 'all' && (
<span className="inline-flex items-center gap-1 text-[11px] bg-blue-500/15 text-blue-400 px-2 py-0.5 rounded-full">
{locationFilter}
<button onClick={() => setLocationFilter('all')} className="hover:text-blue-300">×</button>
</span>
)}
{sourceFilter !== 'all' && (
<span className="inline-flex items-center gap-1 text-[11px] bg-orange-500/15 text-orange-400 px-2 py-0.5 rounded-full">
{sourceFilter}
<button onClick={() => setSourceFilter('all')} className="hover:text-orange-300">×</button>
</span>
)}
{companyFilter !== 'all' && (
<span className="inline-flex items-center gap-1 text-[11px] bg-purple-500/15 text-purple-400 px-2 py-0.5 rounded-full">
{companyFilter}
<button onClick={() => setCompanyFilter('all')} className="hover:text-purple-300">×</button>
</span>
)}
{selectedTag !== 'all' && (
<span className="inline-flex items-center gap-1 text-[11px] bg-emerald-500/15 text-emerald-400 px-2 py-0.5 rounded-full">
{selectedTag}
<button onClick={() => setSelectedTag('all')} className="hover:text-emerald-300">×</button>
</span>
)}
{searchQuery && (
<span className="inline-flex items-center gap-1 text-[11px] bg-pink-500/15 text-pink-400 px-2 py-0.5 rounded-full">
"{searchQuery}"
<button onClick={() => setSearchQuery('')} className="hover:text-pink-300">×</button>
</span>
)}
<button
onClick={() => {
setLocationFilter('all');
setSourceFilter('all');
setCompanyFilter('all');
setSelectedTag('all');
setSearchQuery('');
}}
className="text-[11px] text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)] underline"
>
Clear all
</button>
</div>
</div>
)}
</div>
{/* Results count */}
<div className="px-6 pb-3 flex items-center justify-between">
<span className="text-[12px] font-medium text-[var(--color-text-secondary)]">
Showing {filteredJobs.length} of {data.totalJobs} job{filteredJobs.length !== 1 ? 's' : ''}
</span>
{filteredJobs.length === 0 && (
<button
onClick={() => {
setLocationFilter('all');
setSourceFilter('all');
setCompanyFilter('all');
setSelectedTag('all');
setSearchQuery('');
}}
className="text-[12px] text-blue-400 hover:text-blue-300 underline"
>
Reset filters
</button>
)}
</div>
{/* Job cards */}
{filteredJobs.length === 0 ? (
<div className="px-6 pb-8 flex flex-col items-center justify-center py-12">
<div className="w-16 h-16 rounded-full bg-[var(--color-surface-2)] border border-[var(--color-border)] flex items-center justify-center mb-4">
<svg className="w-8 h-8 text-[var(--color-text-tertiary)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="1.5">
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
<h3 className="text-[14px] font-semibold text-[var(--color-text-primary)] mb-1">No jobs found</h3>
<p className="text-[12px] text-[var(--color-text-tertiary)] text-center max-w-sm">
Try adjusting your filters or search query to find more opportunities.
</p>
</div>
) : (
<div className="px-6 pb-8 grid grid-cols-1 md:grid-cols-2 gap-3">
{filteredJobs.map((job) => (
<a
key={job.id}
href={isSignedIn ? safeUrl(job.applyUrl) : '#'}
target={isSignedIn ? '_blank' : undefined}
rel={isSignedIn ? 'noopener noreferrer' : undefined}
onClick={(e) => handleJobClick(e, job)}
className="block bg-[var(--color-card-bg)] border border-[var(--color-border)] rounded-lg p-4 hover:border-[var(--color-border-hover)] hover:bg-[var(--color-card-hover)] hover:shadow-lg transition-all group relative"
>
<div className="flex items-start gap-3">
{/* Company icon - Enhanced with internet fallback */}
<div className="w-12 h-12 rounded-lg bg-[var(--color-surface-2)] border border-[var(--color-border)] flex items-center justify-center shrink-0 overflow-hidden group-hover:border-[var(--color-border-hover)] transition-colors">
<img
src={job.companyIcon || getCompanyLogo(job.company)}
alt={job.company}
className="w-7 h-7 object-contain"
style={{ display: 'block' }}
onError={(e) => {
const target = e.target as HTMLImageElement;
target.style.display = 'none';
const fallback = target.nextElementSibling as HTMLElement;
if (fallback) fallback.style.display = 'flex';
}}
/>
<span
className="text-[16px] font-bold text-[var(--color-text-tertiary)]"
style={{ display: 'none' }}
>
{job.company.charAt(0).toUpperCase()}
</span>
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-start gap-2 flex-wrap">
<h3 className="text-[14px] font-semibold text-[var(--color-text-primary)] group-hover:text-[var(--color-text-primary)] transition-colors flex-1">
{job.position}
</h3>
{job.salary && (
<span className="flex items-center gap-1 text-[11px] font-semibold bg-emerald-500/15 text-emerald-400 px-2 py-1 rounded border border-emerald-500/20">
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{job.salary}
</span>
)}
</div>
<div className="flex items-center gap-2 mt-1.5 flex-wrap">
<span className="text-[13px] font-medium text-[var(--color-text-secondary)]">{job.company}</span>
<span className="text-[10px] text-[var(--color-text-tertiary)]">•</span>
<span className="text-[12px] text-[var(--color-text-tertiary)]">{job.location}</span>
</div>
<div className="flex items-center gap-1.5 mt-2 flex-wrap">
{job.remote && (
<span className="flex items-center gap-1 text-[10px] font-semibold bg-blue-500/15 text-blue-400 px-2 py-1 rounded border border-blue-500/20">
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="10" />
<path strokeLinecap="round" strokeLinejoin="round" d="M2 12h20M12 2a15.3 15.3 0 014 10 15.3 15.3 0 01-4 10 15.3 15.3 0 01-4-10 15.3 15.3 0 014-10z" />
</svg>
Remote
</span>
)}
<span className={`text-[10px] font-semibold px-2 py-1 rounded border ${SOURCE_COLORS[job.source] || 'bg-[var(--color-surface-3)] text-[var(--color-text-secondary)]'}`}>
{job.source}
</span>
{job.postedAt && (
<span className="flex items-center gap-1 text-[11px] text-[var(--color-text-tertiary)] ml-auto">
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{timeAgo(job.postedAt)}
</span>
)}
</div>
{job.description && (
<p className="text-[12px] text-[var(--color-text-secondary)] mt-2.5 line-clamp-2 leading-relaxed">{job.description}</p>
)}
{job.tags.length > 0 && (
<div className="flex items-center gap-1.5 mt-2.5 flex-wrap">
{job.tags.slice(0, 6).map((tag) => (
<span
key={tag}
className="text-[10px] text-[var(--color-text-secondary)] bg-[var(--color-surface-2)] px-2 py-0.5 rounded border border-[var(--color-border)] hover:border-[var(--color-border-hover)] transition-colors cursor-pointer"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setSelectedTag(tag);
}}
>
{tag}
</span>
))}
{job.tags.length > 6 && (
<span className="text-[10px] text-[var(--color-text-tertiary)]">
+{job.tags.length - 6}
</span>
)}
</div>
)}
</div>
{/* Right side: apply arrow or lock icon */}
{showAuthGate ? (
<svg
className="w-4 h-4 text-[var(--color-text-tertiary)] shrink-0 mt-1"
fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}
>
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0110 0v4" />
</svg>
) : (
<svg
className="w-4 h-4 text-[var(--color-text-tertiary)] group-hover:text-[var(--color-text-secondary)] group-hover:translate-x-0.5 group-hover:-translate-y-0.5 shrink-0 mt-1 transition-all"
fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
)}
</div>
</a>
))}
</div>
)}
{/* Footer */}
<div className="px-6 pb-6 text-center">
<p className="text-[11px] text-[var(--color-text-tertiary)]">
Last updated: {new Date(data.lastUpdated).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
{' | '}Data sourced from HackerNews "Who is Hiring", RemoteOK, WeWorkRemotely, and Anthropic Careers
</p>
</div>
</div>
);
}
+255
View File
@@ -0,0 +1,255 @@
import { useState, useCallback } from 'react';
interface JsonViewerProps {
content: string;
}
type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };
function parseJson(content: string): JsonValue | null {
try {
return JSON.parse(content);
} catch {
return null;
}
}
export default function JsonViewer({ content }: JsonViewerProps) {
const parsed = parseJson(content);
const handleCopy = () => {
navigator.clipboard.writeText(content);
};
if (parsed === null) {
return (
<div className="bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded-lg p-6 text-sm text-red-400 font-mono whitespace-pre-wrap">
Invalid JSON
<pre className="mt-4 text-[var(--color-text-tertiary)]">{content}</pre>
</div>
);
}
return (
<div>
<div className="flex items-center justify-between mb-3">
<span className="text-[10px] font-semibold text-[var(--color-text-tertiary)] uppercase tracking-wider">JSON</span>
<button
onClick={handleCopy}
className="text-[11px] px-2.5 py-1 rounded-md bg-white/[0.06] hover:bg-white/[0.12] text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)] transition-colors"
>
Copy
</button>
</div>
<div className="bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded-lg py-3 px-1 font-mono text-sm overflow-x-auto">
<JsonNode value={parsed} path="" depth={0} defaultOpen />
</div>
</div>
);
}
function JsonNode({
value,
path,
depth,
defaultOpen = false,
keyName,
}: {
value: JsonValue;
path: string;
depth: number;
defaultOpen?: boolean;
keyName?: string;
}) {
if (value === null) return <JsonPrimitive keyName={keyName} depth={depth} value="null" className="text-[var(--color-text-tertiary)] italic" />;
if (typeof value === 'boolean') return <JsonPrimitive keyName={keyName} depth={depth} value={String(value)} className="text-purple-400" />;
if (typeof value === 'number') return <JsonPrimitive keyName={keyName} depth={depth} value={String(value)} className="text-cyan-400" />;
if (typeof value === 'string') return <JsonString keyName={keyName} depth={depth} value={value} />;
if (Array.isArray(value)) return <JsonArray keyName={keyName} depth={depth} value={value} path={path} defaultOpen={defaultOpen} />;
if (typeof value === 'object') return <JsonObject keyName={keyName} depth={depth} value={value as Record<string, JsonValue>} path={path} defaultOpen={defaultOpen} />;
return null;
}
function JsonPrimitive({ keyName, depth, value, className }: { keyName?: string; depth: number; value: string; className: string }) {
return (
<div className="flex items-start" style={{ paddingLeft: `${depth * 1.25 + 0.75}rem` }}>
{keyName !== undefined && (
<span className="text-accent-400 shrink-0">&quot;{keyName}&quot;<span className="text-[var(--color-text-tertiary)]">: </span></span>
)}
<span className={className}>{value}</span>
</div>
);
}
function JsonString({ keyName, depth, value }: { keyName?: string; depth: number; value: string }) {
const isLong = value.length > 120;
const [expanded, setExpanded] = useState(!isLong);
const display = expanded ? value : value.slice(0, 80) + '...';
return (
<div className="flex items-start group/str" style={{ paddingLeft: `${depth * 1.25 + 0.75}rem` }}>
{keyName !== undefined && (
<span className="text-accent-400 shrink-0">&quot;{keyName}&quot;<span className="text-[var(--color-text-tertiary)]">: </span></span>
)}
<span className="text-green-400 break-all">
&quot;{display}&quot;
{isLong && (
<button
onClick={() => setExpanded(!expanded)}
className="ml-1.5 text-[10px] px-1.5 py-0.5 rounded bg-white/[0.06] hover:bg-white/[0.1] text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)] transition-colors align-middle"
>
{expanded ? 'less' : `+${value.length - 80} chars`}
</button>
)}
</span>
</div>
);
}
function JsonObject({
keyName,
depth,
value,
path,
defaultOpen,
}: {
keyName?: string;
depth: number;
value: Record<string, JsonValue>;
path: string;
defaultOpen?: boolean;
}) {
const keys = Object.keys(value);
const [open, setOpen] = useState(defaultOpen || depth < 2);
const handleCopySection = useCallback(() => {
navigator.clipboard.writeText(JSON.stringify(value, null, 2));
}, [value]);
if (keys.length === 0) {
return (
<div style={{ paddingLeft: `${depth * 1.25 + 0.75}rem` }}>
{keyName !== undefined && (
<span className="text-accent-400">&quot;{keyName}&quot;<span className="text-[var(--color-text-tertiary)]">: </span></span>
)}
<span className="text-[var(--color-text-tertiary)]">{'{}'}</span>
</div>
);
}
return (
<div>
<div
className="flex items-center gap-1 group/obj cursor-pointer hover:bg-white/[0.02] rounded py-0.5 transition-colors"
style={{ paddingLeft: `${depth * 1.25 + 0.25}rem` }}
onClick={() => setOpen(!open)}
>
<svg
className={`w-3 h-3 text-[var(--color-text-tertiary)] shrink-0 transition-transform duration-150 ${open ? 'rotate-90' : ''}`}
fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
{keyName !== undefined && (
<span className="text-accent-400">&quot;{keyName}&quot;<span className="text-[var(--color-text-tertiary)]">: </span></span>
)}
<span className="text-[var(--color-text-tertiary)]">{'{'}</span>
{!open && (
<span className="text-[var(--color-text-tertiary)] text-xs ml-1">
{keys.length} key{keys.length !== 1 ? 's' : ''}
</span>
)}
{!open && <span className="text-[var(--color-text-tertiary)]">{'}'}</span>}
<button
onClick={(e) => { e.stopPropagation(); handleCopySection(); }}
className="ml-auto mr-2 opacity-0 group-hover/obj:opacity-100 text-[10px] px-1.5 py-0.5 rounded bg-white/[0.06] hover:bg-white/[0.1] text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)] transition-all"
title="Copy this section"
>
Copy
</button>
</div>
{open && (
<>
{keys.map((k) => (
<JsonNode key={k} keyName={k} value={value[k]} path={`${path}.${k}`} depth={depth + 1} />
))}
<div className="text-[var(--color-text-tertiary)]" style={{ paddingLeft: `${depth * 1.25 + 0.75}rem` }}>{'}'}</div>
</>
)}
</div>
);
}
function JsonArray({
keyName,
depth,
value,
path,
defaultOpen,
}: {
keyName?: string;
depth: number;
value: JsonValue[];
path: string;
defaultOpen?: boolean;
}) {
const [open, setOpen] = useState(defaultOpen || depth < 2);
const handleCopySection = useCallback(() => {
navigator.clipboard.writeText(JSON.stringify(value, null, 2));
}, [value]);
if (value.length === 0) {
return (
<div style={{ paddingLeft: `${depth * 1.25 + 0.75}rem` }}>
{keyName !== undefined && (
<span className="text-accent-400">&quot;{keyName}&quot;<span className="text-[var(--color-text-tertiary)]">: </span></span>
)}
<span className="text-[var(--color-text-tertiary)]">{'[]'}</span>
</div>
);
}
return (
<div>
<div
className="flex items-center gap-1 group/arr cursor-pointer hover:bg-white/[0.02] rounded py-0.5 transition-colors"
style={{ paddingLeft: `${depth * 1.25 + 0.25}rem` }}
onClick={() => setOpen(!open)}
>
<svg
className={`w-3 h-3 text-[var(--color-text-tertiary)] shrink-0 transition-transform duration-150 ${open ? 'rotate-90' : ''}`}
fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
{keyName !== undefined && (
<span className="text-accent-400">&quot;{keyName}&quot;<span className="text-[var(--color-text-tertiary)]">: </span></span>
)}
<span className="text-[var(--color-text-tertiary)]">{'['}</span>
{!open && (
<span className="text-[var(--color-text-tertiary)] text-xs ml-1">
{value.length} item{value.length !== 1 ? 's' : ''}
</span>
)}
{!open && <span className="text-[var(--color-text-tertiary)]">{']'}</span>}
<button
onClick={(e) => { e.stopPropagation(); handleCopySection(); }}
className="ml-auto mr-2 opacity-0 group-hover/arr:opacity-100 text-[10px] px-1.5 py-0.5 rounded bg-white/5 hover:bg-white/10 text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)] transition-all"
title="Copy this section"
>
Copy
</button>
</div>
{open && (
<>
{value.map((item, i) => (
<JsonNode key={i} value={item} path={`${path}[${i}]`} depth={depth + 1} />
))}
<div className="text-[var(--color-text-tertiary)]" style={{ paddingLeft: `${depth * 1.25 + 0.75}rem` }}>{']'}</div>
</>
)}
</div>
);
}
+521
View File
@@ -0,0 +1,521 @@
import { useState, useEffect, useRef, useMemo, useCallback } from 'react';
import { marked } from 'marked';
interface Heading {
level: number;
text: string;
id: string;
}
interface SearchMatch {
type: 'heading' | 'text';
text: string;
id: string; // heading id to scroll to
context?: string; // surrounding text for text matches
}
interface MarkdownViewerProps {
content: string;
headings: Heading[];
}
function renderMarkdown(content: string): string {
const stripped = content.replace(/^---\s*\n[\s\S]*?\n---\s*\n?/, '');
marked.setOptions({ gfm: true, breaks: false });
const renderer = new marked.Renderer();
renderer.heading = ({ text, depth }: { text: string; depth: number }) => {
const id = text.toLowerCase().replace(/[^\w]+/g, '-').replace(/^-|-$/g, '');
return `<h${depth} id="${id}" class="md-heading">${text}</h${depth}>`;
};
renderer.code = ({ text, lang }: { text: string; lang?: string }) => {
return `<div class="md-code-block"><div class="md-code-lang">${lang || ''}</div><pre><code class="language-${lang || ''}">${text.replace(/</g, '&lt;').replace(/>/g, '&gt;')}</code></pre></div>`;
};
return marked.parse(stripped, { renderer }) as string;
}
/** Build a searchable index: map each paragraph/line to its nearest preceding heading */
function buildSearchIndex(content: string, headings: Heading[]): { text: string; headingId: string }[] {
const stripped = content.replace(/^---\s*\n[\s\S]*?\n---\s*\n?/, '');
const lines = stripped.split('\n');
const entries: { text: string; headingId: string }[] = [];
let currentHeadingId = headings[0]?.id ?? '';
let paragraph = '';
for (const line of lines) {
const headingMatch = line.match(/^(#{1,4})\s+(.+)$/);
if (headingMatch) {
if (paragraph.trim()) {
entries.push({ text: paragraph.trim(), headingId: currentHeadingId });
paragraph = '';
}
const text = headingMatch[2].replace(/[*_`\[\]]/g, '').trim();
currentHeadingId = text.toLowerCase().replace(/[^\w]+/g, '-').replace(/^-|-$/g, '');
continue;
}
if (line.trim() === '') {
if (paragraph.trim()) {
entries.push({ text: paragraph.trim(), headingId: currentHeadingId });
paragraph = '';
}
} else {
paragraph += (paragraph ? ' ' : '') + line.trim();
}
}
if (paragraph.trim()) {
entries.push({ text: paragraph.trim(), headingId: currentHeadingId });
}
return entries;
}
export default function MarkdownViewer({ content, headings }: MarkdownViewerProps) {
const [mode, setMode] = useState<'code' | 'preview'>('preview');
const [expanded, setExpanded] = useState(false);
const [activeHeading, setActiveHeading] = useState('');
const [searchQuery, setSearchQuery] = useState('');
const [searchOpen, setSearchOpen] = useState(false);
const [selectedIdx, setSelectedIdx] = useState(0);
const [highlightCount, setHighlightCount] = useState(0);
const previewRef = useRef<HTMLDivElement>(null);
const codeRef = useRef<HTMLDivElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
const html = useMemo(() => renderMarkdown(content), [content]);
const searchIndex = useMemo(() => buildSearchIndex(content, headings), [content, headings]);
// Search results
const searchResults = useMemo((): SearchMatch[] => {
if (!searchQuery.trim() || searchQuery.length < 2) return [];
const q = searchQuery.toLowerCase();
const results: SearchMatch[] = [];
const seen = new Set<string>();
// Search headings first
for (const h of headings) {
if (h.text.toLowerCase().includes(q)) {
results.push({ type: 'heading', text: h.text, id: h.id });
seen.add(h.id);
}
}
// Search content paragraphs
for (const entry of searchIndex) {
if (entry.text.toLowerCase().includes(q) && !seen.has(entry.headingId)) {
const idx = entry.text.toLowerCase().indexOf(q);
const start = Math.max(0, idx - 40);
const end = Math.min(entry.text.length, idx + q.length + 40);
const context = (start > 0 ? '...' : '') +
entry.text.slice(start, end) +
(end < entry.text.length ? '...' : '');
results.push({ type: 'text', text: entry.text.slice(idx, idx + q.length), id: entry.headingId, context });
seen.add(entry.headingId);
}
}
return results.slice(0, 15);
}, [searchQuery, headings, searchIndex]);
// Highlight matches inside preview
useEffect(() => {
if (!previewRef.current || mode !== 'preview') return;
// Remove old highlights
previewRef.current.querySelectorAll('mark.md-search-hl').forEach((m) => {
const parent = m.parentNode;
if (parent) {
parent.replaceChild(document.createTextNode(m.textContent || ''), m);
parent.normalize();
}
});
if (!searchQuery.trim() || searchQuery.length < 2) {
setHighlightCount(0);
return;
}
const walker = document.createTreeWalker(previewRef.current, NodeFilter.SHOW_TEXT);
const q = searchQuery.toLowerCase();
const nodesToProcess: { node: Text; indices: number[] }[] = [];
let node: Text | null;
while ((node = walker.nextNode() as Text | null)) {
const text = node.textContent || '';
const lower = text.toLowerCase();
const indices: number[] = [];
let pos = 0;
while ((pos = lower.indexOf(q, pos)) !== -1) {
indices.push(pos);
pos += q.length;
}
if (indices.length > 0) {
nodesToProcess.push({ node, indices });
}
}
let count = 0;
for (const { node: textNode, indices } of nodesToProcess) {
const text = textNode.textContent || '';
const parent = textNode.parentNode;
if (!parent || parent.nodeName === 'CODE' || parent.nodeName === 'PRE') continue;
const frag = document.createDocumentFragment();
let lastIdx = 0;
for (const idx of indices) {
if (idx > lastIdx) frag.appendChild(document.createTextNode(text.slice(lastIdx, idx)));
const mark = document.createElement('mark');
mark.className = 'md-search-hl';
mark.textContent = text.slice(idx, idx + q.length);
frag.appendChild(mark);
lastIdx = idx + q.length;
count++;
}
if (lastIdx < text.length) frag.appendChild(document.createTextNode(text.slice(lastIdx)));
parent.replaceChild(frag, textNode);
}
setHighlightCount(count);
}, [searchQuery, mode, html]);
// Track active heading on scroll
useEffect(() => {
if (mode !== 'preview' || !previewRef.current) return;
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) setActiveHeading(entry.target.id);
}
},
{ rootMargin: '-80px 0px -70% 0px', threshold: 0 }
);
const headingEls = previewRef.current.querySelectorAll('.md-heading');
headingEls.forEach((el) => observer.observe(el));
return () => observer.disconnect();
}, [mode, html]);
// Keyboard shortcut: Cmd/Ctrl+F to open search
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'f') {
// Only intercept if the viewer is visible
if (previewRef.current || codeRef.current) {
e.preventDefault();
setSearchOpen(true);
setTimeout(() => searchInputRef.current?.focus(), 50);
}
}
if (e.key === 'Escape' && searchOpen) {
setSearchOpen(false);
setSearchQuery('');
}
};
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [searchOpen]);
// Reset selection when results change
useEffect(() => { setSelectedIdx(0); }, [searchResults]);
const handleCopy = () => {
navigator.clipboard.writeText(content);
};
const scrollToHeading = useCallback((id: string) => {
if (!expanded) setExpanded(true);
// Small delay to allow expand animation
setTimeout(() => {
const container = previewRef.current || codeRef.current;
const el = container?.querySelector(`#${CSS.escape(id)}`);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
setActiveHeading(id);
}
}, expanded ? 0 : 100);
}, [expanded]);
const navigateToResult = useCallback((result: SearchMatch) => {
if (mode !== 'preview') setMode('preview');
scrollToHeading(result.id);
setSearchOpen(false);
}, [mode, scrollToHeading]);
const handleSearchKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
setSelectedIdx((i) => Math.min(i + 1, searchResults.length - 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setSelectedIdx((i) => Math.max(i - 1, 0));
} else if (e.key === 'Enter' && searchResults[selectedIdx]) {
e.preventDefault();
navigateToResult(searchResults[selectedIdx]);
}
};
return (
<div className="flex gap-4">
{/* Main content */}
<div className="flex-1 min-w-0">
{/* Toolbar */}
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<div className="flex items-center gap-1 bg-[var(--color-surface-3)] rounded-lg p-0.5">
<button
onClick={() => setMode('code')}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${
mode === 'code'
? 'bg-[var(--color-surface-2)] text-[var(--color-text-primary)] shadow-sm'
: 'text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)]'
}`}
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M17.25 6.75L22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3l-4.5 16.5" />
</svg>
Code
</button>
<button
onClick={() => setMode('preview')}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${
mode === 'preview'
? 'bg-[var(--color-surface-2)] text-[var(--color-text-primary)] shadow-sm'
: 'text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)]'
}`}
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
Preview
</button>
</div>
{/* Search toggle */}
<button
onClick={() => {
setSearchOpen(!searchOpen);
if (!searchOpen) setTimeout(() => searchInputRef.current?.focus(), 50);
}}
className={`flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs transition-colors ${
searchOpen
? 'bg-accent-500/15 text-accent-400'
: 'text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)] hover:bg-white/[0.04]'
}`}
title="Search in document (Cmd+F)"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<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>
<span className="hidden sm:inline">Search</span>
<kbd className="hidden sm:inline text-[9px] px-1 py-0.5 rounded bg-white/[0.06] text-[var(--color-text-tertiary)] font-mono ml-1">
{navigator.platform?.includes('Mac') ? '\u2318' : 'Ctrl'}F
</kbd>
</button>
</div>
<button
onClick={handleCopy}
className="text-[11px] px-2.5 py-1 rounded-md bg-white/[0.06] hover:bg-white/[0.12] text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)] transition-colors"
>
Copy
</button>
</div>
{/* Search bar */}
{searchOpen && (
<div className="mb-3 relative">
<div className="relative">
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-[var(--color-text-tertiary)] pointer-events-none" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<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
ref={searchInputRef}
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={handleSearchKeyDown}
placeholder="Search sections and content..."
className="w-full bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded-lg pl-9 pr-20 py-2 text-sm text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] focus:outline-none focus:border-accent-400/50 focus:ring-1 focus:ring-accent-400/20 font-sans"
autoFocus
/>
<div className="absolute right-3 top-1/2 -translate-y-1/2 flex items-center gap-2">
{searchQuery.length >= 2 && (
<span className="text-[10px] text-[var(--color-text-tertiary)]">
{highlightCount > 0 ? `${highlightCount} match${highlightCount !== 1 ? 'es' : ''}` : 'No matches'}
</span>
)}
<button
onClick={() => { setSearchOpen(false); setSearchQuery(''); }}
className="text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)]"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
{/* Search results dropdown */}
{searchResults.length > 0 && (
<div className="absolute z-20 top-full left-0 right-0 mt-1 bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded-lg shadow-xl overflow-hidden max-h-72 overflow-y-auto">
{searchResults.map((result, i) => (
<button
key={`${result.id}-${i}`}
onClick={() => navigateToResult(result)}
onMouseEnter={() => setSelectedIdx(i)}
className={`w-full text-left px-3 py-2.5 flex items-start gap-2.5 transition-colors ${
i === selectedIdx ? 'bg-white/[0.06]' : 'hover:bg-white/[0.03]'
}`}
>
{result.type === 'heading' ? (
<svg className="w-3.5 h-3.5 mt-0.5 shrink-0 text-accent-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5.25 7.5h13.5m-13.5 4.5h7.5m-7.5 4.5h13.5" />
</svg>
) : (
<svg className="w-3.5 h-3.5 mt-0.5 shrink-0 text-[var(--color-text-tertiary)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
</svg>
)}
<div className="flex-1 min-w-0">
{result.type === 'heading' ? (
<HighlightText text={result.text} query={searchQuery} className="text-sm text-[var(--color-text-primary)] font-medium" />
) : (
<>
<div className="text-[11px] text-[var(--color-text-tertiary)] mb-0.5">
{headings.find((h) => h.id === result.id)?.text ?? ''}
</div>
<HighlightText
text={result.context || ''}
query={searchQuery}
className="text-xs text-[var(--color-text-secondary)] leading-relaxed"
/>
</>
)}
</div>
{i === selectedIdx && (
<kbd className="text-[9px] px-1 py-0.5 rounded bg-white/[0.06] text-[var(--color-text-tertiary)] font-mono self-center shrink-0">
Enter
</kbd>
)}
</button>
))}
</div>
)}
</div>
)}
{/* Content area */}
<div className="relative">
{mode === 'code' ? (
<div
ref={codeRef}
className={`bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded-lg p-6 text-sm text-[var(--color-text-primary)] leading-relaxed whitespace-pre-wrap font-mono overflow-hidden transition-[max-height] duration-300 ${
expanded ? '' : 'max-h-[32rem]'
}`}
>
{content || 'No content available'}
</div>
) : (
<div
ref={previewRef}
className={`md-preview bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded-lg p-6 overflow-hidden transition-[max-height] duration-300 ${
expanded ? '' : 'max-h-[32rem]'
}`}
dangerouslySetInnerHTML={{ __html: html }}
/>
)}
{!expanded && (
<div className="mt-4 flex justify-center">
<button
onClick={() => setExpanded(true)}
className="text-[13px] font-medium text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] flex items-center gap-2 px-4 py-2 rounded-lg hover:bg-[var(--color-surface-3)] border border-[var(--color-border)] hover:border-[var(--color-primary-500)]/40 transition-all duration-150 active:scale-95"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
Show full {mode === 'preview' ? 'document' : 'source'}
</button>
</div>
)}
{expanded && (
<div className="flex justify-center mt-4">
<button
onClick={() => setExpanded(false)}
className="text-[13px] font-medium text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] flex items-center gap-2 px-4 py-2 rounded-lg hover:bg-[var(--color-surface-3)] border border-[var(--color-border)] hover:border-[var(--color-primary-500)]/40 transition-all duration-150 active:scale-95"
>
<svg className="w-4 h-4 rotate-180" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
Collapse
</button>
</div>
)}
</div>
</div>
{/* Table of Contents sidebar */}
{headings.length > 1 && mode === 'preview' && (
<nav className="hidden xl:block w-56 shrink-0 ml-6">
<div className="sticky top-28">
<h4 className="text-[10px] font-bold text-[var(--color-text-tertiary)] uppercase tracking-[0.08em] mb-3 px-3">
On this page
</h4>
<ul className="space-y-1 text-[12px] border-l-2 border-[var(--color-border)] max-h-[calc(100vh-12rem)] overflow-y-auto">
{headings.map((h) => (
<li key={h.id}>
<button
onClick={() => scrollToHeading(h.id)}
className={`block w-full text-left py-1.5 transition-all duration-150 border-l-2 -ml-0.5 ${
h.level === 1 ? 'pl-3' : h.level === 2 ? 'pl-5' : 'pl-7'
} ${
activeHeading === h.id
? 'border-[var(--color-primary-400)] text-[var(--color-primary-400)] font-medium'
: 'border-transparent text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)] hover:border-[var(--color-border)]'
}`}
title={h.text}
>
<span className="line-clamp-2 leading-snug">{h.text}</span>
</button>
</li>
))}
</ul>
</div>
</nav>
)}
</div>
);
}
/** Highlights query matches within text */
function HighlightText({ text, query, className }: { text: string; query: string; className?: string }) {
if (!query.trim()) return <span className={className}>{text}</span>;
const parts: { text: string; match: boolean }[] = [];
const lower = text.toLowerCase();
const q = query.toLowerCase();
let lastIdx = 0;
let pos = 0;
while ((pos = lower.indexOf(q, lastIdx)) !== -1) {
if (pos > lastIdx) parts.push({ text: text.slice(lastIdx, pos), match: false });
parts.push({ text: text.slice(pos, pos + q.length), match: true });
lastIdx = pos + q.length;
}
if (lastIdx < text.length) parts.push({ text: text.slice(lastIdx), match: false });
return (
<span className={className}>
{parts.map((p, i) =>
p.match ? (
<mark key={i} className="bg-accent-400/25 text-accent-300 rounded-sm px-px">{p.text}</mark>
) : (
<span key={i}>{p.text}</span>
)
)}
</span>
);
}
@@ -0,0 +1,354 @@
import { useState, useMemo, useRef, useEffect } from 'react';
interface ComponentItem {
name: string;
description?: string;
}
interface MarketplacePlugin {
name: string;
description?: string;
source?: string;
source_path?: string;
author?: string;
tags?: string[];
components?: Record<string, number>;
components_items?: Record<string, ComponentItem[]>;
}
interface Props {
pluginsList: MarketplacePlugin[];
marketplaceAuthor: string;
marketplaceName: string;
tagColors: Record<string, string>;
}
function getSourceLabel(p: MarketplacePlugin): 'local' | 'github' {
const src = p.source ?? '';
if (typeof src === 'string') {
if (src.startsWith('./') || src.startsWith('../')) return 'local';
if (src.includes('github.com') || src.includes('.git')) return 'github';
if (src.includes('/')) return 'github';
}
return 'local';
}
export default function MarketplacePluginsList({
pluginsList,
marketplaceAuthor,
marketplaceName,
tagColors,
}: Props) {
const [search, setSearch] = useState('');
const [selected, setSelected] = useState<MarketplacePlugin | null>(null);
const backdropRef = useRef<HTMLDivElement>(null);
const filtered = useMemo(() => {
if (!search.trim()) return pluginsList;
const q = search.toLowerCase().trim();
return pluginsList.filter(
(p) =>
p.name?.toLowerCase().includes(q) ||
p.description?.toLowerCase().includes(q) ||
p.author?.toLowerCase().includes(q) ||
p.tags?.some((t) => t.toLowerCase().includes(q)) ||
Object.keys(p.components ?? {}).some((t) => t.toLowerCase().includes(q))
);
}, [pluginsList, search]);
useEffect(() => {
if (!selected) return;
function handleKey(e: KeyboardEvent) {
if (e.key === 'Escape') setSelected(null);
}
window.addEventListener('keydown', handleKey);
return () => window.removeEventListener('keydown', handleKey);
}, [selected]);
function handleBackdropClick(e: React.MouseEvent) {
if (e.target === backdropRef.current) setSelected(null);
}
const namedTypes = new Set(Object.keys(selected?.components_items ?? {}));
const countOnlyTypes = Object.entries(selected?.components ?? {}).filter(
([type]) => !namedTypes.has(type)
);
return (
<div>
<div className="flex items-center justify-between gap-3 flex-wrap mb-4">
<h2 className="text-lg font-bold text-[var(--color-text-primary)]">
Plugins ({filtered.length}
{filtered.length !== pluginsList.length ? ` of ${pluginsList.length}` : ''})
</h2>
<div className="relative w-full sm:w-72">
<svg
className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 pointer-events-none"
style={{ color: 'var(--color-text-tertiary)' }}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.35-4.35" />
</svg>
<input
type="text"
placeholder="Search plugins in this marketplace..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full pl-9 pr-8 py-2 rounded-lg text-[12.5px] outline-none transition-all duration-200"
style={{
background: 'var(--color-surface-2)',
border: '1px solid var(--color-border)',
color: 'var(--color-text-primary)',
}}
onFocus={(e) => {
e.currentTarget.style.borderColor = 'var(--color-border-hover)';
e.currentTarget.style.boxShadow = '0 0 0 3px rgba(255,217,61,0.1)';
}}
onBlur={(e) => {
e.currentTarget.style.borderColor = 'var(--color-border)';
e.currentTarget.style.boxShadow = 'none';
}}
/>
{search && (
<button
onClick={() => setSearch('')}
className="absolute right-2.5 top-1/2 -translate-y-1/2 w-4 h-4 rounded-full flex items-center justify-center transition-colors hover:opacity-80"
style={{ background: 'var(--color-surface-3)', color: 'var(--color-text-tertiary)' }}
aria-label="Clear search"
>
<svg className="w-2.5 h-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path d="M18 6 6 18M6 6l12 12" />
</svg>
</button>
)}
</div>
</div>
{filtered.length === 0 ? (
<div
className="flex flex-col items-center justify-center py-14 rounded-2xl"
style={{ background: 'var(--color-surface-1)', border: '1px dashed var(--color-border)' }}
>
<p className="text-[13px] font-medium" style={{ color: 'var(--color-text-secondary)' }}>
No plugins match "{search}"
</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{filtered.map((p, idx) => {
const sourceType = getSourceLabel(p);
const hasAuthor = p.author && p.author !== marketplaceAuthor;
const hasComponents = p.components && Object.keys(p.components).length > 0;
return (
<button
key={`${p.name}-${idx}`}
type="button"
onClick={() => setSelected(p)}
className="text-left flex flex-col p-4 rounded-xl bg-[var(--color-card-bg)] border border-[var(--color-border)] hover:border-[var(--color-border-hover)] transition-all duration-200 cursor-pointer"
style={{ boxShadow: 'var(--shadow-card)' }}
>
<div className="flex items-start gap-3 mb-2">
<div
className="w-8 h-8 rounded-lg flex items-center justify-center shrink-0 text-[12px] font-bold select-none"
style={{
background: 'linear-gradient(135deg, #ffd93d12 0%, #ffd93d08 100%)',
border: '1px solid #ffd93d20',
color: '#ffd93d99',
}}
>
{(p.name ?? '?').charAt(0).toUpperCase()}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h3 className="text-[13px] font-semibold text-[var(--color-text-primary)] truncate">
{p.name}
</h3>
{sourceType === 'github' && (
<span className="text-[9px] font-medium px-1.5 py-0.5 rounded bg-[var(--color-surface-3)] text-[var(--color-text-tertiary)] border border-[var(--color-border)]">
external
</span>
)}
</div>
{hasAuthor && (
<p className="text-[11px] text-[var(--color-text-tertiary)]">by {p.author}</p>
)}
</div>
</div>
{p.description && (
<p className="text-[12px] text-[var(--color-text-secondary)] leading-[1.5] line-clamp-2 mb-2 flex-1">
{p.description}
</p>
)}
{hasComponents && (
<div className="flex items-center gap-1.5 flex-wrap mt-auto">
{Object.entries(p.components!).map(([type, count]) => (
<span
key={type}
className="text-[9px] font-semibold px-2 py-0.5 rounded-full border"
style={{
background: `${tagColors[type] ?? '#666'}10`,
color: tagColors[type] ?? '#666',
borderColor: `${tagColors[type] ?? '#666'}20`,
}}
>
{count} {type}
</span>
))}
</div>
)}
{p.tags && p.tags.length > 0 && !hasComponents && (
<div className="flex items-center gap-1.5 flex-wrap mt-auto">
{p.tags.slice(0, 4).map((tag) => (
<span
key={tag}
className="text-[9px] font-medium px-2 py-0.5 rounded-full bg-[var(--color-surface-3)] text-[var(--color-text-tertiary)] border border-[var(--color-border)]"
>
{tag}
</span>
))}
</div>
)}
</button>
);
})}
</div>
)}
{selected && (
<div
ref={backdropRef}
onClick={handleBackdropClick}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4"
>
<div className="w-full max-w-xl bg-[var(--color-surface-1)] border border-[var(--color-border)] rounded-xl shadow-2xl overflow-hidden max-h-[85vh] flex flex-col">
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-[var(--color-border)] shrink-0">
<div className="flex items-center gap-2.5 min-w-0">
<div
className="w-8 h-8 rounded-lg flex items-center justify-center shrink-0 text-[13px] font-bold select-none"
style={{
background: 'linear-gradient(135deg, #ffd93d18 0%, #ffd93d08 100%)',
border: '1.5px solid #ffd93d30',
color: '#ffd93d',
}}
>
{(selected.name ?? '?').charAt(0).toUpperCase()}
</div>
<h3 className="text-sm font-semibold text-[var(--color-text-primary)] truncate">
{selected.name}
</h3>
</div>
<button
onClick={() => setSelected(null)}
className="p-1 rounded hover:bg-white/10 text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)] transition-colors shrink-0"
aria-label="Close"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Body */}
<div className="px-5 py-4 overflow-y-auto space-y-4">
{selected.description && (
<p className="text-[13px] text-[var(--color-text-secondary)] leading-relaxed">
{selected.description}
</p>
)}
{selected.author && selected.author !== marketplaceAuthor && (
<p className="text-[12px] text-[var(--color-text-tertiary)]">by {selected.author}</p>
)}
{/* Components with resolved item names */}
{Object.entries(selected.components_items ?? {}).map(([type, items]) => (
<div key={type}>
<div className="flex items-center gap-2 mb-2">
<span
className="text-[10px] font-semibold px-2.5 py-1 rounded-full border uppercase tracking-wider"
style={{
background: `${tagColors[type] ?? '#666'}15`,
color: tagColors[type] ?? '#666',
borderColor: `${tagColors[type] ?? '#666'}30`,
}}
>
{type}
</span>
<span className="text-[11px] text-[var(--color-text-tertiary)]">{items.length} items</span>
</div>
<div className="space-y-1.5">
{items.map((item) => (
<div
key={item.name}
className="flex items-start gap-2 px-2.5 py-2 rounded-lg bg-[var(--color-surface-2)] border border-[var(--color-border)] text-[12px]"
>
<span
className="w-1.5 h-1.5 rounded-full shrink-0 mt-1.5"
style={{ background: tagColors[type] ?? '#666' }}
/>
<div className="min-w-0">
<div className="text-[var(--color-text-primary)] font-mono text-[11.5px] font-medium">
{item.name}
</div>
{item.description && (
<p className="text-[var(--color-text-tertiary)] text-[11.5px] leading-snug mt-0.5">
{item.description}
</p>
)}
</div>
</div>
))}
</div>
</div>
))}
{/* Component types where only a count is known (names not resolved) */}
{countOnlyTypes.length > 0 && (
<div className="flex items-center gap-1.5 flex-wrap">
{countOnlyTypes.map(([type, count]) => (
<span
key={type}
className="text-[10px] font-semibold px-2.5 py-1 rounded-full border"
style={{
background: `${tagColors[type] ?? '#666'}12`,
color: tagColors[type] ?? '#666',
borderColor: `${tagColors[type] ?? '#666'}25`,
}}
>
{count} {type}
</span>
))}
</div>
)}
{!selected.components || Object.keys(selected.components).length === 0 ? (
<p className="text-[12px] text-[var(--color-text-tertiary)] italic">
No component breakdown available for this plugin yet.
</p>
) : null}
{/* Install command */}
<div>
<div className="text-[11px] uppercase tracking-wider text-[var(--color-text-tertiary)] mb-1.5 font-medium">
Install this plugin
</div>
<pre className="p-3 rounded-lg bg-[var(--color-surface-2)] border border-[var(--color-border)] text-[12px] text-[var(--color-text-secondary)] overflow-x-auto font-mono">
<code>{`/plugin install ${selected.name}@${marketplaceName}`}</code>
</pre>
</div>
</div>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,78 @@
import { useState, useEffect } from 'react';
function useGlobalAuth() {
const [state, setState] = useState({ isSignedIn: false, isLoaded: false });
useEffect(() => {
function check() {
const clerk = (window as any).Clerk;
if (clerk?.loaded) {
setState({ isSignedIn: !!clerk.user, isLoaded: true });
}
}
check();
const interval = setInterval(check, 500);
const handleChange = () => check();
window.addEventListener('clerk:session', handleChange);
return () => { clearInterval(interval); window.removeEventListener('clerk:session', handleChange); };
}, []);
return state;
}
export function NavContent({ isActive }: { isActive: boolean }) {
const { isSignedIn, isLoaded } = useGlobalAuth();
if (!isLoaded) {
return (
<div className="flex items-center gap-3 px-3 py-2 rounded-lg">
<div className="w-[18px] h-[18px] flex items-center justify-center shrink-0">
<div className="w-[18px] h-[18px] rounded bg-[var(--color-surface-3)] animate-pulse" />
</div>
<div className="sidebar-text w-24 h-3.5 rounded bg-[var(--color-surface-3)] animate-pulse" />
</div>
);
}
const icon = (
<svg className="w-[18px] h-[18px] flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="1.5">
<path strokeLinecap="round" strokeLinejoin="round" d="M2 17V7a2 2 0 012-2h5l2 2h9a2 2 0 012 2v8a2 2 0 01-2 2H4a2 2 0 01-2-2z" />
</svg>
);
if (isSignedIn) {
return (
<a
href="/my-components"
data-tooltip="My Components"
className={`flex items-center gap-3 px-3 py-2 rounded-lg text-[13px] transition-all duration-150 group ${
isActive
? 'bg-[var(--color-surface-3)] text-[var(--color-text-primary)] font-medium shadow-sm'
: 'text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-surface-2)] hover:shadow-sm'
}`}
>
<span className={`w-[18px] h-[18px] shrink-0 flex items-center justify-center transition-all duration-150 ${isActive ? 'text-[var(--color-text-primary)] scale-105' : 'text-[var(--color-text-tertiary)] group-hover:text-[var(--color-text-secondary)] group-hover:scale-105'}`}>
{icon}
</span>
<span className="sidebar-text truncate transition-all duration-300">My Components</span>
</a>
);
}
return (
<button
onClick={() => (window as any).Clerk?.openSignIn?.()}
data-tooltip="My Components"
className="flex items-center gap-3 px-3 py-2 rounded-lg text-[13px] text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-surface-2)] hover:shadow-sm transition-all duration-150 group w-full text-left"
>
<span className="w-[18px] h-[18px] shrink-0 flex items-center justify-center text-[var(--color-text-tertiary)] group-hover:text-[var(--color-text-secondary)] transition-all duration-150 group-hover:scale-105">
{icon}
</span>
<span className="sidebar-text truncate transition-all duration-300">My Components</span>
</button>
);
}
export default function MyComponentsSidebarItem({ isActive = false }: { isActive?: boolean }) {
return <NavContent isActive={isActive} />;
}
File diff suppressed because it is too large Load Diff
+37
View File
@@ -0,0 +1,37 @@
---
interface Props {
href: string;
label: string;
icon?: string;
active?: boolean;
external?: boolean;
}
const { href, label, icon, active = false, external = false } = Astro.props;
const Tag = external ? 'a' : 'a';
---
<a
href={href}
class:list={[
'flex items-center gap-2.5 px-3 py-1.5 rounded-md text-sm transition-colors',
active
? 'bg-primary-500/15 text-primary-400 font-medium'
: 'text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-surface-3)]',
]}
{...external ? { target: '_blank', rel: 'noopener noreferrer' } : {}}
>
{icon && (
<span
class="w-5 h-5 shrink-0 inline-flex items-center justify-center leading-none text-center [&>svg]:w-full [&>svg]:h-full [&>svg]:block [&>svg]:m-auto"
>
{icon}
</span>
)}
<span class="truncate">{label}</span>
{external && (
<svg class="w-3 h-3 ml-auto opacity-40 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
)}
</a>
+278
View File
@@ -0,0 +1,278 @@
import { useState, useMemo } from 'react';
interface Plugin {
slug: string;
name: string;
author: string;
description: string;
github: string;
website?: string;
stars: number;
type: string;
tags: string[];
contains: Record<string, number>;
highlights: string[];
}
const TAG_COLORS: Record<string, string> = {
skills: '#f59e0b',
agents: '#3b82f6',
commands: '#10b981',
hooks: '#f97316',
mcps: '#06b6d4',
settings: '#8b5cf6',
rules: '#ec4899',
};
function formatStars(stars: number): string {
if (stars >= 1000) return `${(stars / 1000).toFixed(stars >= 10000 ? 0 : 1)}k`;
return String(stars);
}
type SortMode = 'stars' | 'alpha';
export default function PluginsGrid({ plugins }: { plugins: Plugin[] }) {
const [search, setSearch] = useState('');
const [sort, setSort] = useState<SortMode>('stars');
const filtered = useMemo(() => {
let result = plugins;
if (search.trim()) {
const q = search.toLowerCase().trim();
result = result.filter(
(p) =>
p.name.toLowerCase().includes(q) ||
p.author.toLowerCase().includes(q) ||
p.description.toLowerCase().includes(q) ||
p.tags.some((t) => t.toLowerCase().includes(q))
);
}
if (sort === 'stars') {
result = [...result].sort((a, b) => b.stars - a.stars);
} else {
result = [...result].sort((a, b) => a.name.localeCompare(b.name));
}
return result;
}, [plugins, search, sort]);
return (
<div className="px-6 py-8 max-w-7xl mx-auto">
{/* Controls */}
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-3 mb-6">
{/* Search */}
<div className="relative flex-1">
<svg
className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 pointer-events-none"
style={{ color: 'var(--color-text-tertiary)' }}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.35-4.35" />
</svg>
<input
type="text"
placeholder="Search plugins by name, author, or tag..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full pl-10 pr-4 py-2.5 rounded-xl text-[13px] outline-none transition-all duration-200"
style={{
background: 'var(--color-surface-2)',
border: '1px solid var(--color-border)',
color: 'var(--color-text-primary)',
}}
onFocus={(e) => {
e.currentTarget.style.borderColor = 'var(--color-border-hover)';
e.currentTarget.style.boxShadow = '0 0 0 3px rgba(255,217,61,0.1)';
}}
onBlur={(e) => {
e.currentTarget.style.borderColor = 'var(--color-border)';
e.currentTarget.style.boxShadow = 'none';
}}
/>
{search && (
<button
onClick={() => setSearch('')}
className="absolute right-3 top-1/2 -translate-y-1/2 w-5 h-5 rounded-full flex items-center justify-center transition-colors hover:opacity-80"
style={{
background: 'var(--color-surface-3)',
color: 'var(--color-text-tertiary)',
}}
>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path d="M18 6 6 18M6 6l12 12" />
</svg>
</button>
)}
</div>
{/* Sort selector */}
<div
className="flex items-center rounded-xl overflow-hidden shrink-0"
style={{
background: 'var(--color-surface-2)',
border: '1px solid var(--color-border)',
}}
>
<button
onClick={() => setSort('stars')}
className="flex items-center gap-1.5 px-4 py-2.5 text-[12px] font-semibold transition-all duration-200"
style={{
background: sort === 'stars' ? 'var(--color-surface-3)' : 'transparent',
color: sort === 'stars' ? '#ffd93d' : 'var(--color-text-tertiary)',
}}
>
<svg className="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
</svg>
Stars
</button>
<div style={{ width: '1px', height: '20px', background: 'var(--color-border)' }} />
<button
onClick={() => setSort('alpha')}
className="flex items-center gap-1.5 px-4 py-2.5 text-[12px] font-semibold transition-all duration-200"
style={{
background: sort === 'alpha' ? 'var(--color-surface-3)' : 'transparent',
color: sort === 'alpha' ? '#ffd93d' : 'var(--color-text-tertiary)',
}}
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path d="M3 6h7M3 12h10M3 18h5M16 6l3 3m0 0l3-3m-3 3V3m0 18l-3-3m3 3l3-3m-3 3V12" strokeLinecap="round" strokeLinejoin="round" />
</svg>
A-Z
</button>
</div>
</div>
{/* Results count */}
{search && (
<p className="text-[12px] mb-4" style={{ color: 'var(--color-text-tertiary)' }}>
{filtered.length} {filtered.length === 1 ? 'result' : 'results'} for "{search}"
</p>
)}
{/* Grid */}
{filtered.length === 0 ? (
<div
className="flex flex-col items-center justify-center py-20 rounded-2xl"
style={{
background: 'var(--color-surface-1)',
border: '1px dashed var(--color-border)',
}}
>
<svg
className="w-12 h-12 mb-3"
style={{ color: 'var(--color-text-tertiary)', opacity: 0.4 }}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.35-4.35" strokeLinecap="round" />
</svg>
<p className="text-[14px] font-medium" style={{ color: 'var(--color-text-secondary)' }}>
No plugins found
</p>
<p className="text-[12px] mt-1" style={{ color: 'var(--color-text-tertiary)' }}>
Try a different search term
</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-5">
{filtered.map((plugin, idx) => (
<a
key={plugin.slug}
href={`/plugins/${plugin.slug}`}
className="group relative flex flex-col p-6 rounded-2xl transition-all duration-300 cursor-pointer no-underline"
style={{
background: 'var(--color-card-bg)',
border: '1px solid var(--color-border)',
boxShadow: 'var(--shadow-card)',
animationDelay: `${idx * 50}ms`,
animationFillMode: 'both',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'var(--color-card-hover)';
e.currentTarget.style.borderColor = 'var(--color-border-hover)';
e.currentTarget.style.transform = 'scale(1.02) translateY(-4px)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'var(--color-card-bg)';
e.currentTarget.style.borderColor = 'var(--color-border)';
e.currentTarget.style.transform = 'scale(1) translateY(0)';
}}
>
{/* Gradient overlay on hover */}
<div className="absolute inset-0 rounded-2xl bg-gradient-to-br from-yellow-500/0 to-purple-500/0 group-hover:from-yellow-500/5 group-hover:to-purple-500/5 transition-all duration-300 pointer-events-none" />
{/* Header */}
<div className="relative z-10 flex items-start gap-4 mb-4">
<div
className="w-12 h-12 rounded-xl flex items-center justify-center shrink-0 transition-all duration-300 group-hover:scale-110 group-hover:rotate-3 text-[22px] font-bold select-none"
style={{
background: 'linear-gradient(135deg, #ffd93d18 0%, #ffd93d08 100%)',
border: '1.5px solid #ffd93d30',
color: '#ffd93d',
}}
>
{plugin.author.charAt(0).toUpperCase()}
</div>
<div className="min-w-0 flex-1">
<h3
className="text-[15px] font-bold leading-tight mb-1 transition-colors"
style={{ color: 'var(--color-text-primary)' }}
>
{plugin.name}
</h3>
<p className="text-[12px]" style={{ color: 'var(--color-text-tertiary)' }}>
by {plugin.author}
</p>
</div>
{/* Stars badge */}
<span className="shrink-0 text-[11px] font-semibold px-2.5 py-1 rounded-full flex items-center gap-1 transition-all bg-gradient-to-r from-yellow-500/15 to-amber-400/15 text-yellow-400 border border-yellow-500/25 group-hover:from-yellow-500/25 group-hover:to-amber-400/25 group-hover:border-yellow-500/40">
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
</svg>
{formatStars(plugin.stars)}
</span>
</div>
{/* Description */}
<p
className="relative z-10 text-[13px] line-clamp-2 leading-[1.6] mb-4 flex-1"
style={{ color: 'var(--color-text-secondary)' }}
>
{plugin.description}
</p>
{/* Tags */}
<div className="relative z-10 flex items-center gap-1.5 flex-wrap">
{plugin.tags.slice(0, 5).map((tag) => (
<span
key={tag}
className="text-[10px] font-semibold px-2.5 py-1 rounded-full border transition-all"
style={{
background: `${TAG_COLORS[tag] ?? '#666'}12`,
color: TAG_COLORS[tag] ?? '#666',
borderColor: `${TAG_COLORS[tag] ?? '#666'}25`,
}}
>
{tag}
</span>
))}
</div>
</a>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,296 @@
import { useState, useEffect, useRef } from 'react';
import { collectionsApi } from '../lib/collections-api';
import type { Collection } from '../lib/types';
// Hook that reads auth state from Clerk's global instance (window.Clerk)
// Works without needing a ClerkProvider in the same React tree
function useGlobalAuth() {
const [state, setState] = useState({ isSignedIn: false, isLoaded: false });
useEffect(() => {
function check() {
const clerk = (window as any).Clerk;
if (clerk?.loaded) {
setState({ isSignedIn: !!clerk.user, isLoaded: true });
}
}
check();
// Re-check periodically until Clerk loads
const interval = setInterval(check, 500);
// Also listen for clerk state changes
const handleChange = () => check();
window.addEventListener('clerk:session', handleChange);
return () => { clearInterval(interval); window.removeEventListener('clerk:session', handleChange); };
}, []);
const getToken = async () => {
const clerk = (window as any).Clerk;
return clerk?.session?.getToken() ?? null;
};
return { ...state, getToken };
}
interface Props {
componentType: string;
componentPath: string;
componentName: string;
componentCategory?: string;
}
function SaveButton({ componentType, componentPath, componentName, componentCategory }: Props) {
const { isSignedIn, isLoaded, getToken } = useGlobalAuth();
const [open, setOpen] = useState(false);
const [collections, setCollections] = useState<Collection[]>([]);
const [loading, setLoading] = useState(false);
const [newName, setNewName] = useState('');
const buttonRef = useRef<HTMLButtonElement>(null);
const [dropdownPos, setDropdownPos] = useState({ top: 0, left: 0 });
const [creating, setCreating] = useState(false);
const [savedIn, setSavedIn] = useState<Set<string>>(new Set());
const dropdownRef = useRef<HTMLDivElement>(null);
// Close dropdown on outside click
useEffect(() => {
if (!open) return;
function handleClick(e: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
setOpen(false);
}
}
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, [open]);
async function loadCollections() {
setLoading(true);
try {
const token = await getToken();
if (!token) return;
const cols = await collectionsApi.list(token);
setCollections(cols);
// Track which collections already contain this component
const saved = new Set<string>();
for (const col of cols) {
if (col.collection_items?.some((i) => i.component_path === componentPath && i.component_type === componentType)) {
saved.add(col.id);
}
}
setSavedIn(saved);
} catch (err) {
console.error('Failed to load collections:', err);
} finally {
setLoading(false);
}
}
// Elevate the parent card's z-index when dropdown is open
useEffect(() => {
const card = dropdownRef.current?.closest('.group') as HTMLElement | null;
if (!card) return;
if (open) {
card.style.zIndex = '50';
card.style.position = 'relative';
} else {
card.style.zIndex = '';
card.style.position = '';
}
return () => { card.style.zIndex = ''; card.style.position = ''; };
}, [open]);
async function handleToggle(e: React.MouseEvent) {
e.stopPropagation();
if (!open) {
setOpen(true);
await loadCollections();
} else {
setOpen(false);
}
}
async function handleToggleCollection(collectionId: string) {
const token = await getToken();
if (!token) return;
try {
if (savedIn.has(collectionId)) {
// Remove from collection
const col = collections.find((c) => c.id === collectionId);
const item = col?.collection_items?.find(
(i) => i.component_path === componentPath && i.component_type === componentType
);
if (item) {
await collectionsApi.removeItem(token, item.id, collectionId);
setSavedIn((prev) => {
const next = new Set(prev);
next.delete(collectionId);
return next;
});
// Update local state
setCollections((prev) =>
prev.map((c) =>
c.id === collectionId
? { ...c, collection_items: c.collection_items.filter((i) => i.id !== item.id) }
: c
)
);
}
} else {
// Add to collection
const newItem = await collectionsApi.addItem(token, collectionId, {
type: componentType,
path: componentPath,
name: componentName,
category: componentCategory,
});
setSavedIn((prev) => new Set(prev).add(collectionId));
setCollections((prev) =>
prev.map((c) =>
c.id === collectionId
? { ...c, collection_items: [...(c.collection_items ?? []), newItem] }
: c
)
);
}
} catch (err: any) {
console.error('Failed to toggle collection item:', err);
}
}
async function handleCreate() {
if (!newName.trim()) return;
const token = await getToken();
if (!token) return;
setCreating(true);
try {
const col = await collectionsApi.create(token, newName.trim());
// Add item to the new collection immediately
const newItem = await collectionsApi.addItem(token, col.id, {
type: componentType,
path: componentPath,
name: componentName,
category: componentCategory,
});
col.collection_items = [newItem];
setCollections((prev) => [...prev, col]);
setSavedIn((prev) => new Set(prev).add(col.id));
setNewName('');
} catch (err: any) {
console.error('Failed to create collection:', err);
} finally {
setCreating(false);
}
}
if (!isLoaded) return null;
const isSaved = savedIn.size > 0;
// Not signed in - open Clerk modal via global instance
if (!isSignedIn) {
return (
<button
onClick={(e) => { e.stopPropagation(); (window as any).Clerk?.openSignIn?.(); }}
className="w-7 h-7 rounded-lg flex items-center justify-center shrink-0 mt-0.5 text-[var(--color-text-tertiary)] opacity-0 group-hover:opacity-100 hover:text-white hover:bg-white/10 transition-all"
title="Sign in to save"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="1.5">
<path strokeLinecap="round" strokeLinejoin="round" d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z" />
</svg>
</button>
);
}
return (
<div className="relative" ref={dropdownRef}>
<button
ref={buttonRef}
onClick={handleToggle}
className={`w-7 h-7 rounded-lg flex items-center justify-center shrink-0 mt-0.5 transition-all ${
isSaved
? 'text-blue-400'
: 'text-[var(--color-text-tertiary)] opacity-0 group-hover:opacity-100 hover:text-white hover:bg-white/10'
}`}
title={isSaved ? 'Saved to collection' : 'Save to collection'}
>
<svg className="w-3.5 h-3.5" fill={isSaved ? 'currentColor' : 'none'} viewBox="0 0 24 24" stroke="currentColor" strokeWidth="1.5">
<path strokeLinecap="round" strokeLinejoin="round" d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z" />
</svg>
</button>
{open && (
<div className="absolute right-0 top-full mt-1 w-56 bg-[var(--color-surface-1)] border border-[var(--color-border)] rounded-lg shadow-xl z-[9999] py-1">
{loading ? (
<div className="px-3 py-4 text-center">
<div className="w-4 h-4 border-2 border-[var(--color-text-tertiary)] border-t-transparent rounded-full animate-spin mx-auto" />
</div>
) : (
<>
<div className="px-3 py-1.5 text-[11px] font-medium text-[var(--color-text-tertiary)] uppercase tracking-wide">
Save to collection
</div>
{collections.length === 0 && (
<div className="px-3 py-2 text-[12px] text-[var(--color-text-tertiary)]">
No collections yet
</div>
)}
<div className="max-h-40 overflow-y-auto">
{collections.map((col) => (
<button
key={col.id}
onClick={(e) => { e.stopPropagation(); handleToggleCollection(col.id); }}
className="flex items-center gap-2 w-full px-3 py-1.5 text-[12px] text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-white/[0.06] transition-colors"
>
<span className={`w-3.5 h-3.5 rounded border flex items-center justify-center shrink-0 ${
savedIn.has(col.id) ? 'bg-blue-500 border-blue-500' : 'border-[var(--color-border-hover)]'
}`}>
{savedIn.has(col.id) && (
<svg className="w-2.5 h-2.5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="3">
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
)}
</span>
<span className="truncate">{col.name}</span>
<span className="ml-auto text-[10px] text-[var(--color-text-tertiary)]">
{col.collection_items?.length ?? 0}
</span>
</button>
))}
</div>
<div className="border-t border-[var(--color-border)] mt-1 pt-1 px-2 pb-1">
<div className="flex items-center gap-1">
<input
type="text"
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') handleCreate(); }}
onClick={(e) => e.stopPropagation()}
placeholder="New collection..."
className="flex-1 bg-transparent border-none text-[12px] text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] px-1 py-1 outline-none"
maxLength={100}
/>
<button
onClick={(e) => { e.stopPropagation(); handleCreate(); }}
disabled={!newName.trim() || creating}
className="text-[11px] px-2 py-0.5 rounded bg-blue-600 hover:bg-blue-500 text-white disabled:opacity-40 transition-colors"
>
{creating ? '...' : 'Add'}
</button>
</div>
</div>
</>
)}
</div>
)}
</div>
);
}
export default function SaveToCollectionButton(props: Props) {
return <SaveButton {...props} />;
}
+282
View File
@@ -0,0 +1,282 @@
import { useState, useEffect, useRef, useMemo } from 'react';
import { TYPE_CONFIG } from '../lib/icons';
import TypeIcon from './TypeIcon';
import { fetchSearchIndex, type SearchIndexEntry } from '../lib/data';
function cleanPath(path: string): string {
return path?.replace(/\.(md|json)$/, '') ?? '';
}
function formatName(name: string): string {
if (!name) return '';
return name
.replace(/\.(md|json)$/, '')
.replace(/[-_]/g, ' ')
.split(' ')
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(' ');
}
export default function SearchModal() {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
const [data, setData] = useState<SearchIndexEntry[] | null>(null);
const [selectedIndex, setSelectedIndex] = useState(0);
const [isAnimating, setIsAnimating] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const resultsRef = useRef<HTMLDivElement>(null);
const closeTimeoutRef = useRef<number | null>(null);
// Load the lightweight search index lazily on first open
useEffect(() => {
if (!open || data) return;
fetchSearchIndex()
.then((entries) => setData(entries))
.catch(() => {});
}, [open, data]);
// Cmd+K listener
useEffect(() => {
function handleKey(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault();
if (open) {
closeModal();
} else {
openModal();
}
}
if (e.key === 'Escape' && open) closeModal();
}
const handleTriggerClick = (e: Event) => {
e.preventDefault();
openModal();
};
window.addEventListener('keydown', handleKey);
// Use MutationObserver to watch for the trigger button
let observer: MutationObserver | null = null;
let timeoutId: number | null = null;
const trigger = document.getElementById('searchTrigger');
if (trigger) {
trigger.addEventListener('click', handleTriggerClick);
} else {
// Watch for the button to be added to the DOM
observer = new MutationObserver(() => {
const btn = document.getElementById('searchTrigger');
if (btn) {
btn.addEventListener('click', handleTriggerClick);
observer?.disconnect();
observer = null;
}
});
observer.observe(document.body, { childList: true, subtree: true });
// Cleanup observer after 5 seconds if button never appears
timeoutId = window.setTimeout(() => {
observer?.disconnect();
observer = null;
}, 5000);
}
return () => {
window.removeEventListener('keydown', handleKey);
const trigger = document.getElementById('searchTrigger');
trigger?.removeEventListener('click', handleTriggerClick);
if (observer) {
observer.disconnect();
}
if (timeoutId !== null) {
clearTimeout(timeoutId);
}
};
}, [open]);
function openModal() {
// Clear any pending close timeout to prevent race conditions
if (closeTimeoutRef.current !== null) {
clearTimeout(closeTimeoutRef.current);
closeTimeoutRef.current = null;
}
setOpen(true);
setIsAnimating(true);
}
function closeModal() {
setIsAnimating(false);
// Clear any existing timeout before setting a new one
if (closeTimeoutRef.current !== null) {
clearTimeout(closeTimeoutRef.current);
}
closeTimeoutRef.current = window.setTimeout(() => {
setOpen(false);
closeTimeoutRef.current = null;
}, 200); // Match animation duration
}
// Focus input when opened
useEffect(() => {
if (open) {
setQuery('');
setSelectedIndex(0);
setTimeout(() => inputRef.current?.focus(), 50);
}
}, [open]);
// Search results
const results = useMemo(() => {
if (!data || !query.trim()) return [];
const q = query.toLowerCase();
return data
.filter(
(c) =>
c.name.toLowerCase().includes(q) ||
c.description?.toLowerCase().includes(q) ||
c.category?.toLowerCase().includes(q)
)
.slice(0, 20);
}, [data, query]);
// Keyboard navigation
useEffect(() => {
setSelectedIndex(0);
}, [query]);
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === 'ArrowDown') {
e.preventDefault();
setSelectedIndex((i) => Math.min(results.length - 1, i + 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setSelectedIndex((i) => Math.max(0, i - 1));
} else if (e.key === 'Enter' && results[selectedIndex]) {
const c = results[selectedIndex];
navigate(c);
}
}
function navigate(component: SearchIndexEntry) {
closeModal();
window.location.href = `/component/${component.type}/${cleanPath(component.path ?? component.name)}`;
}
// Scroll selected into view
useEffect(() => {
const container = resultsRef.current;
const selected = container?.children[selectedIndex] as HTMLElement;
selected?.scrollIntoView({ block: 'nearest' });
}, [selectedIndex]);
if (!open) return null;
return (
<div
className="fixed inset-0 z-50 flex items-start justify-center pt-[20vh]"
onClick={closeModal}
>
{/* Backdrop */}
<div
className={`absolute inset-0 bg-black/60 backdrop-blur-sm transition-opacity duration-200 ${
isAnimating ? 'opacity-100' : 'opacity-0'
}`}
/>
{/* Modal */}
<div
className={`relative w-full max-w-xl bg-[var(--color-surface-1)] border border-[var(--color-border)] rounded-xl shadow-2xl overflow-hidden transition-all duration-200 ${
isAnimating
? 'opacity-100 scale-100 translate-y-0'
: 'opacity-0 scale-95 translate-y-4'
}`}
onClick={(e) => e.stopPropagation()}
>
{/* Input */}
<div className={`flex items-center gap-3 px-4 py-3 border-b border-[var(--color-border)] transition-all duration-200 delay-[75ms] ${
isAnimating ? 'opacity-100 translate-x-0' : 'opacity-0 translate-x-4'
}`}>
<svg className="w-5 h-5 text-[var(--color-text-tertiary)] shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
ref={inputRef}
type="text"
placeholder="Search all components..."
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
className="flex-1 bg-transparent text-sm text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] outline-none"
/>
<kbd className="px-1.5 py-0.5 text-[10px] bg-[var(--color-surface-3)] border border-[var(--color-border)] rounded text-[var(--color-text-tertiary)]">
ESC
</kbd>
</div>
{/* Results */}
<div ref={resultsRef} className={`max-h-80 overflow-y-auto transition-all duration-200 delay-100 ${
isAnimating ? 'opacity-100' : 'opacity-0'
}`}>
{query.trim() && results.length === 0 && (
<div className="flex flex-col items-center justify-center px-4 py-12">
{/* Icon */}
<div className="w-16 h-16 rounded-xl bg-[var(--color-surface-2)] border border-[var(--color-border)] flex items-center justify-center mb-4">
<svg className="w-8 h-8 text-[var(--color-text-tertiary)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="1.5">
<path strokeLinecap="round" strokeLinejoin="round" d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M12 12h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
{/* Message */}
<p className="text-[14px] font-medium text-[var(--color-text-primary)] mb-1">No results found</p>
<p className="text-[13px] text-[var(--color-text-secondary)] text-center max-w-xs">
We couldn't find any components for "<span className="font-medium text-[var(--color-text-primary)]">{query}</span>". Try different keywords.
</p>
</div>
)}
{results.map((component, i) => {
const typePlural = component.type.endsWith('s') ? component.type : component.type + 's';
const config = TYPE_CONFIG[typePlural];
return (
<button
key={component.path ?? `${component.name}-${i}`}
onClick={() => navigate(component)}
className={`w-full flex items-center gap-3 px-4 py-2.5 text-left transition-colors ${
i === selectedIndex
? 'bg-[var(--color-primary-500)]/10'
: 'hover:bg-[var(--color-surface-2)]'
}`}
>
<TypeIcon type={typePlural} size={16} className="w-4 h-4 shrink-0 [&>svg]:w-4 [&>svg]:h-4 text-[var(--color-text-tertiary)]" />
<div className="min-w-0 flex-1">
<span className="text-sm text-[var(--color-text-primary)]">{formatName(component.name)}</span>
<span className="text-xs text-[var(--color-text-tertiary)] ml-2">{config?.label ?? component.type}</span>
</div>
{component.category && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-[var(--color-surface-3)] text-[var(--color-text-tertiary)] shrink-0">
{component.category}
</span>
)}
</button>
);
})}
</div>
{/* Footer */}
{query.trim() && results.length > 0 && (
<div className={`flex items-center gap-4 px-4 py-2 border-t border-[var(--color-border)] text-[10px] text-[var(--color-text-tertiary)] transition-all duration-200 delay-[150ms] ${
isAnimating ? 'opacity-100 translate-y-0' : 'opacity-0 -translate-y-2'
}`}>
<span>↑↓ navigate</span>
<span>↵ open</span>
<span>esc close</span>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,396 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { listRepos, createPR, type GitHubRepo, type PRResult } from '../lib/github-api';
import type { CollectionItem } from '../lib/types';
import { fetchComponentContent } from '../lib/data';
type Step = 'connect' | 'select-repo' | 'creating' | 'done' | 'error';
interface Props {
items: CollectionItem[];
collectionName: string;
onClose: () => void;
}
const GITHUB_CLIENT_ID = import.meta.env.PUBLIC_GITHUB_CLIENT_ID ?? '';
function pluralType(type: string): string {
return type.endsWith('s') ? type : type + 's';
}
function cleanPath(path: string): string {
return path?.replace(/\.(md|json)$/, '') ?? '';
}
// Map collection items to file paths + content.
// Content lives in per-component files (/component-content/{type}/{slug}.json),
// not in the index, so we fetch each item's content on demand.
async function buildFileMap(
items: CollectionItem[]
): Promise<Record<string, string>> {
const files: Record<string, string> = {};
await Promise.all(
items.map(async (item) => {
const type = pluralType(item.component_type);
const cleanItemPath = cleanPath(item.component_path);
const content = await fetchComponentContent(item.component_type, cleanItemPath);
if (!content) return;
const name = item.component_name?.replace(/\.(md|json)$/, '') ?? '';
switch (type) {
case 'agents':
files[`.claude/agents/${name}.md`] = content;
break;
case 'commands':
files[`.claude/commands/${name}.md`] = content;
break;
case 'skills': {
// Skills may have multiple files; content is the main SKILL.md
const skillPath = cleanItemPath.replace(/^[^/]+\//, '');
files[`.claude/skills/${skillPath}/SKILL.md`] = content;
break;
}
case 'hooks':
files[`.claude/hooks/${name}.json`] = content;
break;
case 'settings':
// Settings content is JSON that should be merged into settings.json
files[`.claude/settings/${name}.json`] = content;
break;
case 'mcps':
files[`.mcp-components/${name}.json`] = content;
break;
}
})
);
return files;
}
export default function SendToRepoModal({ items, collectionName, onClose }: Props) {
const [step, setStep] = useState<Step>(() => {
const token = sessionStorage.getItem('github_token');
return token ? 'select-repo' : 'connect';
});
const [token, setToken] = useState(() => sessionStorage.getItem('github_token') ?? '');
const [repos, setRepos] = useState<GitHubRepo[]>([]);
const [filteredRepos, setFilteredRepos] = useState<GitHubRepo[]>([]);
const [search, setSearch] = useState('');
const [loadingRepos, setLoadingRepos] = useState(false);
const [selectedRepo, setSelectedRepo] = useState<GitHubRepo | null>(null);
const [prResult, setPrResult] = useState<PRResult | null>(null);
const [error, setError] = useState('');
const backdropRef = useRef<HTMLDivElement>(null);
// Listen for OAuth callback
useEffect(() => {
function handleMessage(event: MessageEvent) {
if (event.origin !== window.location.origin) return;
if (event.data?.type === 'github-oauth' && event.data.token) {
sessionStorage.setItem('github_token', event.data.token);
setToken(event.data.token);
setStep('select-repo');
}
}
window.addEventListener('message', handleMessage);
return () => window.removeEventListener('message', handleMessage);
}, []);
// Load repos when we have a token
useEffect(() => {
if (step === 'select-repo' && token && repos.length === 0) {
loadRepos();
}
}, [step, token]);
// Filter repos on search
useEffect(() => {
if (!search.trim()) {
setFilteredRepos(repos);
} else {
const q = search.toLowerCase();
setFilteredRepos(repos.filter((r) => r.full_name.toLowerCase().includes(q)));
}
}, [search, repos]);
async function loadRepos() {
setLoadingRepos(true);
try {
const data = await listRepos(token);
setRepos(data);
setFilteredRepos(data);
} catch (err: any) {
if (err.message?.includes('401')) {
sessionStorage.removeItem('github_token');
setToken('');
setStep('connect');
} else {
setError(err.message);
setStep('error');
}
} finally {
setLoadingRepos(false);
}
}
function startOAuth() {
if (!GITHUB_CLIENT_ID) {
setError('GitHub OAuth is not configured (missing PUBLIC_GITHUB_CLIENT_ID)');
setStep('error');
return;
}
const redirectUri = `${window.location.origin}/github-callback`;
const url = `https://github.com/login/oauth/authorize?client_id=${GITHUB_CLIENT_ID}&redirect_uri=${encodeURIComponent(redirectUri)}&scope=repo`;
const w = 600, h = 700;
const left = window.screenX + (window.outerWidth - w) / 2;
const top = window.screenY + (window.outerHeight - h) / 2;
window.open(url, 'github-oauth', `width=${w},height=${h},left=${left},top=${top}`);
}
const handleCreatePR = useCallback(async () => {
if (!selectedRepo || !token) return;
setStep('creating');
try {
// Fetch per-component content on demand and assemble the PR file map.
const files = await buildFileMap(items);
if (Object.keys(files).length === 0) {
throw new Error('No component content found to include in PR');
}
const branchName = `claude-components/${collectionName.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`;
const timestamp = new Date().toISOString().slice(0, 10);
const pr = await createPR(
token,
selectedRepo.full_name,
files,
`${branchName}-${timestamp}`,
`Add Claude Code components: ${collectionName}`,
`Add Claude Code components: ${collectionName}`,
[
`## Claude Code Components`,
'',
`This PR adds **${items.length}** component${items.length !== 1 ? 's' : ''} from the "${collectionName}" collection.`,
'',
'### Files',
'',
...Object.keys(files).map((f) => `- \`${f}\``),
'',
'---',
`Sent from [AI Templates](https://app.aitmpl.com/my-components)`,
].join('\n')
);
setPrResult(pr);
setStep('done');
} catch (err: any) {
setError(err.message);
setStep('error');
}
}, [selectedRepo, token, items, collectionName]);
function handleBackdropClick(e: React.MouseEvent) {
if (e.target === backdropRef.current) onClose();
}
return (
<div
ref={backdropRef}
onClick={handleBackdropClick}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
>
<div className="w-full max-w-lg mx-4 bg-[var(--color-surface-1)] border border-[var(--color-border)] rounded-xl shadow-2xl overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-[var(--color-border)]">
<div className="flex items-center gap-2.5">
<svg className="w-5 h-5 text-[var(--color-text-primary)]" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z" />
</svg>
<h3 className="text-sm font-semibold text-white">Send to GitHub</h3>
</div>
<button
onClick={onClose}
className="p-1 rounded hover:bg-white/10 text-[var(--color-text-tertiary)] hover:text-white transition-colors"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Body */}
<div className="px-5 py-4">
{/* Step: Connect GitHub */}
{step === 'connect' && (
<div className="text-center py-6">
<p className="text-sm text-[var(--color-text-secondary)] mb-4">
Connect your GitHub account to create a PR with your components.
</p>
<button
onClick={startOAuth}
className="inline-flex items-center gap-2 px-5 py-2.5 bg-white text-black rounded-lg text-sm font-medium hover:bg-gray-100 transition-colors"
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z" />
</svg>
Connect GitHub
</button>
</div>
)}
{/* Step: Select repo */}
{step === 'select-repo' && (
<div>
<div className="mb-3">
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search repositories..."
className="w-full bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded-lg px-3 py-2 text-sm text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] outline-none focus:border-[var(--color-border-hover)] transition-colors"
autoFocus
/>
</div>
{loadingRepos ? (
<div className="flex items-center justify-center py-8">
<div className="w-5 h-5 border-2 border-[var(--color-text-tertiary)] border-t-transparent rounded-full animate-spin" />
</div>
) : (
<div className="max-h-72 overflow-y-auto space-y-0.5 -mx-1 px-1">
{filteredRepos.map((repo) => (
<button
key={repo.id}
onClick={() => setSelectedRepo(repo)}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-left transition-colors ${
selectedRepo?.id === repo.id
? 'bg-blue-500/15 border border-blue-500/30'
: 'hover:bg-white/[0.04] border border-transparent'
}`}
>
<svg className="w-4 h-4 shrink-0 text-[var(--color-text-tertiary)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="1.5">
<path strokeLinecap="round" strokeLinejoin="round" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
</svg>
<div className="min-w-0 flex-1">
<div className="text-[13px] text-white truncate">{repo.full_name}</div>
{repo.description && (
<div className="text-[11px] text-[var(--color-text-tertiary)] truncate mt-0.5">
{repo.description}
</div>
)}
</div>
{repo.private && (
<svg className="w-3.5 h-3.5 shrink-0 text-[var(--color-text-tertiary)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="1.5">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0110 0v4" />
</svg>
)}
</button>
))}
{filteredRepos.length === 0 && !loadingRepos && (
<div className="flex flex-col items-center justify-center py-12 px-4">
<div className="w-16 h-16 rounded-xl bg-[var(--color-surface-2)] border border-[var(--color-border)] flex items-center justify-center mb-4">
<svg className="w-8 h-8 text-[var(--color-text-tertiary)]" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" />
</svg>
</div>
<p className="text-[14px] font-medium text-[var(--color-text-primary)] mb-1">
{search ? 'No repositories found' : 'No repositories available'}
</p>
<p className="text-[13px] text-[var(--color-text-secondary)] text-center max-w-xs">
{search ? 'Try a different search term' : 'Connect your GitHub account to see your repositories'}
</p>
</div>
)}
</div>
)}
{/* Footer with create PR button */}
{selectedRepo && (
<div className="mt-4 pt-3 border-t border-[var(--color-border)] flex items-center justify-between">
<div className="text-[12px] text-[var(--color-text-tertiary)]">
{items.length} component{items.length !== 1 ? 's' : ''} <span className="text-[var(--color-text-primary)]">{selectedRepo.full_name}</span>
</div>
<button
onClick={handleCreatePR}
className="flex items-center gap-2 px-4 py-2 bg-green-600 hover:bg-green-500 text-white rounded-lg text-[13px] font-medium transition-colors"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
<circle cx="18" cy="18" r="3" />
<circle cx="6" cy="6" r="3" />
<path d="M13 6h3a2 2 0 012 2v7" />
<line x1="6" y1="9" x2="6" y2="21" />
</svg>
Create PR
</button>
</div>
)}
</div>
)}
{/* Step: Creating */}
{step === 'creating' && (
<div className="text-center py-8">
<div className="w-6 h-6 border-2 border-[var(--color-text-tertiary)] border-t-[var(--color-text-primary)] rounded-full animate-spin mx-auto mb-3" />
<p className="text-sm text-[var(--color-text-secondary)]">Creating pull request...</p>
<p className="text-[11px] text-[var(--color-text-tertiary)] mt-1">
Adding {items.length} component{items.length !== 1 ? 's' : ''} to {selectedRepo?.full_name}
</p>
</div>
)}
{/* Step: Done */}
{step === 'done' && prResult && (
<div className="text-center py-6">
<div className="w-10 h-10 rounded-full bg-green-500/15 flex items-center justify-center mx-auto mb-3">
<svg className="w-5 h-5 text-green-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2.5">
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
</div>
<p className="text-sm font-medium text-white mb-1">Pull Request Created!</p>
<p className="text-[12px] text-[var(--color-text-tertiary)] mb-4">
PR #{prResult.number} in {selectedRepo?.full_name}
</p>
<a
href={prResult.html_url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-5 py-2.5 bg-white text-black rounded-lg text-sm font-medium hover:bg-gray-100 transition-colors"
>
View on GitHub
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
</div>
)}
{/* Step: Error */}
{step === 'error' && (
<div className="text-center py-6">
<div className="w-10 h-10 rounded-full bg-red-500/15 flex items-center justify-center mx-auto mb-3">
<svg className="w-5 h-5 text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
<p className="text-sm font-medium text-white mb-1">Something went wrong</p>
<p className="text-[12px] text-[var(--color-text-tertiary)] mb-4 max-w-sm mx-auto">{error}</p>
<button
onClick={() => {
setError('');
setStep(token ? 'select-repo' : 'connect');
}}
className="px-4 py-2 bg-[var(--color-surface-3)] hover:bg-[var(--color-surface-4)] rounded-lg text-sm text-[var(--color-text-secondary)] hover:text-white transition-colors"
>
Try Again
</button>
</div>
)}
</div>
</div>
</div>
);
}
+369
View File
@@ -0,0 +1,369 @@
---
import { NAV_LINKS } from '../lib/constants';
import { TYPE_CONFIG, ICONS } from '../lib/icons';
import AuthButton from './AuthButton';
import MyComponentsSidebarItem from './MyComponentsSidebarItem';
const currentPath = Astro.url.pathname;
function isActive(path: string): boolean {
if (path === '/') return currentPath === '/';
return currentPath.startsWith(path);
}
---
<div class="flex flex-col h-full bg-[var(--color-surface-0)] relative">
<!-- Logo -->
<div class="px-4 py-4 border-b border-[var(--color-border)] flex items-center justify-between min-h-[65px] transition-all duration-300 bg-[var(--color-surface-0)]">
<a href="/" class="flex items-center gap-2.5 group min-w-0 overflow-hidden transition-all duration-300">
<span
class="w-8 h-8 shrink-0 rounded-md border border-[var(--color-accent)]/40 bg-[var(--color-accent)]/10 flex items-center justify-center transition-all duration-200 group-hover:scale-110 group-hover:border-[var(--color-accent)]"
aria-hidden="true"
>
<svg class="w-4 h-4 text-[var(--color-accent)]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="4 17 10 11 4 5" />
<line x1="12" y1="19" x2="20" y2="19" />
</svg>
</span>
<span class="sidebar-text text-[14px] font-bold text-[var(--color-text-primary)] tracking-tight whitespace-nowrap transition-all duration-300">AI Templates</span>
</a>
<button
id="sidebar-toggle"
class="hidden md:flex shrink-0 w-8 h-8 items-center justify-center rounded-lg hover:bg-[var(--color-surface-2)] transition-all duration-200 text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)] active:scale-95 hover:rotate-180"
aria-label="Toggle sidebar"
title="Collapse sidebar"
>
<svg class="w-4 h-4 transition-transform duration-300 ease-in-out" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M11 19l-7-7 7-7m8 14l-7-7 7-7" />
</svg>
</button>
<button
id="mobile-sidebar-close"
class="md:hidden shrink-0 w-8 h-8 flex items-center justify-center rounded-lg hover:bg-[var(--color-surface-2)] transition-all duration-200 text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)] active:scale-95"
onclick="window.toggleSidebar && window.toggleSidebar()"
aria-label="Close menu"
>
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Navigation -->
<nav class="flex-1 overflow-y-auto overflow-x-hidden px-3 py-3 space-y-1 scrollbar-thin scrollbar-thumb-[var(--color-surface-3)] scrollbar-track-transparent transition-all duration-300">
<!-- Workplace section -->
<div class="px-2 pt-1 pb-2 overflow-hidden">
<span class="sidebar-text font-ui-mono text-[10px] font-semibold text-[var(--color-text-tertiary)] uppercase tracking-[0.1em] select-none transition-all duration-300 block">Workplace</span>
</div>
<!-- My Components (React island) -->
<MyComponentsSidebarItem client:load isActive={isActive('/my-components')} />
<!-- Divider -->
<div class="my-4 mx-2 border-t border-[var(--color-border)] opacity-60"></div>
<!-- Browse section -->
<div class="px-2 pt-1 pb-2 overflow-hidden">
<span class="sidebar-text font-ui-mono text-[10px] font-semibold text-[var(--color-text-tertiary)] uppercase tracking-[0.1em] select-none transition-all duration-300 block">Browse</span>
</div>
{Object.entries(TYPE_CONFIG).map(([type, config]) => (
<a
href={`/${type}`}
data-nav-type={type}
data-tooltip={config.label}
class:list={[
'sidebar-nav-link flex items-center gap-3 px-3 py-2 rounded text-[13px] transition-all duration-150 group relative overflow-hidden font-ui-mono',
isActive(`/${type}`)
? 'bg-[var(--color-surface-2)] text-[var(--color-text-primary)] font-semibold border border-[var(--color-accent)]/40'
: 'text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-surface-2)] border border-transparent hover:border-[var(--color-border)]',
]}
>
{isActive(`/${type}`) && (
<span class="absolute left-0 top-1/2 -translate-y-1/2 w-1 h-6 bg-[var(--color-accent)] rounded-r-full"></span>
)}
<span
class="sidebar-nav-icon w-[18px] h-[18px] shrink-0 inline-flex items-center justify-center"
>
<span
class:list={[
'w-full h-full inline-flex items-center justify-center [&>svg]:w-full [&>svg]:h-full [&>svg]:flex-shrink-0 [&>svg]:block [&>svg]:m-auto transition-all duration-200',
isActive(`/${type}`)
? 'text-[var(--color-accent)] scale-110'
: 'text-[var(--color-text-tertiary)] group-hover:text-[var(--color-text-secondary)] group-hover:scale-105',
]}
set:html={ICONS[type]}
/>
</span>
<span class="sidebar-text truncate transition-all duration-300">{config.label}</span>
<span class="sidebar-nav-count font-ui-mono ml-auto text-[11px] font-semibold tabular-nums text-[var(--color-text-tertiary)] bg-[var(--color-surface-2)] border border-[var(--color-border)] px-2 py-0.5 rounded min-w-[24px] text-center transition-all duration-300 group-hover:bg-[var(--color-surface-3)]" data-count-type={type}></span>
</a>
))}
<!-- Plugins (external marketplaces) -->
<a
href="/plugins"
data-nav-type="plugins"
data-tooltip="Plugins"
class:list={[
'sidebar-nav-link flex items-center gap-3 px-3 py-2 rounded text-[13px] transition-all duration-150 group relative overflow-hidden font-ui-mono',
isActive('/plugins')
? 'bg-[var(--color-surface-2)] text-[var(--color-text-primary)] font-semibold border border-[var(--color-accent)]/40'
: 'text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-surface-2)] border border-transparent hover:border-[var(--color-border)]',
]}
>
{isActive('/plugins') && (
<span class="absolute left-0 top-1/2 -translate-y-1/2 w-1 h-6 bg-[var(--color-accent)] rounded-r-full"></span>
)}
<span class="sidebar-nav-icon w-[18px] h-[18px] shrink-0 inline-flex items-center justify-center">
<span
class:list={[
'w-full h-full inline-flex items-center justify-center [&>svg]:w-full [&>svg]:h-full [&>svg]:flex-shrink-0 [&>svg]:block [&>svg]:m-auto transition-all duration-200',
isActive('/plugins')
? 'text-[var(--color-accent)] scale-110'
: 'text-[var(--color-text-tertiary)] group-hover:text-[var(--color-text-secondary)] group-hover:scale-105',
]}
>
<svg class="w-full h-full" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M14.7 6.3a1 1 0 000 1.4l1.6 1.6a1 1 0 001.4 0l3.77-3.77a6 6 0 01-7.94 7.94l-6.91 6.91a2.12 2.12 0 01-3-3l6.91-6.91a6 6 0 017.94-7.94l-3.76 3.76z"/>
</svg>
</span>
</span>
<span class="sidebar-text truncate transition-all duration-300">Plugins</span>
<span class="sidebar-nav-count font-ui-mono ml-auto text-[11px] font-semibold tabular-nums text-[var(--color-text-tertiary)] bg-[var(--color-surface-2)] border border-[var(--color-border)] px-2 py-0.5 rounded min-w-[24px] text-center transition-all duration-300 group-hover:bg-[var(--color-surface-3)]" data-count-type="plugins"></span>
</a>
<!-- Divider -->
<div class="my-4 mx-2 border-t border-[var(--color-border)] opacity-60"></div>
<!-- Resources -->
<div class="px-2 pt-1 pb-2 overflow-hidden">
<span class="sidebar-text font-ui-mono text-[10px] font-semibold text-[var(--color-text-tertiary)] uppercase tracking-[0.1em] select-none transition-all duration-300 block">Resources</span>
</div>
<a href="/trending"
data-tooltip="Trending"
class:list={[
'flex items-center gap-3 px-3 py-2 rounded-lg text-[13px] transition-all duration-200 group overflow-hidden',
isActive('/trending')
? 'bg-[var(--color-surface-3)] text-[var(--color-text-primary)] font-medium shadow-sm'
: 'text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-surface-2)] hover:shadow-sm',
]}>
<span class:list={[
'w-[18px] h-[18px] shrink-0 inline-flex items-center justify-center transition-all duration-200',
isActive('/trending') ? 'text-[var(--color-text-primary)] scale-105' : 'text-[var(--color-text-tertiary)] group-hover:text-[var(--color-text-secondary)] group-hover:scale-105',
]}>
<svg class="w-full h-full flex-shrink-0 block m-auto" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12" />
</svg>
</span>
<span class="sidebar-text transition-all duration-300">Trending</span>
</a>
<a href="/jobs"
data-tooltip="Jobs"
class:list={[
'flex items-center gap-3 px-3 py-2 rounded-lg text-[13px] transition-all duration-200 group overflow-hidden',
isActive('/jobs')
? 'bg-[var(--color-surface-3)] text-[var(--color-text-primary)] font-medium shadow-sm'
: 'text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-surface-2)] hover:shadow-sm',
]}>
<span class:list={[
'w-[18px] h-[18px] shrink-0 inline-flex items-center justify-center transition-all duration-200',
isActive('/jobs') ? 'text-[var(--color-text-primary)] scale-105' : 'text-[var(--color-text-tertiary)] group-hover:text-[var(--color-text-secondary)] group-hover:scale-105',
]}>
<svg class="w-full h-full flex-shrink-0 block m-auto" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="7" width="20" height="14" rx="2" ry="2" /><path d="M16 7V5a2 2 0 00-2-2h-4a2 2 0 00-2 2v2" /><line x1="12" y1="12" x2="12" y2="12.01" />
</svg>
</span>
<span class="sidebar-text transition-all duration-300">Jobs</span>
<span class="sidebar-text ml-auto text-[9px] font-bold uppercase tracking-wider bg-orange-500/15 text-orange-400 px-1.5 py-0.5 rounded-md shadow-sm transition-all duration-300">New</span>
</a>
<a href={NAV_LINKS.blog} target="_blank" rel="noopener noreferrer"
data-tooltip="Blog"
class="flex items-center gap-3 px-3 py-2 rounded-lg text-[13px] text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-surface-2)] hover:shadow-sm transition-all duration-200 group overflow-hidden">
<span class="w-[18px] h-[18px] shrink-0 inline-flex items-center justify-center text-[var(--color-text-tertiary)] group-hover:text-[var(--color-text-secondary)] transition-all duration-200 group-hover:scale-105">
<svg class="w-full h-full flex-shrink-0 block m-auto" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 19.5v-15A2.5 2.5 0 016.5 2H20v20H6.5a2.5 2.5 0 010-5H20" />
</svg>
</span>
<span class="sidebar-text transition-all duration-300">Blog</span>
<svg class="sidebar-text w-3 h-3 ml-auto opacity-0 group-hover:opacity-50 transition-all duration-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" /></svg>
</a>
<a href={NAV_LINKS.docs} target="_blank" rel="noopener noreferrer"
data-tooltip="Docs"
class="flex items-center gap-3 px-3 py-2 rounded-lg text-[13px] text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-surface-2)] hover:shadow-sm transition-all duration-150 group">
<span class="w-[18px] h-[18px] shrink-0 inline-flex items-center justify-center text-[var(--color-text-tertiary)] group-hover:text-[var(--color-text-secondary)] transition-transform duration-150 group-hover:scale-105">
<svg class="w-full h-full flex-shrink-0 block m-auto" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" /><polyline points="14 2 14 8 20 8" /><line x1="16" y1="13" x2="8" y2="13" /><line x1="16" y1="17" x2="8" y2="17" /><polyline points="10 9 9 9 8 9" />
</svg>
</span>
<span class="sidebar-text transition-all duration-300">Docs</span>
<svg class="sidebar-text w-3 h-3 ml-auto opacity-0 group-hover:opacity-50 transition-opacity" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" /></svg>
</a>
<a href={NAV_LINKS.github} target="_blank" rel="noopener noreferrer"
data-tooltip="GitHub"
class="flex items-center gap-3 px-3 py-2 rounded-lg text-[13px] text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-surface-2)] hover:shadow-sm transition-all duration-200 group overflow-hidden">
<span class="w-[18px] h-[18px] shrink-0 inline-flex items-center justify-center text-[var(--color-text-tertiary)] group-hover:text-[var(--color-text-secondary)] transition-all duration-200 group-hover:scale-105">
<svg class="w-full h-full flex-shrink-0 block m-auto" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" />
</svg>
</span>
<span class="sidebar-text transition-all duration-300">GitHub</span>
<svg class="sidebar-text w-3 h-3 ml-auto opacity-0 group-hover:opacity-50 transition-all duration-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" /></svg>
</a>
</nav>
<!-- Bottom -->
<div class="px-3 py-3 border-t border-[var(--color-border)] space-y-2.5 bg-[var(--color-surface-0)] overflow-hidden">
<AuthButton client:idle />
</div>
</div>
<script>
function updateSidebarActive() {
const path = window.location.pathname.replace(/^\//, '').split('/')[0] || 'skills';
document.querySelectorAll('.sidebar-nav-link').forEach((link) => {
const type = (link as HTMLElement).dataset.navType;
const iconWrapper = link.querySelector('.sidebar-nav-icon');
const iconSpan = iconWrapper?.querySelector('span');
if (type === path) {
link.classList.remove('text-[var(--color-text-secondary)]', 'hover:text-[var(--color-text-primary)]', 'hover:bg-[var(--color-surface-2)]', 'hover:shadow-sm');
link.classList.add('bg-[var(--color-surface-3)]', 'text-[var(--color-text-primary)]', 'font-medium', 'shadow-sm');
iconSpan?.classList.remove('text-[var(--color-text-tertiary)]', 'group-hover:text-[var(--color-text-secondary)]', 'group-hover:scale-105');
iconSpan?.classList.add('text-[var(--color-accent)]', 'scale-110');
} else {
link.classList.remove('bg-[var(--color-surface-3)]', 'text-[var(--color-text-primary)]', 'font-medium', 'shadow-sm');
link.classList.add('text-[var(--color-text-secondary)]', 'hover:text-[var(--color-text-primary)]', 'hover:bg-[var(--color-surface-2)]', 'hover:shadow-sm');
iconSpan?.classList.remove('text-[var(--color-accent)]', 'scale-110');
iconSpan?.classList.add('text-[var(--color-text-tertiary)]', 'group-hover:text-[var(--color-text-secondary)]', 'group-hover:scale-105');
}
});
}
// Patch pushState to emit events for sidebar sync
const origPushState = history.pushState.bind(history);
history.pushState = function(...args: Parameters<typeof origPushState>) {
origPushState(...args);
updateSidebarActive();
};
window.addEventListener('popstate', updateSidebarActive);
// Load component counts directly — no dependency on page components
function applyCounts(counts: Record<string, number>) {
document.querySelectorAll('.sidebar-nav-count').forEach((el) => {
const type = (el as HTMLElement).dataset.countType;
if (type && counts[type]) {
el.textContent = String(counts[type]);
}
});
}
// Fetch counts on load (tiny counts.json, not the full index)
fetch('/counts.json')
.then((r) => r.json())
.then((counts: Record<string, number>) => {
applyCounts(counts);
})
.catch(() => {});
// Plugins isn't part of the component catalog (counts.json), it's a
// curated list of external marketplaces — count comes from plugins.json.
fetch('/plugins.json')
.then((r) => r.json())
.then((plugins: unknown[]) => {
applyCounts({ plugins: plugins.length });
})
.catch(() => {});
// Also listen for counts from React grid (may arrive with more accurate data)
window.addEventListener('component-counts', ((e: CustomEvent) => {
applyCounts(e.detail as Record<string, number>);
}) as EventListener);
// Custom tooltip functionality
let tooltipEl: HTMLElement | null = null;
let hideTimeout: number | null = null;
function createTooltip() {
if (tooltipEl) return tooltipEl;
tooltipEl = document.createElement('div');
tooltipEl.className = 'sidebar-tooltip';
tooltipEl.style.cssText = `
position: fixed;
background: var(--color-surface-3);
color: var(--color-text-primary);
padding: 6px 10px;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
pointer-events: none;
z-index: 9999;
opacity: 0;
transition: opacity 0.15s ease;
white-space: nowrap;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
border: 1px solid var(--color-border);
`;
document.body.appendChild(tooltipEl);
return tooltipEl;
}
function showTooltip(element: HTMLElement, text: string) {
const sidebar = document.getElementById('sidebar');
const isCollapsed = sidebar?.dataset.collapsed === 'true';
// Only show tooltip when sidebar is collapsed
if (!isCollapsed) return;
if (hideTimeout) {
clearTimeout(hideTimeout);
hideTimeout = null;
}
const tooltip = createTooltip();
tooltip.textContent = text;
const rect = element.getBoundingClientRect();
tooltip.style.left = `${rect.right + 12}px`;
tooltip.style.top = `${rect.top + rect.height / 2}px`;
tooltip.style.transform = 'translateY(-50%)';
// Force reflow
tooltip.offsetHeight;
tooltip.style.opacity = '1';
}
function hideTooltip() {
if (!tooltipEl) return;
hideTimeout = window.setTimeout(() => {
if (tooltipEl) {
tooltipEl.style.opacity = '0';
}
}, 100);
}
function initTooltips() {
document.querySelectorAll('[data-tooltip]').forEach((el) => {
const element = el as HTMLElement;
const text = element.dataset.tooltip || '';
// Check if already bound to prevent duplicate listeners
if (element.dataset.tooltipBound === 'true') return;
element.addEventListener('mouseenter', () => showTooltip(element, text));
element.addEventListener('mouseleave', hideTooltip);
element.dataset.tooltipBound = 'true';
});
}
// Initialize tooltips
initTooltips();
document.addEventListener('astro:page-load', initTooltips);
</script>
+628
View File
@@ -0,0 +1,628 @@
import { useState, useEffect, useRef, useMemo, useCallback } from 'react';
import { marked } from 'marked';
import SkillSlideView from './SkillSlideView';
interface Heading {
level: number;
text: string;
id: string;
}
interface SearchMatch {
type: 'heading' | 'text';
text: string;
id: string;
context?: string;
}
interface TreeNode {
name: string;
path: string;
isFile: boolean;
children: TreeNode[];
ext?: string;
}
interface SkillExplorerProps {
skillContent: string;
skillName: string;
skillPath: string;
references: string[];
headings: Heading[];
}
const GITHUB_RAW_BASE = 'https://raw.githubusercontent.com/davila7/claude-code-templates/main/cli-tool/components/skills';
const EXT_COLORS: Record<string, string> = {
md: '#60a5fa', js: '#facc15', ts: '#60a5fa', tsx: '#60a5fa', jsx: '#facc15',
py: '#4ade80', html: '#fb923c', json: '#fde047', txt: 'var(--color-text-tertiary)',
sh: '#86efac', yml: '#f472b6', yaml: '#f472b6', css: '#c084fc', toml: '#f472b6',
};
// ─── Markdown rendering ────────────────────────────────────────────
function renderMarkdown(content: string): string {
const stripped = content.replace(/^---\s*\n[\s\S]*?\n---\s*\n?/, '');
marked.setOptions({ gfm: true, breaks: false });
const renderer = new marked.Renderer();
renderer.heading = ({ text, depth }: { text: string; depth: number }) => {
const id = text.toLowerCase().replace(/[^\w]+/g, '-').replace(/^-|-$/g, '');
return `<h${depth} id="${id}" class="md-heading">${text}</h${depth}>`;
};
renderer.code = ({ text, lang }: { text: string; lang?: string }) => {
return `<div class="md-code-block"><div class="md-code-lang">${lang || ''}</div><pre><code class="language-${lang || ''}">${text.replace(/</g, '&lt;').replace(/>/g, '&gt;')}</code></pre></div>`;
};
return marked.parse(stripped, { renderer }) as string;
}
function extractHeadings(content: string): Heading[] {
const stripped = content.replace(/^---\s*\n[\s\S]*?\n---\s*\n?/, '');
const headings: Heading[] = [];
const regex = /^(#{1,4})\s+(.+)$/gm;
let m;
while ((m = regex.exec(stripped)) !== null) {
const text = m[2].replace(/[*_`\[\]]/g, '').trim();
const id = text.toLowerCase().replace(/[^\w]+/g, '-').replace(/^-|-$/g, '');
headings.push({ level: m[1].length, text, id });
}
return headings;
}
function buildSearchIndex(content: string, headings: Heading[]): { text: string; headingId: string }[] {
const stripped = content.replace(/^---\s*\n[\s\S]*?\n---\s*\n?/, '');
const lines = stripped.split('\n');
const entries: { text: string; headingId: string }[] = [];
let currentHeadingId = headings[0]?.id ?? '';
let paragraph = '';
for (const line of lines) {
const headingMatch = line.match(/^(#{1,4})\s+(.+)$/);
if (headingMatch) {
if (paragraph.trim()) { entries.push({ text: paragraph.trim(), headingId: currentHeadingId }); paragraph = ''; }
const text = headingMatch[2].replace(/[*_`\[\]]/g, '').trim();
currentHeadingId = text.toLowerCase().replace(/[^\w]+/g, '-').replace(/^-|-$/g, '');
continue;
}
if (line.trim() === '') {
if (paragraph.trim()) { entries.push({ text: paragraph.trim(), headingId: currentHeadingId }); paragraph = ''; }
} else {
paragraph += (paragraph ? ' ' : '') + line.trim();
}
}
if (paragraph.trim()) entries.push({ text: paragraph.trim(), headingId: currentHeadingId });
return entries;
}
// ─── Tree building ─────────────────────────────────────────────────
function buildTree(paths: string[], skillName: string): TreeNode[] {
const root: TreeNode[] = [];
root.push({ name: 'SKILL.md', path: 'SKILL.md', isFile: true, children: [], ext: 'md' });
for (const p of paths) {
const parts = p.split('/');
let current = root;
for (let i = 0; i < parts.length; i++) {
const name = parts[i];
const isFile = i === parts.length - 1;
const fullPath = parts.slice(0, i + 1).join('/');
let existing = current.find((n) => n.name === name && n.isFile === isFile);
if (!existing) {
const ext = isFile ? name.split('.').pop()?.toLowerCase() : undefined;
existing = { name, path: fullPath, isFile, children: [], ext };
current.push(existing);
}
current = existing.children;
}
}
function sortNodes(nodes: TreeNode[]) {
nodes.sort((a, b) => {
if (a.name === 'SKILL.md') return -1;
if (b.name === 'SKILL.md') return 1;
if (a.isFile !== b.isFile) return a.isFile ? 1 : -1;
return a.name.localeCompare(b.name);
});
for (const n of nodes) if (!n.isFile) sortNodes(n.children);
}
sortNodes(root);
return root;
}
function countFiles(nodes: TreeNode[]): number {
let c = 0;
for (const n of nodes) { if (n.isFile) c++; else c += countFiles(n.children); }
return c;
}
// ─── Main Component ────────────────────────────────────────────────
export default function SkillExplorer({ skillContent, skillName, skillPath, references, headings: initialHeadings }: SkillExplorerProps) {
const [selectedFile, setSelectedFile] = useState('SKILL.md');
const [fileContent, setFileContent] = useState(skillContent);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [mode, setMode] = useState<'code' | 'preview' | 'slides'>('preview');
const [expanded, setExpanded] = useState(false);
const [activeHeading, setActiveHeading] = useState('');
const [searchQuery, setSearchQuery] = useState('');
const [searchOpen, setSearchOpen] = useState(false);
const [selectedIdx, setSelectedIdx] = useState(0);
const [highlightCount, setHighlightCount] = useState(0);
const [treeOpen, setTreeOpen] = useState(false);
const [treeFilter, setTreeFilter] = useState('');
const previewRef = useRef<HTMLDivElement>(null);
const codeRef = useRef<HTMLDivElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
const isMarkdown = selectedFile.endsWith('.md');
const html = useMemo(() => isMarkdown ? renderMarkdown(fileContent) : '', [fileContent, isMarkdown]);
const currentHeadings = useMemo(() => {
if (selectedFile === 'SKILL.md') return initialHeadings;
if (isMarkdown) return extractHeadings(fileContent);
return [];
}, [selectedFile, fileContent, isMarkdown, initialHeadings]);
const searchIndex = useMemo(() => buildSearchIndex(fileContent, currentHeadings), [fileContent, currentHeadings]);
const tree = useMemo(() => buildTree(references, skillName), [references, skillName]);
const filteredTree = useMemo(() => {
if (!treeFilter.trim()) return tree;
const q = treeFilter.toLowerCase();
const filtered = references.filter((r) => r.toLowerCase().includes(q));
const t = buildTree(filtered, skillName);
if (!('SKILL.md').toLowerCase().includes(q)) {
const idx = t.findIndex((n) => n.name === 'SKILL.md');
if (idx >= 0) t.splice(idx, 1);
}
return t;
}, [treeFilter, references, tree, skillName]);
// Fetch file content
useEffect(() => {
if (selectedFile === 'SKILL.md') { setFileContent(skillContent); setError(''); return; }
setLoading(true); setError('');
fetch(`${GITHUB_RAW_BASE}/${skillPath}/${selectedFile}`)
.then((res) => { if (!res.ok) throw new Error(`HTTP ${res.status}`); return res.text(); })
.then((text) => setFileContent(text))
.catch((e) => { setError(`Failed to load file: ${e.message}`); setFileContent(''); })
.finally(() => setLoading(false));
}, [selectedFile, skillPath, skillContent]);
// Search results
const searchResults = useMemo((): SearchMatch[] => {
if (!searchQuery.trim() || searchQuery.length < 2) return [];
const q = searchQuery.toLowerCase();
const results: SearchMatch[] = [];
const seen = new Set<string>();
for (const h of currentHeadings) {
if (h.text.toLowerCase().includes(q)) { results.push({ type: 'heading', text: h.text, id: h.id }); seen.add(h.id); }
}
for (const entry of searchIndex) {
if (entry.text.toLowerCase().includes(q) && !seen.has(entry.headingId)) {
const idx = entry.text.toLowerCase().indexOf(q);
const start = Math.max(0, idx - 40);
const end = Math.min(entry.text.length, idx + q.length + 40);
const context = (start > 0 ? '...' : '') + entry.text.slice(start, end) + (end < entry.text.length ? '...' : '');
results.push({ type: 'text', text: entry.text.slice(idx, idx + q.length), id: entry.headingId, context });
seen.add(entry.headingId);
}
}
return results.slice(0, 15);
}, [searchQuery, currentHeadings, searchIndex]);
// Highlight matches
useEffect(() => {
if (!previewRef.current || mode !== 'preview') return;
previewRef.current.querySelectorAll('mark.md-search-hl').forEach((m) => {
const p = m.parentNode; if (p) { p.replaceChild(document.createTextNode(m.textContent || ''), m); p.normalize(); }
});
if (!searchQuery.trim() || searchQuery.length < 2) { setHighlightCount(0); return; }
const walker = document.createTreeWalker(previewRef.current, NodeFilter.SHOW_TEXT);
const q = searchQuery.toLowerCase();
const nodesToProcess: { node: Text; indices: number[] }[] = [];
let node: Text | null;
while ((node = walker.nextNode() as Text | null)) {
const text = (node.textContent || '').toLowerCase();
const indices: number[] = []; let pos = 0;
while ((pos = text.indexOf(q, pos)) !== -1) { indices.push(pos); pos += q.length; }
if (indices.length) nodesToProcess.push({ node, indices });
}
let count = 0;
for (const { node: textNode, indices } of nodesToProcess) {
const text = textNode.textContent || '';
const parent = textNode.parentNode;
if (!parent || parent.nodeName === 'CODE' || parent.nodeName === 'PRE') continue;
const frag = document.createDocumentFragment();
let lastIdx = 0;
for (const idx of indices) {
if (idx > lastIdx) frag.appendChild(document.createTextNode(text.slice(lastIdx, idx)));
const mark = document.createElement('mark'); mark.className = 'md-search-hl';
mark.textContent = text.slice(idx, idx + q.length); frag.appendChild(mark);
lastIdx = idx + q.length; count++;
}
if (lastIdx < text.length) frag.appendChild(document.createTextNode(text.slice(lastIdx)));
parent.replaceChild(frag, textNode);
}
setHighlightCount(count);
}, [searchQuery, mode, html]);
// Heading observer
useEffect(() => {
if (mode !== 'preview' || !previewRef.current) return;
const observer = new IntersectionObserver(
(entries) => { for (const e of entries) if (e.isIntersecting) setActiveHeading(e.target.id); },
{ rootMargin: '-80px 0px -70% 0px', threshold: 0 }
);
previewRef.current.querySelectorAll('.md-heading').forEach((el) => observer.observe(el));
return () => observer.disconnect();
}, [mode, html]);
// Keyboard shortcuts
useEffect(() => {
const h = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'f' && (previewRef.current || codeRef.current)) {
e.preventDefault(); setSearchOpen(true);
setTimeout(() => searchInputRef.current?.focus(), 50);
}
if (e.key === 'Escape' && searchOpen) { setSearchOpen(false); setSearchQuery(''); }
};
document.addEventListener('keydown', h);
return () => document.removeEventListener('keydown', h);
}, [searchOpen]);
useEffect(() => { setSelectedIdx(0); }, [searchResults]);
const handleCopy = () => navigator.clipboard.writeText(fileContent);
const scrollToHeading = useCallback((id: string) => {
if (!expanded) setExpanded(true);
setTimeout(() => {
const container = previewRef.current || codeRef.current;
container?.querySelector(`#${CSS.escape(id)}`)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
setActiveHeading(id);
}, expanded ? 0 : 100);
}, [expanded]);
const navigateToResult = useCallback((result: SearchMatch) => {
if (mode !== 'preview') setMode('preview');
scrollToHeading(result.id);
setSearchOpen(false);
}, [mode, scrollToHeading]);
const handleSearchKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'ArrowDown') { e.preventDefault(); setSelectedIdx((i) => Math.min(i + 1, searchResults.length - 1)); }
else if (e.key === 'ArrowUp') { e.preventDefault(); setSelectedIdx((i) => Math.max(i - 1, 0)); }
else if (e.key === 'Enter' && searchResults[selectedIdx]) { e.preventDefault(); navigateToResult(searchResults[selectedIdx]); }
};
const handleFileClick = (path: string) => {
setSelectedFile(path);
if (mode === 'slides') setMode('preview');
setSearchOpen(false);
setSearchQuery('');
setExpanded(false);
};
const fileTreePanel = treeOpen ? (
<div className="w-56 shrink-0 bg-[var(--color-surface-2)] border border-r-0 border-[var(--color-border)] rounded-l-lg overflow-hidden flex flex-col animate-slide-in-left">
<div className="px-3 py-2.5 border-b border-[var(--color-border)] flex items-center gap-2">
<button
onClick={() => setTreeOpen(false)}
className="text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-surface-3)] p-1 rounded-md shrink-0 transition-all duration-150 active:scale-95"
title="Close file tree"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
</button>
<span className="text-[11px] font-bold text-[var(--color-text-primary)] truncate flex-1">{skillName}</span>
<span className="text-[10px] font-semibold text-[var(--color-text-tertiary)] bg-[var(--color-surface-3)] px-1.5 py-0.5 rounded-md shrink-0">{references.length + 1}</span>
</div>
<div className="px-2.5 py-2 border-b border-[var(--color-border)]">
<div className="relative">
<svg className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-[var(--color-text-tertiary)] pointer-events-none" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<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={treeFilter} onChange={(e) => setTreeFilter(e.target.value)}
placeholder="Filter files..." className="w-full bg-[var(--color-surface-3)] border border-[var(--color-border)] rounded-lg pl-8 pr-2.5 py-1.5 text-[11px] text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] focus:outline-none focus:border-[var(--color-primary-500)]/40 focus:ring-1 focus:ring-[var(--color-primary-500)]/20 transition-all" />
</div>
</div>
<div className="flex-1 overflow-y-auto py-1" style={{ scrollbarWidth: 'thin' }}>
<TreeNodes nodes={filteredTree} depth={0} forceOpen={!!treeFilter} selectedFile={selectedFile} onSelect={handleFileClick} />
</div>
</div>
) : (
<button
onClick={() => setTreeOpen(true)}
className="shrink-0 w-10 bg-[var(--color-surface-2)] border border-r-0 border-[var(--color-border)] rounded-l-lg flex flex-col items-center justify-start pt-4 gap-2.5 hover:bg-[var(--color-surface-3)] hover:w-11 hover:shadow-sm transition-all duration-200 group active:scale-95"
title={`Browse files (${references.length + 1})`}
>
<svg className="w-5 h-5 text-[var(--color-text-tertiary)] group-hover:text-[var(--color-text-secondary)] transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 12.75V12A2.25 2.25 0 014.5 9.75h15A2.25 2.25 0 0121.75 12v.75m-8.69-6.44l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-5.379a1.5 1.5 0 01-1.06-.44z" />
</svg>
<span className="text-[10px] font-semibold text-[var(--color-text-tertiary)] group-hover:text-[var(--color-text-secondary)] transition-colors" style={{ writingMode: 'vertical-lr' }}>Files</span>
</button>
);
return (
<div className="flex-1 min-w-0">
{/* File indicator when viewing non-SKILL file */}
{selectedFile !== 'SKILL.md' && (
<div className="flex items-center gap-2 mb-2 text-[12px] text-[var(--color-text-secondary)]">
<svg className="w-3.5 h-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke={EXT_COLORS[selectedFile.split('.').pop() || ''] || 'var(--color-text-tertiary)'} strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
</svg>
<span className="font-mono">{selectedFile}</span>
<button onClick={() => handleFileClick('SKILL.md')} className="text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)] ml-1">
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
)}
{/* Toolbar — identical to MarkdownViewer */}
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
{isMarkdown && (
<div className="flex items-center gap-1 bg-[var(--color-surface-3)] rounded-lg p-0.5">
<button onClick={() => setMode('code')}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${mode === 'code' ? 'bg-[var(--color-surface-2)] text-[var(--color-text-primary)] shadow-sm' : 'text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)]'}`}>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M17.25 6.75L22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3l-4.5 16.5" />
</svg>
Code
</button>
<button onClick={() => setMode('preview')}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${mode === 'preview' ? 'bg-[var(--color-surface-2)] text-[var(--color-text-primary)] shadow-sm' : 'text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)]'}`}>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
Preview
</button>
{selectedFile === 'SKILL.md' && (
<button onClick={() => setMode('slides')}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${mode === 'slides' ? 'bg-surface-2 text-text-primary shadow-sm' : 'text-text-tertiary hover:text-text-secondary'}`}>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 3v11.25A2.25 2.25 0 006 16.5h2.25M3.75 3h-1.5m1.5 0h16.5m0 0h1.5m-1.5 0v11.25A2.25 2.25 0 0118 16.5h-2.25m-7.5 0h7.5m-7.5 0l-1 3m8.5-3l1 3m0 0l.5 1.5m-.5-1.5h-9.5m0 0l-.5 1.5" />
</svg>
Slides
</button>
)}
</div>
)}
{isMarkdown && (
<button onClick={() => { setSearchOpen(!searchOpen); if (!searchOpen) setTimeout(() => searchInputRef.current?.focus(), 50); }}
className={`flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs transition-colors ${searchOpen ? 'bg-accent-500/15 text-accent-400' : 'text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)] hover:bg-white/[0.04]'}`}
title="Search in document (Cmd+F)">
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<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>
<span className="hidden sm:inline">Search</span>
<kbd className="hidden sm:inline text-[9px] px-1 py-0.5 rounded bg-white/[0.06] text-[var(--color-text-tertiary)] font-mono ml-1">
{typeof navigator !== 'undefined' && navigator.platform?.includes('Mac') ? '\u2318' : 'Ctrl'}F
</kbd>
</button>
)}
</div>
<button onClick={handleCopy}
className="text-[11px] px-2.5 py-1 rounded-md bg-white/[0.06] hover:bg-white/[0.12] text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)] transition-colors">
Copy
</button>
</div>
{/* Search bar */}
{searchOpen && isMarkdown && mode !== 'slides' && (
<div className="mb-3 relative">
<div className="relative">
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-[var(--color-text-tertiary)] pointer-events-none" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<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 ref={searchInputRef} type="text" value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={handleSearchKeyDown}
placeholder="Search sections and content..."
className="w-full bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded-lg pl-9 pr-20 py-2 text-sm text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] focus:outline-none focus:border-accent-400/50 focus:ring-1 focus:ring-accent-400/20 font-sans" autoFocus />
<div className="absolute right-3 top-1/2 -translate-y-1/2 flex items-center gap-2">
{searchQuery.length >= 2 && (
<span className="text-[10px] text-[var(--color-text-tertiary)]">
{highlightCount > 0 ? `${highlightCount} match${highlightCount !== 1 ? 'es' : ''}` : 'No matches'}
</span>
)}
<button onClick={() => { setSearchOpen(false); setSearchQuery(''); }} className="text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)]">
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
{searchResults.length > 0 && (
<div className="absolute z-20 top-full left-0 right-0 mt-1 bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded-lg shadow-xl overflow-hidden max-h-72 overflow-y-auto">
{searchResults.map((result, i) => (
<button key={`${result.id}-${i}`} onClick={() => navigateToResult(result)} onMouseEnter={() => setSelectedIdx(i)}
className={`w-full text-left px-3 py-2.5 flex items-start gap-2.5 transition-colors ${i === selectedIdx ? 'bg-white/[0.06]' : 'hover:bg-white/[0.03]'}`}>
{result.type === 'heading' ? (
<svg className="w-3.5 h-3.5 mt-0.5 shrink-0 text-accent-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5.25 7.5h13.5m-13.5 4.5h7.5m-7.5 4.5h13.5" />
</svg>
) : (
<svg className="w-3.5 h-3.5 mt-0.5 shrink-0 text-[var(--color-text-tertiary)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
</svg>
)}
<div className="flex-1 min-w-0">
{result.type === 'heading' ? (
<HighlightText text={result.text} query={searchQuery} className="text-sm text-[var(--color-text-primary)] font-medium" />
) : (
<>
<div className="text-[11px] text-[var(--color-text-tertiary)] mb-0.5">{currentHeadings.find((h) => h.id === result.id)?.text ?? ''}</div>
<HighlightText text={result.context || ''} query={searchQuery} className="text-xs text-[var(--color-text-secondary)] leading-relaxed" />
</>
)}
</div>
{i === selectedIdx && <kbd className="text-[9px] px-1 py-0.5 rounded bg-white/[0.06] text-[var(--color-text-tertiary)] font-mono self-center shrink-0">Enter</kbd>}
</button>
))}
</div>
)}
</div>
)}
{/* Slides mode */}
{mode === 'slides' && selectedFile === 'SKILL.md' ? (
<SkillSlideView content={skillContent} skillName={skillName} />
) : (
<>
{/* Content + TOC — identical layout to MarkdownViewer */}
<div className="flex gap-0 items-stretch">
{/* File tree panel */}
{fileTreePanel}
<div className="flex-1 min-w-0 flex gap-4">
<div className="flex-1 min-w-0 relative">
{loading && (
<div className="absolute inset-0 bg-[var(--color-surface-2)]/80 z-10 flex items-center justify-center rounded-r-lg">
<div className="flex items-center gap-2 text-sm text-[var(--color-text-secondary)]">
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24"><circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" /><path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" /></svg>
Loading...
</div>
</div>
)}
{error ? (
<div className="bg-[var(--color-surface-2)] border border-l-0 border-[var(--color-border)] rounded-lg rounded-l-none p-6 text-sm text-red-400">{error}</div>
) : isMarkdown && mode === 'preview' ? (
<div ref={previewRef}
className={`md-preview bg-[var(--color-surface-2)] border border-l-0 border-[var(--color-border)] rounded-lg rounded-l-none p-6 overflow-hidden transition-[max-height] duration-300 ${expanded ? '' : 'max-h-[32rem]'}`}
dangerouslySetInnerHTML={{ __html: html }} />
) : (
<div ref={codeRef}
className={`bg-[var(--color-surface-2)] border border-l-0 border-[var(--color-border)] rounded-lg rounded-l-none p-6 text-sm text-[var(--color-text-primary)] leading-relaxed whitespace-pre-wrap font-mono overflow-hidden transition-[max-height] duration-300 ${expanded ? '' : 'max-h-[32rem]'}`}>
{fileContent || 'No content available'}
</div>
)}
{/* Gradient fade + expand */}
{!expanded && (
<div className="absolute bottom-0 left-0 right-0 rounded-br-lg overflow-hidden">
<div className="h-24 bg-gradient-to-t from-[var(--color-surface-2)] to-transparent pointer-events-none" />
<div className="bg-[var(--color-surface-2)] px-4 pb-3 flex justify-center border-r border-b border-[var(--color-border)] rounded-br-lg -mt-px">
<button onClick={() => setExpanded(true)}
className="text-[13px] text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] flex items-center gap-1.5 px-3 py-1.5 rounded-md hover:bg-[var(--color-surface-3)] transition-colors">
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
Show full {mode === 'preview' ? 'document' : 'source'}
</button>
</div>
</div>
)}
{expanded && (
<div className="flex justify-center py-2 bg-[var(--color-surface-2)] border border-l-0 border-t-0 border-[var(--color-border)] rounded-br-lg">
<button onClick={() => setExpanded(false)}
className="text-[13px] text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] flex items-center gap-1.5 px-3 py-1.5 rounded-md hover:bg-[var(--color-surface-3)] transition-colors">
<svg className="w-3.5 h-3.5 rotate-180" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
Collapse
</button>
</div>
)}
</div>
{/* TOC sidebar — identical to MarkdownViewer */}
{isMarkdown && currentHeadings.length >= 1 && mode === 'preview' && (
<nav className="hidden lg:block w-52 shrink-0">
<div className="sticky top-24">
<h4 className="text-[10px] font-semibold text-[var(--color-text-tertiary)] uppercase tracking-wider mb-2">On this page</h4>
<ul className="space-y-0.5 text-[12px] border-l border-[var(--color-border)] max-h-[calc(100vh-10rem)] overflow-y-auto">
{currentHeadings.map((h) => (
<li key={h.id}>
<button onClick={() => scrollToHeading(h.id)}
className={`block w-full text-left py-1 transition-colors border-l-2 -ml-px ${
h.level === 1 ? 'pl-3' : h.level === 2 ? 'pl-5' : 'pl-7'
} ${activeHeading === h.id ? 'border-accent-400 text-accent-400' : 'border-transparent text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)] hover:border-[var(--color-border)]'}`}
title={h.text}>
<span className="line-clamp-1">{h.text}</span>
</button>
</li>
))}
</ul>
</div>
</nav>
)}
</div>
</div>
</>
)}
</div>
);
}
// ─── Tree components ───────────────────────────────────────────────
function TreeNodes({ nodes, depth, forceOpen, selectedFile, onSelect }: { nodes: TreeNode[]; depth: number; forceOpen?: boolean; selectedFile: string; onSelect: (path: string) => void }) {
return (
<>
{nodes.map((node) =>
node.isFile ? (
<FileNode key={node.path} node={node} depth={depth} selected={selectedFile === node.path} onSelect={onSelect} />
) : (
<FolderNode key={node.path} node={node} depth={depth} forceOpen={forceOpen} selectedFile={selectedFile} onSelect={onSelect} />
)
)}
</>
);
}
function FolderNode({ node, depth, forceOpen, selectedFile, onSelect }: { node: TreeNode; depth: number; forceOpen?: boolean; selectedFile: string; onSelect: (path: string) => void }) {
const [open, setOpen] = useState(depth < 1);
const isOpen = forceOpen || open;
const fc = useMemo(() => countFiles(node.children), [node.children]);
return (
<div>
<button onClick={() => setOpen(!open)}
className="w-full flex items-center gap-1 py-[3px] pr-2 hover:bg-white/[0.04] transition-colors group/f"
style={{ paddingLeft: `${depth * 10 + 6}px` }}>
<svg className={`w-2.5 h-2.5 text-[var(--color-text-tertiary)] shrink-0 transition-transform duration-100 ${isOpen ? 'rotate-90' : ''}`}
fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
<svg className="w-3.5 h-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke={isOpen ? '#facc15' : 'var(--color-text-tertiary)'} strokeWidth={1.5}>
{isOpen
? <path strokeLinecap="round" strokeLinejoin="round" d="M3.75 9.776c.112-.017.227-.026.344-.026h15.812c.117 0 .232.009.344.026m-16.5 0a2.25 2.25 0 00-1.883 2.542l.857 6a2.25 2.25 0 002.227 1.932H19.05a2.25 2.25 0 002.227-1.932l.857-6a2.25 2.25 0 00-1.883-2.542m-16.5 0V6A2.25 2.25 0 016 3.75h3.879a1.5 1.5 0 011.06.44l2.122 2.12a1.5 1.5 0 001.06.44H18A2.25 2.25 0 0120.25 9v.776" />
: <path strokeLinecap="round" strokeLinejoin="round" d="M2.25 12.75V12A2.25 2.25 0 014.5 9.75h15A2.25 2.25 0 0121.75 12v.75m-8.69-6.44l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-5.379a1.5 1.5 0 01-1.06-.44z" />}
</svg>
<span className="text-[11px] text-[var(--color-text-secondary)] truncate">{node.name}</span>
<span className="text-[8px] text-[var(--color-text-tertiary)] ml-auto opacity-0 group-hover/f:opacity-100">{fc}</span>
</button>
{isOpen && <TreeNodes nodes={node.children} depth={depth + 1} forceOpen={forceOpen} selectedFile={selectedFile} onSelect={onSelect} />}
</div>
);
}
function FileNode({ node, depth, selected, onSelect }: { node: TreeNode; depth: number; selected: boolean; onSelect: (path: string) => void }) {
const color = EXT_COLORS[node.ext ?? ''] ?? 'var(--color-text-tertiary)';
return (
<button onClick={() => onSelect(node.path)}
className={`w-full flex items-center gap-1.5 py-[3px] pr-2 transition-colors ${selected ? 'bg-accent-400/10 text-accent-300' : 'hover:bg-white/[0.04] text-[var(--color-text-primary)]'}`}
style={{ paddingLeft: `${depth * 10 + 16}px` }} title={node.path}>
<svg className="w-3.5 h-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke={selected ? 'currentColor' : color} strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
</svg>
<span className="text-[11px] truncate font-mono">{node.name}</span>
</button>
);
}
function HighlightText({ text, query, className }: { text: string; query: string; className?: string }) {
if (!query.trim()) return <span className={className}>{text}</span>;
const parts: { text: string; match: boolean }[] = [];
const lower = text.toLowerCase();
const q = query.toLowerCase();
let lastIdx = 0, pos = 0;
while ((pos = lower.indexOf(q, lastIdx)) !== -1) {
if (pos > lastIdx) parts.push({ text: text.slice(lastIdx, pos), match: false });
parts.push({ text: text.slice(pos, pos + q.length), match: true });
lastIdx = pos + q.length;
}
if (lastIdx < text.length) parts.push({ text: text.slice(lastIdx), match: false });
return (
<span className={className}>
{parts.map((p, i) => p.match ? <mark key={i} className="bg-accent-400/25 text-accent-300 rounded-sm px-px">{p.text}</mark> : <span key={i}>{p.text}</span>)}
</span>
);
}
+492
View File
@@ -0,0 +1,492 @@
import { useState, useEffect, useRef, useMemo, useCallback } from 'react';
import { marked } from 'marked';
// Lightweight token-count approximation: ~4 characters per token for English prose.
// Avoids shipping the heavy gpt-tokenizer BPE table to the browser.
function estimateTokens(text: string): number {
return Math.ceil(text.length / 4);
}
interface Slide {
type: 'title' | 'content';
title: string;
body: string;
html: string;
metadata?: Record<string, string>;
}
interface SkillSlideViewProps {
content: string;
skillName: string;
}
// ─── YAML frontmatter parsing ─────────────────────────────────────
function parseFrontmatter(content: string): { meta: Record<string, string>; body: string } {
const match = content.match(/^---\s*\n([\s\S]*?)\n---\s*\n?([\s\S]*)$/);
if (!match) return { meta: {}, body: content };
const meta: Record<string, string> = {};
let currentKey = '';
let currentValue = '';
for (const line of match[1].split('\n')) {
const kv = line.match(/^(\S[\w-]*)\s*:\s*(.*)/);
if (kv) {
if (currentKey) meta[currentKey] = currentValue.trim();
currentKey = kv[1];
currentValue = kv[2].replace(/^["']|["']$/g, '');
} else if (currentKey && (line.startsWith(' ') || line.startsWith('\t'))) {
currentValue += ' ' + line.trim();
}
}
if (currentKey) meta[currentKey] = currentValue.trim();
return { meta, body: match[2] || '' };
}
// ─── Markdown rendering ───────────────────────────────────────────
function renderSlideMarkdown(md: string): string {
marked.setOptions({ gfm: true, breaks: false });
const renderer = new marked.Renderer();
renderer.heading = ({ text, depth }: { text: string; depth: number }) => {
const tag = `h${Math.min(depth + 1, 6)}`;
return `<${tag} class="slide-heading">${text}</${tag}>`;
};
renderer.code = ({ text, lang }: { text: string; lang?: string }) => {
return `<div class="slide-code-block"><div class="slide-code-lang">${lang || ''}</div><pre><code class="language-${lang || ''}">${text.replace(/</g, '&lt;').replace(/>/g, '&gt;')}</code></pre></div>`;
};
return marked.parse(md, { renderer }) as string;
}
// ─── Slide building ───────────────────────────────────────────────
function buildSlides(content: string, skillName: string): Slide[] {
const { meta, body } = parseFrontmatter(content);
const slides: Slide[] = [];
// Title slide
const titleName = meta.name || skillName.replace(/[-_]/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
// Get the first H1 for subtitle if available
const h1Match = body.match(/^#\s+(.+)$/m);
const subtitle = h1Match ? h1Match[1] : '';
// Build description - truncate if too long
let desc = meta.description || '';
if (desc.length > 300) {
desc = desc.slice(0, 300).replace(/\s+\S*$/, '') + '...';
}
// Remove <example> blocks from description for cleaner display
desc = desc.replace(/<example>[\s\S]*?<\/example>/g, '').trim();
const metaFields: Record<string, string> = {};
if (meta.version) metaFields['Version'] = meta.version;
if (meta.tools) metaFields['Tools'] = meta.tools;
if (meta['allowed-tools']) metaFields['Tools'] = meta['allowed-tools'];
if (meta.model) metaFields['Model'] = meta.model;
if (meta.color) metaFields['Color'] = meta.color;
if (meta.author) metaFields['Author'] = meta.author;
if (meta.license) metaFields['License'] = meta.license;
slides.push({
type: 'title',
title: titleName,
body: subtitle ? `${subtitle}\n\n${desc}` : desc,
html: '',
metadata: metaFields,
});
// Split body by H2 sections
const bodyWithoutH1 = body.replace(/^#\s+.+$/m, '').trim();
const sections = bodyWithoutH1.split(/(?=^##\s)/m).filter((s) => s.trim());
for (const section of sections) {
const headingMatch = section.match(/^##\s+(.+)$/m);
if (!headingMatch) {
// Content before first H2 — add as intro slide if substantial
if (section.trim().length > 50) {
slides.push({
type: 'content',
title: 'Introduction',
body: section.trim(),
html: renderSlideMarkdown(section.trim()),
});
}
continue;
}
const title = headingMatch[1].replace(/[*_`\[\]]/g, '').trim();
const sectionBody = section.replace(/^##\s+.+$/m, '').trim();
slides.push({
type: 'content',
title,
body: sectionBody,
html: renderSlideMarkdown(sectionBody),
});
}
// If no H2 sections found, create a single content slide with full body
if (slides.length === 1 && bodyWithoutH1.trim()) {
slides.push({
type: 'content',
title: 'Content',
body: bodyWithoutH1.trim(),
html: renderSlideMarkdown(bodyWithoutH1.trim()),
});
}
return slides;
}
// ─── Main Component ───────────────────────────────────────────────
const SLIDE_HEIGHT = '32rem';
export default function SkillSlideView({ content, skillName }: SkillSlideViewProps) {
const [currentSlide, setCurrentSlide] = useState(0);
const [isFullscreen, setIsFullscreen] = useState(false);
const [expanded, setExpanded] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const slides = useMemo(() => buildSlides(content, skillName), [content, skillName]);
const totalSlides = slides.length;
// Token counting (approximation: ~4 chars per token)
const totalTokens = useMemo(() => estimateTokens(content), [content]);
const slideTokens = useMemo(() => slides.map((s) => estimateTokens(s.body)), [slides]);
const goTo = useCallback((idx: number) => {
setCurrentSlide(Math.max(0, Math.min(idx, totalSlides - 1)));
}, [totalSlides]);
const goNext = useCallback(() => goTo(currentSlide + 1), [currentSlide, goTo]);
const goPrev = useCallback(() => goTo(currentSlide - 1), [currentSlide, goTo]);
// Keyboard navigation
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { e.preventDefault(); goNext(); }
else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') { e.preventDefault(); goPrev(); }
else if (e.key === 'Home') { e.preventDefault(); goTo(0); }
else if (e.key === 'End') { e.preventDefault(); goTo(totalSlides - 1); }
else if (e.key === 'Escape' && isFullscreen) { toggleFullscreen(); }
};
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [goNext, goPrev, goTo, totalSlides, isFullscreen]);
// Fullscreen API
const toggleFullscreen = useCallback(() => {
if (!containerRef.current) return;
if (!document.fullscreenElement) {
containerRef.current.requestFullscreen().then(() => setIsFullscreen(true)).catch(() => {});
} else {
document.exitFullscreen().then(() => setIsFullscreen(false)).catch(() => {});
}
}, []);
useEffect(() => {
const handler = () => setIsFullscreen(!!document.fullscreenElement);
document.addEventListener('fullscreenchange', handler);
return () => document.removeEventListener('fullscreenchange', handler);
}, []);
const slide = slides[currentSlide];
if (!slide) return null;
return (
<div
ref={containerRef}
className={`flex flex-col ${isFullscreen ? 'bg-[#0d0d0f] h-screen' : ''}`}
>
{/* Slide area */}
<div className={`relative bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded-xl overflow-hidden flex flex-col ${isFullscreen ? 'flex-1 rounded-none border-0' : ''}`}>
{/* Slide content */}
<div
className={`${isFullscreen ? 'flex-1 px-16 py-12 overflow-y-auto' : 'px-8 py-8'}`}
style={!isFullscreen && !expanded ? { height: SLIDE_HEIGHT, overflow: 'hidden' } : undefined}
>
{slide.type === 'title' ? (
<TitleSlide slide={slide} isFullscreen={isFullscreen} totalTokens={totalTokens} />
) : (
<ContentSlide slide={slide} isFullscreen={isFullscreen} />
)}
</div>
{/* Show more / Collapse */}
{!isFullscreen && (
<button
onClick={() => setExpanded(!expanded)}
className="flex items-center justify-center gap-1.5 w-full py-2 text-xs font-medium text-[var(--color-accent-400)] hover:text-[var(--color-text-primary)] border-t border-[var(--color-border)] hover:bg-[var(--color-surface-3)] transition-colors"
>
{expanded ? (
<>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 15.75l7.5-7.5 7.5 7.5" />
</svg>
Collapse
</>
) : (
<>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
</svg>
Show full document
</>
)}
</button>
)}
{/* Navigation bar */}
<div className="flex items-center justify-between px-4 py-3 border-t border-[var(--color-border)] bg-[var(--color-surface-1)]/50 backdrop-blur-sm">
{/* Left: prev button + token info */}
<div className="flex items-center gap-3">
<button
onClick={goPrev}
disabled={currentSlide === 0}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors disabled:opacity-30 disabled:cursor-not-allowed text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-white/[0.06]"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg>
Prev
</button>
<div className="hidden sm:flex items-center gap-1.5 font-mono">
<span title="Tokens in this slide" className="flex items-center gap-1 px-2 py-0.5 rounded-md bg-[var(--color-accent-400)]/10 text-[var(--color-accent-400)] text-[10px] font-bold">
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M17.25 6.75L22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3l-4.5 16.5" />
</svg>
{slideTokens[currentSlide]?.toLocaleString()}
</span>
<span className="text-[var(--color-text-tertiary)] text-[9px]">/</span>
<span title="Total tokens" className="text-[10px] text-[var(--color-text-tertiary)] font-medium">{totalTokens.toLocaleString()}</span>
</div>
</div>
{/* Center: slide dots + counter */}
<div className="flex items-center gap-3">
<div className="flex items-center gap-1">
{slides.map((_, i) => (
<button
key={i}
onClick={() => goTo(i)}
className={`rounded-full transition-all duration-200 ${
i === currentSlide
? 'w-6 h-1.5 bg-[var(--color-accent-400)]'
: 'w-1.5 h-1.5 bg-[var(--color-text-tertiary)]/40 hover:bg-[var(--color-text-tertiary)]'
}`}
title={`Slide ${i + 1}: ${slides[i].title}`}
/>
))}
</div>
<span className="text-[11px] text-[var(--color-text-tertiary)] font-mono tabular-nums">
{currentSlide + 1} / {totalSlides}
</span>
</div>
{/* Right: next + fullscreen */}
<div className="flex items-center gap-1">
<button
onClick={goNext}
disabled={currentSlide === totalSlides - 1}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors disabled:opacity-30 disabled:cursor-not-allowed text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-white/[0.06]"
>
Next
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</button>
<button
onClick={toggleFullscreen}
className="p-1.5 rounded-lg text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)] hover:bg-white/[0.06] transition-colors"
title={isFullscreen ? 'Exit fullscreen (Esc)' : 'Fullscreen'}
>
{isFullscreen ? (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 9V4.5M9 9H4.5M9 9L3.75 3.75M9 15v4.5M9 15H4.5M9 15l-5.25 5.25M15 9h4.5M15 9V4.5M15 9l5.25-5.25M15 15h4.5M15 15v4.5m0-4.5l5.25 5.25" />
</svg>
) : (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 3.75v4.5m0-4.5h4.5m-4.5 0L9 9M3.75 20.25v-4.5m0 4.5h4.5m-4.5 0L9 15M20.25 3.75h-4.5m4.5 0v4.5m0-4.5L15 9m5.25 11.25h-4.5m4.5 0v-4.5m0 4.5L15 15" />
</svg>
)}
</button>
</div>
</div>
</div>
{/* Keyboard hints */}
<div className="flex justify-center gap-3 mt-3 text-[10px] text-[var(--color-text-tertiary)]">
<span className="flex items-center gap-1">
<kbd className="px-1 py-0.5 rounded bg-[var(--color-surface-3)] font-mono text-[9px]">&larr;</kbd>
<kbd className="px-1 py-0.5 rounded bg-[var(--color-surface-3)] font-mono text-[9px]">&rarr;</kbd>
Navigate
</span>
<span className="flex items-center gap-1">
<kbd className="px-1 py-0.5 rounded bg-[var(--color-surface-3)] font-mono text-[9px]">Home</kbd>
<kbd className="px-1 py-0.5 rounded bg-[var(--color-surface-3)] font-mono text-[9px]">End</kbd>
First / Last
</span>
</div>
</div>
);
}
// ─── Title Slide ──────────────────────────────────────────────────
function TitleSlide({ slide, isFullscreen, totalTokens }: { slide: Slide; isFullscreen: boolean; totalTokens: number }) {
const metaEntries = Object.entries(slide.metadata || {});
const allBadges = [...metaEntries, ['Tokens', totalTokens.toLocaleString()]];
return (
<div className={`flex flex-col items-center justify-center text-center h-full min-h-[20rem] ${isFullscreen ? 'min-h-[60vh]' : ''}`}>
{/* Icon */}
<div className="w-16 h-16 rounded-2xl flex items-center justify-center mb-6" style={{ backgroundColor: 'rgba(213, 116, 85, 0.15)' }}>
<svg className="w-8 h-8" style={{ color: 'var(--color-accent-400)' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.455 2.456L21.75 6l-1.036.259a3.375 3.375 0 00-2.455 2.456z" />
</svg>
</div>
{/* Title */}
<h1 className={`font-bold text-[var(--color-text-primary)] mb-3 leading-tight ${isFullscreen ? 'text-4xl' : 'text-2xl'}`}>
{slide.title}
</h1>
{/* Description */}
{slide.body && (
<p className={`text-[var(--color-text-secondary)] leading-relaxed max-w-2xl ${isFullscreen ? 'text-lg' : 'text-sm'}`}>
{slide.body}
</p>
)}
{/* Metadata badges */}
{allBadges.length > 0 && (
<div className="flex flex-wrap justify-center gap-2.5 mt-8">
{allBadges.map(([key, value]) => {
const isTokens = key === 'Tokens';
return (
<div
key={key}
className={`flex flex-col items-center px-4 py-2.5 rounded-xl border text-center min-w-[5rem] ${
isTokens
? 'bg-[var(--color-accent-400)]/10 border-[var(--color-accent-400)]/25'
: 'bg-[var(--color-surface-3)]/60 border-[var(--color-border)]'
}`}
>
<span className={`text-[10px] uppercase tracking-widest font-semibold mb-0.5 ${
isTokens ? 'text-[var(--color-accent-400)]' : 'text-[var(--color-text-tertiary)]'
}`}>{key}</span>
<span className={`text-sm font-bold font-mono ${
isTokens ? 'text-[var(--color-accent-400)]' : 'text-[var(--color-text-primary)]'
}`}>{value}</span>
</div>
);
})}
</div>
)}
</div>
);
}
// ─── Content Slide ────────────────────────────────────────────────
function ContentSlide({ slide, isFullscreen }: { slide: Slide; isFullscreen: boolean }) {
return (
<div className={`h-full ${isFullscreen ? 'max-w-4xl mx-auto' : ''}`}>
{/* Slide title */}
<h2 className={`font-bold text-[var(--color-text-primary)] mb-6 pb-3 border-b border-[var(--color-border)] ${isFullscreen ? 'text-3xl' : 'text-xl'}`}>
{slide.title}
</h2>
{/* Rendered markdown content */}
<div
className={`slide-md-content ${isFullscreen ? 'text-base' : 'text-sm'}`}
dangerouslySetInnerHTML={{ __html: slide.html }}
/>
{/* Inline styles for slide markdown content */}
<style>{`
.slide-md-content {
color: var(--color-text-secondary);
line-height: 1.7;
}
.slide-md-content .slide-heading {
color: var(--color-text-primary);
font-weight: 600;
margin-top: 1.25rem;
margin-bottom: 0.5rem;
}
.slide-md-content h3.slide-heading { font-size: 1.1em; }
.slide-md-content h4.slide-heading { font-size: 1em; }
.slide-md-content p { margin-bottom: 0.75rem; }
.slide-md-content ul, .slide-md-content ol {
margin-bottom: 0.75rem;
padding-left: 1.5rem;
}
.slide-md-content li { margin-bottom: 0.25rem; }
.slide-md-content ul li { list-style: disc; }
.slide-md-content ol li { list-style: decimal; }
.slide-md-content strong { color: var(--color-text-primary); font-weight: 600; }
.slide-md-content code {
background: var(--color-surface-3);
padding: 0.15em 0.4em;
border-radius: 4px;
font-size: 0.9em;
font-family: ui-monospace, monospace;
color: var(--color-accent-400, #f97316);
}
.slide-md-content .slide-code-block {
background: var(--color-surface-1, #0d0d0f);
border: 1px solid var(--color-border);
border-radius: 8px;
margin: 0.75rem 0;
overflow: hidden;
}
.slide-md-content .slide-code-lang {
padding: 0.35rem 0.75rem;
font-size: 0.7em;
color: var(--color-text-tertiary);
border-bottom: 1px solid var(--color-border);
font-family: ui-monospace, monospace;
}
.slide-md-content .slide-code-lang:empty { display: none; }
.slide-md-content pre {
padding: 0.75rem;
overflow-x: auto;
font-size: 0.85em;
line-height: 1.6;
}
.slide-md-content pre code {
background: none;
padding: 0;
border-radius: 0;
color: var(--color-text-primary);
}
.slide-md-content a {
color: var(--color-accent-400, #f97316);
text-decoration: underline;
text-underline-offset: 2px;
}
.slide-md-content blockquote {
border-left: 3px solid var(--color-border);
padding-left: 1rem;
margin: 0.75rem 0;
color: var(--color-text-tertiary);
font-style: italic;
}
.slide-md-content table {
width: 100%;
border-collapse: collapse;
margin: 0.75rem 0;
font-size: 0.9em;
}
.slide-md-content th, .slide-md-content td {
border: 1px solid var(--color-border);
padding: 0.4rem 0.75rem;
text-align: left;
}
.slide-md-content th {
background: var(--color-surface-3);
color: var(--color-text-primary);
font-weight: 600;
}
`}</style>
</div>
);
}
+63
View File
@@ -0,0 +1,63 @@
import { useState, useEffect } from 'react';
import { toggleTheme, getTheme } from '../lib/theme';
export default function ThemeToggle() {
const [theme, setTheme] = useState<'light' | 'dark'>('dark');
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
setTheme(getTheme());
}, []);
const handleToggle = () => {
const newTheme = toggleTheme();
setTheme(newTheme);
};
if (!mounted) return null;
return (
<button
onClick={handleToggle}
className="relative group p-2 rounded-lg transition-all duration-200 hover:bg-[var(--color-surface-2)] active:scale-95 flex items-center justify-center"
aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
title={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
>
<div className="relative w-5 h-5">
{/* Sun icon for light mode */}
<svg
className={`absolute inset-0 w-5 h-5 transition-all duration-300 ease-out ${
theme === 'light'
? 'opacity-100 rotate-0 scale-100'
: 'opacity-0 rotate-90 scale-75'
}`}
style={{ color: 'var(--color-text-primary)' }}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2m0 16v2M4.93 4.93l1.41 1.41m11.32 11.32l1.41 1.41M2 12h2m16 0h2M4.93 19.07l1.41-1.41m11.32-11.32l1.41-1.41" />
</svg>
{/* Moon icon for dark mode */}
<svg
className={`absolute inset-0 w-5 h-5 transition-all duration-300 ease-out ${
theme === 'dark'
? 'opacity-100 rotate-0 scale-100'
: 'opacity-0 -rotate-90 scale-75'
}`}
style={{ color: 'var(--color-text-primary)' }}
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M21.64 13a1 1 0 00-1.05-.14 8.05 8.05 0 01-3.37.73 8.15 8.15 0 01-8.14-8.1 8.59 8.59 0 01.25-2A1 1 0 008 2.36a10.14 10.14 0 1014 11.69 1 1 0 00-.36-1.05z" />
</svg>
</div>
</button>
);
}
+48
View File
@@ -0,0 +1,48 @@
---
import ThemeToggle from './ThemeToggle.tsx';
---
<header class="sticky top-0 z-20 bg-[var(--color-surface-0)] border-b border-[var(--color-border)] transition-all duration-200">
<div class="flex items-center gap-3 justify-between px-6 h-14">
<!-- Mobile menu button -->
<button
class="md:hidden shrink-0 p-2 rounded border border-[var(--color-border)] hover:bg-[var(--color-surface-2)] text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)] transition-all duration-150 flex items-center justify-center"
onclick="toggleSidebar()"
aria-label="Toggle menu"
>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
<!-- Search trigger — terminal style -->
<button
id="searchTrigger"
class="flex items-center gap-2 px-4 py-2 border border-[var(--color-border)] rounded text-[13px] text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:border-[var(--color-accent)] hover:bg-[var(--color-surface-1)] transition-all duration-150 flex-1 min-w-0 max-w-md group font-ui-mono"
>
<span class="terminal-prompt text-[13px]">&gt;</span>
<span class="text-[var(--color-text-tertiary)] group-hover:text-[var(--color-text-secondary)]">Search components...</span>
<kbd class="hidden sm:inline-flex ml-auto items-center gap-1 px-2 py-0.5 text-[10px] font-mono font-bold border border-[var(--color-border)] rounded bg-[var(--color-surface-2)] text-[var(--color-text-tertiary)] group-hover:border-[var(--color-accent)] transition-all">
<span class="text-[11px]">⌘</span>K
</kbd>
</button>
<!-- Right actions -->
<div class="flex items-center gap-2 ml-4">
<a
href="https://github.com/davila7/claude-code-templates"
target="_blank"
rel="noopener noreferrer"
class="flex items-center gap-2 px-3 py-2 rounded border border-[var(--color-border)] text-[13px] font-ui-mono font-semibold text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:border-[var(--color-accent)] hover:bg-[var(--color-surface-1)] transition-all duration-150 group"
aria-label="View on GitHub"
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" />
</svg>
<span class="hidden sm:inline">GitHub</span>
</a>
<ThemeToggle client:load />
<div id="cart-button-mount"></div>
</div>
</div>
</header>
+446
View File
@@ -0,0 +1,446 @@
import { useState, useEffect, useMemo } from 'react';
import { TYPE_CONFIG } from '../lib/icons';
import TypeIcon from './TypeIcon';
import CountryFlag from './CountryFlag';
interface TrendingItem {
id: string;
name: string;
category: string;
downloadsToday: number;
downloadsWeek: number;
downloadsMonth: number;
downloadsTotal: number;
}
interface GlobalStats {
totalComponents: number;
totalDownloads: number;
monthlyDownloads: number;
weeklyDownloads: number;
todayDownloads: number;
totalCountries: number;
}
interface TopCountry {
code: string;
name: string;
flag: string;
downloads: number;
percentage: number;
}
interface TrendingData {
lastUpdated: string;
globalStats: GlobalStats;
topCountries: TopCountry[];
trending: Record<string, TrendingItem[]>;
}
function formatName(name: string): string {
return name.replace(/[-_]/g, ' ').split(' ').map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
}
function formatNumber(n: number): string {
if (n >= 1000000) return (n / 1000000).toFixed(1).replace(/\.0$/, '') + 'M';
if (n >= 1000) return (n / 1000).toFixed(1).replace(/\.0$/, '') + 'k';
return String(n);
}
const TRENDING_TYPES = ['all', 'skills', 'agents', 'commands', 'settings', 'hooks', 'mcps'] as const;
export default function TrendingView() {
const [data, setData] = useState<TrendingData | null>(null);
const [loading, setLoading] = useState(true);
const [activeType, setActiveType] = useState<string>('all');
const [period, setPeriod] = useState<'downloadsWeek' | 'downloadsMonth' | 'downloadsTotal'>('downloadsWeek');
useEffect(() => {
fetch('/trending-data.json')
.then((r) => r.json())
.then((d) => { setData(d); setLoading(false); })
.catch(() => setLoading(false));
}, []);
const items = useMemo(() => {
if (!data) return [];
const list = data.trending[activeType] ?? [];
return [...list].sort((a, b) => (b[period] ?? 0) - (a[period] ?? 0));
}, [data, activeType, period]);
if (loading) {
return (
<div className="px-6 py-20 flex flex-col items-center gap-3">
<div className="w-5 h-5 border-2 border-[var(--color-text-tertiary)] border-t-transparent rounded-full animate-spin" />
<span className="text-[13px] text-[var(--color-text-tertiary)]">Loading trending data...</span>
</div>
);
}
if (!data) {
return (
<div className="px-6 py-20 text-center">
<p className="text-[13px] text-red-400">Failed to load trending data</p>
</div>
);
}
const stats = data.globalStats;
return (
<div className="pb-8">
{/* Hero Section with Gradient */}
<div className="relative px-6 pt-6 pb-8 overflow-hidden">
<div className="absolute inset-0 bg-gradient-to-br from-[var(--color-accent)]/5 via-transparent to-transparent pointer-events-none" />
<div className="relative">
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--color-accent)] to-[var(--color-accent)]/70 flex items-center justify-center shadow-lg shadow-[var(--color-accent)]/20">
<svg className="w-5 h-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
</svg>
</div>
<div>
<h1 className="text-[20px] font-semibold text-[var(--color-text-primary)]">Trending Components</h1>
<p className="text-[12px] text-[var(--color-text-tertiary)] mt-0.5">Most popular downloads this week</p>
</div>
</div>
{/* Stats cards with enhanced design */}
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3">
{[
{
label: 'Total Downloads',
value: formatNumber(stats.totalDownloads),
icon: (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M9 19l3 3m0 0l3-3m-3 3V10" />
</svg>
),
colorClass: 'stat-blue'
},
{
label: 'This Month',
value: formatNumber(stats.monthlyDownloads),
icon: (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
),
colorClass: 'stat-purple'
},
{
label: 'This Week',
value: formatNumber(stats.weeklyDownloads),
icon: (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
</svg>
),
colorClass: 'stat-emerald'
},
{
label: 'Today',
value: formatNumber(stats.todayDownloads),
icon: (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
),
colorClass: 'stat-orange'
},
{
label: 'Components',
value: formatNumber(stats.totalComponents),
icon: (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
),
colorClass: 'stat-pink'
},
{
label: 'Countries',
value: String(stats.totalCountries),
icon: (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
),
colorClass: 'stat-cyan'
},
].map((s) => (
<div
key={s.label}
className="group relative bg-[var(--color-surface-1)] border border-[var(--color-border)] rounded-xl px-4 py-3.5 hover:border-[var(--color-accent)]/30 transition-all duration-200 hover:shadow-lg hover:shadow-[var(--color-accent)]/5 hover:-translate-y-0.5"
>
<div className="flex items-start justify-between mb-2">
<div className={`stat-icon ${s.colorClass}`}>
{s.icon}
</div>
</div>
<div className="text-[22px] font-bold text-[var(--color-text-primary)] tabular-nums leading-none mb-1.5">{s.value}</div>
<div className="text-[11px] text-[var(--color-text-tertiary)] font-medium">{s.label}</div>
</div>
))}
</div>
</div>
</div>
{/* Top countries with icons */}
<div className="px-6 pb-6">
<div className="bg-[var(--color-surface-1)] border border-[var(--color-border)] rounded-xl p-4">
<div className="flex items-center gap-2 mb-3">
<svg className="w-4 h-4 text-[var(--color-accent)]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span className="text-[12px] text-[var(--color-text-secondary)] font-semibold uppercase tracking-wider">Top Countries</span>
</div>
<div className="flex items-center gap-3 overflow-x-auto pb-1">
{data.topCountries.map((c, idx) => (
<div
key={c.code}
className="flex items-center gap-2.5 shrink-0 bg-[var(--color-surface-2)] rounded-lg px-3 py-2.5 border border-[var(--color-border)] hover:border-[var(--color-accent)]/30 transition-all duration-200 hover:shadow-md"
>
<div className="flex items-center justify-center w-6 h-6 rounded-full bg-gradient-to-br from-[var(--color-accent)]/20 to-[var(--color-accent)]/10 text-[10px] font-bold text-[var(--color-accent)]">
{idx + 1}
</div>
<div className="w-7 h-7 rounded-md overflow-hidden shadow-sm border border-[var(--color-border)]">
<CountryFlag code={c.code} className="w-full h-full object-cover" />
</div>
<div className="flex flex-col">
<span className="text-[12px] text-[var(--color-text-primary)] font-medium">{c.name}</span>
<span className="text-[10px] text-[var(--color-text-tertiary)] tabular-nums">{formatNumber(c.downloads)} downloads</span>
</div>
<span className="text-[11px] text-[var(--color-accent)] font-semibold tabular-nums ml-1">{c.percentage}%</span>
</div>
))}
</div>
</div>
</div>
{/* Divider */}
<div className="border-t border-[var(--color-border)] mx-6" />
{/* Filter bar with enhanced design */}
<div className="px-6 py-4">
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-3">
{/* Type filter */}
<div className="flex items-center gap-1.5 flex-wrap">
{TRENDING_TYPES.map((type) => {
const config = TYPE_CONFIG[type];
return (
<button
key={type}
onClick={() => setActiveType(type)}
className={`group flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[12px] font-medium transition-all duration-200 ${
activeType === type
? 'bg-[var(--color-accent)] text-white shadow-md shadow-[var(--color-accent)]/20'
: 'bg-[var(--color-surface-2)] text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-surface-3)] border border-[var(--color-border)]'
}`}
>
{type !== 'all' && (
<div className={`w-4 h-4 flex items-center justify-center ${activeType === type ? 'text-white' : ''}`}>
<TypeIcon type={type} size={14} />
</div>
)}
{type === 'all' ? 'All' : TYPE_CONFIG[type]?.label ?? type}
</button>
);
})}
</div>
{/* Period selector with icon */}
<div className="flex items-center gap-2 ml-auto">
<svg className="w-4 h-4 text-[var(--color-text-tertiary)]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" />
</svg>
<select
value={period}
onChange={(e) => setPeriod(e.target.value as any)}
className="bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded-lg text-[12px] text-[var(--color-text-primary)] font-medium px-3 py-1.5 outline-none cursor-pointer hover:border-[var(--color-accent)]/30 transition-colors"
>
<option value="downloadsWeek">This Week</option>
<option value="downloadsMonth">This Month</option>
<option value="downloadsTotal">All Time</option>
</select>
</div>
</div>
{/* Results count with icon */}
<div className="flex items-center gap-2 mt-3">
<svg className="w-3.5 h-3.5 text-[var(--color-text-tertiary)]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
<span className="text-[11px] text-[var(--color-text-tertiary)] font-medium">
{items.length} trending component{items.length !== 1 ? 's' : ''}
</span>
</div>
</div>
{/* Trending list with enhanced design */}
<div className="px-6 pb-8">
<div className="bg-[var(--color-surface-1)] border border-[var(--color-border)] rounded-xl overflow-hidden shadow-sm">
{/* Table header */}
<div className="hidden md:grid grid-cols-[50px_1fr_110px_110px_110px_110px] gap-3 px-5 py-3 bg-[var(--color-surface-2)] border-b border-[var(--color-border)]">
<div className="flex items-center gap-1.5 text-[11px] text-[var(--color-text-tertiary)] font-semibold uppercase tracking-wider">
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 20l4-16m2 16l4-16M6 9h14M4 15h14" />
</svg>
Rank
</div>
<span className="text-[11px] text-[var(--color-text-tertiary)] font-semibold uppercase tracking-wider">Component</span>
<div className="flex items-center justify-end gap-1.5 text-[11px] text-[var(--color-text-tertiary)] font-semibold uppercase tracking-wider">
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Today
</div>
<div className="flex items-center justify-end gap-1.5 text-[11px] text-[var(--color-text-tertiary)] font-semibold uppercase tracking-wider">
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
Week
</div>
<div className="flex items-center justify-end gap-1.5 text-[11px] text-[var(--color-text-tertiary)] font-semibold uppercase tracking-wider">
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
Month
</div>
<div className="flex items-center justify-end gap-1.5 text-[11px] text-[var(--color-text-tertiary)] font-semibold uppercase tracking-wider">
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
Total
</div>
</div>
{/* Table rows */}
<div className="divide-y divide-[var(--color-border)]">
{items.map((item, idx) => {
// Extract type from id (e.g. "command-generate-tests" -> "commands")
const typeKey = item.id.split('-')[0];
const typePlural = typeKey === 'mcp' ? 'mcps' : typeKey + 's';
const config = TYPE_CONFIG[typePlural];
// Medal colors for top 3
const getMedalClass = (rank: number) => {
if (rank === 1) return 'medal-gold';
if (rank === 2) return 'medal-silver';
if (rank === 3) return 'medal-bronze';
return 'medal-default';
};
return (
<div
key={item.id}
className="grid md:grid-cols-[50px_1fr_110px_110px_110px_110px] gap-3 px-5 py-3.5 hover:bg-[var(--color-surface-2)] transition-all duration-150 group"
>
{/* Rank with medal for top 3 */}
<div className="flex items-center">
<div className={`w-8 h-8 rounded-lg flex items-center justify-center text-[13px] font-bold ${getMedalClass(idx + 1)}`}>
{idx + 1}
</div>
</div>
{/* Component info */}
<div className="flex items-center gap-3 min-w-0">
<div
className="w-9 h-9 rounded-lg flex items-center justify-center shrink-0 shadow-sm group-hover:scale-110 transition-transform duration-200"
style={{
backgroundColor: config?.color ? `${config.color}15` : 'rgba(115, 115, 115, 0.15)',
color: config?.color ?? 'var(--color-text-tertiary)'
}}
>
<TypeIcon type={typePlural} size={16} />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-[13px] text-[var(--color-text-primary)] font-medium truncate block group-hover:text-[var(--color-accent)] transition-colors">
{formatName(item.name)}
</span>
{item.downloadsToday > 0 && (
<span className="hot-badge">
<svg className="w-2.5 h-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 10l7-7m0 0l7 7m-7-7v18" />
</svg>
Hot
</span>
)}
</div>
<div className="flex items-center gap-1.5 mt-0.5">
<span className="text-[10px] text-[var(--color-text-tertiary)] px-1.5 py-0.5 bg-[var(--color-surface-2)] rounded">{item.category}</span>
</div>
</div>
</div>
{/* Stats - Desktop */}
<div className="hidden md:flex items-center justify-end">
<span className={`text-[13px] font-semibold tabular-nums ${item.downloadsToday > 0 ? 'stat-today-active' : 'text-[var(--color-text-tertiary)]'}`}>
{item.downloadsToday > 0 ? `+${item.downloadsToday}` : '—'}
</span>
</div>
<div className="hidden md:flex items-center justify-end">
<span className="text-[13px] font-medium tabular-nums text-[var(--color-text-secondary)]">
{formatNumber(item.downloadsWeek)}
</span>
</div>
<div className="hidden md:flex items-center justify-end">
<span className="text-[13px] font-medium tabular-nums text-[var(--color-text-secondary)]">
{formatNumber(item.downloadsMonth)}
</span>
</div>
<div className="hidden md:flex items-center justify-end">
<span className="text-[13px] font-bold tabular-nums text-[var(--color-text-primary)]">
{formatNumber(item.downloadsTotal)}
</span>
</div>
{/* Stats - Mobile */}
<div className="md:hidden col-span-full grid grid-cols-4 gap-2 mt-2 pt-2 border-t border-[var(--color-border)]">
<div className="text-center">
<div className="text-[10px] text-[var(--color-text-tertiary)] mb-0.5">Today</div>
<div className={`text-[12px] font-semibold tabular-nums ${item.downloadsToday > 0 ? 'stat-today-active' : 'text-[var(--color-text-tertiary)]'}`}>
{item.downloadsToday > 0 ? `+${item.downloadsToday}` : '—'}
</div>
</div>
<div className="text-center">
<div className="text-[10px] text-[var(--color-text-tertiary)] mb-0.5">Week</div>
<div className="text-[12px] font-medium tabular-nums text-[var(--color-text-secondary)]">
{formatNumber(item.downloadsWeek)}
</div>
</div>
<div className="text-center">
<div className="text-[10px] text-[var(--color-text-tertiary)] mb-0.5">Month</div>
<div className="text-[12px] font-medium tabular-nums text-[var(--color-text-secondary)]">
{formatNumber(item.downloadsMonth)}
</div>
</div>
<div className="text-center">
<div className="text-[10px] text-[var(--color-text-tertiary)] mb-0.5">Total</div>
<div className="text-[12px] font-bold tabular-nums text-[var(--color-text-primary)]">
{formatNumber(item.downloadsTotal)}
</div>
</div>
</div>
</div>
);
})}
</div>
</div>
</div>
{/* Last updated with icon */}
<div className="px-6 pb-6">
<div className="flex items-center justify-center gap-2 text-[11px] text-[var(--color-text-tertiary)]">
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>
Last updated: {new Date(data.lastUpdated.replace(/\+00:00Z$/, 'Z')).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: '2-digit', minute: '2-digit' })}
</span>
</div>
</div>
</div>
);
}
+15
View File
@@ -0,0 +1,15 @@
import { ICONS } from '../lib/icons';
/**
* Renders a type icon SVG inline.
* Safe to use dangerouslySetInnerHTML here because ICONS are hardcoded
* SVG strings defined in icons.ts — not user-supplied data.
*/
export default function TypeIcon({ type, size = 16, className = '' }: { type: string; size?: number; className?: string }) {
const svg = ICONS[type];
if (!svg) return <span className={className} />;
const sized = svg.replace(/width="16"/, `width="${size}"`).replace(/height="16"/, `height="${size}"`);
return <span className={className} dangerouslySetInnerHTML={{ __html: sized }} />;
}
+44
View File
@@ -0,0 +1,44 @@
---
import { TYPE_CONFIG } from '../lib/icons';
interface Props {
activeType: string;
counts?: Record<string, number>;
}
const { activeType, counts = {} } = Astro.props;
---
<nav class="flex items-center gap-1 px-6 py-2 border-b border-[var(--color-border)] overflow-x-auto">
{Object.entries(TYPE_CONFIG).map(([type, config]) => {
const isActive = type === activeType;
const count = counts[type] ?? 0;
return (
<a
href={`/${type}`}
class:list={[
'flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm whitespace-nowrap transition-colors',
isActive
? 'bg-primary-500/15 text-primary-400 font-medium'
: 'text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-surface-3)]',
]}
data-astro-prefetch
>
<span>{config.icon}</span>
<span>{config.label}</span>
{count > 0 && (
<span
class:list={[
'text-xs px-1.5 py-0.5 rounded-full',
isActive
? 'bg-primary-500/20 text-primary-400'
: 'bg-[var(--color-surface-3)] text-[var(--color-text-tertiary)]',
]}
>
{count}
</span>
)}
</a>
);
})}
</nav>
@@ -0,0 +1,161 @@
import type { ReviewCycle } from '../../lib/live-task/types';
const statusColors: Record<string, string> = {
active: '#22c55e',
completed: '#6b7280',
merged: '#3b82f6',
failed: '#ef4444',
};
const phaseLabels: Record<string, string> = {
selection: 'Selecting',
research: 'Researching',
improve: 'Improving',
pr_created: 'PR Created',
reporting: 'Reporting',
merging: 'Merging',
};
function timeAgo(dateStr: string): string {
const diff = Date.now() - new Date(dateStr).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return 'just now';
if (mins < 60) return `${mins}m ago`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h ago`;
return `${Math.floor(hours / 24)}d ago`;
}
function duration(start: string, end: string | null): string {
const endTime = end ? new Date(end).getTime() : Date.now();
const diff = endTime - new Date(start).getTime();
const mins = Math.floor(diff / 60000);
const secs = Math.floor((diff % 60000) / 1000);
if (mins === 0) return `${secs}s`;
return `${mins}m ${secs}s`;
}
function shortPath(path: string): string {
const parts = path.split('/');
return parts.slice(-2).join('/');
}
interface Props {
cycles: ReviewCycle[];
selectedId: string | null;
onSelect: (id: string) => void;
}
export default function CycleTable({ cycles, selectedId, onSelect }: Props) {
if (cycles.length === 0) {
return (
<div style={{
padding: '32px 16px',
textAlign: 'center',
color: '#6b7280',
fontSize: 14,
fontFamily: 'monospace',
}}>
No review cycles yet. The daily scheduled task will create the first one.
</div>
);
}
return (
<div style={{ overflowX: 'auto' }}>
<table style={{
width: '100%',
borderCollapse: 'collapse',
fontSize: 13,
fontFamily: "'Geist Mono', 'JetBrains Mono', monospace",
}}>
<thead>
<tr style={{ borderBottom: '1px solid #2a2a2a' }}>
{['Status', 'Component', 'Type', 'Phase', 'PR', 'Started', 'Duration'].map(h => (
<th key={h} style={{
padding: '8px 12px',
textAlign: 'left',
color: '#6b7280',
fontWeight: 500,
fontSize: 11,
textTransform: 'uppercase',
letterSpacing: '0.05em',
}}>{h}</th>
))}
</tr>
</thead>
<tbody>
{cycles.map(cycle => {
const isSelected = cycle.id === selectedId;
const isActive = cycle.status === 'active';
return (
<tr
key={cycle.id}
onClick={() => onSelect(cycle.id)}
style={{
borderBottom: '1px solid #1a1a1a',
cursor: 'pointer',
background: isSelected ? '#1a1a2e' : 'transparent',
borderLeft: isSelected ? '2px solid #3b82f6' : '2px solid transparent',
transition: 'background 0.15s',
}}
onMouseEnter={e => { if (!isSelected) e.currentTarget.style.background = '#111'; }}
onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = 'transparent'; }}
>
<td style={{ padding: '10px 12px' }}>
<span style={{
display: 'inline-block',
width: 8,
height: 8,
borderRadius: '50%',
background: statusColors[cycle.status] || '#6b7280',
animation: isActive ? 'pulse-dot 2s infinite' : 'none',
}} />
<style>{`@keyframes pulse-dot { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }`}</style>
</td>
<td style={{ padding: '10px 12px', color: '#e5e7eb' }} title={cycle.component_path}>
{shortPath(cycle.component_path)}
</td>
<td style={{ padding: '10px 12px', color: '#9ca3af' }}>
{cycle.component_type}
</td>
<td style={{ padding: '10px 12px' }}>
<span style={{
padding: '2px 8px',
borderRadius: 4,
fontSize: 11,
background: isActive ? '#1e3a5f' : '#1a1a1a',
color: isActive ? '#60a5fa' : '#6b7280',
}}>
{phaseLabels[cycle.phase] || cycle.phase}
</span>
</td>
<td style={{ padding: '10px 12px' }}>
{cycle.pr_url && cycle.pr_url.startsWith('https://') ? (
<a
href={cycle.pr_url}
target="_blank"
rel="noopener noreferrer"
onClick={e => e.stopPropagation()}
style={{ color: '#3b82f6', textDecoration: 'none', fontSize: 12 }}
>
#{cycle.pr_number}
</a>
) : (
<span style={{ color: '#4b5563' }}>-</span>
)}
</td>
<td style={{ padding: '10px 12px', color: '#6b7280', fontSize: 12 }}>
{timeAgo(cycle.started_at)}
</td>
<td style={{ padding: '10px 12px', color: '#6b7280', fontSize: 12 }}>
{duration(cycle.started_at, cycle.completed_at)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}
@@ -0,0 +1,358 @@
import { useState, useEffect, useCallback } from 'react';
import type { ReviewCycle, ToolExecution, CycleControl } from '../../lib/live-task/types';
import CycleTable from './CycleTable';
import ToolTimeline from './ToolTimeline';
const POLL_INTERVAL = 5000;
type FilterMode = 'open' | 'all' | 'done';
const FILTER_LABELS: Record<FilterMode, string> = {
open: 'Open',
all: 'All',
done: 'Done',
};
const OPEN_STATUSES = new Set(['active', 'completed']);
const DONE_STATUSES = new Set(['merged', 'failed']);
export default function LiveTaskPanel() {
const [cycles, setCycles] = useState<ReviewCycle[]>([]);
const [selectedCycleId, setSelectedCycleId] = useState<string | null>(null);
const [tools, setTools] = useState<ToolExecution[]>([]);
const [control, setControl] = useState<CycleControl | null>(null);
const [loading, setLoading] = useState(true);
const [toggling, setToggling] = useState(false);
const [filter, setFilter] = useState<FilterMode>('open');
const fetchCycles = useCallback(async () => {
try {
const res = await fetch('/api/live-task/cycles?limit=20');
const data = await res.json();
setCycles(data.cycles || []);
} catch { /* silent */ }
}, []);
const fetchControl = useCallback(async () => {
try {
const res = await fetch('/api/live-task/control');
const data = await res.json();
setControl(data.control || null);
} catch { /* silent */ }
}, []);
const fetchTools = useCallback(async (cycleId: string) => {
try {
const res = await fetch(`/api/live-task/tools?cycle_id=${cycleId}`);
const data = await res.json();
setTools(data.tools || []);
} catch { /* silent */ }
}, []);
// Initial load
useEffect(() => {
Promise.all([fetchCycles(), fetchControl()]).then(() => setLoading(false));
}, [fetchCycles, fetchControl]);
// Polling
useEffect(() => {
const interval = setInterval(() => {
fetchCycles();
fetchControl();
if (selectedCycleId) fetchTools(selectedCycleId);
}, POLL_INTERVAL);
return () => clearInterval(interval);
}, [fetchCycles, fetchControl, fetchTools, selectedCycleId]);
// Fetch tools when selection changes
useEffect(() => {
if (selectedCycleId) {
fetchTools(selectedCycleId);
} else {
setTools([]);
}
}, [selectedCycleId, fetchTools]);
const [authError, setAuthError] = useState<string | null>(null);
const [isAdmin, setIsAdmin] = useState(false);
// Check if current user is the authorized admin
useEffect(() => {
const checkAdmin = () => {
const clerk = (window as any).Clerk;
if (!clerk?.user) {
setIsAdmin(false);
return;
}
const email = clerk.user.primaryEmailAddress?.emailAddress;
setIsAdmin(email === 'dan.avila7@gmail.com');
};
// Clerk may not be loaded yet
const interval = setInterval(() => {
if ((window as any).Clerk?.loaded) {
checkAdmin();
clearInterval(interval);
}
}, 500);
return () => clearInterval(interval);
}, []);
const handleTogglePause = async () => {
if (!control) return;
setToggling(true);
setAuthError(null);
try {
const clerk = (window as any).Clerk;
if (!clerk?.session) {
setAuthError('Sign in required');
setToggling(false);
return;
}
const token = await clerk.session.getToken();
const res = await fetch('/api/live-task/control', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({
is_paused: !control.is_paused,
reason: control.is_paused ? 'Resumed from dashboard' : 'Paused from dashboard',
}),
});
if (res.status === 401) {
setAuthError('Sign in required');
} else if (res.status === 403) {
setAuthError('Only the project admin can control the review cycle');
} else if (!res.ok) {
setAuthError('Failed to update control');
}
await fetchControl();
} catch { setAuthError('Network error'); }
setToggling(false);
};
const selectedCycle = cycles.find(c => c.id === selectedCycleId) || null;
const activeCycles = cycles.filter(c => c.status === 'active').length;
const filteredCycles = cycles.filter(c => {
if (filter === 'open') return OPEN_STATUSES.has(c.status);
if (filter === 'done') return DONE_STATUSES.has(c.status);
return true;
});
const filterCounts: Record<FilterMode, number> = {
open: cycles.filter(c => OPEN_STATUSES.has(c.status)).length,
all: cycles.length,
done: cycles.filter(c => DONE_STATUSES.has(c.status)).length,
};
return (
<div style={{
maxWidth: 1200,
margin: '0 auto',
padding: '24px 16px',
fontFamily: "'Geist', -apple-system, sans-serif",
}}>
{/* Header */}
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 24,
}}>
<div>
<h1 style={{
fontSize: 22,
fontWeight: 600,
color: '#e5e7eb',
margin: 0,
fontFamily: "'Geist Mono', monospace",
}}>
Component Review Loop
</h1>
<p style={{ color: '#6b7280', fontSize: 13, margin: '4px 0 0' }}>
{loading ? 'Loading...' : `${cycles.length} cycles total | ${activeCycles} active`}
</p>
<p style={{ color: '#4b5563', fontSize: 11, margin: '2px 0 0', fontFamily: 'monospace' }}>
{loading ? '' : `showing ${filteredCycles.length} of ${cycles.length}`}
</p>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
{control && (
<>
<span style={{
fontSize: 12,
color: control.is_paused ? '#f59e0b' : '#22c55e',
fontFamily: 'monospace',
}}>
{control.is_paused ? 'PAUSED' : 'RUNNING'}
</span>
{isAdmin && (
<button
onClick={handleTogglePause}
disabled={toggling}
style={{
padding: '6px 16px',
borderRadius: 6,
border: '1px solid #2a2a2a',
background: control.is_paused ? '#1e3a5f' : '#1a1a1a',
color: control.is_paused ? '#60a5fa' : '#9ca3af',
fontSize: 13,
cursor: toggling ? 'wait' : 'pointer',
fontFamily: "'Geist Mono', monospace",
opacity: toggling ? 0.6 : 1,
}}
>
{control.is_paused ? 'Resume' : 'Pause'}
</button>
)}
</>
)}
{authError && (
<span style={{ fontSize: 12, color: '#ef4444', fontFamily: 'monospace' }}>
{authError}
</span>
)}
</div>
</div>
{/* Cycles Table */}
<div style={{
background: '#0a0a0a',
border: '1px solid #1a1a1a',
borderRadius: 8,
marginBottom: 16,
overflow: 'hidden',
}}>
<div style={{
padding: '8px 16px',
borderBottom: '1px solid #1a1a1a',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}>
<span style={{
fontSize: 12,
color: '#6b7280',
fontFamily: "'Geist Mono', monospace",
textTransform: 'uppercase',
letterSpacing: '0.05em',
}}>
Review Cycles
</span>
<div style={{ display: 'flex', gap: 4 }}>
{(['open', 'all', 'done'] as FilterMode[]).map(f => (
<button
key={f}
onClick={() => setFilter(f)}
style={{
padding: '3px 10px',
borderRadius: 4,
border: '1px solid',
borderColor: filter === f ? '#3b82f6' : '#2a2a2a',
background: filter === f ? '#1e3a5f' : 'transparent',
color: filter === f ? '#60a5fa' : '#6b7280',
fontSize: 11,
fontFamily: "'Geist Mono', monospace",
cursor: 'pointer',
transition: 'all 0.15s',
}}
>
{FILTER_LABELS[f]}
<span style={{
marginLeft: 5,
padding: '0 4px',
borderRadius: 3,
background: filter === f ? '#2563eb33' : '#ffffff0d',
color: filter === f ? '#93c5fd' : '#4b5563',
fontSize: 10,
}}>
{filterCounts[f]}
</span>
</button>
))}
</div>
</div>
<CycleTable
cycles={filteredCycles}
selectedId={selectedCycleId}
onSelect={setSelectedCycleId}
/>
</div>
{/* Tool Timeline */}
<div style={{
background: '#0a0a0a',
border: '1px solid #1a1a1a',
borderRadius: 8,
overflow: 'hidden',
}}>
<div style={{
padding: '10px 16px',
borderBottom: '1px solid #1a1a1a',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}>
<span style={{
fontSize: 12,
color: '#6b7280',
fontFamily: "'Geist Mono', monospace",
textTransform: 'uppercase',
letterSpacing: '0.05em',
}}>
Tool Executions
</span>
{selectedCycle && (
<span style={{
fontSize: 11,
color: '#4b5563',
fontFamily: 'monospace',
}}>
{tools.length} tools | {selectedCycle.component_path.split('/').pop()}
</span>
)}
</div>
<ToolTimeline
tools={tools}
cycleStartTime={selectedCycle?.started_at || null}
/>
</div>
{/* Error display for selected cycle */}
{selectedCycle?.error_message && (
<div style={{
marginTop: 16,
padding: '12px 16px',
background: '#1a0a0a',
border: '1px solid #3f1515',
borderRadius: 8,
fontSize: 13,
color: '#ef4444',
fontFamily: "'Geist Mono', monospace",
}}>
Error: {selectedCycle.error_message}
</div>
)}
{/* Improvements summary for selected cycle */}
{selectedCycle?.improvements_summary && (
<div style={{
marginTop: 16,
padding: '12px 16px',
background: '#0a1a0a',
border: '1px solid #153f15',
borderRadius: 8,
fontSize: 13,
color: '#9ca3af',
fontFamily: "'Geist Mono', monospace",
whiteSpace: 'pre-wrap',
}}>
{selectedCycle.improvements_summary}
</div>
)}
</div>
);
}
@@ -0,0 +1,169 @@
import { useEffect, useRef } from 'react';
import type { ToolExecution } from '../../lib/live-task/types';
const toolColors: Record<string, string> = {
Read: '#22c55e',
Write: '#f59e0b',
Edit: '#f59e0b',
Bash: '#ef4444',
Grep: '#8b5cf6',
Glob: '#8b5cf6',
WebSearch: '#3b82f6',
WebFetch: '#3b82f6',
Agent: '#ec4899',
};
function sanitizePaths(s: string): string {
return s
.replace(/\/Users\/[^/]+\/[^"' ]*(Proyectos|Projects|github|repos)\/[^/]+\//gi, '')
.replace(/\/Users\/[^/]+\//g, '~/');
}
function relativeTime(baseTime: string, eventTime: string): string {
const diff = new Date(eventTime).getTime() - new Date(baseTime).getTime();
const totalSecs = Math.floor(diff / 1000);
if (totalSecs < 0) return '+0s';
const mins = Math.floor(totalSecs / 60);
const secs = totalSecs % 60;
if (mins === 0) return `+${secs}s`;
return `+${mins}m ${secs}s`;
}
interface Props {
tools: ToolExecution[];
cycleStartTime: string | null;
}
export default function ToolTimeline({ tools, cycleStartTime }: Props) {
const bottomRef = useRef<HTMLDivElement>(null);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [tools.length, cycleStartTime]);
if (!cycleStartTime) {
return (
<div style={{
padding: '32px 16px',
textAlign: 'center',
color: '#6b7280',
fontSize: 14,
fontFamily: 'monospace',
}}>
Select a cycle to view tool executions
</div>
);
}
if (tools.length === 0) {
return (
<div style={{
padding: '32px 16px',
textAlign: 'center',
color: '#6b7280',
fontSize: 14,
fontFamily: 'monospace',
}}>
No tool executions recorded yet for this cycle
</div>
);
}
let currentPhase = '';
return (
<div style={{
maxHeight: 400,
overflowY: 'auto',
padding: '8px 0',
}}>
{tools.map((tool, i) => {
const showPhaseSeparator = tool.phase && tool.phase !== currentPhase;
if (tool.phase) currentPhase = tool.phase;
return (
<div key={tool.id}>
{showPhaseSeparator && (
<div style={{
display: 'flex',
alignItems: 'center',
gap: 8,
padding: '12px 16px 4px',
}}>
<div style={{ height: 1, flex: 1, background: '#2a2a2a' }} />
<span style={{
fontSize: 10,
color: '#6b7280',
textTransform: 'uppercase',
letterSpacing: '0.1em',
fontFamily: 'monospace',
}}>
{tool.phase}
</span>
<div style={{ height: 1, flex: 1, background: '#2a2a2a' }} />
</div>
)}
<div style={{
display: 'flex',
alignItems: 'flex-start',
gap: 12,
padding: '6px 16px',
fontSize: 13,
fontFamily: "'Geist Mono', 'JetBrains Mono', monospace",
}}>
{/* Timestamp */}
<span style={{ color: '#4b5563', fontSize: 11, minWidth: 70, textAlign: 'right', flexShrink: 0 }}>
{relativeTime(cycleStartTime, tool.created_at)}
</span>
{/* Timeline dot + line */}
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', flexShrink: 0 }}>
<div style={{
width: 8,
height: 8,
borderRadius: '50%',
background: tool.result_status === 'error' ? '#ef4444' : (toolColors[tool.tool_name] || '#6b7280'),
marginTop: 4,
}} />
{i < tools.length - 1 && (
<div style={{ width: 1, height: 20, background: '#2a2a2a', marginTop: 2 }} />
)}
</div>
{/* Tool info */}
<div style={{ flex: 1, minWidth: 0 }}>
<span style={{
display: 'inline-block',
padding: '1px 6px',
borderRadius: 3,
fontSize: 11,
fontWeight: 600,
background: `${toolColors[tool.tool_name] || '#6b7280'}20`,
color: toolColors[tool.tool_name] || '#6b7280',
marginRight: 8,
}}>
{tool.tool_name}
</span>
<span style={{
color: '#9ca3af',
fontSize: 12,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}>
{sanitizePaths(tool.tool_args_summary || '')}
</span>
</div>
{/* Status icon */}
<span style={{ fontSize: 12, flexShrink: 0 }}>
{tool.result_status === 'error' ? '!' : ''}
</span>
</div>
</div>
);
})}
<div ref={bottomRef} />
</div>
);
}
+317
View File
@@ -0,0 +1,317 @@
---
import Sidebar from '../components/Sidebar.astro';
import ClerkIsland from '../components/ClerkIsland.tsx';
import '../styles/global.css';
interface Props {
title?: string;
description?: string;
ogImage?: string;
}
const {
title = 'Claude Code Templates',
description = 'Browse and install 1000+ components for Claude Code: AI agents, slash commands, MCP integrations, hooks, settings, and skills. Free, open-source CLI tool.',
ogImage = '/logo.png'
} = Astro.props;
const canonicalURL = new URL(Astro.url.pathname, Astro.site);
const ogImageURL = new URL(ogImage, Astro.site).href;
const schemaData = JSON.stringify({
"@context": "https://schema.org",
"@graph": [
{
"@type": "WebSite",
"@id": "https://www.aitmpl.com/#website",
"url": "https://www.aitmpl.com/",
"name": "Claude Code Templates",
"description": "Marketplace for Claude Code components: agents, commands, skills, settings, hooks, and MCPs",
"publisher": { "@id": "https://www.aitmpl.com/#organization" }
},
{
"@type": "Organization",
"@id": "https://www.aitmpl.com/#organization",
"name": "Claude Code Templates",
"url": "https://www.aitmpl.com/",
"logo": {
"@type": "ImageObject",
"url": "https://www.aitmpl.com/logo.png"
},
"sameAs": [
"https://github.com/davila7/claude-code-templates"
]
}
]
});
---
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{title}</title>
<meta name="description" content={description} />
<link rel="canonical" href={canonicalURL.href} />
<!-- Open Graph -->
<meta property="og:type" content="website" />
<meta property="og:url" content={canonicalURL.href} />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:image" content={ogImageURL} />
<meta property="og:site_name" content="Claude Code Templates" />
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={title} />
<meta name="twitter:description" content={description} />
<meta name="twitter:image" content={ogImageURL} />
<!-- Structured Data -->
<script type="application/ld+json" set:html={schemaData} />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<!-- Preload LCP image (sidebar logo, visible on every page above the fold) -->
<link rel="preload" href="/logo.webp" as="image" type="image/webp" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&family=Geist+Mono:wght@400;500&display=swap" rel="stylesheet" />
</head>
<body class="bg-[var(--color-surface-0)] text-[var(--color-text-primary)] antialiased transition-colors duration-200 overflow-x-hidden">
<script is:inline>
// Initialize theme on page load (prevent flash)
(function() {
var stored = localStorage.getItem('claude-theme');
var theme = (stored === 'light' || stored === 'dark') ? stored
: (window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark');
var html = document.documentElement;
html.classList.remove('light', 'dark');
html.classList.add(theme);
})();
</script>
<div class="flex h-screen overflow-hidden">
<!-- Mobile sidebar overlay -->
<div
id="sidebar-overlay"
class="hidden md:hidden fixed inset-0 bg-black/50 z-30"
></div>
<!-- Sidebar (fixed and out of flow — width/position must not affect
the flex layout below, or it pushes main content off-screen) -->
<aside
id="sidebar"
class="w-[220px] flex flex-col border-r border-[var(--color-border)] bg-[var(--color-surface-0)] fixed inset-y-0 left-0 z-40 -translate-x-full md:translate-x-0 transition-all duration-300 ease-in-out"
data-collapsed="false"
data-mobile-open="false"
>
<Sidebar />
</aside>
<!-- Main content -->
<main id="main-content" class="flex-1 min-w-0 md:ml-[220px] overflow-y-auto bg-[var(--color-surface-0)] transition-all duration-300 ease-in-out">
<slot />
<footer class="border-t border-[var(--color-border)] mt-12 py-6 px-6 text-center text-sm text-[var(--color-text-secondary)]">
<p class="mb-3">Supported by the open source programs of</p>
<div class="flex items-center justify-center gap-6 flex-wrap">
<a href="https://vercel.com/open-source-program" target="_blank" rel="noopener noreferrer" class="flex items-center gap-2 hover:text-[var(--color-text-primary)] transition-colors">
<img src="https://cdn.simpleicons.org/vercel/ffffff" alt="Vercel" class="h-5 w-5 oss-icon" />
<span>Vercel</span>
</a>
<a href="https://neon.tech/open-source" target="_blank" rel="noopener noreferrer" class="flex items-center gap-2 hover:text-[var(--color-text-primary)] transition-colors">
<img src="https://neon.tech/favicon/favicon.svg" alt="Neon" class="h-5 w-5" />
<span>Neon</span>
</a>
<a href="https://claude.com/contact-sales/claude-for-oss" target="_blank" rel="noopener noreferrer" class="flex items-center gap-2 hover:text-[var(--color-text-primary)] transition-colors">
<img src="https://cdn.simpleicons.org/anthropic/ffffff" alt="Claude" class="h-5 w-5 oss-icon" />
<span>Claude</span>
</a>
</div>
</footer>
</main>
</div>
<script>
// Sidebar collapse functionality
function initSidebarCollapse() {
const sidebar = document.getElementById('sidebar');
const mainContent = document.getElementById('main-content');
const toggleBtn = document.getElementById('sidebar-toggle');
if (!sidebar || !mainContent || !toggleBtn) return;
// Load saved state
const savedState = localStorage.getItem('sidebar-collapsed');
const isCollapsed = savedState === 'true';
if (isCollapsed) {
applySidebarState(true);
}
// Toggle handler
toggleBtn.addEventListener('click', () => {
const currentState = sidebar.dataset.collapsed === 'true';
const newState = !currentState;
applySidebarState(newState);
localStorage.setItem('sidebar-collapsed', String(newState));
});
function applySidebarState(collapsed: boolean) {
if (!sidebar || !mainContent) return;
sidebar.dataset.collapsed = String(collapsed);
if (collapsed) {
sidebar.classList.remove('w-[220px]');
sidebar.classList.add('w-[72px]');
mainContent.classList.remove('md:ml-[220px]');
mainContent.classList.add('md:ml-[72px]');
// Hide text elements
document.querySelectorAll('.sidebar-text').forEach(el => {
(el as HTMLElement).style.opacity = '0';
(el as HTMLElement).style.width = '0';
(el as HTMLElement).style.overflow = 'hidden';
});
// Hide nav counts
document.querySelectorAll('.sidebar-nav-count').forEach(el => {
(el as HTMLElement).style.opacity = '0';
(el as HTMLElement).style.width = '0';
});
// Center all nav links with flex
document.querySelectorAll('.sidebar-nav-link, nav > a, nav > button').forEach(el => {
(el as HTMLElement).style.display = 'flex';
(el as HTMLElement).style.justifyContent = 'center';
(el as HTMLElement).style.alignItems = 'center';
(el as HTMLElement).style.padding = '0.5rem';
(el as HTMLElement).style.gap = '0';
});
// Ensure icon containers are centered
document.querySelectorAll('.sidebar-nav-icon, nav > a > span:first-child, nav > button > span:first-child').forEach(el => {
(el as HTMLElement).style.display = 'flex';
(el as HTMLElement).style.justifyContent = 'center';
(el as HTMLElement).style.alignItems = 'center';
(el as HTMLElement).style.margin = '0';
});
// Rotate toggle icon
if (toggleBtn) {
const icon = toggleBtn.querySelector('svg');
if (icon) icon.style.transform = 'rotate(180deg)';
}
} else {
sidebar.classList.remove('w-[72px]');
sidebar.classList.add('w-[220px]');
mainContent.classList.remove('md:ml-[72px]');
mainContent.classList.add('md:ml-[220px]');
// Show text elements
document.querySelectorAll('.sidebar-text').forEach(el => {
(el as HTMLElement).style.opacity = '1';
(el as HTMLElement).style.width = 'auto';
(el as HTMLElement).style.overflow = 'visible';
});
// Show nav counts
document.querySelectorAll('.sidebar-nav-count').forEach(el => {
(el as HTMLElement).style.opacity = '1';
(el as HTMLElement).style.width = 'auto';
});
// Reset nav links alignment
document.querySelectorAll('.sidebar-nav-link, nav > a, nav > button').forEach(el => {
(el as HTMLElement).style.display = '';
(el as HTMLElement).style.justifyContent = '';
(el as HTMLElement).style.alignItems = '';
(el as HTMLElement).style.padding = '';
(el as HTMLElement).style.gap = '';
});
// Reset icon containers
document.querySelectorAll('.sidebar-nav-icon, nav > a > span:first-child, nav > button > span:first-child').forEach(el => {
(el as HTMLElement).style.display = '';
(el as HTMLElement).style.justifyContent = '';
(el as HTMLElement).style.alignItems = '';
(el as HTMLElement).style.margin = '';
});
// Reset toggle icon
if (toggleBtn) {
const icon = toggleBtn.querySelector('svg');
if (icon) icon.style.transform = 'rotate(0deg)';
}
}
}
}
// Initialize on load
initSidebarCollapse();
// Re-initialize on navigation (for SPA-like behavior)
document.addEventListener('astro:page-load', initSidebarCollapse);
// Mobile sidebar drawer (separate from the desktop collapse above —
// on mobile the sidebar is hidden off-screen via -translate-x-full
// and slides in as an overlay instead of shrinking to icons).
function openMobileSidebar() {
const sidebar = document.getElementById('sidebar');
const overlay = document.getElementById('sidebar-overlay');
if (!sidebar || !overlay) return;
sidebar.classList.remove('-translate-x-full');
sidebar.dataset.mobileOpen = 'true';
overlay.classList.remove('hidden');
document.body.style.overflow = 'hidden';
}
function closeMobileSidebar() {
const sidebar = document.getElementById('sidebar');
const overlay = document.getElementById('sidebar-overlay');
if (!sidebar || !overlay) return;
sidebar.classList.add('-translate-x-full');
sidebar.dataset.mobileOpen = 'false';
overlay.classList.add('hidden');
document.body.style.overflow = '';
}
// @ts-ignore — called from the inline onclick in TopBar.astro
window.toggleSidebar = function toggleSidebar() {
const sidebar = document.getElementById('sidebar');
if (!sidebar) return;
if (sidebar.dataset.mobileOpen === 'true') {
closeMobileSidebar();
} else {
openMobileSidebar();
}
};
function initMobileSidebar() {
const overlay = document.getElementById('sidebar-overlay');
overlay?.addEventListener('click', closeMobileSidebar);
// Close the drawer after navigating to a page (mobile only)
document.getElementById('sidebar')?.addEventListener('click', (e) => {
const link = (e.target as HTMLElement).closest('a');
if (link && window.innerWidth < 768) {
closeMobileSidebar();
}
});
// Close if resizing up to desktop width while open
window.addEventListener('resize', () => {
if (window.innerWidth >= 768) closeMobileSidebar();
});
}
initMobileSidebar();
</script>
<!-- Single Clerk island for auth — idle so it doesn't block first paint -->
<ClerkIsland client:idle />
</body>
</html>
+43
View File
@@ -0,0 +1,43 @@
import { verifyToken, createClerkClient } from '@clerk/backend';
export async function authenticateRequest(request: Request): Promise<string | null> {
const authHeader = request.headers.get('authorization');
if (!authHeader?.startsWith('Bearer ')) {
return null;
}
const token = authHeader.slice(7);
try {
const payload = await verifyToken(token, {
secretKey: (import.meta.env.CLERK_SECRET_KEY || process.env.CLERK_SECRET_KEY),
});
return payload.sub;
} catch (err) {
console.error('Clerk token verification failed:', (err as Error).message);
return null;
}
}
/**
* Authenticates and returns the user's primary email address.
* Returns null if unauthenticated or email cannot be resolved.
*/
export async function authenticateAndGetEmail(request: Request): Promise<{ userId: string; email: string } | null> {
const userId = await authenticateRequest(request);
if (!userId) return null;
try {
const clerk = createClerkClient({ secretKey: (import.meta.env.CLERK_SECRET_KEY || process.env.CLERK_SECRET_KEY) });
const user = await clerk.users.getUser(userId);
const email = user.emailAddresses.find(
(e) => e.id === user.primaryEmailAddressId
)?.emailAddress;
if (!email) return null;
return { userId, email };
} catch (err) {
console.error('Failed to resolve user email:', (err as Error).message);
return null;
}
}
+159
View File
@@ -0,0 +1,159 @@
interface Change {
type: string;
description: string;
category: string | null;
raw: string;
}
interface ParsedVersion {
version: string;
content: string | null;
changes: Change[];
changeCount?: number;
error?: string;
}
interface Summary {
total: number;
byType: Record<string, number>;
byCategory: Record<string, number>;
highlights: Change[];
}
interface FormattedChanges {
features: string;
fixes: string;
improvements: string;
breaking: string;
other: string;
}
export function parseVersionChangelog(changelog: string, version: string): ParsedVersion {
const cleanVersion = version.replace(/^v/, '');
const versionRegex = new RegExp(`^##\\s+(?:v)?${cleanVersion.replace(/\./g, '\\.')}`, 'm');
const match = changelog.match(versionRegex);
if (!match) {
return { version: cleanVersion, content: null, changes: [], error: 'Version not found in changelog' };
}
const startIndex = match.index!;
const nextVersionRegex = /^##\s+(?:v)?\d+\.\d+\.\d+/m;
const remainingChangelog = changelog.substring(startIndex + match[0].length);
const nextMatch = remainingChangelog.match(nextVersionRegex);
const endIndex = nextMatch ? startIndex + match[0].length + nextMatch.index! : changelog.length;
const versionContent = changelog.substring(startIndex, endIndex).trim();
const changes = parseChanges(versionContent);
return { version: cleanVersion, content: versionContent, changes, changeCount: changes.length };
}
function parseChanges(content: string): Change[] {
const changes: Change[] = [];
const lines = content.split('\n');
let currentCategory: string | null = null;
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('##')) continue;
if (trimmed.startsWith('###')) {
currentCategory = trimmed.replace(/^###\s*/, '').trim();
continue;
}
if (trimmed.startsWith('-') || trimmed.startsWith('*')) {
const description = trimmed.replace(/^[-*]\s*/, '').trim();
if (!description) continue;
changes.push({
type: classifyChange(description),
description,
category: currentCategory || detectCategory(description),
raw: line,
});
}
}
return changes;
}
function classifyChange(description: string): string {
const lower = description.toLowerCase();
if (lower.includes('breaking') || lower.includes('removed') || lower.includes('deprecated')) return 'breaking';
if (lower.includes('add') || lower.includes('new') || lower.includes('introduce') || lower.includes('support for')) return 'feature';
if (lower.includes('fix') || lower.includes('resolve') || lower.includes('correct') || lower.includes('patch')) return 'fix';
if (lower.includes('improve') || lower.includes('enhance') || lower.includes('optimize') || lower.includes('better') || lower.includes('refactor')) return 'improvement';
if (lower.includes('deprecate')) return 'deprecation';
if (lower.includes('performance') || lower.includes('speed') || lower.includes('faster')) return 'performance';
if (lower.includes('docs') || lower.includes('documentation')) return 'documentation';
return 'other';
}
function detectCategory(description: string): string | null {
const lower = description.toLowerCase();
const categories: Record<string, string[]> = {
'Plugin System': ['plugin', 'plugins', 'marketplace'],
CLI: ['cli', 'command', 'terminal', 'bash'],
Performance: ['performance', 'speed', 'faster', 'optimize', 'cache'],
'UI/UX': ['ui', 'ux', 'interface', 'display', 'output'],
API: ['api', 'endpoint', 'rest', 'graphql'],
Models: ['model', 'sonnet', 'opus', 'haiku', 'claude'],
MCP: ['mcp', 'model context protocol'],
Agents: ['agent', 'subagent', 'explore'],
Settings: ['setting', 'config', 'configuration'],
Hooks: ['hook', 'trigger', 'event'],
Security: ['security', 'auth', 'authentication', 'permission'],
Documentation: ['docs', 'documentation', 'readme'],
Windows: ['windows', 'win32'],
macOS: ['macos', 'darwin', 'mac'],
Linux: ['linux', 'unix'],
};
for (const [category, keywords] of Object.entries(categories)) {
if (keywords.some((keyword) => lower.includes(keyword))) return category;
}
return null;
}
export function generateSummary(changes: Change[]): Summary {
const summary: Summary = { total: changes.length, byType: {}, byCategory: {}, highlights: [] };
for (const change of changes) {
summary.byType[change.type] = (summary.byType[change.type] || 0) + 1;
if (change.category) {
summary.byCategory[change.category] = (summary.byCategory[change.category] || 0) + 1;
}
if (change.type === 'breaking' || change.type === 'feature') {
summary.highlights.push(change);
}
}
return summary;
}
export function formatForDiscord(changes: Change[], maxLength = 1024): FormattedChanges {
const grouped: Record<string, string[]> = { features: [], fixes: [], improvements: [], breaking: [], other: [] };
for (const change of changes) {
switch (change.type) {
case 'feature': grouped.features.push(change.description); break;
case 'fix': grouped.fixes.push(change.description); break;
case 'improvement': case 'performance': grouped.improvements.push(change.description); break;
case 'breaking': case 'deprecation': grouped.breaking.push(change.description); break;
default: grouped.other.push(change.description);
}
}
const truncate = (items: string[], max: number) => {
const text = items.map((item) => `\u2022 ${item}`).join('\n');
if (text.length > max) {
const truncated = text.substring(0, max - 20);
const lastNewline = truncated.lastIndexOf('\n');
return truncated.substring(0, lastNewline) + '\n... [Read more in changelog]';
}
return text;
};
return {
features: truncate(grouped.features, maxLength),
fixes: truncate(grouped.fixes, maxLength),
improvements: truncate(grouped.improvements, maxLength),
breaking: truncate(grouped.breaking, maxLength),
other: truncate(grouped.other, maxLength),
};
}
+16
View File
@@ -0,0 +1,16 @@
export const corsHeaders: Record<string, string> = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, PATCH, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
};
export function corsResponse() {
return new Response(null, { status: 204, headers: corsHeaders });
}
export function jsonResponse(data: unknown, status = 200) {
return new Response(JSON.stringify(data), {
status,
headers: { 'Content-Type': 'application/json', ...corsHeaders },
});
}
+105
View File
@@ -0,0 +1,105 @@
/**
* Minimal Sentry error reporter for dashboard API routes.
*
* Sends errors directly to the Sentry envelope API via fetch() — no SDK
* dependency, works the same on Cloudflare Pages' Workers runtime as it
* does locally. Mirrors the pattern used in cloudflare-workers/{worker}/sentry.js.
*
* Env var required: SENTRY_DSN (DSN from the "aitmpl-dashboard" Sentry project)
*/
interface ParsedDsn {
publicKey: string;
host: string;
projectId: string;
}
function parseDsn(dsn: string): ParsedDsn | null {
try {
const url = new URL(dsn);
return {
publicKey: url.username,
host: url.host,
projectId: url.pathname.replace(/^\//, ''),
};
} catch {
return null;
}
}
function getDsn(): string | undefined {
// Astro exposes server env both via import.meta.env and process.env
// depending on adapter/runtime — check both.
return (import.meta.env.SENTRY_DSN as string | undefined) || process.env.SENTRY_DSN;
}
/**
* Report an error from an API route to Sentry. Safe to call unconditionally:
* no-ops if SENTRY_DSN isn't configured, and never throws.
*
* @param error - The error (or message) to report
* @param context - Extra tags/context, e.g. { route: '/api/health-check' }
*/
export async function captureApiError(
error: unknown,
context: Record<string, unknown> = {}
): Promise<void> {
const dsn = getDsn();
if (!dsn) {
console.error('[error-tracking] SENTRY_DSN not configured, skipping report:', error);
return;
}
const parsed = parseDsn(dsn);
if (!parsed) {
console.error('[error-tracking] invalid SENTRY_DSN, skipping report');
return;
}
const message = error instanceof Error ? error.message : String(error);
const stack = error instanceof Error ? error.stack : undefined;
const eventId = crypto.randomUUID().replace(/-/g, '');
const timestamp = new Date().toISOString();
const event = {
event_id: eventId,
timestamp,
platform: 'javascript',
logger: 'dashboard-api',
environment: process.env.NODE_ENV === 'production' ? 'production' : 'development',
tags: { route: context.route || 'unknown' },
extra: context,
exception: {
values: [
{
type: error instanceof Error ? error.name || 'Error' : 'Error',
value: message,
stacktrace: stack
? { frames: [{ filename: 'dashboard-api', function: 'anonymous', context_line: stack }] }
: undefined,
},
],
},
};
const envelopeHeader = JSON.stringify({ event_id: eventId, sent_at: timestamp });
const itemHeader = JSON.stringify({ type: 'event' });
const body = `${envelopeHeader}\n${itemHeader}\n${JSON.stringify(event)}\n`;
const endpoint = `https://${parsed.host}/api/${parsed.projectId}/envelope/`;
const authHeader = `Sentry sentry_version=7, sentry_client=aitmpl-dashboard/1.0, sentry_key=${parsed.publicKey}`;
try {
await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-sentry-envelope',
'X-Sentry-Auth': authHeader,
},
body,
});
} catch (sendError) {
// Never let error reporting break the API route itself
console.error('[error-tracking] failed to send report:', (sendError as Error).message);
}
}
+9
View File
@@ -0,0 +1,9 @@
import { neon } from '@neondatabase/serverless';
export function getNeonClient() {
const connectionString = import.meta.env.NEON_DATABASE_URL || process.env.NEON_DATABASE_URL;
if (!connectionString) {
throw new Error('NEON_DATABASE_URL not configured');
}
return neon(connectionString);
}
+109
View File
@@ -0,0 +1,109 @@
import type { Collection, CollectionItem } from './types';
const API_BASE = '';
const MAX_RETRIES = 2;
async function apiFetch<T>(path: string, token: string, options: RequestInit = {}): Promise<T> {
let lastError: Error | null = null;
const attempts = options.method && options.method !== 'GET' ? 1 : MAX_RETRIES + 1;
for (let i = 0; i < attempts; i++) {
try {
if (i > 0) await new Promise((r) => setTimeout(r, 500 * i));
const res = await fetch(`${API_BASE}${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
...options.headers,
},
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error ?? `Request failed (${res.status})`);
}
return data;
} catch (err) {
lastError = err as Error;
if (i < attempts - 1) continue;
}
}
throw lastError;
}
export const collectionsApi = {
/** List all collections with their items */
async list(token: string): Promise<Collection[]> {
const data = await apiFetch<{ collections: Collection[] }>('/api/collections', token);
return data.collections;
},
/** Create a new collection */
async create(token: string, name: string): Promise<Collection> {
const data = await apiFetch<{ collection: Collection }>('/api/collections', token, {
method: 'POST',
body: JSON.stringify({ name }),
});
return data.collection;
},
/** Rename a collection */
async rename(token: string, id: string, name: string): Promise<Collection> {
const data = await apiFetch<{ collection: Collection }>(`/api/collections/${id}`, token, {
method: 'PATCH',
body: JSON.stringify({ name }),
});
return data.collection;
},
/** Delete a collection */
async delete(token: string, id: string): Promise<void> {
await apiFetch(`/api/collections/${id}`, token, { method: 'DELETE' });
},
/** Add a component to a collection */
async addItem(
token: string,
collectionId: string,
component: { type: string; path: string; name: string; category?: string }
): Promise<CollectionItem> {
const data = await apiFetch<{ item: CollectionItem }>('/api/collections/items', token, {
method: 'POST',
body: JSON.stringify({
collectionId,
componentType: component.type,
componentPath: component.path,
componentName: component.name,
componentCategory: component.category,
}),
});
return data.item;
},
/** Remove an item from a collection */
async removeItem(token: string, itemId: string, collectionId: string): Promise<void> {
await apiFetch('/api/collections/items', token, {
method: 'DELETE',
body: JSON.stringify({ itemId, collectionId }),
});
},
/** Move an item between collections */
async moveItem(
token: string,
itemId: string,
fromCollectionId: string,
toCollectionId: string
): Promise<CollectionItem> {
const data = await apiFetch<{ item: CollectionItem }>('/api/collections/items', token, {
method: 'PATCH',
body: JSON.stringify({ itemId, fromCollectionId, toCollectionId }),
});
return data.item;
},
};
+80
View File
@@ -0,0 +1,80 @@
import type { FeaturedItem } from './types';
export const COMPONENTS_JSON_URL =
import.meta.env.PUBLIC_COMPONENTS_JSON_URL ?? '/components.json';
export const ITEMS_PER_PAGE = 24;
export const FEATURED_ITEMS: FeaturedItem[] = [
{
name: 'Bright Data',
description: 'Complete Web Data Template',
logo: 'https://avatars.githubusercontent.com/u/213028976?v=4',
url: '/featured/brightdata',
tag: 'Web Data',
tagColor: '#2563eb',
category: 'Infrastructure',
ctaLabel: 'Try Bright Data Free',
ctaUrl: 'https://get.brightdata.com/lcqorc6nzp9w',
websiteUrl: 'https://get.brightdata.com/lcqorc6nzp9w',
installCommand:
'npx claude-code-templates@latest --skill web-data/search,web-data/scrape,web-data/data-feeds,web-data/bright-data-mcp,web-data/bright-data-best-practices,development/brightdata-local-search --mcp web-data/brightdata --yes',
metadata: {
Components: '8',
Tools: '60+',
Integration: 'MCP, Skills, CLI',
},
links: [
{ label: 'Skills Repository', url: 'https://github.com/brightdata/skills' },
{ label: 'MCP Server', url: 'https://github.com/brightdata/brightdata-mcp' },
{ label: 'API Documentation', url: 'https://docs.brightdata.com' },
{ label: 'brightdata.com', url: 'https://get.brightdata.com/lcqorc6nzp9w' },
],
},
{
name: 'ClaudeKit',
description: 'AI Agents & Skills',
logo: 'https://docs.claudekit.cc/logo-horizontal.png',
url: '/featured/claudekit',
tag: 'Toolkit',
tagColor: '#d97706',
category: 'AI Engineering',
ctaLabel: 'Get ClaudeKit',
ctaUrl: 'https://claudekit.cc',
websiteUrl: 'https://claudekit.cc',
metadata: {
Users: '4,000+',
Countries: '109',
},
links: [
{ label: 'Documentation', url: 'https://docs.claudekit.cc' },
{ label: 'claudekit.cc', url: 'https://claudekit.cc' },
],
},
{
name: 'BrainGrid',
description: 'Plan. Build. Verify. Repeat.',
logo: '/braingrid-logo.webp',
url: '/featured/braingrid',
tag: 'Planning',
tagColor: '#c5e063',
category: 'Product Planning',
ctaLabel: 'Try BrainGrid',
ctaUrl: 'https://www.braingrid.ai?utm_source=aitmpl&utm_medium=featured&utm_campaign=partner',
websiteUrl: 'https://www.braingrid.ai',
metadata: {
Integration: 'MCP, CLI',
Stage: 'Battle-tested',
},
links: [
{ label: 'braingrid.ai', url: 'https://www.braingrid.ai' },
],
},
];
export const NAV_LINKS = {
github: 'https://github.com/davila7/claude-code-templates',
docs: 'https://docs.aitmpl.com/',
blog: 'https://aitmpl.com/blog/',
trending: 'https://aitmpl.com/trending.html',
};
+292
View File
@@ -0,0 +1,292 @@
import type { Component, ComponentsData, ComponentType } from './types';
import { COMPONENTS_JSON_URL } from './constants';
// Base URL for per-component content files (generated by the catalog script).
// Each file is /component-content/{type-plural}/{slug}.json and contains { content: string }.
const CONTENT_BASE = '/component-content';
let cachedData: ComponentsData | null = null;
let cacheTimestamp = 0;
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
export async function fetchComponents(): Promise<ComponentsData> {
const now = Date.now();
if (cachedData && now - cacheTimestamp < CACHE_TTL) {
return cachedData;
}
// In dev server-side, read from local file directly to avoid stale production data
if (typeof window === 'undefined' && import.meta.env.DEV) {
try {
const fs = await import('node:fs');
const path = await import('node:path');
const filePath = path.resolve('public/components.json');
const raw = fs.readFileSync(filePath, 'utf-8');
const data: ComponentsData = JSON.parse(raw);
cachedData = data;
cacheTimestamp = now;
return data;
} catch {
// fallback to fetch
}
}
// Server-side: resolve relative URLs to absolute (fetch() needs full URL on Node)
let url = COMPONENTS_JSON_URL;
if (typeof window === 'undefined' && url.startsWith('/')) {
const base = import.meta.env.SITE || 'https://www.aitmpl.com';
url = `${base}${url}`;
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 15000);
try {
const res = await fetch(url, {
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data: ComponentsData = await res.json();
cachedData = data;
cacheTimestamp = now;
return data;
} catch (err) {
clearTimeout(timeoutId);
if (cachedData) return cachedData;
throw err;
}
}
// ---------------------------------------------------------------------------
// Per-type index slices and search index (Cloudflare Pages static artifacts,
// emitted by scripts/generate_components_json.py). These keep the browser from
// downloading the whole ~1.9MB index when it only needs one type or a search.
// ---------------------------------------------------------------------------
const COMPONENTS_BASE = '/components';
const SEARCH_INDEX_URL = '/search-index.json';
const typeCache = new Map<string, { data: Component[]; ts: number }>();
let searchIndexCache: { data: SearchIndexEntry[]; ts: number } | null = null;
export interface SearchIndexEntry {
type: string;
name: string;
path: string;
description: string;
category: string;
}
/** Resolve a same-origin path to an absolute URL when running server-side. */
function toAbsolute(pathUrl: string): string {
if (typeof window === 'undefined' && pathUrl.startsWith('/')) {
const base = import.meta.env.SITE || 'https://www.aitmpl.com';
return `${base}${pathUrl}`;
}
return pathUrl;
}
/**
* Fetch a single component type's array from /components/{type}.json.
* Falls back to an empty array on error. Uses a 5-min in-memory cache per type.
*/
export async function fetchComponentsByType(type: string): Promise<Component[]> {
const now = Date.now();
const cached = typeCache.get(type);
if (cached && now - cached.ts < CACHE_TTL) {
return cached.data;
}
const urlPath = `${COMPONENTS_BASE}/${type}.json`;
// Dev SSR: read from disk to avoid stale production data.
if (typeof window === 'undefined' && import.meta.env.DEV) {
try {
const fs = await import('node:fs');
const path = await import('node:path');
const filePath = path.resolve(`public/components/${type}.json`);
const raw = fs.readFileSync(filePath, 'utf-8');
const data: Component[] = JSON.parse(raw);
typeCache.set(type, { data, ts: now });
return data;
} catch {
// fall through to fetch
}
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 15000);
try {
const res = await fetch(toAbsolute(urlPath), { signal: controller.signal });
clearTimeout(timeoutId);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data: Component[] = await res.json();
typeCache.set(type, { data, ts: now });
return data;
} catch (err) {
clearTimeout(timeoutId);
if (cached) return cached.data;
return [];
}
}
/**
* Fetch the flat search index (name/description/category/type/path only).
* Used by the global Cmd+K search, which needs every type at once but not the
* heavier per-component metadata. 5-min in-memory cache.
*/
export async function fetchSearchIndex(): Promise<SearchIndexEntry[]> {
const now = Date.now();
if (searchIndexCache && now - searchIndexCache.ts < CACHE_TTL) {
return searchIndexCache.data;
}
// Dev SSR: read from disk.
if (typeof window === 'undefined' && import.meta.env.DEV) {
try {
const fs = await import('node:fs');
const path = await import('node:path');
const filePath = path.resolve('public/search-index.json');
const raw = fs.readFileSync(filePath, 'utf-8');
const data: SearchIndexEntry[] = JSON.parse(raw);
searchIndexCache = { data, ts: now };
return data;
} catch {
// fall through to fetch
}
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 15000);
try {
const res = await fetch(toAbsolute(SEARCH_INDEX_URL), { signal: controller.signal });
clearTimeout(timeoutId);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data: SearchIndexEntry[] = await res.json();
searchIndexCache = { data, ts: now };
return data;
} catch (err) {
clearTimeout(timeoutId);
if (searchIndexCache) return searchIndexCache.data;
return [];
}
}
export function getCategories(components: Component[]): string[] {
const cats = new Set<string>();
for (const c of components) {
if (c.category) cats.add(c.category);
}
return Array.from(cats).sort();
}
export function filterByCategory(components: Component[], category: string): Component[] {
if (!category || category === 'all') return components;
return components.filter((c) => c.category === category);
}
export function searchComponents(components: Component[], query: string): Component[] {
if (!query.trim()) return components;
const q = query.toLowerCase();
return components.filter(
(c) =>
c.name.toLowerCase().includes(q) ||
(c.description?.toLowerCase().includes(q)) ||
c.category?.toLowerCase().includes(q)
// Note: full-text search over content was removed; content is now loaded
// on demand per component (see fetchComponentContent).
);
}
/**
* Fetch the raw content string for a single component.
* Content is stored in separate files to keep components.json lightweight.
*
* @param type - Singular component type from URL params (e.g. "agent", "skill")
* @param slug - Path without extension (e.g. "code-quality/linter")
*/
export async function fetchComponentContent(type: string, slug: string): Promise<string> {
// type from the URL param is singular; the file path uses the plural form
const typePlural = type.endsWith('s') ? type : type + 's';
const urlPath = `${CONTENT_BASE}/${typePlural}/${slug}.json`;
// In dev server-side, read from local file to avoid stale production data
if (typeof window === 'undefined' && import.meta.env.DEV) {
try {
const fs = await import('node:fs');
const path = await import('node:path');
const filePath = path.resolve(`public/component-content/${typePlural}/${slug}.json`);
const raw = fs.readFileSync(filePath, 'utf-8');
const data = JSON.parse(raw);
return data.content ?? '';
} catch {
// fall through to fetch
}
}
// Resolve to absolute URL on the server (fetch() requires it)
let url = urlPath;
if (typeof window === 'undefined' && urlPath.startsWith('/')) {
const base = import.meta.env.SITE || 'https://www.aitmpl.com';
url = `${base}${urlPath}`;
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000);
try {
const res = await fetch(url, { signal: controller.signal });
clearTimeout(timeoutId);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
return data.content ?? '';
} catch {
clearTimeout(timeoutId);
return '';
}
}
export function sortComponents(
components: Component[],
sortBy: 'downloads' | 'name' = 'downloads'
): Component[] {
const sorted = [...components];
if (sortBy === 'downloads') {
sorted.sort((a, b) => (b.downloads ?? 0) - (a.downloads ?? 0));
} else {
sorted.sort((a, b) => a.name.localeCompare(b.name));
}
return sorted;
}
export function getComponentCounts(data: ComponentsData): Record<ComponentType, number> {
return {
skills: data.skills?.length ?? 0,
agents: data.agents?.length ?? 0,
commands: data.commands?.length ?? 0,
settings: data.settings?.length ?? 0,
hooks: data.hooks?.length ?? 0,
mcps: data.mcps?.length ?? 0,
loops: data.loops?.length ?? 0,
templates: data.templates?.length ?? 0,
};
}
export function getInstallCommand(component: Component): string {
const typeFlag: Record<string, string> = {
agent: '--agent',
command: '--command',
mcp: '--mcp',
setting: '--setting',
hook: '--hook',
skill: '--skill',
loop: '--loop',
template: '--template',
};
const flag = typeFlag[component.type] ?? '--agent';
const cleanPath = component.path?.replace(/\.(md|json)$/, '') ?? component.name;
return `npx claude-code-templates@latest ${flag} ${cleanPath}`;
}
+139
View File
@@ -0,0 +1,139 @@
const GITHUB_API = 'https://api.github.com';
export interface GitHubRepo {
id: number;
full_name: string;
name: string;
owner: { login: string };
default_branch: string;
private: boolean;
description: string | null;
updated_at: string;
}
interface GitHubRef {
ref: string;
object: { sha: string };
}
interface GitHubTreeItem {
path: string;
mode: '100644';
type: 'blob';
content: string;
}
export interface PRResult {
html_url: string;
number: number;
}
async function ghFetch<T>(path: string, token: string, options: RequestInit = {}): Promise<T> {
const res = await fetch(`${GITHUB_API}${path}`, {
...options,
headers: {
Accept: 'application/vnd.github+json',
Authorization: `Bearer ${token}`,
'X-GitHub-Api-Version': '2022-11-28',
...options.headers,
},
});
if (!res.ok) {
const body = await res.text();
throw new Error(`GitHub API error ${res.status}: ${body}`);
}
return res.json();
}
export async function listRepos(token: string): Promise<GitHubRepo[]> {
const repos: GitHubRepo[] = [];
let page = 1;
while (page <= 5) {
const batch = await ghFetch<GitHubRepo[]>(
`/user/repos?sort=updated&per_page=100&page=${page}&affiliation=owner,collaborator,organization_member`,
token
);
repos.push(...batch);
if (batch.length < 100) break;
page++;
}
return repos;
}
export async function createPR(
token: string,
repo: string,
files: Record<string, string>,
branchName: string,
commitMessage: string,
prTitle: string,
prBody: string
): Promise<PRResult> {
// 1. Get default branch ref
const repoData = await ghFetch<GitHubRepo>(`/repos/${repo}`, token);
const baseBranch = repoData.default_branch;
const baseRef = await ghFetch<GitHubRef>(
`/repos/${repo}/git/ref/heads/${baseBranch}`,
token
);
const baseSha = baseRef.object.sha;
// 2. Create tree with all files
const treeItems: GitHubTreeItem[] = Object.entries(files).map(([path, content]) => ({
path,
mode: '100644',
type: 'blob',
content,
}));
const tree = await ghFetch<{ sha: string }>(
`/repos/${repo}/git/trees`,
token,
{
method: 'POST',
body: JSON.stringify({ base_tree: baseSha, tree: treeItems }),
}
);
// 3. Create commit
const commit = await ghFetch<{ sha: string }>(
`/repos/${repo}/git/commits`,
token,
{
method: 'POST',
body: JSON.stringify({
message: commitMessage,
tree: tree.sha,
parents: [baseSha],
}),
}
);
// 4. Create branch
await ghFetch(
`/repos/${repo}/git/refs`,
token,
{
method: 'POST',
body: JSON.stringify({ ref: `refs/heads/${branchName}`, sha: commit.sha }),
}
);
// 5. Create PR
const pr = await ghFetch<PRResult>(
`/repos/${repo}/pulls`,
token,
{
method: 'POST',
body: JSON.stringify({
title: prTitle,
body: prBody,
head: branchName,
base: baseBranch,
}),
}
);
return pr;
}
+29
View File
@@ -0,0 +1,29 @@
// SVG icon paths - Vercel-style minimal dev icons
export const ICONS: Record<string, string> = {
skills: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>`,
agents: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="4"/><circle cx="9" cy="12" r="1.5"/><circle cx="15" cy="12" r="1.5"/><path d="M9 17h6"/><path d="M12 2v2"/><path d="M2 10h2"/><path d="M20 10h2"/></svg>`,
commands: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/></svg>`,
settings: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12.22 2h-.44a2 2 0 00-2 2v.18a2 2 0 01-1 1.73l-.43.25a2 2 0 01-2 0l-.15-.08a2 2 0 00-2.73.73l-.22.38a2 2 0 00.73 2.73l.15.1a2 2 0 011 1.72v.51a2 2 0 01-1 1.74l-.15.09a2 2 0 00-.73 2.73l.22.38a2 2 0 002.73.73l.15-.08a2 2 0 012 0l.43.25a2 2 0 011 1.73V20a2 2 0 002 2h.44a2 2 0 002-2v-.18a2 2 0 011-1.73l.43-.25a2 2 0 012 0l.15.08a2 2 0 002.73-.73l.22-.39a2 2 0 00-.73-2.73l-.15-.08a2 2 0 01-1-1.74v-.5a2 2 0 011-1.74l.15-.09a2 2 0 00.73-2.73l-.22-.38a2 2 0 00-2.73-.73l-.15.08a2 2 0 01-2 0l-.43-.25a2 2 0 01-1-1.73V4a2 2 0 00-2-2z"/><circle cx="12" cy="12" r="3"/></svg>`,
hooks: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71"/></svg>`,
mcps: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 14.899A7 7 0 1115.71 8h1.79a4.5 4.5 0 012.5 8.242"/><path d="M12 12v9"/><path d="M8 17l4 4 4-4"/></svg>`,
loops: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 11-2.64-6.36"/><polyline points="21 3 21 8 16 8"/></svg>`,
};
// Compact inline SVG for React (as string)
export const ICON_SVGS = ICONS;
export const TYPE_CONFIG: Record<string, { icon: string; label: string; singular: string; color: string; flag: string }> = {
skills: { icon: 'skills', label: 'Skills', singular: 'skill', color: '#f59e0b', flag: '--skill' },
agents: { icon: 'agents', label: 'Agents', singular: 'agent', color: '#3b82f6', flag: '--agent' },
commands: { icon: 'commands', label: 'Commands', singular: 'command', color: '#10b981', flag: '--command' },
settings: { icon: 'settings', label: 'Settings', singular: 'setting', color: '#8b5cf6', flag: '--setting' },
hooks: { icon: 'hooks', label: 'Hooks', singular: 'hook', color: '#f97316', flag: '--hook' },
mcps: { icon: 'mcps', label: 'MCPs', singular: 'mcp', color: '#06b6d4', flag: '--mcp' },
loops: { icon: 'loops', label: 'Loops', singular: 'loop', color: '#ec4899', flag: '--loop' },
};
export const VALID_TYPES = Object.keys(TYPE_CONFIG);
export function getTypeConfig(type: string) {
return TYPE_CONFIG[type] ?? TYPE_CONFIG.skills;
}
+47
View File
@@ -0,0 +1,47 @@
-- Component Improvement Loop - Database Schema
-- Run against the existing Neon database
CREATE TABLE IF NOT EXISTS review_cycles (
id BIGSERIAL PRIMARY KEY,
session_id TEXT NOT NULL,
component_path TEXT NOT NULL,
component_type TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
phase TEXT NOT NULL DEFAULT 'selection',
pr_url TEXT,
pr_number INTEGER,
branch_name TEXT,
linear_issue_id TEXT,
improvements_summary TEXT,
error_message TEXT,
started_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
completed_at TIMESTAMPTZ
);
CREATE TABLE IF NOT EXISTS tool_executions (
id BIGSERIAL PRIMARY KEY,
cycle_id BIGINT REFERENCES review_cycles(id),
session_id TEXT NOT NULL,
tool_name TEXT NOT NULL,
tool_args_summary TEXT,
phase TEXT,
result_status TEXT DEFAULT 'success',
result_summary TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS cycle_control (
id INTEGER PRIMARY KEY DEFAULT 1 CHECK (id = 1),
is_paused BOOLEAN DEFAULT false,
paused_by TEXT,
reason TEXT,
updated_at TIMESTAMPTZ DEFAULT NOW()
);
INSERT INTO cycle_control (id, is_paused) VALUES (1, false) ON CONFLICT DO NOTHING;
CREATE INDEX IF NOT EXISTS idx_review_cycles_status ON review_cycles(status);
CREATE INDEX IF NOT EXISTS idx_review_cycles_session ON review_cycles(session_id);
CREATE INDEX IF NOT EXISTS idx_tool_executions_cycle ON tool_executions(cycle_id, created_at);
CREATE INDEX IF NOT EXISTS idx_tool_executions_session ON tool_executions(session_id);
+37
View File
@@ -0,0 +1,37 @@
export interface ReviewCycle {
id: string;
session_id: string;
component_path: string;
component_type: string;
status: 'active' | 'completed' | 'failed' | 'merged';
phase: 'selection' | 'research' | 'improve' | 'pr_created' | 'reporting' | 'merging';
pr_url: string | null;
pr_number: number | null;
branch_name: string | null;
linear_issue_id: string | null;
improvements_summary: string | null;
error_message: string | null;
started_at: string;
updated_at: string;
completed_at: string | null;
}
export interface ToolExecution {
id: string;
cycle_id: string;
session_id: string;
tool_name: string;
tool_args_summary: string | null;
phase: string | null;
result_status: 'success' | 'error';
result_summary: string | null;
created_at: string;
}
export interface CycleControl {
id: string;
is_paused: boolean;
paused_by: string | null;
reason: string | null;
updated_at?: string;
}
+47
View File
@@ -0,0 +1,47 @@
// Theme management utility
export type Theme = 'light' | 'dark';
export const THEME_KEY = 'claude-theme';
export function getTheme(): Theme {
if (typeof window === 'undefined') return 'dark';
const stored = localStorage.getItem(THEME_KEY);
if (stored === 'light' || stored === 'dark') {
return stored;
}
// Detect OS preference on first visit
if (window.matchMedia?.('(prefers-color-scheme: light)').matches) {
return 'light';
}
return 'dark';
}
export function setTheme(theme: Theme): void {
if (typeof window === 'undefined') return;
localStorage.setItem(THEME_KEY, theme);
applyTheme(theme);
}
export function toggleTheme(): Theme {
const current = getTheme();
const next = current === 'dark' ? 'light' : 'dark';
setTheme(next);
return next;
}
export function applyTheme(theme: Theme): void {
if (typeof document === 'undefined') return;
const html = document.documentElement;
// Remove both classes first
html.classList.remove('light', 'dark');
// Add the correct class
html.classList.add(theme);
}
// Initialize theme on page load
export function initTheme(): void {
if (typeof window === 'undefined') return;
const theme = getTheme();
applyTheme(theme);
}
+84
View File
@@ -0,0 +1,84 @@
export interface Component {
name: string;
path: string;
category: string;
type: string;
content: string;
description?: string;
url?: string;
downloads?: number;
references?: string[];
}
export interface ComponentsData {
agents: Component[];
commands: Component[];
mcps: Component[];
settings: Component[];
hooks: Component[];
skills: Component[];
loops: Component[];
templates: Component[];
}
export type ComponentType = keyof ComponentsData;
export interface CartItem {
name: string;
path: string;
category: string;
description: string;
icon: string;
}
export interface Cart {
agents: CartItem[];
commands: CartItem[];
settings: CartItem[];
hooks: CartItem[];
mcps: CartItem[];
skills: CartItem[];
loops: CartItem[];
templates: CartItem[];
}
export interface FeaturedLink {
label: string;
url: string;
}
export interface FeaturedItem {
name: string;
description: string;
logo: string;
url: string;
tag: string;
tagColor: string;
category: string;
ctaLabel: string;
ctaUrl: string;
websiteUrl: string;
installCommand?: string;
metadata: Record<string, string>;
links: FeaturedLink[];
}
export interface CollectionItem {
id: string;
collection_id: string;
component_type: string;
component_path: string;
component_name: string;
component_category: string | null;
added_at: string;
}
export interface Collection {
id: string;
clerk_user_id: string;
name: string;
position: number;
created_at: string;
updated_at: string;
collection_items: CollectionItem[];
}
+24
View File
@@ -0,0 +1,24 @@
import { defineMiddleware } from 'astro:middleware';
/**
* Injects Cloudflare Pages runtime env bindings into process.env so that
* API routes and libs can use `import.meta.env.X || process.env.X` as a
* unified pattern that works on both Vercel (Node.js) and Cloudflare Pages.
*
* On Cloudflare Pages, secrets set via `wrangler pages secret put` are
* available only through `context.locals.runtime.env`, not process.env.
* This middleware bridges that gap.
*/
export const onRequest = defineMiddleware(async (context, next) => {
const runtime = (context.locals as { runtime?: { env?: Record<string, string> } }).runtime;
if (runtime?.env) {
for (const [key, value] of Object.entries(runtime.env)) {
if (typeof value === 'string' && !process.env[key]) {
process.env[key] = value;
}
}
}
return next();
});
+111
View File
@@ -0,0 +1,111 @@
---
import DashboardLayout from '../layouts/DashboardLayout.astro';
import TopBar from '../components/TopBar.astro';
import FeaturedSection from '../components/FeaturedSection.astro';
import ComponentGrid from '../components/ComponentGrid.tsx';
import SearchModal from '../components/SearchModal.tsx';
import CartSidebar from '../components/CartSidebar.tsx';
import { VALID_TYPES, TYPE_CONFIG, ICONS } from '../lib/icons';
export const prerender = true;
const TYPE_META: Record<string, { title: string; description: string; tagline: string }> = {
skills: {
title: 'Claude Code Skills: Pre-built Templates & Configurations',
description: 'Browse skills for Claude Code. Pre-built templates and configurations to enhance your AI development workflow. Install with a single npx command.',
tagline: 'Pre-built templates and configurations to supercharge your AI workflow'
},
agents: {
title: '600+ AI Agents for Claude Code — Development, Security, DevOps',
description: '600+ specialized AI agents for Claude Code. Frontend, backend, security, DevOps, data science, and more. Install with a single npx command.',
tagline: 'Specialized AI agents for every development task'
},
commands: {
title: '200+ Slash Commands for Claude Code Workflows',
description: '200+ custom slash commands for Claude Code. Automate testing, deployment, code review, and more. Install with a single npx command.',
tagline: 'Custom slash commands to automate your workflow'
},
settings: {
title: 'Claude Code Settings: 60+ Configuration Templates',
description: '60+ configuration templates for Claude Code. Statuslines, permissions, model settings, and more. Install with a single npx command.',
tagline: 'Configuration templates for optimal Claude Code setup'
},
hooks: {
title: '39+ Claude Code Hooks: Automation Triggers & Scripts',
description: '39+ automation hooks for Claude Code. Git hooks, notification triggers, and custom scripts. Install with a single npx command.',
tagline: 'Automation triggers and scripts for seamless integration'
},
mcps: {
title: '55+ MCP Integrations for Claude Code — External Services',
description: '55+ Model Context Protocol integrations for Claude Code. Connect to databases, APIs, and external services. Install with a single npx command.',
tagline: 'Connect to databases, APIs, and external services'
},
loops: {
title: 'Claude Code Loops: Autonomous Agentic Workflows (Loop Engineering)',
description: 'Ready-to-run agentic loops for Claude Code. Each loop defines a goal, an interval, and a stop condition, and composes agents, skills and hooks. Install with a single npx command.',
tagline: 'Autonomous agentic workflows that run until the job is done'
},
};
const { type } = Astro.params;
const activeType = type && VALID_TYPES.includes(type) ? type : 'skills';
const config = TYPE_CONFIG[activeType];
const meta = TYPE_META[activeType];
export function getStaticPaths() {
return [
{ params: { type: 'skills' } },
{ params: { type: 'agents' } },
{ params: { type: 'commands' } },
{ params: { type: 'settings' } },
{ params: { type: 'hooks' } },
{ params: { type: 'mcps' } },
{ params: { type: 'loops' } },
];
}
---
<DashboardLayout
title={meta?.title ?? `${activeType.charAt(0).toUpperCase() + activeType.slice(1)} - Claude Code Templates`}
description={meta?.description ?? `Browse ${activeType} for Claude Code. Install with a single npx command.`}
>
<TopBar />
<!-- Premium Page Header -->
<div class="relative overflow-hidden border-b border-[var(--color-border)] bg-gradient-to-b from-[var(--color-surface-0)] to-[var(--color-surface-1)]">
<!-- Animated background gradient -->
<div class="absolute inset-0 opacity-30">
<div class="absolute inset-0 bg-gradient-to-br from-[var(--color-primary-500)]/10 via-transparent to-[var(--color-accent-400)]/10" style="animation: gradient-shimmer 8s ease infinite; background-size: 200% 200%;"></div>
</div>
<!-- Decorative elements -->
<div class="absolute top-0 right-0 w-96 h-96 bg-gradient-to-br from-[var(--color-primary-500)]/5 to-transparent rounded-full blur-3xl"></div>
<div class="absolute bottom-0 left-0 w-96 h-96 bg-gradient-to-tr from-[var(--color-accent-400)]/5 to-transparent rounded-full blur-3xl"></div>
<div class="relative px-6 py-12 max-w-7xl mx-auto">
<div class="flex items-center gap-6">
<!-- Large icon -->
<div
class="w-20 h-20 rounded-2xl flex items-center justify-center shrink-0 shadow-2xl animate-float"
style={`background: linear-gradient(135deg, ${config?.color ?? '#5e6ad2'}20 0%, ${config?.color ?? '#5e6ad2'}10 100%); border: 2px solid ${config?.color ?? '#5e6ad2'}30;`}
>
<div class="[&>svg]:w-10 [&>svg]:h-10" style={`color: ${config?.color ?? '#5e6ad2'}`} set:html={ICONS[activeType]} />
</div>
<div class="flex-1">
<h1 class="text-3xl font-bold text-[var(--color-text-primary)] mb-2 tracking-tight">
{config?.label ?? activeType}
</h1>
<p class="text-[15px] text-[var(--color-text-secondary)] leading-relaxed max-w-2xl">
{meta?.tagline ?? meta?.description}
</p>
</div>
</div>
</div>
</div>
<FeaturedSection />
<ComponentGrid client:load initialType={activeType} />
<SearchModal client:idle />
<CartSidebar client:idle />
</DashboardLayout>
@@ -0,0 +1,278 @@
import type { APIRoute } from 'astro';
import { getNeonClient } from '../../lib/api/neon';
import { corsResponse, jsonResponse } from '../../lib/api/cors';
import { parseVersionChangelog, formatForDiscord, generateSummary } from '../../lib/api/changelog-parser';
import { captureApiError } from '../../lib/api/error-tracking';
const NPM_PACKAGE = '@anthropic-ai/claude-code';
const CHANGELOG_URL = 'https://raw.githubusercontent.com/anthropics/claude-code/main/CHANGELOG.md';
async function getLatestNPMVersion() {
const response = await fetch(`https://registry.npmjs.org/${NPM_PACKAGE}/latest`);
const data = await response.json();
return {
version: data.version,
publishedAt: data.time?.modified || new Date().toISOString(),
npmUrl: `https://www.npmjs.com/package/${NPM_PACKAGE}/v/${data.version}`,
};
}
async function sendToDiscord(
versionData: { version: string; npmUrl: string; githubUrl: string },
formatted: ReturnType<typeof formatForDiscord>,
summary: ReturnType<typeof generateSummary>,
) {
const webhookUrl = import.meta.env.DISCORD_WEBHOOK_URL_CHANGELOG || process.env.DISCORD_WEBHOOK_URL_CHANGELOG || import.meta.env.DISCORD_WEBHOOK_URL || process.env.DISCORD_WEBHOOK_URL;
if (!webhookUrl) {
throw new Error('Discord webhook URL not configured');
}
const embed: Record<string, unknown> = {
title: `Claude Code ${versionData.version} Released`,
description: `A new version of Claude Code is available with **${summary.total} changes**!`,
url: versionData.githubUrl,
color: 0x8b5cf6,
fields: [] as Record<string, unknown>[],
footer: {
text: 'Claude Code Changelog Monitor',
icon_url: 'https://avatars.githubusercontent.com/u/100788936?s=200&v=4',
},
timestamp: new Date().toISOString(),
};
const fields = embed.fields as Record<string, unknown>[];
if (formatted.breaking && formatted.breaking.length > 0) {
fields.push({ name: 'Breaking Changes', value: formatted.breaking, inline: false });
}
if (formatted.features && formatted.features.length > 0) {
fields.push({ name: 'New Features', value: formatted.features, inline: false });
}
if (formatted.improvements && formatted.improvements.length > 0) {
fields.push({ name: 'Improvements', value: formatted.improvements, inline: false });
}
if (formatted.fixes && formatted.fixes.length > 0) {
fields.push({ name: 'Bug Fixes', value: formatted.fixes, inline: false });
}
fields.push({
name: 'Installation',
value: `\`\`\`bash\nnpm install -g @anthropic-ai/claude-code@${versionData.version}\n\`\`\``,
inline: false,
});
fields.push({
name: 'Links',
value: `[NPM Package](${versionData.npmUrl}) | [Full Changelog](${versionData.githubUrl})`,
inline: false,
});
const payload = {
username: 'Claude Code Monitor',
avatar_url: 'https://raw.githubusercontent.com/anthropics/claude-code/main/assets/icon.png',
embeds: [embed],
};
const response = await fetch(webhookUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
return { success: true, status: response.status, payload };
}
async function handleCheck() {
try {
const sql = getNeonClient();
console.log('Checking for new Claude Code version...');
const latestVersion = await getLatestNPMVersion();
console.log(`Latest NPM version: ${latestVersion.version}`);
const existing = await sql`
SELECT id, discord_notified
FROM claude_code_versions
WHERE version = ${latestVersion.version}
`;
if (existing.length > 0 && existing[0].discord_notified) {
console.log(`Version ${latestVersion.version} already processed and notified`);
await sql`
UPDATE monitoring_metadata
SET last_check_at = NOW(), last_version_found = ${latestVersion.version}
WHERE id = 1
`;
return jsonResponse({
status: 'already_processed',
version: latestVersion.version,
message: 'Version already notified to Discord',
});
}
console.log(`New version detected: ${latestVersion.version}`);
const changelogResponse = await fetch(CHANGELOG_URL);
const fullChangelog = await changelogResponse.text();
const parsed = parseVersionChangelog(fullChangelog, latestVersion.version);
if (!parsed.content) {
throw new Error(`Could not find version ${latestVersion.version} in changelog`);
}
console.log(`Parsed ${parsed.changeCount} changes`);
const formatted = formatForDiscord(parsed.changes);
const summary = generateSummary(parsed.changes);
const githubUrl = `https://github.com/anthropics/claude-code/blob/main/CHANGELOG.md#${latestVersion.version.replace(/\./g, '')}`;
const versionData = {
version: latestVersion.version,
publishedAt: latestVersion.publishedAt,
npmUrl: latestVersion.npmUrl,
githubUrl,
changelogContent: fullChangelog.substring(0, 50000),
};
let versionId: number;
if (existing.length > 0) {
versionId = existing[0].id;
console.log(`Updating existing version record (ID: ${versionId})`);
} else {
const insertResult = await sql`
INSERT INTO claude_code_versions (
version,
published_at,
changelog_content,
npm_url,
github_url,
discord_notified
) VALUES (
${versionData.version},
${versionData.publishedAt},
${versionData.changelogContent},
${versionData.npmUrl},
${versionData.githubUrl},
false
)
RETURNING id
`;
versionId = insertResult[0].id;
console.log(`Version saved to database (ID: ${versionId})`);
}
for (const change of parsed.changes) {
await sql`
INSERT INTO claude_code_changes (
version_id,
change_type,
description,
category
) VALUES (
${versionId},
${change.type},
${change.description},
${change.category}
)
`;
}
console.log(`Saved ${parsed.changes.length} individual changes`);
console.log('Sending Discord notification...');
const discordResult = await sendToDiscord(versionData, formatted, summary);
console.log('Discord notification sent successfully!');
await sql`
INSERT INTO discord_notifications_log (
version_id,
webhook_url,
payload,
response_status,
response_body
) VALUES (
${versionId},
${import.meta.env.DISCORD_WEBHOOK_URL_CHANGELOG || process.env.DISCORD_WEBHOOK_URL_CHANGELOG || import.meta.env.DISCORD_WEBHOOK_URL || process.env.DISCORD_WEBHOOK_URL},
${JSON.stringify(discordResult.payload)},
${discordResult.status},
${'Success'}
)
`;
await sql`
UPDATE claude_code_versions
SET discord_notified = true, discord_notification_sent_at = NOW()
WHERE id = ${versionId}
`;
await sql`
UPDATE monitoring_metadata
SET
last_check_at = NOW(),
last_version_found = ${latestVersion.version},
check_count = check_count + 1
WHERE id = 1
`;
console.log('Process completed successfully!');
return jsonResponse({
status: 'success',
version: latestVersion.version,
versionId,
changes: {
total: summary.total,
byType: summary.byType,
},
discord: {
sent: true,
status: discordResult.status,
},
});
} catch (error: unknown) {
const err = error as Error;
console.error('Error:', err);
await captureApiError(err, { route: '/api/claude-code-check' });
try {
const sql = getNeonClient();
await sql`
UPDATE monitoring_metadata
SET
last_check_at = NOW(),
error_count = error_count + 1,
last_error = ${err.message}
WHERE id = 1
`;
} catch (metaError) {
console.error('Failed to update metadata:', metaError);
}
return jsonResponse(
{
error: 'Internal server error',
message: err.message,
},
500,
);
}
}
export const OPTIONS: APIRoute = async () => {
return corsResponse();
};
export const GET: APIRoute = async () => {
return handleCheck();
};
export const POST: APIRoute = async () => {
return handleCheck();
};
@@ -0,0 +1,82 @@
import type { APIRoute } from 'astro';
import { corsResponse, jsonResponse } from '../../../lib/api/cors';
import { authenticateRequest } from '../../../lib/api/auth';
import { getNeonClient } from '../../../lib/api/neon';
export const OPTIONS: APIRoute = async () => corsResponse();
export const PATCH: APIRoute = async ({ request, params }) => {
const userId = await authenticateRequest(request);
if (!userId) return jsonResponse({ error: 'Missing or invalid Authorization header' }, 401);
const { id } = params;
if (!id) return jsonResponse({ error: 'Collection ID is required' }, 400);
const sql = getNeonClient();
try {
const existing = await sql`
SELECT id FROM user_collections
WHERE id = ${id} AND clerk_user_id = ${userId}
`;
if (existing.length === 0) {
return jsonResponse({ error: 'Collection not found' }, 404);
}
const { name } = await request.json();
if (!name || typeof name !== 'string' || name.trim().length === 0) {
return jsonResponse({ error: 'Collection name is required' }, 400);
}
if (name.length > 100) {
return jsonResponse({ error: 'Collection name too long (max 100 characters)' }, 400);
}
const rows = await sql`
UPDATE user_collections
SET name = ${name.trim()}, updated_at = NOW()
WHERE id = ${id} AND clerk_user_id = ${userId}
RETURNING *
`;
const items = await sql`
SELECT * FROM collection_items
WHERE collection_id = ${id}
ORDER BY added_at ASC
`;
const collection = { ...rows[0], collection_items: items };
return jsonResponse({ collection });
} catch (error) {
console.error('Collection [id] error:', error);
return jsonResponse({ error: 'Internal server error' }, 500);
}
};
export const DELETE: APIRoute = async ({ request, params }) => {
const userId = await authenticateRequest(request);
if (!userId) return jsonResponse({ error: 'Missing or invalid Authorization header' }, 401);
const { id } = params;
if (!id) return jsonResponse({ error: 'Collection ID is required' }, 400);
const sql = getNeonClient();
try {
const existing = await sql`
SELECT id FROM user_collections
WHERE id = ${id} AND clerk_user_id = ${userId}
`;
if (existing.length === 0) {
return jsonResponse({ error: 'Collection not found' }, 404);
}
await sql`DELETE FROM collection_items WHERE collection_id = ${id}`;
await sql`DELETE FROM user_collections WHERE id = ${id} AND clerk_user_id = ${userId}`;
return jsonResponse({ success: true });
} catch (error) {
console.error('Collection [id] error:', error);
return jsonResponse({ error: 'Internal server error' }, 500);
}
};
@@ -0,0 +1,85 @@
import type { APIRoute } from 'astro';
import { corsResponse, jsonResponse } from '../../../lib/api/cors';
import { authenticateRequest } from '../../../lib/api/auth';
import { getNeonClient } from '../../../lib/api/neon';
export const OPTIONS: APIRoute = async () => corsResponse();
export const GET: APIRoute = async ({ request }) => {
const userId = await authenticateRequest(request);
if (!userId) return jsonResponse({ error: 'Missing or invalid Authorization header' }, 401);
const sql = getNeonClient();
try {
const collections = await sql`
SELECT * FROM user_collections
WHERE clerk_user_id = ${userId}
ORDER BY position ASC, created_at ASC
`;
const items = collections.length > 0
? await sql`
SELECT * FROM collection_items
WHERE collection_id = ANY(${collections.map((c: any) => c.id)})
ORDER BY added_at ASC
`
: [];
const itemsByCollection: Record<string, any[]> = {};
for (const item of items) {
if (!itemsByCollection[item.collection_id]) {
itemsByCollection[item.collection_id] = [];
}
itemsByCollection[item.collection_id].push(item);
}
const result = collections.map((c: any) => ({
...c,
collection_items: itemsByCollection[c.id] || [],
}));
return jsonResponse({ collections: result });
} catch (error) {
console.error('Collections error:', error);
return jsonResponse({ error: 'Internal server error' }, 500);
}
};
export const POST: APIRoute = async ({ request }) => {
const userId = await authenticateRequest(request);
if (!userId) return jsonResponse({ error: 'Missing or invalid Authorization header' }, 401);
const sql = getNeonClient();
try {
const { name } = await request.json();
if (!name || typeof name !== 'string' || name.trim().length === 0) {
return jsonResponse({ error: 'Collection name is required' }, 400);
}
if (name.length > 100) {
return jsonResponse({ error: 'Collection name too long (max 100 characters)' }, 400);
}
const maxPos = await sql`
SELECT COALESCE(MAX(position), -1) AS max_pos
FROM user_collections
WHERE clerk_user_id = ${userId}
`;
const newPosition = maxPos[0].max_pos + 1;
const rows = await sql`
INSERT INTO user_collections (clerk_user_id, name, position)
VALUES (${userId}, ${name.trim()}, ${newPosition})
RETURNING *
`;
const collection = { ...rows[0], collection_items: [] };
return jsonResponse({ collection }, 201);
} catch (error) {
console.error('Collections error:', error);
return jsonResponse({ error: 'Internal server error' }, 500);
}
};
@@ -0,0 +1,120 @@
import type { APIRoute } from 'astro';
import { corsResponse, jsonResponse } from '../../../lib/api/cors';
import { authenticateRequest } from '../../../lib/api/auth';
import { getNeonClient } from '../../../lib/api/neon';
export const OPTIONS: APIRoute = async () => corsResponse();
export const POST: APIRoute = async ({ request }) => {
const userId = await authenticateRequest(request);
if (!userId) return jsonResponse({ error: 'Missing or invalid Authorization header' }, 401);
const sql = getNeonClient();
try {
const { collectionId, componentType, componentPath, componentName, componentCategory } = await request.json();
if (!collectionId || !componentType || !componentPath || !componentName) {
return jsonResponse({ error: 'collectionId, componentType, componentPath, and componentName are required' }, 400);
}
const col = await sql`
SELECT id FROM user_collections
WHERE id = ${collectionId} AND clerk_user_id = ${userId}
`;
if (col.length === 0) {
return jsonResponse({ error: 'Collection not found' }, 404);
}
const dup = await sql`
SELECT id FROM collection_items
WHERE collection_id = ${collectionId} AND component_path = ${componentPath}
`;
if (dup.length > 0) {
return jsonResponse({ error: 'Component already in this collection' }, 409);
}
const rows = await sql`
INSERT INTO collection_items (collection_id, component_type, component_path, component_name, component_category)
VALUES (${collectionId}, ${componentType}, ${componentPath}, ${componentName}, ${componentCategory || null})
RETURNING *
`;
return jsonResponse({ item: rows[0] }, 201);
} catch (error) {
console.error('Collection items error:', error);
return jsonResponse({ error: 'Internal server error' }, 500);
}
};
export const DELETE: APIRoute = async ({ request }) => {
const userId = await authenticateRequest(request);
if (!userId) return jsonResponse({ error: 'Missing or invalid Authorization header' }, 401);
const sql = getNeonClient();
try {
const { itemId, collectionId } = await request.json();
if (!itemId || !collectionId) {
return jsonResponse({ error: 'itemId and collectionId are required' }, 400);
}
const col = await sql`
SELECT id FROM user_collections
WHERE id = ${collectionId} AND clerk_user_id = ${userId}
`;
if (col.length === 0) {
return jsonResponse({ error: 'Collection not found' }, 404);
}
await sql`
DELETE FROM collection_items
WHERE id = ${itemId} AND collection_id = ${collectionId}
`;
return jsonResponse({ success: true });
} catch (error) {
console.error('Collection items error:', error);
return jsonResponse({ error: 'Internal server error' }, 500);
}
};
export const PATCH: APIRoute = async ({ request }) => {
const userId = await authenticateRequest(request);
if (!userId) return jsonResponse({ error: 'Missing or invalid Authorization header' }, 401);
const sql = getNeonClient();
try {
const { itemId, fromCollectionId, toCollectionId } = await request.json();
if (!itemId || !fromCollectionId || !toCollectionId) {
return jsonResponse({ error: 'itemId, fromCollectionId, and toCollectionId are required' }, 400);
}
const cols = await sql`
SELECT id FROM user_collections
WHERE id = ANY(${[fromCollectionId, toCollectionId]}) AND clerk_user_id = ${userId}
`;
if (cols.length < 2) {
return jsonResponse({ error: 'One or both collections not found' }, 404);
}
const rows = await sql`
UPDATE collection_items
SET collection_id = ${toCollectionId}
WHERE id = ${itemId} AND collection_id = ${fromCollectionId}
RETURNING *
`;
if (rows.length === 0) {
return jsonResponse({ error: 'Item not found in source collection' }, 404);
}
return jsonResponse({ item: rows[0] });
} catch (error) {
console.error('Collection items error:', error);
return jsonResponse({ error: 'Internal server error' }, 500);
}
};
@@ -0,0 +1,136 @@
import type { APIRoute } from 'astro';
import { corsResponse, jsonResponse } from '../../../lib/api/cors';
import { authenticateRequest } from '../../../lib/api/auth';
import { getNeonClient } from '../../../lib/api/neon';
export const OPTIONS: APIRoute = async () => corsResponse();
function generateSlug(): string {
const chars = 'abcdefghijkmnpqrstuvwxyz23456789';
let slug = '';
for (let i = 0; i < 8; i++) {
slug += chars.charAt(Math.floor(Math.random() * chars.length));
}
return slug;
}
// POST: Toggle sharing on a collection (generate or remove share slug)
export const POST: APIRoute = async ({ request }) => {
const userId = await authenticateRequest(request);
if (!userId) return jsonResponse({ error: 'Missing or invalid Authorization header' }, 401);
const sql = getNeonClient();
try {
const { collectionId, enable } = await request.json();
if (!collectionId) {
return jsonResponse({ error: 'collectionId is required' }, 400);
}
const existing = await sql`
SELECT id, share_slug, is_public FROM user_collections
WHERE id = ${collectionId} AND clerk_user_id = ${userId}
`;
if (existing.length === 0) {
return jsonResponse({ error: 'Collection not found' }, 404);
}
if (enable === false) {
// Disable sharing
await sql`
UPDATE user_collections
SET share_slug = NULL, is_public = false, updated_at = NOW()
WHERE id = ${collectionId} AND clerk_user_id = ${userId}
`;
return jsonResponse({ share_slug: null, is_public: false });
}
// Enable sharing — reuse existing slug or generate new one
let shareSlug = existing[0].share_slug;
if (!shareSlug) {
// Generate unique slug with retry
for (let attempt = 0; attempt < 5; attempt++) {
shareSlug = generateSlug();
const dup = await sql`
SELECT id FROM user_collections WHERE share_slug = ${shareSlug}
`;
if (dup.length === 0) break;
}
}
const rows = await sql`
UPDATE user_collections
SET share_slug = ${shareSlug}, is_public = true, updated_at = NOW()
WHERE id = ${collectionId} AND clerk_user_id = ${userId}
RETURNING share_slug, is_public
`;
return jsonResponse({ share_slug: rows[0].share_slug, is_public: true });
} catch (error) {
console.error('Share toggle error:', error);
return jsonResponse({ error: 'Internal server error' }, 500);
}
};
// GET: Fetch a public collection by share_slug (no auth required)
export const GET: APIRoute = async ({ request }) => {
const url = new URL(request.url);
const slug = url.searchParams.get('slug');
if (!slug) {
return jsonResponse({ error: 'slug parameter is required' }, 400);
}
const sql = getNeonClient();
try {
const collections = await sql`
SELECT uc.id, uc.name, uc.share_slug, uc.clerk_user_id, uc.created_at
FROM user_collections uc
WHERE uc.share_slug = ${slug} AND uc.is_public = true
`;
if (collections.length === 0) {
return jsonResponse({ error: 'Collection not found' }, 404);
}
const collection = collections[0];
// Only select public-safe fields from collection_items
const items = await sql`
SELECT component_type, component_path, component_name, component_category
FROM collection_items
WHERE collection_id = ${collection.id}
ORDER BY added_at ASC
`;
// Get display name from Clerk (only username/firstName, never email or IDs)
let displayName = 'user';
try {
const { createClerkClient } = await import('@clerk/backend');
const clerkSecret = import.meta.env.CLERK_SECRET_KEY || process.env.CLERK_SECRET_KEY;
if (clerkSecret) {
const clerk = createClerkClient({ secretKey: clerkSecret });
const user = await clerk.users.getUser(collection.clerk_user_id);
displayName = user.username || user.firstName || 'user';
}
} catch {
// Fallback to generic name
}
// Return only public-safe data — no IDs, no clerk_user_id, no internal UUIDs
return jsonResponse({
collection: {
name: collection.name,
share_slug: collection.share_slug,
created_at: collection.created_at,
author: displayName,
items,
},
});
} catch (error) {
console.error('Share GET error:', error);
return jsonResponse({ error: 'Internal server error' }, 500);
}
};
@@ -0,0 +1,252 @@
import type { APIRoute } from 'astro';
import { verifyKey, InteractionType, InteractionResponseType } from 'discord-interactions';
import { jsonResponse } from '../../../lib/api/cors';
const componentTypes: Record<string, { icon: string; color: number }> = {
skills: { icon: '🎨', color: 0x9b59b6 },
agents: { icon: '🤖', color: 0xff6b6b },
commands: { icon: '⚡', color: 0x4ecdc4 },
mcps: { icon: '🔌', color: 0x95e1d3 },
settings: { icon: '⚙️', color: 0xf9ca24 },
hooks: { icon: '🪝', color: 0x6c5ce7 },
templates: { icon: '📋', color: 0xa8e6cf },
plugins: { icon: '🧩', color: 0xffd93d },
};
let cachedComponents: Record<string, unknown[]> | null = null;
let cacheTimestamp: number | null = null;
const CACHE_DURATION = 5 * 60 * 1000;
async function getComponents(): Promise<Record<string, unknown[]>> {
const now = Date.now();
if (cachedComponents && cacheTimestamp && now - cacheTimestamp < CACHE_DURATION) {
return cachedComponents;
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000);
const response = await fetch('https://www.aitmpl.com/components.json', {
signal: controller.signal,
});
clearTimeout(timeoutId);
cachedComponents = (await response.json()) as Record<string, unknown[]>;
cacheTimestamp = now;
return cachedComponents;
}
interface Component {
name: string;
category?: string;
description?: string;
content?: string;
downloads?: number;
type?: string;
score?: number;
}
function searchComponents(
components: Record<string, unknown[]>,
query: string,
type: string | null = null,
): Component[] {
const results: Component[] = [];
const lowerQuery = query.toLowerCase();
const typesToSearch = type ? [type] : Object.keys(componentTypes);
for (const componentType of typesToSearch) {
const componentList = (components[componentType] || []) as Component[];
for (const component of componentList) {
if (
component.name.toLowerCase().includes(lowerQuery) ||
component.category?.toLowerCase().includes(lowerQuery)
) {
results.push({
...component,
type: componentType,
score:
component.name.toLowerCase() === lowerQuery
? 100
: component.name.toLowerCase().startsWith(lowerQuery)
? 50
: 20,
});
}
}
}
return results.sort((a, b) => (b.score || 0) - (a.score || 0)).slice(0, 10);
}
function createEmbed(component: Component, type = 'info') {
const typeConfig = componentTypes[component.type || ''];
const icon = typeConfig?.icon || '📦';
const color = typeConfig?.color || 0x00d9ff;
const typeLabel =
component.type === 'agents'
? 'agent'
: component.type === 'commands'
? 'command'
: component.type === 'mcps'
? 'mcp'
: component.type === 'settings'
? 'setting'
: component.type === 'hooks'
? 'hook'
: component.type;
const category = component.category || 'general';
const url = `https://www.aitmpl.com/component/${typeLabel}/${category}/${component.name}`;
if (type === 'install') {
const flagName = component.type === 'templates' ? 'template' : component.type;
const installCommand = `npx claude-code-templates@latest --${flagName} ${component.name}`;
return {
title: `${icon} Install ${component.name}`,
description: 'Copy and paste this command in your terminal:',
color: 0x00d9ff,
url,
fields: [
{ name: 'Installation Command', value: `\`\`\`bash\n${installCommand}\n\`\`\``, inline: false },
{ name: 'Component Page', value: `[View on aitmpl.com](${url})`, inline: false },
],
timestamp: new Date().toISOString(),
};
}
return {
title: `${icon} ${component.name}`,
url,
description: component.description || component.content?.substring(0, 200) || 'No description',
color,
fields: [
{ name: 'Type', value: `\`${component.type}\``, inline: true },
{ name: 'Category', value: component.category || 'N/A', inline: true },
{ name: 'Downloads', value: `${component.downloads || 0}`, inline: true },
{ name: 'Component Page', value: `[View on aitmpl.com](${url})`, inline: false },
],
timestamp: new Date().toISOString(),
};
}
export const POST: APIRoute = async ({ request }) => {
const rawBody = await request.text();
const body = JSON.parse(rawBody);
const signature = request.headers.get('x-signature-ed25519');
const timestamp = request.headers.get('x-signature-timestamp');
const publicKey = import.meta.env.DISCORD_PUBLIC_KEY;
if (!publicKey) {
return jsonResponse({ error: 'Server configuration error' }, 500);
}
const isValidRequest = verifyKey(rawBody, signature as string, timestamp as string, publicKey);
if (!isValidRequest) {
return jsonResponse({ error: 'Invalid request signature' }, 401);
}
const interaction = body;
if (interaction.type === InteractionType.PING) {
return jsonResponse({ type: InteractionResponseType.PONG });
}
if (interaction.type === InteractionType.APPLICATION_COMMAND) {
try {
const components = await getComponents();
const commandName = interaction.data.name;
const options = interaction.data.options || [];
let response: Record<string, unknown> | undefined;
if (commandName === 'search') {
const query = options.find((o: { name: string }) => o.name === 'query')?.value;
const type = options.find((o: { name: string }) => o.name === 'type')?.value;
const results = searchComponents(components, query, type);
response = {
type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
embeds: [
{
title: `Search Results for "${query}"`,
description: `Found ${results.length} result(s)`,
color: 0x00d9ff,
fields: results.map((c, i) => {
const tLabel =
c.type === 'agents'
? 'agent'
: c.type === 'commands'
? 'command'
: c.type === 'mcps'
? 'mcp'
: c.type === 'settings'
? 'setting'
: c.type === 'hooks'
? 'hook'
: c.type;
const cat = c.category || 'general';
const cUrl = `https://www.aitmpl.com/component/${tLabel}/${cat}/${c.name}`;
return {
name: `${i + 1}. ${componentTypes[c.type || '']?.icon || '📦'} ${c.name}`,
value: `**Type:** ${c.type} | **Downloads:** ${c.downloads || 0}\n[View on aitmpl.com](${cUrl})`,
inline: false,
};
}),
timestamp: new Date().toISOString(),
},
],
},
};
} else if (commandName === 'info' || commandName === 'install') {
const name = options.find((o: { name: string }) => o.name === 'name')?.value;
const type = options.find((o: { name: string }) => o.name === 'type')?.value;
let component: Component | null = null;
const types = type ? [type] : Object.keys(componentTypes);
for (const t of types) {
const found = (components[t] as Component[])?.find((c) => c.name === name);
if (found) {
component = { ...found, type: t };
break;
}
}
if (!component) {
response = {
type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
data: { content: `Component "${name}" not found. Use \`/search\` to find components.`, flags: 64 },
};
} else {
response = {
type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
data: { embeds: [createEmbed(component, commandName)] },
};
}
} else if (commandName === 'popular' || commandName === 'random') {
const type = options.find((o: { name: string }) => o.name === 'type')?.value;
const componentList = (components[type] || []) as Component[];
let component: Component | undefined;
if (commandName === 'popular') {
const sorted = [...componentList].sort((a, b) => (b.downloads || 0) - (a.downloads || 0));
component = sorted[0];
} else {
component = componentList[Math.floor(Math.random() * componentList.length)];
}
if (component) {
response = {
type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
data: { embeds: [createEmbed({ ...component, type })] },
};
}
}
return jsonResponse(response);
} catch (error) {
console.error('Error:', error);
return jsonResponse({
type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
data: { content: 'An error occurred', flags: 64 },
});
}
}
return jsonResponse({ error: 'Unknown interaction type' }, 400);
};
+56
View File
@@ -0,0 +1,56 @@
import type { APIRoute } from 'astro';
export const POST: APIRoute = async ({ request }) => {
const { code } = await request.json();
if (!code) {
return new Response(JSON.stringify({ error: 'Missing code' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
const clientId = (import.meta.env.GITHUB_CLIENT_ID || process.env.GITHUB_CLIENT_ID);
const clientSecret = (import.meta.env.GITHUB_CLIENT_SECRET || process.env.GITHUB_CLIENT_SECRET);
if (!clientId || !clientSecret) {
return new Response(JSON.stringify({ error: 'GitHub OAuth not configured' }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
try {
const res = await fetch('https://github.com/login/oauth/access_token', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({
client_id: clientId,
client_secret: clientSecret,
code,
}),
});
const data = await res.json();
if (data.error) {
return new Response(JSON.stringify({ error: data.error_description || data.error }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
return new Response(JSON.stringify({ access_token: data.access_token }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
} catch (err) {
return new Response(JSON.stringify({ error: 'Token exchange failed' }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
};
+149
View File
@@ -0,0 +1,149 @@
import type { APIRoute } from 'astro';
import { getNeonClient } from '../../lib/api/neon';
import { corsResponse, jsonResponse } from '../../lib/api/cors';
import { captureApiError } from '../../lib/api/error-tracking';
const ENDPOINTS_TO_CHECK = [
{ url: 'https://www.aitmpl.com/api/track-download-supabase', method: 'OPTIONS' },
{ url: 'https://www.aitmpl.com/api/track-command-usage', method: 'OPTIONS' },
{ url: 'https://www.aitmpl.com/api/track-website-events', method: 'OPTIONS' },
];
const TIMEOUT_MS = 10000;
interface EndpointResult {
endpoint: string;
method: string;
statusCode: number;
responseTimeMs: number;
errorMessage: string | null;
}
async function checkEndpoint(endpoint: { url: string; method: string }): Promise<EndpointResult> {
const startTime = Date.now();
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), TIMEOUT_MS);
const response = await fetch(endpoint.url, {
method: endpoint.method,
signal: controller.signal,
});
clearTimeout(timeoutId);
const responseTimeMs = Date.now() - startTime;
return {
endpoint: endpoint.url,
method: endpoint.method,
statusCode: response.status,
responseTimeMs,
errorMessage: null,
};
} catch (error: unknown) {
const responseTimeMs = Date.now() - startTime;
const err = error as Error;
return {
endpoint: endpoint.url,
method: endpoint.method,
statusCode: 0,
responseTimeMs,
errorMessage: err.name === 'AbortError' ? 'Timeout' : err.message,
};
}
}
async function sendDiscordAlert(failures: EndpointResult[]) {
const webhookUrl = import.meta.env.DISCORD_WEBHOOK_URL_CHANGELOG || process.env.DISCORD_WEBHOOK_URL_CHANGELOG;
if (!webhookUrl || failures.length === 0) return;
const failureList = failures
.map((f) => `- \`${f.endpoint}\`: ${f.errorMessage || `HTTP ${f.statusCode}`} (${f.responseTimeMs}ms)`)
.join('\n');
try {
await fetch(webhookUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
embeds: [
{
title: 'API Health Alert',
description: `The following endpoints are experiencing issues:\n${failureList}`,
color: 0xff4444,
timestamp: new Date().toISOString(),
},
],
}),
});
} catch (error: unknown) {
const err = error as Error;
console.error('Failed to send Discord alert:', err.message);
}
}
export const OPTIONS: APIRoute = async () => {
return corsResponse();
};
export const GET: APIRoute = async () => {
try {
const results = await Promise.all(ENDPOINTS_TO_CHECK.map(checkEndpoint));
const sql = getNeonClient();
for (const result of results) {
await sql`
INSERT INTO api_health_logs (
endpoint, method, status_code, response_time_ms, error_message
) VALUES (
${result.endpoint},
${result.method},
${result.statusCode},
${result.responseTimeMs},
${result.errorMessage}
)
`;
}
const failures = results.filter(
(r) => r.statusCode === 0 || r.statusCode >= 500 || r.responseTimeMs >= TIMEOUT_MS,
);
if (failures.length > 0) {
await sendDiscordAlert(failures);
for (const failure of failures) {
await captureApiError(
new Error(failure.errorMessage || `HTTP ${failure.statusCode}`),
{ route: '/api/health-check', checkedEndpoint: failure.endpoint }
);
}
}
const allHealthy = failures.length === 0;
return jsonResponse({
success: true,
healthy: allHealthy,
results: results.map((r) => ({
endpoint: r.endpoint,
status: r.statusCode,
responseTime: r.responseTimeMs,
error: r.errorMessage,
})),
timestamp: new Date().toISOString(),
});
} catch (error: unknown) {
const err = error as Error;
console.error('Health check error:', err);
await captureApiError(err, { route: '/api/health-check' });
return jsonResponse(
{
error: 'Internal server error',
message: 'Health check failed',
},
500,
);
}
};
@@ -0,0 +1,94 @@
import type { APIRoute } from 'astro';
import { getNeonClient } from '../../../lib/api/neon';
import { authenticateAndGetEmail } from '../../../lib/api/auth';
export const prerender = false;
const ALLOWED_EMAIL = 'dan.avila7@gmail.com';
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
};
export const OPTIONS: APIRoute = async () => {
return new Response(null, { status: 204, headers: corsHeaders });
};
// GET /api/live-task/control
export const GET: APIRoute = async () => {
try {
const sql = getNeonClient();
const result = await sql`SELECT * FROM cycle_control WHERE id = 1`;
if (result.length === 0) {
return new Response(JSON.stringify({ control: { id: 1, is_paused: false, paused_by: null, reason: null } }), {
status: 200,
headers: { 'Content-Type': 'application/json', ...corsHeaders },
});
}
return new Response(JSON.stringify({ control: result[0] }), {
status: 200,
headers: { 'Content-Type': 'application/json', ...corsHeaders },
});
} catch (err) {
return new Response(JSON.stringify({ error: 'Failed to fetch control status' }), {
status: 500,
headers: { 'Content-Type': 'application/json', ...corsHeaders },
});
}
};
// POST /api/live-task/control - Update pause state (restricted to ALLOWED_EMAIL)
export const POST: APIRoute = async ({ request }) => {
try {
// Authenticate and verify email
const auth = await authenticateAndGetEmail(request);
if (!auth) {
return new Response(JSON.stringify({ error: 'Authentication required' }), {
status: 401,
headers: { 'Content-Type': 'application/json', ...corsHeaders },
});
}
if (auth.email !== ALLOWED_EMAIL) {
return new Response(JSON.stringify({ error: 'Forbidden: insufficient permissions' }), {
status: 403,
headers: { 'Content-Type': 'application/json', ...corsHeaders },
});
}
const sql = getNeonClient();
const body = await request.json();
const { is_paused, reason } = body;
if (typeof is_paused !== 'boolean') {
return new Response(JSON.stringify({ error: 'Missing required field: is_paused (boolean)' }), {
status: 400,
headers: { 'Content-Type': 'application/json', ...corsHeaders },
});
}
const result = await sql`
UPDATE cycle_control SET
is_paused = ${is_paused},
paused_by = ${auth.email},
reason = ${reason || (is_paused ? 'Paused by admin' : 'Resumed by admin')},
updated_at = NOW()
WHERE id = 1
RETURNING *
`;
return new Response(JSON.stringify({ control: result[0] }), {
status: 200,
headers: { 'Content-Type': 'application/json', ...corsHeaders },
});
} catch (err) {
return new Response(JSON.stringify({ error: 'Failed to update control' }), {
status: 500,
headers: { 'Content-Type': 'application/json', ...corsHeaders },
});
}
};
+163
View File
@@ -0,0 +1,163 @@
import type { APIRoute } from 'astro';
import { getNeonClient } from '../../../lib/api/neon';
export const prerender = false;
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PATCH, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
};
export const OPTIONS: APIRoute = async () => {
return new Response(null, { status: 204, headers: corsHeaders });
};
// GET /api/live-task/cycles?status=active&limit=20
export const GET: APIRoute = async ({ url }) => {
try {
const sql = getNeonClient();
const status = url.searchParams.get('status');
const parsedLimit = parseInt(url.searchParams.get('limit') || '20', 10);
const limit = Math.min(Number.isNaN(parsedLimit) ? 20 : parsedLimit, 100);
let cycles;
if (status) {
cycles = await sql`
SELECT * FROM review_cycles
WHERE status = ${status}
ORDER BY started_at DESC
LIMIT ${limit}
`;
} else {
cycles = await sql`
SELECT * FROM review_cycles
ORDER BY started_at DESC
LIMIT ${limit}
`;
}
return new Response(JSON.stringify({ cycles }), {
status: 200,
headers: { 'Content-Type': 'application/json', ...corsHeaders },
});
} catch (err) {
return new Response(JSON.stringify({ error: 'Failed to fetch cycles' }), {
status: 500,
headers: { 'Content-Type': 'application/json', ...corsHeaders },
});
}
};
// POST /api/live-task/cycles - Create a new cycle
export const POST: APIRoute = async ({ request }) => {
try {
const sql = getNeonClient();
const body = await request.json();
const { session_id, component_path, component_type } = body;
if (!session_id || !component_path || !component_type) {
return new Response(JSON.stringify({ error: 'Missing required fields: session_id, component_path, component_type' }), {
status: 400,
headers: { 'Content-Type': 'application/json', ...corsHeaders },
});
}
// Check if cycle is paused
const control = await sql`SELECT is_paused FROM cycle_control WHERE id = 1`;
if (control.length > 0 && control[0].is_paused) {
return new Response(JSON.stringify({ error: 'Review cycle is paused' }), {
status: 409,
headers: { 'Content-Type': 'application/json', ...corsHeaders },
});
}
const result = await sql`
INSERT INTO review_cycles (session_id, component_path, component_type)
VALUES (${session_id}, ${component_path}, ${component_type})
RETURNING *
`;
return new Response(JSON.stringify({ cycle: result[0] }), {
status: 201,
headers: { 'Content-Type': 'application/json', ...corsHeaders },
});
} catch (err) {
return new Response(JSON.stringify({ error: 'Failed to create cycle' }), {
status: 500,
headers: { 'Content-Type': 'application/json', ...corsHeaders },
});
}
};
// PATCH /api/live-task/cycles - Update a cycle
export const PATCH: APIRoute = async ({ request }) => {
try {
const sql = getNeonClient();
const body = await request.json();
const { id, ...updates } = body;
if (!id) {
return new Response(JSON.stringify({ error: 'Missing required field: id' }), {
status: 400,
headers: { 'Content-Type': 'application/json', ...corsHeaders },
});
}
const allowedFields = ['status', 'phase', 'pr_url', 'pr_number', 'branch_name', 'linear_issue_id', 'improvements_summary', 'error_message'];
const setters: string[] = [];
const values: any[] = [];
for (const field of allowedFields) {
if (updates[field] !== undefined) {
setters.push(field);
values.push(updates[field]);
}
}
if (setters.length === 0) {
return new Response(JSON.stringify({ error: 'No valid fields to update' }), {
status: 400,
headers: { 'Content-Type': 'application/json', ...corsHeaders },
});
}
// Set completed_at if status is terminal
const isTerminal = ['completed', 'failed', 'merged'].includes(updates.status);
// Build dynamic update — Neon tagged templates don't support dynamic columns,
// so we update all fields, using COALESCE to keep existing values
const result = await sql`
UPDATE review_cycles SET
status = COALESCE(${updates.status ?? null}, status),
phase = COALESCE(${updates.phase ?? null}, phase),
pr_url = COALESCE(${updates.pr_url ?? null}, pr_url),
pr_number = COALESCE(${updates.pr_number ?? null}, pr_number),
branch_name = COALESCE(${updates.branch_name ?? null}, branch_name),
linear_issue_id = COALESCE(${updates.linear_issue_id ?? null}, linear_issue_id),
improvements_summary = COALESCE(${updates.improvements_summary ?? null}, improvements_summary),
error_message = COALESCE(${updates.error_message ?? null}, error_message),
updated_at = NOW(),
completed_at = CASE WHEN ${isTerminal} THEN NOW() ELSE completed_at END
WHERE id = ${id}
RETURNING *
`;
if (result.length === 0) {
return new Response(JSON.stringify({ error: 'Cycle not found' }), {
status: 404,
headers: { 'Content-Type': 'application/json', ...corsHeaders },
});
}
return new Response(JSON.stringify({ cycle: result[0] }), {
status: 200,
headers: { 'Content-Type': 'application/json', ...corsHeaders },
});
} catch (err) {
return new Response(JSON.stringify({ error: 'Failed to update cycle' }), {
status: 500,
headers: { 'Content-Type': 'application/json', ...corsHeaders },
});
}
};
@@ -0,0 +1,92 @@
import type { APIRoute } from 'astro';
import { getNeonClient } from '../../../lib/api/neon';
export const prerender = false;
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
};
export const OPTIONS: APIRoute = async () => {
return new Response(null, { status: 204, headers: corsHeaders });
};
// GET /api/live-task/tools?cycle_id=X&limit=200
export const GET: APIRoute = async ({ url }) => {
try {
const sql = getNeonClient();
const cycleId = url.searchParams.get('cycle_id');
const parsedLimit = parseInt(url.searchParams.get('limit') || '200', 10);
const limit = Math.min(Number.isNaN(parsedLimit) ? 200 : parsedLimit, 500);
if (!cycleId) {
return new Response(JSON.stringify({ error: 'Missing required parameter: cycle_id' }), {
status: 400,
headers: { 'Content-Type': 'application/json', ...corsHeaders },
});
}
const parsedCycleId = parseInt(cycleId, 10);
if (Number.isNaN(parsedCycleId)) {
return new Response(JSON.stringify({ error: 'cycle_id must be a valid number' }), {
status: 400,
headers: { 'Content-Type': 'application/json', ...corsHeaders },
});
}
const tools = await sql`
SELECT * FROM tool_executions
WHERE cycle_id = ${parsedCycleId}
ORDER BY created_at ASC
LIMIT ${limit}
`;
return new Response(JSON.stringify({ tools }), {
status: 200,
headers: { 'Content-Type': 'application/json', ...corsHeaders },
});
} catch (err) {
return new Response(JSON.stringify({ error: 'Failed to fetch tool executions' }), {
status: 500,
headers: { 'Content-Type': 'application/json', ...corsHeaders },
});
}
};
// POST /api/live-task/tools - Log a tool execution
export const POST: APIRoute = async ({ request }) => {
try {
const sql = getNeonClient();
const body = await request.json();
const { cycle_id, session_id, tool_name, tool_args_summary, phase, result_status, result_summary } = body;
if (!cycle_id || !session_id || !tool_name) {
return new Response(JSON.stringify({ error: 'Missing required fields: cycle_id, session_id, tool_name' }), {
status: 400,
headers: { 'Content-Type': 'application/json', ...corsHeaders },
});
}
// Truncate server-side
const truncatedArgs = tool_args_summary ? String(tool_args_summary).slice(0, 500) : null;
const truncatedResult = result_summary ? String(result_summary).slice(0, 200) : null;
const result = await sql`
INSERT INTO tool_executions (cycle_id, session_id, tool_name, tool_args_summary, phase, result_status, result_summary)
VALUES (${cycle_id}, ${session_id}, ${tool_name}, ${truncatedArgs}, ${phase || null}, ${result_status || 'success'}, ${truncatedResult})
RETURNING id
`;
return new Response(JSON.stringify({ id: result[0].id }), {
status: 201,
headers: { 'Content-Type': 'application/json', ...corsHeaders },
});
} catch (err) {
return new Response(JSON.stringify({ error: 'Failed to log tool execution' }), {
status: 500,
headers: { 'Content-Type': 'application/json', ...corsHeaders },
});
}
};
@@ -0,0 +1,97 @@
import type { APIRoute } from 'astro';
import { corsResponse, jsonResponse } from '../../lib/api/cors';
import { getNeonClient } from '../../lib/api/neon';
import { captureApiError } from '../../lib/api/error-tracking';
const VALID_COMMANDS = [
'chats',
'analytics',
'health-check',
'plugins',
'sandbox',
'agents',
'chats-mobile',
'studio',
'command-stats',
'hook-stats',
'mcp-stats',
'skills-manager',
'2025-year-in-review',
];
function validateCommandData(data: { command?: string }) {
const { command } = data;
if (!command) {
return { valid: false, error: 'Command name is required' };
}
if (!VALID_COMMANDS.includes(command)) {
return { valid: false, error: 'Invalid command name' };
}
if (command.length > 100) {
return { valid: false, error: 'Command name too long' };
}
return { valid: true, error: null };
}
export const POST: APIRoute = async ({ request }) => {
try {
const {
command,
cliVersion,
nodeVersion,
platform,
arch,
sessionId,
metadata,
} = await request.json();
const validation = validateCommandData({ command });
if (!validation.valid) {
return jsonResponse({ error: validation.error }, 400);
}
const sql = getNeonClient();
await sql`
INSERT INTO command_usage_logs (
command_name,
cli_version,
node_version,
platform,
arch,
session_id,
metadata
) VALUES (
${command},
${cliVersion || 'unknown'},
${nodeVersion || 'unknown'},
${platform || 'unknown'},
${arch || 'unknown'},
${sessionId || null},
${metadata ? JSON.stringify(metadata) : null}
)
`;
return jsonResponse({
success: true,
message: 'Command execution tracked successfully',
data: { command, timestamp: new Date().toISOString() },
});
} catch (error) {
console.error('Command tracking error:', error);
await captureApiError(error, { route: '/api/track-command-usage' });
return jsonResponse(
{
error: 'Internal server error',
message: 'Failed to track command execution',
},
500
);
}
};
export const OPTIONS: APIRoute = async () => corsResponse();
@@ -0,0 +1,112 @@
import type { APIRoute } from 'astro';
import { createClient } from '@supabase/supabase-js';
import { corsResponse, jsonResponse } from '../../lib/api/cors';
import { captureApiError } from '../../lib/api/error-tracking';
function getSupabaseClient() {
const supabaseUrl = import.meta.env.SUPABASE_URL || process.env.SUPABASE_URL;
const supabaseServiceKey = import.meta.env.SUPABASE_SERVICE_ROLE_KEY || process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!supabaseUrl || !supabaseServiceKey) {
throw new Error('Missing Supabase configuration');
}
return createClient(supabaseUrl, supabaseServiceKey);
}
function validateComponentData(data: { type?: string; name?: string; path?: string; category?: string }) {
const { type, name } = data;
if (!type || !name) {
return { valid: false, error: 'Component type and name are required' };
}
const validTypes = ['agent', 'command', 'setting', 'hook', 'mcp', 'skill', 'template'];
if (!validTypes.includes(type)) {
return { valid: false, error: 'Invalid component type' };
}
if (name.length > 255) {
return { valid: false, error: 'Component name too long' };
}
return { valid: true, error: null };
}
export const POST: APIRoute = async ({ request }) => {
try {
const { type, name, path, category, cliVersion } = await request.json();
const validation = validateComponentData({ type, name, path, category });
if (!validation.valid) {
return jsonResponse({ error: validation.error }, 400);
}
const ipAddress =
request.headers.get('x-forwarded-for')?.split(',')[0] ||
request.headers.get('x-real-ip') ||
'127.0.0.1';
const country = request.headers.get('x-vercel-ip-country') || null;
const userAgent = request.headers.get('user-agent');
const supabase = getSupabaseClient();
const { error: insertError } = await supabase
.from('component_downloads')
.insert({
component_type: type,
component_name: name,
component_path: path,
category: category,
user_agent: userAgent,
ip_address: ipAddress,
country: country,
cli_version: cliVersion,
download_timestamp: new Date().toISOString(),
created_at: new Date().toISOString(),
});
if (insertError) {
console.error('Supabase insert error:', insertError);
throw new Error(`Database insert failed: ${insertError.message}`);
}
const { error: upsertError } = await supabase
.from('download_stats')
.upsert(
{
component_type: type,
component_name: name,
total_downloads: 1,
last_download: new Date().toISOString(),
updated_at: new Date().toISOString(),
},
{
onConflict: 'component_type,component_name',
ignoreDuplicates: false,
}
);
if (upsertError) {
console.error('Supabase upsert error:', upsertError);
}
return jsonResponse({
success: true,
message: 'Download tracked successfully',
data: { type, name, timestamp: new Date().toISOString() },
});
} catch (error) {
console.error('Download tracking error:', error);
await captureApiError(error, { route: '/api/track-download-supabase' });
return jsonResponse(
{
error: 'Internal server error',
message: 'Failed to track download',
},
500
);
}
};
export const OPTIONS: APIRoute = async () => corsResponse();
@@ -0,0 +1,95 @@
import type { APIRoute } from 'astro';
import { corsResponse, jsonResponse } from '../../lib/api/cors';
import { getNeonClient } from '../../lib/api/neon';
import { captureApiError } from '../../lib/api/error-tracking';
function validateOutcomeData(data: {
componentType?: string;
componentName?: string;
outcome?: string;
}) {
const { componentType, componentName, outcome } = data;
if (!componentType || !componentName || !outcome) {
return { valid: false, error: 'componentType, componentName, and outcome are required' };
}
const validTypes = ['agent', 'command', 'mcp', 'setting', 'hook', 'skill', 'template'];
if (!validTypes.includes(componentType)) {
return { valid: false, error: 'Invalid component type' };
}
const validOutcomes = ['success', 'failure', 'partial'];
if (!validOutcomes.includes(outcome)) {
return { valid: false, error: 'Invalid outcome. Must be: success, failure, or partial' };
}
if (componentName.length > 255) {
return { valid: false, error: 'Component name too long' };
}
return { valid: true, error: null };
}
export const POST: APIRoute = async ({ request }) => {
try {
const {
componentType,
componentName,
outcome,
errorType,
errorMessage,
durationMs,
cliVersion,
nodeVersion,
platform,
arch,
batchId,
} = await request.json();
const validation = validateOutcomeData({ componentType, componentName, outcome });
if (!validation.valid) {
return jsonResponse({ error: validation.error }, 400);
}
const sql = getNeonClient();
await sql`
INSERT INTO installation_outcomes (
component_type, component_name, outcome,
error_type, error_message, duration_ms,
cli_version, node_version, platform, arch, batch_id
) VALUES (
${componentType},
${componentName},
${outcome},
${errorType || null},
${errorMessage ? errorMessage.substring(0, 1000) : null},
${durationMs || null},
${cliVersion || 'unknown'},
${nodeVersion || 'unknown'},
${platform || 'unknown'},
${arch || 'unknown'},
${batchId || null}
)
`;
return jsonResponse({
success: true,
message: 'Installation outcome tracked',
data: { componentType, componentName, outcome, timestamp: new Date().toISOString() },
});
} catch (error) {
console.error('Installation outcome tracking error:', error);
await captureApiError(error, { route: '/api/track-installation-outcome' });
return jsonResponse(
{
error: 'Internal server error',
message: 'Failed to track installation outcome',
},
500
);
}
};
export const OPTIONS: APIRoute = async () => corsResponse();
@@ -0,0 +1,92 @@
import type { APIRoute } from 'astro';
import { corsResponse, jsonResponse } from '../../lib/api/cors';
import { getNeonClient } from '../../lib/api/neon';
const VALID_EVENT_TYPES = [
'search',
'cart_add',
'cart_remove',
'cart_checkout',
'component_view',
'copy_command',
];
const MAX_EVENTS_PER_BATCH = 50;
function validateEventsData(data: { events?: Array<{ event_type?: string }> }) {
const { events } = data;
if (!events || !Array.isArray(events) || events.length === 0) {
return { valid: false, error: 'events array is required and must not be empty' };
}
if (events.length > MAX_EVENTS_PER_BATCH) {
return { valid: false, error: `Maximum ${MAX_EVENTS_PER_BATCH} events per batch` };
}
for (const event of events) {
if (!event.event_type || !VALID_EVENT_TYPES.includes(event.event_type)) {
return { valid: false, error: `Invalid event_type: ${event.event_type}` };
}
}
return { valid: true, error: null };
}
export const POST: APIRoute = async ({ request }) => {
try {
const {
events,
session_id,
visitor_id,
screen_width,
referrer,
} = await request.json();
const validation = validateEventsData({ events });
if (!validation.valid) {
return jsonResponse({ error: validation.error }, 400);
}
const country = request.headers.get('x-vercel-ip-country') || null;
const sql = getNeonClient();
let inserted = 0;
for (const event of events) {
await sql`
INSERT INTO website_events (
event_type, event_data, page_path,
referrer, session_id, visitor_id,
country, screen_width
) VALUES (
${event.event_type},
${event.event_data ? JSON.stringify(event.event_data) : null},
${event.page_path || null},
${referrer ? referrer.substring(0, 1000) : null},
${session_id || null},
${visitor_id || null},
${country},
${screen_width || null}
)
`;
inserted++;
}
return jsonResponse({
success: true,
message: `${inserted} events tracked`,
data: { count: inserted, timestamp: new Date().toISOString() },
});
} catch (error) {
console.error('Website events tracking error:', error);
return jsonResponse(
{
error: 'Internal server error',
message: 'Failed to track website events',
},
500
);
}
};
export const OPTIONS: APIRoute = async () => corsResponse();
+282
View File
@@ -0,0 +1,282 @@
---
import DashboardLayout from '../../layouts/DashboardLayout.astro';
import TopBar from '../../components/TopBar.astro';
import SearchModal from '../../components/SearchModal.tsx';
import { getNeonClient } from '../../lib/api/neon';
export const prerender = false;
const { slug } = Astro.params;
if (!slug) return Astro.redirect('/');
// Fetch collection data server-side
const sql = getNeonClient();
let collection: any = null;
let items: any[] = [];
let authorName = 'user';
try {
const collections = await sql`
SELECT uc.id, uc.name, uc.share_slug, uc.clerk_user_id, uc.created_at
FROM user_collections uc
WHERE uc.share_slug = ${slug} AND uc.is_public = true
`;
if (collections.length === 0) return Astro.redirect('/');
collection = collections[0];
items = await sql`
SELECT component_type, component_path, component_name, component_category
FROM collection_items
WHERE collection_id = ${collection.id}
ORDER BY added_at ASC
`;
// Get author name from Clerk
try {
const { createClerkClient } = await import('@clerk/backend');
const clerkSecret = import.meta.env.CLERK_SECRET_KEY;
if (clerkSecret) {
const clerk = createClerkClient({ secretKey: clerkSecret });
const user = await clerk.users.getUser(collection.clerk_user_id);
authorName = user.username || user.firstName || 'user';
}
} catch {}
} catch (e) {
console.error('Shared collection error:', e);
return Astro.redirect('/');
}
// Helpers
const TAG_COLORS: Record<string, string> = {
skills: '#f59e0b',
agents: '#3b82f6',
commands: '#10b981',
hooks: '#f97316',
mcps: '#06b6d4',
settings: '#8b5cf6',
};
const TYPE_FLAGS: Record<string, string> = {
agents: '--agent', commands: '--command', settings: '--setting',
hooks: '--hook', mcps: '--mcp', skills: '--skill', templates: '--template',
};
function pluralType(type: string): string {
return type.endsWith('s') ? type : type + 's';
}
function cleanPath(path: string): string {
return path.replace(/^cli-tool\/components\/[^/]+\//, '');
}
function humanName(name: string): string {
return name.replace(/\.(md|json)$/, '').replace(/-/g, ' ')
.split(' ').map((w: string) => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
}
// Build install command
function generateCommand(collItems: any[]): string {
const grouped: Record<string, string[]> = {};
for (const item of collItems) {
const t = pluralType(item.component_type);
if (!grouped[t]) grouped[t] = [];
grouped[t].push(cleanPath(item.component_path));
}
let cmd = 'npx claude-code-templates@latest';
for (const [type, paths] of Object.entries(grouped)) {
const flag = TYPE_FLAGS[type];
if (flag) cmd += ` ${flag} ${paths.join(',')}`;
}
return cmd;
}
// Group items by type
function groupByType(collItems: any[]): { type: string; color: string; items: any[] }[] {
const map: Record<string, any[]> = {};
for (const item of collItems) {
const t = pluralType(item.component_type);
if (!map[t]) map[t] = [];
map[t].push(item);
}
const order = ['agents', 'skills', 'commands', 'hooks', 'mcps', 'settings', 'templates'];
return order
.filter(t => map[t])
.map(t => ({ type: t, color: TAG_COLORS[t] ?? '#888', items: map[t] }));
}
const installCmd = generateCommand(items);
const groups = groupByType(items);
const totalComponents = items.length;
---
<DashboardLayout
title={`${collection.name} — Shared Collection | Claude Code Templates`}
description={`${authorName}'s "${collection.name}" collection with ${totalComponents} Claude Code components. Install with one command.`}
>
<TopBar />
<!-- Header -->
<div class="relative overflow-hidden border-b border-[var(--color-border)] bg-gradient-to-b from-[var(--color-surface-0)] to-[var(--color-surface-1)]">
<div class="absolute inset-0 opacity-20">
<div class="absolute inset-0 bg-gradient-to-br from-blue-500/10 via-transparent to-purple-500/10"></div>
</div>
<div class="absolute top-0 right-0 w-64 h-64 bg-gradient-to-br from-blue-500/5 to-transparent rounded-full blur-3xl"></div>
<div class="relative px-6 py-10 max-w-5xl mx-auto">
<div class="flex items-start gap-5">
<!-- Avatar -->
<div
class="w-16 h-16 rounded-2xl flex items-center justify-center shrink-0 shadow-xl text-[28px] font-bold select-none"
style="background: linear-gradient(135deg, #3b82f620 0%, #8b5cf620 100%); border: 2px solid #3b82f630; color: #60a5fa;"
>
{authorName.charAt(0).toUpperCase()}
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<span class="text-[13px] text-[var(--color-text-tertiary)]">
Shared by <span class="text-[var(--color-text-secondary)] font-medium">{authorName}</span>
</span>
</div>
<p class="text-[13px] text-[var(--color-text-tertiary)] mb-1">Component Collection</p>
<h1 class="text-2xl md:text-3xl font-bold text-[var(--color-text-primary)] tracking-tight mb-2">
{collection.name}
</h1>
<div class="flex items-center gap-3 flex-wrap">
<span class="text-[13px] text-[var(--color-text-tertiary)]">
{totalComponents} {totalComponents === 1 ? 'component' : 'components'}
</span>
{groups.map(g => (
<span
class="text-[10px] font-semibold px-2.5 py-1 rounded-full border"
style={`background: ${g.color}12; color: ${g.color}; border-color: ${g.color}25;`}
>
{g.items.length} {g.type}
</span>
))}
</div>
</div>
</div>
</div>
</div>
<!-- Content -->
<div class="px-6 py-8 max-w-5xl mx-auto space-y-8">
<!-- Install Command -->
<div>
<h2 class="text-sm font-semibold text-[var(--color-text-tertiary)] uppercase tracking-wider mb-3">Install all components</h2>
<div class="relative group">
<pre
id="installCmd"
class="p-4 rounded-xl bg-[var(--color-surface-2)] border border-[var(--color-border)] text-[13px] text-[var(--color-text-secondary)] overflow-x-auto font-mono leading-relaxed whitespace-pre-wrap break-all"
><code>$ {installCmd}</code></pre>
<button
id="copyBtn"
class="absolute top-3 right-3 px-3 py-1.5 rounded-lg text-[11px] font-semibold transition-all duration-200 opacity-0 group-hover:opacity-100"
style="background: var(--color-surface-3); color: var(--color-text-secondary); border: 1px solid var(--color-border);"
>
Copy
</button>
</div>
</div>
<!-- Components by type -->
{groups.map(group => (
<div>
<div class="flex items-center gap-2 mb-4">
<span
class="w-6 h-6 rounded-md flex items-center justify-center"
style={`background: ${group.color}15; border: 1px solid ${group.color}25;`}
>
<span class="w-3 h-3 rounded-full" style={`background: ${group.color};`}></span>
</span>
<h2 class="text-base font-bold text-[var(--color-text-primary)] capitalize">{group.type}</h2>
<span class="text-[12px] text-[var(--color-text-tertiary)]">({group.items.length})</span>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{group.items.map((item: any) => (
<a
href={`/component/${pluralType(item.component_type)}/${cleanPath(item.component_path).replace(/\.(md|json)$/, '')}`}
class="flex items-center gap-3 p-4 rounded-xl bg-[var(--color-card-bg)] border border-[var(--color-border)] hover:border-[var(--color-border-hover)] transition-all duration-200 no-underline group/card"
style="box-shadow: var(--shadow-card);"
>
<div
class="w-10 h-10 rounded-lg flex items-center justify-center shrink-0 text-[14px] font-bold select-none transition-transform duration-200 group-hover/card:scale-110"
style={`background: ${group.color}12; border: 1px solid ${group.color}20; color: ${group.color};`}
>
{humanName(item.component_name).charAt(0)}
</div>
<div class="min-w-0 flex-1">
<div class="text-[13px] font-semibold text-[var(--color-text-primary)] truncate">
{humanName(item.component_name)}
</div>
{item.component_category && (
<div class="text-[11px] text-[var(--color-text-tertiary)]">{item.component_category}</div>
)}
</div>
</a>
))}
</div>
</div>
))}
<!-- CTA: Create your own -->
<div class="relative overflow-hidden rounded-2xl p-8 text-center" style="background: linear-gradient(135deg, rgba(59,130,246,0.08) 0%, rgba(139,92,246,0.08) 100%); border: 1px solid rgba(59,130,246,0.15);">
<div class="absolute top-0 right-0 w-48 h-48 bg-gradient-to-br from-blue-500/10 to-transparent rounded-full blur-3xl"></div>
<div class="relative z-10">
<h3 class="text-lg font-bold text-[var(--color-text-primary)] mb-2">
Create your own collections
</h3>
<p class="text-[14px] text-[var(--color-text-secondary)] mb-5 max-w-md mx-auto">
Sign up for free to create, organize, and share your favorite Claude Code components with your team.
</p>
<a
href="/my-components"
class="inline-flex items-center gap-2 px-6 py-3 rounded-xl text-[13px] font-semibold transition-all duration-200 hover:scale-105 no-underline"
style="background: linear-gradient(135deg, #3b82f6, #8b5cf6); color: white; box-shadow: 0 4px 15px rgba(59,130,246,0.3);"
>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" />
</svg>
Start Building Collections
</a>
</div>
</div>
<!-- Back -->
<div>
<a href="/" class="inline-flex items-center gap-2 text-[13px] text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)] transition-colors no-underline">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M10 19l-7-7m0 0l7-7m-7 7h18" /></svg>
Browse all components
</a>
</div>
</div>
<SearchModal client:idle />
<!-- Emit sidebar counts -->
<script is:inline>
fetch('/counts.json')
.then(function(r) { return r.json(); })
.then(function(counts) {
window.dispatchEvent(new CustomEvent('component-counts', { detail: counts }));
})
.catch(function() {});
</script>
<!-- Copy button -->
<script is:inline>
document.getElementById('copyBtn')?.addEventListener('click', function() {
var cmd = document.getElementById('installCmd')?.textContent?.replace(/^\$\s*/, '') ?? '';
navigator.clipboard.writeText(cmd);
this.textContent = 'Copied!';
var btn = this;
setTimeout(function() { btn.textContent = 'Copy'; }, 2000);
});
</script>
</DashboardLayout>
@@ -0,0 +1,537 @@
---
import DashboardLayout from '../../../layouts/DashboardLayout.astro';
import SearchModal from '../../../components/SearchModal.tsx';
import CartSidebar from '../../../components/CartSidebar.tsx';
import SaveToCollectionButton from '../../../components/SaveToCollectionButton.tsx';
import MarkdownViewer from '../../../components/MarkdownViewer.tsx';
import JsonViewer from '../../../components/JsonViewer.tsx';
import SkillExplorer from '../../../components/SkillExplorer.tsx';
import { TYPE_CONFIG, ICONS } from '../../../lib/icons';
import { fetchComponents, fetchComponentContent, getInstallCommand } from '../../../lib/data';
import type { Component } from '../../../lib/types';
export const prerender = false;
const { type, slug } = Astro.params;
let component: Component | null = null;
let error: string | null = null;
try {
const data = await fetchComponents();
const typeKey = (type?.endsWith('s') ? type : (type + 's')) as keyof typeof data;
const items = data[typeKey] as Component[] | undefined;
if (items) {
component = items.find((c) => {
const cleanPath = c.path?.replace(/\.(md|json)$/, '') ?? '';
return cleanPath === slug || c.name === slug;
}) ?? null;
}
// Load full content on demand — the index (components.json) no longer
// embeds 'content' to keep the payload small (~85% reduction).
if (component) {
const cleanSlug = component.path?.replace(/\.(md|json)$/, '') ?? component.name;
const contentStr = await fetchComponentContent(component.type, cleanSlug);
component = { ...component, content: contentStr };
}
} catch (e: any) {
error = e.message;
}
function formatName(name: string): string {
if (!name) return '';
return name.replace(/\.(md|json)$/, '').replace(/[-_]/g, ' ')
.split(' ').map((w: string) => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
}
function parseFrontmatter(content: string, componentType: string): Record<string, string> {
if (!content) return {};
// JSON-based components (MCPs, Settings, Hooks)
if (componentType === 'mcps' || componentType === 'settings' || componentType === 'hooks') {
try {
const json = JSON.parse(content);
const fields: Record<string, string> = {};
// MCP: extract from mcpServers object
if (componentType === 'mcps') {
const servers = json.mcpServers ?? json;
const serverName = Object.keys(servers)[0];
if (!serverName) return {};
const server = servers[serverName];
if (server.description) fields['description'] = server.description;
if (server.command) fields['command'] = server.command + (server.args?.length ? ' ' + server.args.join(' ') : '');
if (serverName) fields['server-name'] = serverName;
return fields;
}
// Settings / Hooks: top-level description
if (json.description) fields['description'] = json.description;
return fields;
} catch { return {}; }
}
// YAML frontmatter for md files
const match = content.match(/^---\s*\n([\s\S]*?)\n---/);
if (!match) return {};
const fields: Record<string, string> = {};
let currentKey = '';
let currentValue = '';
for (const line of match[1].split('\n')) {
const kv = line.match(/^(\S[\w-]*)\s*:\s*(.*)/);
if (kv) {
if (currentKey) fields[currentKey] = currentValue.trim();
currentKey = kv[1];
currentValue = kv[2].replace(/^["']|["']$/g, '');
} else if (currentKey && (line.startsWith(' ') || line.startsWith('\t'))) {
currentValue += ' ' + line.trim();
}
}
if (currentKey) fields[currentKey] = currentValue.trim();
return fields;
}
const CARD_FIELDS: { key: string; label: string; icon: string }[] = [
{ key: 'description', label: 'Description', icon: '<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25H12" /></svg>' },
{ key: 'tools', label: 'Tools', icon: '<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M11.42 15.17l-5.3-5.3a1.5 1.5 0 010-2.12l.88-.88a1.5 1.5 0 012.12 0l.88.88a1.5 1.5 0 002.12 0l3.18-3.18a1.5 1.5 0 012.12 0l1.77 1.77a1.5 1.5 0 010 2.12L15.66 12" /><path stroke-linecap="round" stroke-linejoin="round" d="M19.07 4.93a1.5 1.5 0 010 2.12L15.66 12" /></svg>' },
{ key: 'allowed-tools', label: 'Allowed Tools', icon: '<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M11.42 15.17l-5.3-5.3a1.5 1.5 0 010-2.12l.88-.88a1.5 1.5 0 012.12 0l.88.88a1.5 1.5 0 002.12 0l3.18-3.18a1.5 1.5 0 012.12 0l1.77 1.77a1.5 1.5 0 010 2.12L15.66 12" /><path stroke-linecap="round" stroke-linejoin="round" d="M19.07 4.93a1.5 1.5 0 010 2.12L15.66 12" /></svg>' },
{ key: 'model', label: 'Model', icon: '<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z" /></svg>' },
{ key: 'argument-hint', label: 'Arguments', icon: '<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M6.75 7.5l3 2.25-3 2.25m4.5 0h3m-9 8.25h13.5A2.25 2.25 0 0021 18V6a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 6v12a2.25 2.25 0 002.25 2.25z" /></svg>' },
{ key: 'interval', label: 'Interval', icon: '<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="9" /><path stroke-linecap="round" stroke-linejoin="round" d="M12 7v5l3 2" /></svg>' },
{ key: 'stop-condition', label: 'Stop Condition', icon: '<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>' },
{ key: 'version', label: 'Version', icon: '<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M9.568 3H5.25A2.25 2.25 0 003 5.25v4.318c0 .597.237 1.17.659 1.591l9.581 9.581c.699.699 1.78.872 2.607.33a18.095 18.095 0 005.223-5.223c.542-.827.369-1.908-.33-2.607L11.16 3.66A2.25 2.25 0 009.568 3z" /><path stroke-linecap="round" stroke-linejoin="round" d="M6 6h.008v.008H6V6z" /></svg>' },
{ key: 'author', label: 'Author', icon: '<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="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 0" /></svg>' },
{ key: 'tags', label: 'Tags', icon: '<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M9.568 3H5.25A2.25 2.25 0 003 5.25v4.318c0 .597.237 1.17.659 1.591l9.581 9.581c.699.699 1.78.872 2.607.33a18.095 18.095 0 005.223-5.223c.542-.827.369-1.908-.33-2.607L11.16 3.66A2.25 2.25 0 009.568 3z" /><path stroke-linecap="round" stroke-linejoin="round" d="M6 6h.008v.008H6V6z" /></svg>' },
{ key: 'license', label: 'License', icon: '<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M12 21a9.004 9.004 0 008.716-6.747M12 21a9.004 9.004 0 01-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 017.843 4.582M12 3a8.997 8.997 0 00-7.843 4.582" /></svg>' },
{ key: 'server-name', label: 'Server', icon: '<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M5.25 14.25h13.5m-13.5 0a3 3 0 01-3-3m3 3a3 3 0 100 6h13.5a3 3 0 100-6m-16.5-3a3 3 0 013-3h13.5a3 3 0 013 3m-19.5 0a4.5 4.5 0 01.9-2.7L5.737 5.1a3.375 3.375 0 012.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 01.9 2.7" /></svg>' },
{ key: 'command', label: 'Command', icon: '<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"><polyline points="4 17 10 11 4 5" /><line x1="12" y1="19" x2="20" y2="19" /></svg>' },
];
const title = component ? formatName(component.name) : 'Component Not Found';
const typePlural = type?.endsWith('s') ? type : (type + 's');
const config = TYPE_CONFIG[typePlural ?? 'agents'];
const installCmd = component ? getInstallCommand(component) : '';
const frontmatter = component ? parseFrontmatter(component.content, typePlural ?? '') : {};
const infoCards = CARD_FIELDS.filter((f) => {
const v = frontmatter[f.key];
return v && v !== '[]' && v !== '""' && v !== "''";
});
// Loops reference other components via the frontmatter `components` field,
// encoded as a flat list of `type:path` tokens, e.g.
// components: [agent:documentation/docs-architect, skill:git/create-pr]
// Parse them into clickable cards shown on the loop detail page.
interface ReferencedComponent {
type: string; // singular, e.g. "agent"
typePlural: string; // e.g. "agents"
path: string; // e.g. "documentation/docs-architect"
name: string; // last path segment
label: string; // TYPE_CONFIG label
color: string;
icon: string;
href: string;
}
const referencedComponents: ReferencedComponent[] = [];
if (typePlural === 'loops' && frontmatter.components) {
const raw = frontmatter.components.replace(/^\[|\]$/g, '');
for (const token of raw.split(',')) {
const trimmed = token.trim().replace(/^["']|["']$/g, '');
if (!trimmed) continue;
const sep = trimmed.indexOf(':');
if (sep === -1) continue;
const refType = trimmed.slice(0, sep).trim();
const refPath = trimmed.slice(sep + 1).trim().replace(/\.(md|json)$/, '');
if (!refType || !refPath) continue;
const refTypePlural = refType.endsWith('s') ? refType : refType + 's';
const cfg = TYPE_CONFIG[refTypePlural];
referencedComponents.push({
type: refType,
typePlural: refTypePlural,
path: refPath,
name: refPath.split('/').pop() ?? refPath,
label: cfg?.label ?? refType,
color: cfg?.color ?? '#5e6ad2',
icon: ICONS[refTypePlural] ?? '',
href: `/component/${refTypePlural}/${refPath}`,
});
}
}
// JSON-based types use JsonViewer instead of MarkdownViewer
const isJsonType = typePlural === 'settings' || typePlural === 'hooks' || typePlural === 'mcps';
// Extract headings from markdown content for TOC
const contentHeadings: { level: number; text: string; id: string }[] = [];
if (component?.content && !isJsonType) {
const stripped = component.content.replace(/^---\s*\n[\s\S]*?\n---\s*\n?/, '');
const headingRegex = /^(#{1,4})\s+(.+)$/gm;
let m;
while ((m = headingRegex.exec(stripped)) !== null) {
const text = m[2].replace(/[*_`\[\]]/g, '').trim();
const id = text.toLowerCase().replace(/[^\w]+/g, '-').replace(/^-|-$/g, '');
contentHeadings.push({ level: m[1].length, text, id });
}
}
// Reference files for skills (used for layout decisions)
// Cast to any to access references which may not be in the TS type yet
const refs: string[] = (component as any)?.references ?? [];
const seoDescription = frontmatter.description
? frontmatter.description.slice(0, 155)
: `${title} is a ${config?.label?.toLowerCase() ?? 'component'} for Claude Code. Install with a single npx command.`;
const canonicalPath = `/component/${typePlural}/${component?.path?.replace(/\.(md|json)$/, '') ?? ''}`;
const componentSchema = component
? {
'@context': 'https://schema.org',
'@graph': [
{
'@type': 'SoftwareApplication',
name: title,
description: seoDescription,
url: `https://www.aitmpl.com${canonicalPath}`,
applicationCategory: 'DeveloperApplication',
operatingSystem: 'Windows, macOS, Linux',
softwareRequirements: 'Node.js 18+, Claude Code CLI',
installUrl: 'https://www.npmjs.com/package/claude-code-templates',
offers: { '@type': 'Offer', price: '0', priceCurrency: 'USD' },
author: { '@id': 'https://www.aitmpl.com/#organization' },
},
{
'@type': 'BreadcrumbList',
itemListElement: [
{ '@type': 'ListItem', position: 1, name: 'Home', item: 'https://www.aitmpl.com/' },
{
'@type': 'ListItem',
position: 2,
name: config?.label ?? typePlural,
item: `https://www.aitmpl.com/${typePlural}`,
},
{ '@type': 'ListItem', position: 3, name: title },
],
},
],
}
: null;
---
<DashboardLayout
title={`${title} — ${config?.label ?? 'Component'} for Claude Code | AI Templates`}
description={seoDescription}
>
{componentSchema && (
<script type="application/ld+json" set:html={JSON.stringify(componentSchema)} />
)}
<div class="max-w-7xl mx-auto py-12 px-6">
{/* Back nav */}
<a
href={`/${typePlural}`}
class="inline-flex items-center gap-2.5 text-[13px] font-semibold text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:gap-3 transition-all duration-200 mb-10 group px-4 py-2 rounded-xl hover:bg-[var(--color-surface-2)] border border-transparent hover:border-[var(--color-border)]"
>
<svg class="w-4 h-4 transition-transform duration-200 group-hover:-translate-x-1" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7" />
</svg>
Back to {config?.label ?? 'Components'}
</a>
{error && (
<div class="bg-red-500/10 border border-red-500/30 rounded-xl p-5 mb-8">
<div class="flex items-start gap-3">
<svg class="w-5 h-5 text-red-400 shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<div>
<p class="text-sm font-medium text-red-400 mb-1">Error loading component</p>
<p class="text-sm text-red-400/80">{error}</p>
</div>
</div>
</div>
)}
{!component && !error && (
<div class="flex flex-col items-center justify-center py-32 px-6">
<div class="w-24 h-24 rounded-2xl bg-[var(--color-surface-2)] border border-[var(--color-border)] flex items-center justify-center mb-6 shadow-lg">
<svg class="w-12 h-12 text-[var(--color-text-tertiary)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<h3 class="text-lg font-bold text-[var(--color-text-primary)] mb-2">Component not found</h3>
<p class="text-[14px] text-[var(--color-text-secondary)] text-center max-w-md mb-8 leading-relaxed">
The component you're looking for doesn't exist or may have been removed.
</p>
<a
href={`/${typePlural}`}
class="px-5 py-2.5 text-[13px] font-semibold rounded-lg bg-[var(--color-primary-500)] text-white hover:bg-[var(--color-primary-600)] hover:shadow-lg transition-all duration-150 active:scale-95"
>
Browse {config?.label}
</a>
</div>
)}
{component && (
<article>
{/* Header with premium styling */}
<div class="relative overflow-hidden rounded-3xl bg-gradient-to-br from-[var(--color-surface-1)] to-[var(--color-surface-2)] border border-[var(--color-border)] p-8 mb-10 shadow-2xl">
{/* Decorative background */}
<div class="absolute inset-0 opacity-20">
<div class="absolute inset-0 bg-gradient-to-br from-[var(--color-primary-500)]/20 via-transparent to-[var(--color-accent-400)]/20" style="background-size: 200% 200%; animation: gradient-shimmer 8s ease infinite;"></div>
</div>
<div class="relative flex items-start gap-6">
<div
class="w-20 h-20 rounded-2xl flex items-center justify-center shrink-0 shadow-2xl animate-float [&>svg]:w-10 [&>svg]:h-10 transition-transform duration-300 hover:scale-110 hover:rotate-6"
style={`background: linear-gradient(135deg, ${config?.color ?? '#5e6ad2'}25 0%, ${config?.color ?? '#5e6ad2'}15 100%); color: ${config?.color ?? '#5e6ad2'}; border: 2px solid ${config?.color ?? '#5e6ad2'}40;`}
set:html={ICONS[typePlural ?? 'agents'] ?? ''}
/>
<div class="flex-1 min-w-0">
<h1 class="text-3xl font-bold text-[var(--color-text-primary)] mb-3 tracking-tight">{title}</h1>
<div class="flex items-center flex-wrap gap-2.5">
<span class="text-xs font-bold px-3 py-1.5 rounded-full bg-[var(--color-surface-3)] text-[var(--color-text-secondary)] border border-[var(--color-border)] shadow-sm">
{config?.label}
</span>
{component.category && (
<span class="text-xs font-semibold px-3 py-1.5 rounded-full bg-[var(--color-surface-3)] text-[var(--color-text-tertiary)] border border-[var(--color-border)] shadow-sm">
{component.category}
</span>
)}
{(component.downloads ?? 0) > 0 && (
<span class="text-xs font-bold text-emerald-400 flex items-center gap-2 px-3 py-1.5 rounded-full bg-gradient-to-r from-emerald-500/15 to-emerald-400/15 border border-emerald-500/30 shadow-lg shadow-emerald-500/10">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" /></svg>
{component.downloads?.toLocaleString()}
</span>
)}
</div>
</div>
</div>
</div>
{/* Install command with premium styling */}
<div class="relative overflow-hidden bg-gradient-to-br from-[var(--color-surface-2)] to-[var(--color-surface-3)] border border-[var(--color-border)] rounded-2xl p-6 mb-10 shadow-xl hover:shadow-2xl transition-all duration-300 group">
{/* Animated gradient overlay */}
<div class="absolute inset-0 bg-gradient-to-r from-[var(--color-primary-500)]/0 via-[var(--color-primary-500)]/5 to-[var(--color-primary-500)]/0 opacity-0 group-hover:opacity-100 transition-opacity duration-500" style="animation: shimmer-glow 3s ease-in-out infinite;"></div>
<div class="relative flex items-center justify-between mb-4">
<div class="flex items-center gap-3">
<div class="w-8 h-8 rounded-lg bg-[var(--color-primary-500)]/15 border border-[var(--color-primary-500)]/30 flex items-center justify-center">
<svg class="w-4 h-4 text-[var(--color-primary-400)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
<span class="text-xs font-bold text-[var(--color-text-secondary)] uppercase tracking-wider">Install Command</span>
</div>
<button
id="copyBtn"
class="flex items-center gap-2 px-4 py-2 text-xs font-bold text-white bg-gradient-to-r from-[var(--color-primary-500)] to-[var(--color-primary-600)] hover:from-[var(--color-primary-600)] hover:to-[var(--color-primary-700)] rounded-xl transition-all duration-200 active:scale-95 shadow-lg hover:shadow-xl"
data-cmd={installCmd}
>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
<span>Copy</span>
</button>
</div>
<code class="relative text-[13px] text-[var(--color-accent-400)] font-mono break-all leading-relaxed block bg-[var(--color-surface-4)]/50 px-4 py-3 rounded-xl border border-[var(--color-border)]">{installCmd}</code>
</div>
{/* Metadata labels + GitHub button — same row */}
<div class="mb-8 flex flex-wrap gap-2">
<a
href={`https://github.com/davila7/claude-code-templates/blob/main/cli-tool/components/${typePlural}/${component.path}`}
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-2 px-3 py-2 bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded-lg text-[12px] font-medium text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:border-[var(--color-primary-500)]/40 hover:bg-[var(--color-surface-3)] hover:shadow-sm transition-all duration-150 active:scale-95"
>
<svg class="w-3.5 h-3.5 shrink-0" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path fill-rule="evenodd" d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" clip-rule="evenodd" />
</svg>
View on GitHub
</a>
{infoCards.map((card, i) => (
<button
class="meta-label flex items-center gap-2 px-3 py-2 bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded-lg text-[12px] font-medium text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:border-[var(--color-primary-500)]/40 hover:bg-[var(--color-surface-3)] hover:shadow-sm transition-all duration-150 cursor-pointer active:scale-95"
data-meta-index={i}
>
<span class="text-[var(--color-primary-400)] shrink-0 [&>svg]:w-3.5 [&>svg]:h-3.5" set:html={card.icon} />
<span>{card.label}</span>
</button>
))}
</div>
{/* Metadata modal */}
<div id="metaModal" class="fixed inset-0 z-50 hidden items-center justify-center bg-black/60 backdrop-blur-sm">
<div class="bg-[var(--color-surface-1)] border border-[var(--color-border)] rounded-2xl shadow-2xl max-w-lg w-full mx-4 overflow-hidden">
<div class="flex items-center justify-between px-5 py-4 border-b border-[var(--color-border)]">
<div class="flex items-center gap-3">
<span id="metaModalIcon" class="text-[var(--color-primary-400)] [&>svg]:w-5 [&>svg]:h-5"></span>
<h4 id="metaModalTitle" class="text-[15px] font-bold text-[var(--color-text-primary)]"></h4>
</div>
<button id="metaModalClose" class="text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)] p-2 rounded-lg hover:bg-[var(--color-surface-3)] transition-all duration-150 active:scale-95">
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" /></svg>
</button>
</div>
<div id="metaModalBody" class="px-5 py-5 text-[13px] text-[var(--color-text-secondary)] max-h-[60vh] overflow-y-auto leading-relaxed"></div>
</div>
</div>
<script id="metaData" type="application/json" set:html={JSON.stringify(
infoCards.map((card) => {
const value = frontmatter[card.key];
const isChipList = card.key === 'tools' || card.key === 'allowed-tools' || card.key === 'tags';
return { label: card.label, icon: card.icon, value, isChipList };
})
)} />
{/* Referenced components (loops only) */}
{referencedComponents.length > 0 && (
<div class="mb-10">
<h3 class="text-xs font-bold text-[var(--color-text-tertiary)] uppercase tracking-[0.08em] mb-4">
Referenced Components ({referencedComponents.length})
</h3>
<p class="text-[13px] text-[var(--color-text-secondary)] mb-5 leading-relaxed">
This loop composes the components below. Installing the loop with the command above also installs each of them.
</p>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{referencedComponents.map((ref) => (
<a
href={ref.href}
class="group flex items-center gap-3 p-4 rounded-xl bg-[var(--color-surface-2)] border border-[var(--color-border)] hover:border-[var(--color-primary-500)]/40 hover:bg-[var(--color-surface-3)] hover:shadow-md transition-all duration-150 active:scale-[0.98]"
>
<div
class="w-10 h-10 rounded-lg flex items-center justify-center shrink-0 [&>svg]:w-5 [&>svg]:h-5"
style={`background: ${ref.color}1a; color: ${ref.color}; border: 1px solid ${ref.color}33;`}
set:html={ref.icon}
/>
<div class="min-w-0">
<div class="text-[13px] font-semibold text-[var(--color-text-primary)] truncate group-hover:text-[var(--color-primary-400)] transition-colors">
{ref.name}
</div>
<div class="text-[11px] text-[var(--color-text-tertiary)] truncate">
<span class="font-medium" style={`color: ${ref.color};`}>{ref.label}</span>
<span class="mx-1.5">·</span>
<span class="font-mono">{ref.path}</span>
</div>
</div>
<svg class="w-4 h-4 text-[var(--color-text-tertiary)] ml-auto shrink-0 transition-transform duration-150 group-hover:translate-x-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
</svg>
</a>
))}
</div>
</div>
)}
{/* Content area with optional file tree sidebar */}
{/* Content viewer */}
<h3 class="text-xs font-bold text-[var(--color-text-tertiary)] uppercase tracking-[0.08em] mb-4">Content</h3>
{refs.length > 0 ? (
<SkillExplorer
client:load
skillContent={component.content || ''}
skillName={component.name}
skillPath={component.path}
references={refs}
headings={contentHeadings}
/>
) : isJsonType ? (
<JsonViewer client:load content={component.content || ''} />
) : (
<MarkdownViewer
client:load
content={component.content || ''}
headings={contentHeadings}
/>
)}
{/* Action buttons */}
<div class="mt-8 flex items-center gap-3">
<SaveToCollectionButton
client:visible
componentType={component.type}
componentPath={component.path}
componentName={component.name}
componentCategory={component.category ?? ''}
/>
</div>
</article>
)}
</div>
<SearchModal client:idle />
<CartSidebar client:idle />
</DashboardLayout>
<script is:inline>
// Copy button
document.getElementById('copyBtn')?.addEventListener('click', (e) => {
const btn = e.currentTarget;
const cmd = btn.dataset.cmd;
if (cmd) {
navigator.clipboard.writeText(cmd);
const icon = btn.querySelector('svg');
const text = btn.querySelector('span');
if (icon) icon.style.display = 'none';
if (text) text.textContent = 'Copied!';
setTimeout(() => {
if (icon) icon.style.display = '';
if (text) text.textContent = 'Copy';
}, 2000);
}
});
// Metadata modal
const metaModal = document.getElementById('metaModal');
const metaModalIcon = document.getElementById('metaModalIcon');
const metaModalTitle = document.getElementById('metaModalTitle');
const metaModalBody = document.getElementById('metaModalBody');
const metaModalClose = document.getElementById('metaModalClose');
const metaDataEl = document.getElementById('metaData');
if (metaModal && metaDataEl) {
const metaItems = JSON.parse(metaDataEl.textContent || '[]');
document.querySelectorAll('.meta-label').forEach((btn) => {
btn.addEventListener('click', () => {
const idx = Number(btn.dataset.metaIndex);
const item = metaItems[idx];
if (!item) return;
if (metaModalIcon) metaModalIcon.innerHTML = item.icon;
if (metaModalTitle) metaModalTitle.textContent = item.label;
if (metaModalBody) {
if (item.isChipList) {
const chips = item.value.replace(/^\[|\]$/g, '').split(/,\s*/).map(s => s.trim()).filter(Boolean);
// Use textContent to prevent XSS
metaModalBody.innerHTML = '';
const container = document.createElement('div');
container.className = 'flex flex-wrap gap-2';
chips.forEach(c => {
const chip = document.createElement('span');
chip.className = 'px-3 py-1.5 rounded-lg bg-[var(--color-surface-3)] border border-[var(--color-border)] text-[var(--color-text-primary)] font-mono text-[12px] font-medium';
chip.textContent = c; // Safe: uses textContent instead of innerHTML
container.appendChild(chip);
});
metaModalBody.appendChild(container);
} else {
metaModalBody.textContent = item.value;
}
}
metaModal.classList.remove('hidden');
metaModal.classList.add('flex');
});
});
metaModalClose?.addEventListener('click', () => {
metaModal.classList.add('hidden');
metaModal.classList.remove('flex');
});
metaModal.addEventListener('click', (e) => {
if (e.target === metaModal) {
metaModal.classList.add('hidden');
metaModal.classList.remove('flex');
}
});
}
</script>
+910
View File
@@ -0,0 +1,910 @@
---
import DashboardLayout from '../../layouts/DashboardLayout.astro';
import SearchModal from '../../components/SearchModal.tsx';
import CartSidebar from '../../components/CartSidebar.tsx';
import { FEATURED_ITEMS } from '../../lib/constants';
export const prerender = false;
const { slug } = Astro.params;
const item = FEATURED_ITEMS.find((f) => f.url === `/featured/${slug}`);
if (!item) {
return Astro.redirect('/');
}
---
<DashboardLayout
title={`${item.name} for Claude Code — Featured Integration | Claude Code Templates`}
description={`${item.description} for Claude Code. ${item.category} integration with detailed setup guide and components.`}
>
<div class="max-w-5xl mx-auto px-6 py-8">
{/* Back nav */}
<a
href="/"
class="inline-flex items-center gap-1.5 text-sm text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] transition-colors mb-6"
>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7" />
</svg>
Back to Components
</a>
{/* Header */}
<div class="flex items-start gap-5 mb-8 pb-6 border-b border-[var(--color-border)] relative animate-fade-in">
{/* Logo with glow effect */}
<div class="w-16 h-16 rounded-xl bg-[var(--color-surface-2)] border border-[var(--color-border)] flex items-center justify-center overflow-hidden shrink-0 p-2 relative group">
<div class="absolute inset-0 bg-gradient-to-br from-accent-500/20 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500"></div>
<img src={item.logo} alt={item.name} class="w-full h-full object-contain rounded-lg relative z-10 group-hover:scale-110 transition-transform duration-500" />
</div>
{/* Title + badges with enhanced styling */}
<div class="flex-1 min-w-0">
<div class="flex items-center gap-3 mb-2">
<h1 class="text-2xl font-bold text-[var(--color-text-primary)] bg-gradient-to-r from-[var(--color-text-primary)] to-accent-400 bg-clip-text">{item.name}</h1>
{/* Verified badge */}
<div class="flex items-center gap-1 px-2 py-0.5 rounded-full bg-accent-500/10 border border-accent-500/30">
<svg class="w-3.5 h-3.5 text-accent-500" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M6.267 3.455a3.066 3.066 0 001.745-.723 3.066 3.066 0 013.976 0 3.066 3.066 0 001.745.723 3.066 3.066 0 012.812 2.812c.051.643.304 1.254.723 1.745a3.066 3.066 0 010 3.976 3.066 3.066 0 00-.723 1.745 3.066 3.066 0 01-2.812 2.812 3.066 3.066 0 00-1.745.723 3.066 3.066 0 01-3.976 0 3.066 3.066 0 00-1.745-.723 3.066 3.066 0 01-2.812-2.812 3.066 3.066 0 00-.723-1.745 3.066 3.066 0 010-3.976 3.066 3.066 0 00.723-1.745 3.066 3.066 0 012.812-2.812zm7.44 5.252a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
</svg>
<span class="text-[10px] font-semibold text-accent-500 uppercase tracking-wide">Verified</span>
</div>
</div>
<div class="flex items-center gap-2 mt-2 flex-wrap">
<span
class="text-[11px] px-2.5 py-1 rounded-full font-semibold text-white uppercase tracking-wide shadow-lg animate-pulse-subtle"
style={`background: linear-gradient(135deg, ${item.tagColor}, ${item.tagColor}dd)`}
>
{item.tag}
</span>
<span class="text-xs px-2.5 py-1 rounded-full bg-[var(--color-surface-3)] text-[var(--color-text-secondary)] border border-[var(--color-border)]">
{item.category}
</span>
{/* Social proof badge */}
<span class="text-xs px-2.5 py-1 rounded-full bg-green-500/10 text-green-600 border border-green-500/30 font-medium flex items-center gap-1">
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path d="M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z"/>
</svg>
{slug === 'claudekit' && '4,000+ users'}
{slug === 'brightdata' && 'Trusted by 1000s'}
{slug === 'braingrid' && 'Battle-tested'}
</span>
</div>
</div>
{/* CTA + Share with premium styling */}
<div class="flex items-center gap-2 shrink-0">
<div class="relative" id="shareDropdown">
<button
id="shareBtn"
class="w-9 h-9 flex items-center justify-center rounded-lg border border-[var(--color-border)] hover:border-accent-500/50 bg-[var(--color-surface-2)] text-[var(--color-text-secondary)] hover:text-accent-500 transition-all hover:shadow-lg hover:shadow-accent-500/20 hover:scale-105"
>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z" />
</svg>
</button>
<div id="shareOptions" class="hidden absolute right-0 top-full mt-2 bg-[var(--color-surface-3)] border border-[var(--color-border)] rounded-lg shadow-2xl z-50 min-w-[180px] overflow-hidden backdrop-blur-xl">
<button class="share-opt w-full text-left px-3 py-2.5 text-sm text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-accent-500/10 transition-all flex items-center gap-2 group" data-platform="x">
<svg class="w-4 h-4 group-hover:scale-110 transition-transform" viewBox="0 0 24 24" fill="currentColor"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/></svg>
Share on X
</button>
<button class="share-opt w-full text-left px-3 py-2.5 text-sm text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-accent-500/10 transition-all flex items-center gap-2 group" data-platform="threads">
<svg class="w-4 h-4 group-hover:scale-110 transition-transform" viewBox="0 0 24 24" fill="currentColor"><path d="M12.186 24h-.007c-3.581-.024-6.334-1.205-8.184-3.509C2.35 18.44 1.5 15.586 1.472 12.01v-.017c.03-3.579.879-6.43 2.525-8.482C5.845 1.205 8.6.024 12.18 0h.014c2.746.02 5.043.725 6.826 2.098 1.677 1.29 2.858 3.13 3.509 5.467l-2.04.569c-1.104-3.96-3.898-5.984-8.304-6.015-2.91.022-5.11.936-6.54 2.717C4.307 6.504 3.616 8.914 3.589 12c.027 3.086.718 5.496 2.057 7.164 1.43 1.781 3.631 2.695 6.54 2.717 1.623-.02 3.094-.37 4.37-1.04.558-.29 1.029-.63 1.414-1.017.33-.33.602-.69.82-1.084.218-.395.381-.84.488-1.336.107-.495.16-1.044.16-1.644 0-.6-.053-1.149-.16-1.644a4.158 4.158 0 00-.488-1.336 3.996 3.996 0 00-.82-1.084 4.015 4.015 0 00-1.414-1.017c-1.276-.67-2.747-1.02-4.37-1.04h-.014c-1.623.02-3.094.37-4.37 1.04a4.015 4.015 0 00-1.414 1.017 3.996 3.996 0 00-.82 1.084 4.158 4.158 0 00-.488 1.336c-.107.495-.16 1.044-.16 1.644 0 .6.053 1.149.16 1.644.107.496.27.941.488 1.336.218.394.49.754.82 1.084.385.387.856.727 1.414 1.017 1.276.67 2.747 1.02 4.37 1.04z"/></svg>
Share on Threads
</button>
</div>
</div>
<a
href={item.ctaUrl}
target="_blank"
rel="noopener noreferrer"
class="cta-button inline-flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-accent-500 via-accent-600 to-accent-500 bg-size-200 hover:bg-pos-100 text-white rounded-lg text-sm font-bold transition-all shadow-xl shadow-accent-500/30 hover:shadow-2xl hover:shadow-accent-500/50 hover:scale-105 relative overflow-hidden group"
>
<div class="absolute inset-0 bg-gradient-to-r from-white/0 via-white/20 to-white/0 translate-x-[-100%] group-hover:translate-x-[100%] transition-transform duration-1000"></div>
<svg class="w-5 h-5 relative z-10 group-hover:rotate-12 transition-transform" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<span class="relative z-10">{item.ctaLabel}</span>
<svg class="w-4 h-4 relative z-10 group-hover:translate-x-1 transition-transform" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M13 7l5 5m0 0l-5 5m5-5H6" />
</svg>
</a>
</div>
</div>
{/* Navigation bar */}
<div class="flex border-b border-[var(--color-border)] mb-0 sticky top-0 bg-[var(--color-surface-0)] z-30">
<span class="px-4 py-3 text-sm font-medium text-[var(--color-text-primary)] border-b-2 border-accent-500">
<span class="flex items-center gap-1.5">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg>
Overview
</span>
</span>
<a href={item.websiteUrl} target="_blank" rel="noopener noreferrer" class="px-4 py-3 text-sm font-medium text-[var(--color-text-tertiary)] border-b-2 border-transparent hover:text-[var(--color-text-secondary)] transition-colors">
<span class="flex items-center gap-1.5">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" /></svg>
Website
</span>
</a>
</div>
{/* Content */}
<div class="mt-0">
{/* Premium Hero CTA Banner with animated background */}
<div class="relative overflow-hidden bg-gradient-to-r from-accent-500/10 via-accent-600/15 to-accent-500/10 border-y border-accent-500/30 py-8 px-6 mb-6 animate-fade-in">
{/* Animated gradient orbs */}
<div class="absolute top-0 left-1/4 w-64 h-64 bg-accent-500/20 rounded-full blur-3xl animate-float"></div>
<div class="absolute bottom-0 right-1/4 w-64 h-64 bg-accent-600/20 rounded-full blur-3xl animate-float-delayed"></div>
<div class="max-w-3xl mx-auto text-center space-y-5 relative z-10">
{/* Eyebrow text */}
<div class="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-accent-500/20 border border-accent-500/30 backdrop-blur-sm">
<span class="relative flex h-2 w-2">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-accent-400 opacity-75"></span>
<span class="relative inline-flex rounded-full h-2 w-2 bg-accent-500"></span>
</span>
<span class="text-xs font-semibold text-accent-600 uppercase tracking-wide">
{slug === 'brightdata' && 'Most Popular Web Data Platform'}
{slug === 'claudekit' && 'Trusted by 4,000+ Developers'}
{slug === 'braingrid' && 'Product Planning Made Simple'}
</span>
</div>
<h2 class="text-3xl md:text-4xl font-black text-[var(--color-text-primary)] leading-tight">
{slug === 'brightdata' && (
<>Start Scraping Web Data <span class="text-transparent bg-clip-text bg-gradient-to-r from-accent-500 to-accent-600">in Minutes</span></>
)}
{slug === 'claudekit' && (
<>Supercharge Your Claude Code <span class="text-transparent bg-clip-text bg-gradient-to-r from-accent-500 to-accent-600">Workflow</span></>
)}
{slug === 'braingrid' && (
<>Plan Your Product <span class="text-transparent bg-clip-text bg-gradient-to-r from-accent-500 to-accent-600">Like a Pro</span></>
)}
</h2>
<p class="text-base text-[var(--color-text-secondary)] max-w-2xl mx-auto leading-relaxed">
{slug === 'brightdata' && 'Join thousands of developers using Bright Data to power their web scraping and data extraction. Free tier includes 60+ tools and unlimited searches.'}
{slug === 'claudekit' && '4,000+ developers across 109 countries trust ClaudeKit. Battle-tested workflows, production-ready agents, and proven commands.'}
{slug === 'braingrid' && 'Turn messy ideas into clear specifications, UX flows, and engineering-ready prompts. The planning layer that makes Claude Code more effective.'}
</p>
<div class="flex flex-col items-center gap-4 pt-2">
<a
href={item.ctaUrl}
target="_blank"
rel="noopener noreferrer"
class="cta-button inline-flex items-center gap-3 px-8 py-4 bg-gradient-to-r from-accent-500 via-accent-600 to-accent-500 bg-size-200 hover:bg-pos-100 text-white rounded-xl text-base font-bold transition-all shadow-2xl shadow-accent-500/40 hover:shadow-accent-500/60 hover:scale-105 relative overflow-hidden group"
>
<div class="absolute inset-0 bg-gradient-to-r from-white/0 via-white/25 to-white/0 translate-x-[-100%] group-hover:translate-x-[100%] transition-transform duration-1000"></div>
<svg class="w-6 h-6 relative z-10 group-hover:rotate-12 transition-transform" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<span class="relative z-10">{item.ctaLabel}</span>
<svg class="w-5 h-5 relative z-10 group-hover:translate-x-1 transition-transform" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M13 7l5 5m0 0l-5 5m5-5H6" />
</svg>
</a>
{/* Trust indicators */}
<div class="flex items-center gap-6 text-xs text-[var(--color-text-tertiary)] flex-wrap justify-center">
<span class="flex items-center gap-1.5">
<svg class="w-4 h-4 text-green-500" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
</svg>
<span class="font-medium">Free tier available</span>
</span>
<span class="flex items-center gap-1.5">
<svg class="w-4 h-4 text-green-500" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
</svg>
<span class="font-medium">No credit card required</span>
</span>
<span class="flex items-center gap-1.5">
<svg class="w-4 h-4 text-green-500" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
</svg>
<span class="font-medium">2 minute setup</span>
</span>
</div>
</div>
</div>
</div>
<div class="pt-6">
<div class="grid grid-cols-1 lg:grid-cols-[1fr_280px] gap-8 items-start">
{/* Main content */}
<div class="min-w-0 featured-prose">
{slug === 'braingrid' && (
<Fragment>
<h2>The Real Problem With AI Coding Is Not Code Generation</h2>
<p>AI coding tools like Claude Code are extremely capable. With models like Opus 4.5, they can reason deeply, write high-quality code, and implement complex features.</p>
<p>The problem shows up when builders try to turn a product idea into working software. As products grow, intent gets blurry, context fragments, and small changes ripple across the system in unexpected ways.</p>
<p><strong>The limitation is not the model. It is the lack of product-level planning, structure, and strategy before any code is written.</strong></p>
<h2>BrainGrid Is Like Claude Code Plan Mode for Your Entire Product</h2>
<p>Think of BrainGrid as Claude Code plan mode, but applied to your entire product, not just a single prompt.</p>
<div class="bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded-lg p-4 my-4 space-y-2">
<p><strong class="text-accent-400">Claude Code</strong> helps you plan a feature.</p>
<p><strong class="text-accent-400">BrainGrid</strong> helps you plan the product.</p>
</div>
<p>BrainGrid acts as a Product Management Agent that sits upstream of Claude Code. It turns messy ideas into clear specifications, end-to-end UX flows, task breakdowns, and engineering-grade prompts that Claude Code can execute reliably.</p>
<h2>How BrainGrid Works With Claude Code</h2>
<p>BrainGrid is not a replacement for Claude Code—it works alongside it. It is a planning and orchestration layer that makes Claude Code more effective.</p>
<div class="space-y-3 my-4">
<div class="flex gap-3 p-3 bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded-lg hover:border-[var(--color-border-hover)] transition-colors"><span class="shrink-0 text-xl">&#128161;</span><div><p class="font-medium text-[var(--color-text-primary)] text-sm">Clarifying Questions</p><p class="text-[var(--color-text-secondary)] text-sm mt-0.5">Asks clarifying questions to surface edge cases and hidden complexity</p></div></div>
<div class="flex gap-3 p-3 bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded-lg hover:border-[var(--color-border-hover)] transition-colors"><span class="shrink-0 text-xl">&#128506;</span><div><p class="font-medium text-[var(--color-text-primary)] text-sm">End-to-End UX Flows</p><p class="text-[var(--color-text-secondary)] text-sm mt-0.5">Maps end-to-end UX flows so features are planned as complete experiences</p></div></div>
<div class="flex gap-3 p-3 bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded-lg hover:border-[var(--color-border-hover)] transition-colors"><span class="shrink-0 text-xl">&#128203;</span><div><p class="font-medium text-[var(--color-text-primary)] text-sm">Task Breakdown</p><p class="text-[var(--color-text-secondary)] text-sm mt-0.5">Breaks large ideas into small, scoped tasks with goals and acceptance criteria</p></div></div>
<div class="flex gap-3 p-3 bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded-lg hover:border-[var(--color-border-hover)] transition-colors"><span class="shrink-0 text-xl">&#9889;</span><div><p class="font-medium text-[var(--color-text-primary)] text-sm">Claude Code-Ready Prompts</p><p class="text-[var(--color-text-secondary)] text-sm mt-0.5">Generates Claude Code-ready prompts with the right context attached</p></div></div>
<div class="flex gap-3 p-3 bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded-lg hover:border-[var(--color-border-hover)] transition-colors"><span class="shrink-0 text-xl">&#128737;</span><div><p class="font-medium text-[var(--color-text-primary)] text-sm">Prevent Regressions</p><p class="text-[var(--color-text-secondary)] text-sm mt-0.5">Helps prevent regressions by ensuring new work fits the existing system</p></div></div>
</div>
<div class="bg-[var(--color-surface-2)] border-l-2 border-accent-500 rounded-r-lg p-3 my-4 text-sm text-[var(--color-text-secondary)] italic">Tasks can be sent to Claude Code via MCP, CLI or copied directly into the Claude Code workflow.</div>
<h2>Claude Code Integration</h2>
<p>BrainGrid seamlessly integrates with your Claude Code workflow. Once BrainGrid generates your product specifications and task breakdowns, you can:</p>
<ul><li>Export tasks directly to Claude Code via MCP integration</li><li>Copy generated prompts into your Claude Code sessions</li><li>Use the CLI to sync tasks between BrainGrid and Claude Code</li></ul>
<h2>Built for Production-Ready Apps</h2>
<p>BrainGrid is designed for builders creating real AI-native SaaS products with:</p>
<ul><li>Authentication and multi-tenancy</li><li>Billing and subscriptions</li><li>AI features like chat, agents, and search</li><li>Ongoing feature development and iteration</li></ul>
<h2>Who BrainGrid Is For</h2>
<ul><li>Non-technical founders building real products, not just prototypes</li><li>Vibe coders hitting complexity walls as their apps grow</li><li>AI builders who need help planning scope, sequencing work, and evolving apps without breaking what already works</li></ul>
{/* Premium Inline CTA */}
<div class="relative overflow-hidden bg-gradient-to-br from-accent-500/15 via-accent-600/10 to-accent-500/15 border-2 border-accent-500/40 rounded-2xl p-8 my-10 text-center shadow-2xl animate-fade-in-up">
<div class="absolute top-0 right-0 w-32 h-32 bg-accent-500/20 rounded-full blur-3xl"></div>
<div class="absolute bottom-0 left-0 w-32 h-32 bg-accent-600/20 rounded-full blur-3xl"></div>
<div class="relative z-10 space-y-4">
<div class="inline-flex items-center justify-center w-14 h-14 rounded-2xl bg-gradient-to-br from-accent-500 to-accent-600 shadow-lg shadow-accent-500/50 mb-2">
<svg class="w-7 h-7 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
</svg>
</div>
<h3 class="text-2xl font-black text-[var(--color-text-primary)]">Plan Your Product Like a Pro</h3>
<p class="text-sm text-[var(--color-text-secondary)] max-w-md mx-auto leading-relaxed">Turn messy ideas into clear specs and engineering-ready prompts. Start building better products today.</p>
<a
href={item.ctaUrl}
target="_blank"
rel="noopener noreferrer"
class="cta-button inline-flex items-center gap-3 px-8 py-4 bg-gradient-to-r from-accent-500 via-accent-600 to-accent-500 bg-size-200 hover:bg-pos-100 text-white rounded-xl text-base font-bold transition-all shadow-2xl shadow-accent-500/40 hover:shadow-accent-500/60 hover:scale-105 relative overflow-hidden group"
>
<div class="absolute inset-0 bg-gradient-to-r from-white/0 via-white/25 to-white/0 translate-x-[-100%] group-hover:translate-x-[100%] transition-transform duration-1000"></div>
<svg class="w-6 h-6 relative z-10 group-hover:rotate-12 transition-transform" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<span class="relative z-10">{item.ctaLabel}</span>
<svg class="w-5 h-5 relative z-10 group-hover:translate-x-1 transition-transform" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M13 7l5 5m0 0l-5 5m5-5H6" />
</svg>
</a>
<div class="flex items-center justify-center gap-4 text-xs text-[var(--color-text-tertiary)] pt-2">
<span class="flex items-center gap-1">
<svg class="w-3.5 h-3.5 text-green-500" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
</svg>
AI-powered
</span>
<span class="flex items-center gap-1">
<svg class="w-3.5 h-3.5 text-green-500" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
</svg>
Battle-tested
</span>
</div>
</div>
</div>
</Fragment>
)}
{slug === 'brightdata' && (
<Fragment>
<h2>Overview</h2>
<p>The Bright Data template provides a complete web data infrastructure for Claude Code projects. It combines Bright Data's Web Unlocker, SERP API, Web Scraper API, and Browser API with specialized skills, MCP integration, production best-practices documentation, and 60+ tools for search, scraping, structured extraction, and browser automation.</p>
<h2>Template Architecture</h2>
<p>The template consists of 7 components organized into three layers:</p>
<h3>1. Skills Layer</h3>
<div class="space-y-3 my-4">
<div class="flex gap-3 p-3 bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded-lg hover:border-[var(--color-border)]-hover transition-colors"><span class="shrink-0 text-xl">&#128269;</span><div class="flex-1"><p class="font-medium text-[var(--color-text-primary)] text-sm">search Skill</p><p class="text-[var(--color-text-secondary)] text-sm mt-0.5">Google search with structured JSON results. Returns titles, links, and descriptions via Web Unlocker API with parsed_light format.</p></div><a href="/component/skill/web-data/search" class="shrink-0 self-center text-xs font-medium text-accent-400 border border-accent-400/30 rounded-md px-3 py-1.5 hover:bg-accent-400/10 transition-colors no-underline">View</a></div>
<div class="flex gap-3 p-3 bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded-lg hover:border-[var(--color-border)]-hover transition-colors"><span class="shrink-0 text-xl">&#127760;</span><div class="flex-1"><p class="font-medium text-[var(--color-text-primary)] text-sm">scrape Skill</p><p class="text-[var(--color-text-secondary)] text-sm mt-0.5">Scrape any webpage as clean markdown. Automatic bot detection bypass, CAPTCHA solving, and JavaScript rendering via Web Unlocker.</p></div><a href="/component/skill/web-data/scrape" class="shrink-0 self-center text-xs font-medium text-accent-400 border border-accent-400/30 rounded-md px-3 py-1.5 hover:bg-accent-400/10 transition-colors no-underline">View</a></div>
<div class="flex gap-3 p-3 bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded-lg hover:border-[var(--color-border)]-hover transition-colors"><span class="shrink-0 text-xl">&#128202;</span><div class="flex-1"><p class="font-medium text-[var(--color-text-primary)] text-sm">data-feeds Skill</p><p class="text-[var(--color-text-secondary)] text-sm mt-0.5">Extract structured data from 40+ websites. Covers e-commerce, professional networks, social media, and more with automatic polling.</p></div><a href="/component/skill/web-data/data-feeds" class="shrink-0 self-center text-xs font-medium text-accent-400 border border-accent-400/30 rounded-md px-3 py-1.5 hover:bg-accent-400/10 transition-colors no-underline">View</a></div>
<div class="flex gap-3 p-3 bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded-lg hover:border-[var(--color-border)]-hover transition-colors"><span class="shrink-0 text-xl">&#9889;</span><div class="flex-1"><p class="font-medium text-[var(--color-text-primary)] text-sm">bright-data-mcp Skill</p><p class="text-[var(--color-text-secondary)] text-sm mt-0.5">Workflow orchestration guide that teaches Claude how to optimally use Bright Data's MCP server across all 60+ tools.</p></div><a href="/component/skill/web-data/bright-data-mcp" class="shrink-0 self-center text-xs font-medium text-accent-400 border border-accent-400/30 rounded-md px-3 py-1.5 hover:bg-accent-400/10 transition-colors no-underline">View</a></div>
<div class="flex gap-3 p-3 bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded-lg hover:border-[var(--color-border)]-hover transition-colors"><span class="shrink-0 text-xl">&#128218;</span><div class="flex-1"><p class="font-medium text-[var(--color-text-primary)] text-sm">bright-data-best-practices Skill</p><p class="text-[var(--color-text-secondary)] text-sm mt-0.5">Production-ready API reference covering Web Unlocker, SERP API, Web Scraper API, and Browser API with code examples.</p></div><a href="/component/skill/web-data/bright-data-best-practices" class="shrink-0 self-center text-xs font-medium text-accent-400 border border-accent-400/30 rounded-md px-3 py-1.5 hover:bg-accent-400/10 transition-colors no-underline">View</a></div>
<div class="flex gap-3 p-3 bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded-lg hover:border-[var(--color-border)]-hover transition-colors"><span class="shrink-0 text-xl">&#127912;</span><div class="flex-1"><p class="font-medium text-[var(--color-text-primary)] text-sm">design-mirror Skill</p><p class="text-[var(--color-text-secondary)] text-sm mt-0.5">Capture visual design from any website and apply it to your codebase. Extracts design tokens, colors, typography, and spacing.</p></div><a href="/component/skill/web-data/design-mirror" class="shrink-0 self-center text-xs font-medium text-accent-400 border border-accent-400/30 rounded-md px-3 py-1.5 hover:bg-accent-400/10 transition-colors no-underline">View</a></div>
<div class="flex gap-3 p-3 bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded-lg hover:border-[var(--color-border)]-hover transition-colors"><span class="shrink-0 text-xl">&#128270;</span><div class="flex-1"><p class="font-medium text-[var(--color-text-primary)] text-sm">brightdata-local-search Skill</p><p class="text-[var(--color-text-secondary)] text-sm mt-0.5">Run local web searches using Bright Data SERP API with query expansion, multi-engine SERP retrieval, RRF reranking, deduplication, and domain clustering.</p></div><a href="/component/skill/development/brightdata-local-search" class="shrink-0 self-center text-xs font-medium text-accent-400 border border-accent-400/30 rounded-md px-3 py-1.5 hover:bg-accent-400/10 transition-colors no-underline">View</a></div>
</div>
<h3>2. Integration Layer</h3>
<p><strong>brightdata MCP</strong> — Model Context Protocol integration providing programmatic access to 60+ tools across three modes:</p>
<div class="space-y-2 my-3">
<div class="p-3 bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded-lg"><p class="text-sm"><strong class="text-[var(--color-text-primary)]">Rapid Mode (Free)</strong> — <code>search_engine</code>, <code>scrape_as_markdown</code>, <code>search_engine_batch</code>, <code>scrape_batch</code></p></div>
<div class="p-3 bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded-lg"><p class="text-sm"><strong class="text-[var(--color-text-primary)]">Pro Mode</strong> — Everything in Rapid plus browser automation (13 tools), web data APIs for 40+ platforms, and advanced scraping</p></div>
<div class="p-3 bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded-lg"><p class="text-sm"><strong class="text-[var(--color-text-primary)]">Custom Mode</strong> — Mix and match tool groups (ecommerce, social, business, finance, research, travel, browser, advanced_scraping)</p></div>
</div>
<h3>3. Configuration Layer</h3>
<p>Two environment variables configure the template:</p>
<ul>
<li><code>BRIGHTDATA_API_KEY</code> — Your Bright Data API key from the dashboard</li>
<li><code>BRIGHTDATA_UNLOCKER_ZONE</code> — Name of your Web Unlocker zone (for search/scrape skills)</li>
</ul>
<h2>Installation</h2>
<h3>Option 1: Skills Only</h3>
<div class="bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded-lg p-3 my-3"><code class="text-sm text-accent-400 font-mono break-all">npx claude-code-templates@latest --skill web-data/search,web-data/scrape,web-data/data-feeds,web-data/bright-data-mcp,web-data/bright-data-best-practices</code></div>
<h3>Option 2: Complete Template (Skills + MCP)</h3>
<div class="bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded-lg p-3 my-3"><code class="text-sm text-accent-400 font-mono break-all whitespace-pre-wrap">npx claude-code-templates@latest \
--skill web-data/search,web-data/scrape,web-data/data-feeds,web-data/bright-data-mcp,web-data/bright-data-best-practices \
--mcp web-data/brightdata \
--yes</code></div>
<h3>Option 3: MCP Only (No Skills)</h3>
<p>Add to your Claude Code MCP settings:</p>
<div class="bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded-lg p-3 my-3"><code class="text-sm text-accent-400 font-mono break-all whitespace-pre-wrap">&#123;
"mcpServers": &#123;
"brightdata": &#123;
"url": "https://mcp.brightdata.com/mcp?token=YOUR_TOKEN&amp;pro=1"
&#125;
&#125;
&#125;</code></div>
<h2>Supported Platforms</h2>
<h3>E-Commerce</h3>
<p>Amazon, Walmart, eBay, Best Buy, Etsy, Home Depot, Zara, Google Shopping — product details, pricing, ratings, and reviews.</p>
<h3>Professional Networks</h3>
<p>LinkedIn (profiles, companies, jobs, posts), Crunchbase, ZoomInfo — company data, funding rounds, and professional profiles.</p>
<h3>Social Media</h3>
<p>Instagram, TikTok, Facebook, X (Twitter), YouTube, Reddit — profiles, posts, reels, engagement metrics, and comments.</p>
<h3>Other</h3>
<p>Google Maps, Yahoo Finance, Zillow, Booking.com, Reuters, GitHub, Apple App Store, Google Play Store.</p>
<h2>Use Cases</h2>
<div class="space-y-3 my-4">
<div class="flex gap-3 p-3 bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded-lg hover:border-[var(--color-border)]-hover transition-colors"><span class="shrink-0 text-xl">&#128200;</span><div><p class="font-medium text-[var(--color-text-primary)] text-sm">Research &amp; Analysis</p><p class="text-[var(--color-text-secondary)] text-sm mt-0.5">Search Google for structured results, scrape full articles as markdown, combine multiple sources into comprehensive reports.</p></div></div>
<div class="flex gap-3 p-3 bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded-lg hover:border-[var(--color-border)]-hover transition-colors"><span class="shrink-0 text-xl">&#127919;</span><div><p class="font-medium text-[var(--color-text-primary)] text-sm">Competitive Intelligence</p><p class="text-[var(--color-text-secondary)] text-sm mt-0.5">Extract Amazon product data, LinkedIn company profiles, Crunchbase funding rounds, and social media metrics.</p></div></div>
<div class="flex gap-3 p-3 bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded-lg hover:border-[var(--color-border)]-hover transition-colors"><span class="shrink-0 text-xl">&#128101;</span><div><p class="font-medium text-[var(--color-text-primary)] text-sm">Lead Generation</p><p class="text-[var(--color-text-secondary)] text-sm mt-0.5">Pull LinkedIn person profiles, company data, and ZoomInfo records. Enrich with social media presence.</p></div></div>
<div class="flex gap-3 p-3 bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded-lg hover:border-[var(--color-border)]-hover transition-colors"><span class="shrink-0 text-xl">&#128241;</span><div><p class="font-medium text-[var(--color-text-primary)] text-sm">Social Media Monitoring</p><p class="text-[var(--color-text-secondary)] text-sm mt-0.5">Track profiles, posts, reels, and engagement metrics across Instagram, TikTok, Facebook, YouTube, Reddit, and X.</p></div></div>
<div class="flex gap-3 p-3 bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded-lg hover:border-[var(--color-border)]-hover transition-colors"><span class="shrink-0 text-xl">&#128722;</span><div><p class="font-medium text-[var(--color-text-primary)] text-sm">E-Commerce Intelligence</p><p class="text-[var(--color-text-secondary)] text-sm mt-0.5">Product details, pricing, ratings, and reviews from Amazon, Walmart, eBay, Best Buy, Etsy, and more.</p></div></div>
<div class="flex gap-3 p-3 bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded-lg hover:border-[var(--color-border)]-hover transition-colors"><span class="shrink-0 text-xl">&#128421;</span><div><p class="font-medium text-[var(--color-text-primary)] text-sm">Browser Automation</p><p class="text-[var(--color-text-secondary)] text-sm mt-0.5">Full browser control for JavaScript-heavy sites: navigate, click, type, screenshot, and extract via Scraping Browser tools.</p></div></div>
</div>
<h2>Additional Resources</h2>
<ul>
<li><a href="https://get.brightdata.com/lcqorc6nzp9w" target="_blank">Bright Data Dashboard</a></li>
<li><a href="https://docs.brightdata.com" target="_blank">API Documentation</a></li>
<li><a href="https://github.com/brightdata/brightdata-mcp" target="_blank">MCP Server Repository</a></li>
<li><a href="https://github.com/brightdata/skills" target="_blank">Skills Plugin Repository</a></li>
<li><a href="https://get.brightdata.com/lcqorc6nzp9w" target="_blank">Create Free Bright Data Account</a></li>
</ul>
{/* Premium Inline CTA */}
<div class="relative overflow-hidden bg-gradient-to-br from-accent-500/15 via-accent-600/10 to-accent-500/15 border-2 border-accent-500/40 rounded-2xl p-8 my-10 text-center shadow-2xl animate-fade-in-up">
<div class="absolute top-0 right-0 w-32 h-32 bg-accent-500/20 rounded-full blur-3xl"></div>
<div class="absolute bottom-0 left-0 w-32 h-32 bg-accent-600/20 rounded-full blur-3xl"></div>
<div class="relative z-10 space-y-4">
<div class="inline-flex items-center justify-center w-14 h-14 rounded-2xl bg-gradient-to-br from-accent-500 to-accent-600 shadow-lg shadow-accent-500/50 mb-2">
<svg class="w-7 h-7 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
</svg>
</div>
<h3 class="text-2xl font-black text-[var(--color-text-primary)]">Start Scraping Web Data Today</h3>
<p class="text-sm text-[var(--color-text-secondary)] max-w-md mx-auto leading-relaxed">60+ tools, unlimited searches, free tier available. No credit card required.</p>
<a
href={item.ctaUrl}
target="_blank"
rel="noopener noreferrer"
class="cta-button inline-flex items-center gap-3 px-8 py-4 bg-gradient-to-r from-accent-500 via-accent-600 to-accent-500 bg-size-200 hover:bg-pos-100 text-white rounded-xl text-base font-bold transition-all shadow-2xl shadow-accent-500/40 hover:shadow-accent-500/60 hover:scale-105 relative overflow-hidden group"
>
<div class="absolute inset-0 bg-gradient-to-r from-white/0 via-white/25 to-white/0 translate-x-[-100%] group-hover:translate-x-[100%] transition-transform duration-1000"></div>
<svg class="w-6 h-6 relative z-10 group-hover:rotate-12 transition-transform" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<span class="relative z-10">{item.ctaLabel}</span>
<svg class="w-5 h-5 relative z-10 group-hover:translate-x-1 transition-transform" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M13 7l5 5m0 0l-5 5m5-5H6" />
</svg>
</a>
<div class="flex items-center justify-center gap-4 text-xs text-[var(--color-text-tertiary)] pt-2">
<span class="flex items-center gap-1">
<svg class="w-3.5 h-3.5 text-green-500" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
</svg>
1000s of users
</span>
<span class="flex items-center gap-1">
<svg class="w-3.5 h-3.5 text-green-500" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
</svg>
60+ tools
</span>
</div>
</div>
</div>
</Fragment>
)}
{slug === 'claudekit' && (
<Fragment>
<h2>What Is ClaudeKit</h2>
<p>ClaudeKit is a production-ready Claude Code setup with subagents, commands, hooks, and skills that have been battle-tested and used in real products. Built by someone who has been using Claude Code for 7+ months and documented everything publicly.</p>
<p><strong>4,000+ happy users across 109 countries trust ClaudeKit to power their development workflow.</strong></p>
<h2>Quick Installation</h2>
<div class="bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded-lg p-3 my-3"><code class="text-sm text-accent-400 font-mono whitespace-pre-wrap"># Login with your GitHub account
gh auth login
# Install ClaudeKit CLI
npm i -g claudekit-cli
# Install CK Engineer in your project
ck init
# Or install globally for every project
ck init -g</code></div>
<h2>The Battle-Tested Workflow</h2>
<div class="space-y-3 my-4">
<div class="flex gap-3 p-3 bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded-lg hover:border-[var(--color-border)]-hover transition-colors"><span class="shrink-0 w-7 h-7 rounded-lg bg-[var(--color-surface-3)] flex items-center justify-center text-sm font-bold text-accent-400">1</span><div><p class="font-medium text-[var(--color-text-primary)] text-sm">/docs:init</p><p class="text-[var(--color-text-secondary)] text-sm mt-0.5">Scans your codebase, generates specs: structure, architecture, code standards, product overview, and design guidelines.</p></div></div>
<div class="flex gap-3 p-3 bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded-lg hover:border-[var(--color-border)]-hover transition-colors"><span class="shrink-0 w-7 h-7 rounded-lg bg-[var(--color-surface-3)] flex items-center justify-center text-sm font-bold text-accent-400">2</span><div><p class="font-medium text-[var(--color-text-primary)] text-sm">/brainstorm</p><p class="text-[var(--color-text-secondary)] text-sm mt-0.5">Describe what you want to build. AI asks questions, challenges you, and produces requirement specs.</p></div></div>
<div class="flex gap-3 p-3 bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded-lg hover:border-[var(--color-border)]-hover transition-colors"><span class="shrink-0 w-7 h-7 rounded-lg bg-[var(--color-surface-3)] flex items-center justify-center text-sm font-bold text-accent-400">3</span><div><p class="font-medium text-[var(--color-text-primary)] text-sm">/plan</p><p class="text-[var(--color-text-secondary)] text-sm mt-0.5">Researches approaches, spawns researcher agents in parallel, creates a comprehensive implementation plan.</p></div></div>
<div class="flex gap-3 p-3 bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded-lg hover:border-[var(--color-border)]-hover transition-colors"><span class="shrink-0 w-7 h-7 rounded-lg bg-[var(--color-surface-3)] flex items-center justify-center text-sm font-bold text-accent-400">4</span><div><p class="font-medium text-[var(--color-text-primary)] text-sm">/clear</p><p class="text-[var(--color-text-secondary)] text-sm mt-0.5">Fresh context window before implementation for best code quality output.</p></div></div>
<div class="flex gap-3 p-3 bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded-lg hover:border-[var(--color-border)]-hover transition-colors"><span class="shrink-0 w-7 h-7 rounded-lg bg-[var(--color-surface-3)] flex items-center justify-center text-sm font-bold text-accent-400">5</span><div><p class="font-medium text-[var(--color-text-primary)] text-sm">/code:auto</p><p class="text-[var(--color-text-secondary)] text-sm mt-0.5">Reads the plan, implements phase by phase. Write code, tests, code review, verification, iterate, then update specs.</p></div></div>
</div>
<h2>ClaudeKit Engineer</h2>
<div class="space-y-3 my-4">
<div class="flex gap-3 p-3 bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded-lg hover:border-[var(--color-border)]-hover transition-colors"><div><p class="font-medium text-[var(--color-text-primary)] text-sm">Feature Development — <code>/cook</code></p><p class="text-[var(--color-text-secondary)] text-sm mt-0.5">Standalone implementation with internal planning and development workflows</p></div></div>
<div class="flex gap-3 p-3 bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded-lg hover:border-[var(--color-border)]-hover transition-colors"><div><p class="font-medium text-[var(--color-text-primary)] text-sm">Multi-Agent Code Review — <code>/code:review</code></p><p class="text-[var(--color-text-secondary)] text-sm mt-0.5">Specialized agents for security, performance, and standards enforcement</p></div></div>
<div class="flex gap-3 p-3 bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded-lg hover:border-[var(--color-border)]-hover transition-colors"><div><p class="font-medium text-[var(--color-text-primary)] text-sm">Automated Testing — <code>/test</code></p><p class="text-[var(--color-text-secondary)] text-sm mt-0.5">Generates comprehensive test suites: E2E, integration, and unit tests</p></div></div>
<div class="flex gap-3 p-3 bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded-lg hover:border-[var(--color-border)]-hover transition-colors"><div><p class="font-medium text-[var(--color-text-primary)] text-sm">Bug Diagnosis — <code>/debug</code></p><p class="text-[var(--color-text-secondary)] text-sm mt-0.5">Analyzes logs, CI/CD failures, root cause analysis</p></div></div>
<div class="flex gap-3 p-3 bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded-lg hover:border-[var(--color-border)]-hover transition-colors"><div><p class="font-medium text-[var(--color-text-primary)] text-sm">Documentation Sync — <code>/docs</code></p><p class="text-[var(--color-text-secondary)] text-sm mt-0.5">Maintains synchronized technical docs: PRD, codebase summary, code standards, architecture</p></div></div>
</div>
<h2>ClaudeKit Marketing</h2>
<p>Go beyond code. ClaudeKit Marketing handles the go-to-market side:</p>
<div class="space-y-2 my-3">
<div class="p-3 bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded-lg"><p class="text-sm"><strong class="text-[var(--color-text-primary)]">Content Strategy</strong> — <code>/plan "Q1 content strategy"</code> with TOFU/MOFU/BOFU funnel stages</p></div>
<div class="p-3 bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded-lg"><p class="text-sm"><strong class="text-[var(--color-text-primary)]">Copywriting</strong> — <code>/content/good</code> and <code>/content/cro</code> for landing pages</p></div>
<div class="p-3 bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded-lg"><p class="text-sm"><strong class="text-[var(--color-text-primary)]">SEO</strong> — <code>/seo:audit</code> and <code>/seo:keywords</code> for keyword research</p></div>
<div class="p-3 bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded-lg"><p class="text-sm"><strong class="text-[var(--color-text-primary)]">Campaign &amp; Email</strong> — <code>/campaign:email</code> via SendGrid and Resend MCPs</p></div>
<div class="p-3 bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded-lg"><p class="text-sm"><strong class="text-[var(--color-text-primary)]">Analytics</strong> — GA4 + Google Ads MCPs for data-driven optimization</p></div>
</div>
{/* Premium Inline CTA */}
<div class="relative overflow-hidden bg-gradient-to-br from-accent-500/15 via-accent-600/10 to-accent-500/15 border-2 border-accent-500/40 rounded-2xl p-8 my-10 text-center shadow-2xl animate-fade-in-up">
<div class="absolute top-0 right-0 w-32 h-32 bg-accent-500/20 rounded-full blur-3xl"></div>
<div class="absolute bottom-0 left-0 w-32 h-32 bg-accent-600/20 rounded-full blur-3xl"></div>
<div class="relative z-10 space-y-4">
<div class="inline-flex items-center justify-center w-14 h-14 rounded-2xl bg-gradient-to-br from-accent-500 to-accent-600 shadow-lg shadow-accent-500/50 mb-2">
<svg class="w-7 h-7 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4M7.835 4.697a3.42 3.42 0 001.946-.806 3.42 3.42 0 014.438 0 3.42 3.42 0 001.946.806 3.42 3.42 0 013.138 3.138 3.42 3.42 0 00.806 1.946 3.42 3.42 0 010 4.438 3.42 3.42 0 00-.806 1.946 3.42 3.42 0 01-3.138 3.138 3.42 3.42 0 00-1.946.806 3.42 3.42 0 01-4.438 0 3.42 3.42 0 00-1.946-.806 3.42 3.42 0 01-3.138-3.138 3.42 3.42 0 00-.806-1.946 3.42 3.42 0 010-4.438 3.42 3.42 0 00.806-1.946 3.42 3.42 0 013.138-3.138z" />
</svg>
</div>
<h3 class="text-2xl font-black text-[var(--color-text-primary)]">Join 4,000+ Developers Using ClaudeKit</h3>
<p class="text-sm text-[var(--color-text-secondary)] max-w-md mx-auto leading-relaxed">Battle-tested workflows trusted across 109 countries. Get started in 2 minutes.</p>
<a
href={item.ctaUrl}
target="_blank"
rel="noopener noreferrer"
class="cta-button inline-flex items-center gap-3 px-8 py-4 bg-gradient-to-r from-accent-500 via-accent-600 to-accent-500 bg-size-200 hover:bg-pos-100 text-white rounded-xl text-base font-bold transition-all shadow-2xl shadow-accent-500/40 hover:shadow-accent-500/60 hover:scale-105 relative overflow-hidden group"
>
<div class="absolute inset-0 bg-gradient-to-r from-white/0 via-white/25 to-white/0 translate-x-[-100%] group-hover:translate-x-[100%] transition-transform duration-1000"></div>
<svg class="w-6 h-6 relative z-10 group-hover:rotate-12 transition-transform" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<span class="relative z-10">{item.ctaLabel}</span>
<svg class="w-5 h-5 relative z-10 group-hover:translate-x-1 transition-transform" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M13 7l5 5m0 0l-5 5m5-5H6" />
</svg>
</a>
<div class="flex items-center justify-center gap-4 text-xs text-[var(--color-text-tertiary)] pt-2">
<span class="flex items-center gap-1">
<svg class="w-3.5 h-3.5 text-green-500" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
</svg>
4,000+ users
</span>
<span class="flex items-center gap-1">
<svg class="w-3.5 h-3.5 text-green-500" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
</svg>
109 countries
</span>
</div>
</div>
</div>
</Fragment>
)}
</div>
{/* Sidebar */}
<aside class="space-y-4 lg:sticky lg:top-20 self-start">
{/* About card */}
<div class="bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded-lg p-4">
<h3 class="text-xs font-semibold text-[var(--color-text-tertiary)] uppercase tracking-wide mb-3 pb-2 border-b border-[var(--color-border)]">About {item.name}</h3>
<div class="flex flex-col items-center gap-3 text-center">
<img src={item.logo} alt={item.name} class="max-w-[160px] w-full h-auto rounded-xl" />
<p class="text-sm text-[var(--color-text-secondary)] leading-relaxed">{item.description} for Claude Code</p>
</div>
</div>
{/* Premium CTA with enhanced styling */}
<div class="space-y-3">
<a
href={item.ctaUrl}
target="_blank"
rel="noopener noreferrer"
class="cta-button flex items-center justify-center gap-2 w-full px-5 py-4 bg-gradient-to-r from-accent-500 via-accent-600 to-accent-500 bg-size-200 hover:bg-pos-100 text-white rounded-xl text-base font-black transition-all shadow-2xl shadow-accent-500/40 hover:shadow-accent-500/60 hover:scale-105 relative overflow-hidden group"
>
<div class="absolute inset-0 bg-gradient-to-r from-white/0 via-white/25 to-white/0 translate-x-[-100%] group-hover:translate-x-[100%] transition-transform duration-1000"></div>
<svg class="w-5 h-5 relative z-10 group-hover:rotate-12 transition-transform" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<span class="relative z-10">{item.ctaLabel}</span>
</a>
<div class="flex items-center justify-center gap-2 text-[10px] text-[var(--color-text-tertiary)]">
<svg class="w-3 h-3 text-green-500" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
</svg>
<span class="font-semibold">Free tier • No credit card</span>
</div>
</div>
{/* Install command (Neon only) */}
{item.installCommand && (
<div class="bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded-lg p-4">
<h3 class="text-xs font-semibold text-[var(--color-text-tertiary)] uppercase tracking-wide mb-3 pb-2 border-b border-[var(--color-border)]">Quick Install</h3>
<div class="bg-[var(--color-surface-1)] border border-[var(--color-border)] rounded-md p-3">
<code id="installCmd" class="text-xs text-accent-400 font-mono break-all leading-relaxed">{item.installCommand}</code>
</div>
<button id="copyInstallBtn" class="mt-2 w-full text-xs font-medium text-white bg-accent-500 hover:bg-accent-600 rounded-md px-3 py-1.5 transition-colors">Copy</button>
</div>
)}
{/* Metadata */}
<div class="bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded-lg p-4">
<h3 class="text-xs font-semibold text-[var(--color-text-tertiary)] uppercase tracking-wide mb-3 pb-2 border-b border-[var(--color-border)]">Details</h3>
<div class="space-y-0">
<div class="flex justify-between items-center py-2 border-b border-[var(--color-border)]">
<span class="text-xs text-[var(--color-text-tertiary)]">Type</span>
<span class="text-xs text-[var(--color-text-primary)] font-mono">{item.tag}</span>
</div>
<div class="flex justify-between items-center py-2 border-b border-[var(--color-border)]">
<span class="text-xs text-[var(--color-text-tertiary)]">Category</span>
<span class="text-xs text-[var(--color-text-primary)] font-mono">{item.category}</span>
</div>
<div class="flex justify-between items-center py-2 border-b border-[var(--color-border)]">
<span class="text-xs text-[var(--color-text-tertiary)]">Website</span>
<a href={item.websiteUrl} target="_blank" class="text-xs text-accent-400 hover:underline font-mono">{item.websiteUrl.replace('https://', '')}</a>
</div>
{Object.entries(item.metadata).map(([label, value]) => (
<div class="flex justify-between items-center py-2 border-b border-[var(--color-border)] last:border-b-0">
<span class="text-xs text-[var(--color-text-tertiary)]">{label}</span>
<span class="text-xs text-[var(--color-text-primary)] font-mono">{value}</span>
</div>
))}
</div>
</div>
{/* Links */}
<div class="bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded-lg p-4">
<h3 class="text-xs font-semibold text-[var(--color-text-tertiary)] uppercase tracking-wide mb-3 pb-2 border-b border-[var(--color-border)]">Links</h3>
<div class="space-y-0">
{item.links.map((link) => (
<a href={link.url} target="_blank" rel="noopener noreferrer" class="flex items-center gap-2 py-2 border-b border-[var(--color-border)] last:border-b-0 text-sm text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] transition-colors">
<svg class="w-3.5 h-3.5 shrink-0 opacity-60" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" /></svg>
{link.label}
</a>
))}
</div>
</div>
</aside>
</div>
</div>
</div>
</div>
<SearchModal client:idle />
<CartSidebar client:idle />
{/* Premium Sticky Bottom CTA Bar */}
<div id="stickyCtaBar" class="hidden fixed bottom-0 left-0 right-0 bg-gradient-to-r from-accent-500 via-accent-600 to-accent-500 border-t-2 border-accent-400 shadow-2xl z-50 backdrop-blur-xl animate-slide-up">
<div class="max-w-5xl mx-auto px-6 py-4 flex items-center justify-between gap-4">
<div class="flex items-center gap-4 min-w-0">
<div class="w-10 h-10 rounded-xl bg-white/20 backdrop-blur-sm border border-white/30 flex items-center justify-center shrink-0 p-1.5">
<img src={item.logo} alt={item.name} class="w-full h-full object-contain rounded-lg" />
</div>
<div class="min-w-0">
<p class="text-sm font-black text-white truncate flex items-center gap-2">
{item.name}
<svg class="w-4 h-4 text-white/80" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M6.267 3.455a3.066 3.066 0 001.745-.723 3.066 3.066 0 013.976 0 3.066 3.066 0 001.745.723 3.066 3.066 0 012.812 2.812c.051.643.304 1.254.723 1.745a3.066 3.066 0 010 3.976 3.066 3.066 0 00-.723 1.745 3.066 3.066 0 01-2.812 2.812 3.066 3.066 0 00-1.745.723 3.066 3.066 0 01-3.976 0 3.066 3.066 0 00-1.745-.723 3.066 3.066 0 01-2.812-2.812 3.066 3.066 0 00-.723-1.745 3.066 3.066 0 010-3.976 3.066 3.066 0 00.723-1.745 3.066 3.066 0 012.812-2.812zm7.44 5.252a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
</svg>
</p>
<p class="text-xs text-white/90 truncate font-medium">{item.description} • Free tier available</p>
</div>
</div>
<a
href={item.ctaUrl}
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-2 px-6 py-3 bg-white hover:bg-gray-50 text-accent-600 rounded-xl text-sm font-black transition-all shadow-2xl hover:scale-105 shrink-0 group"
>
<svg class="w-5 h-5 group-hover:rotate-12 transition-transform" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<span>{item.ctaLabel}</span>
<svg class="w-4 h-4 group-hover:translate-x-1 transition-transform" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M13 7l5 5m0 0l-5 5m5-5H6" />
</svg>
</a>
</div>
</div>
</DashboardLayout>
<style>
/* Featured prose styles */
.featured-prose h2 {
font-size: 1.25rem;
font-weight: 600;
color: var(--color-text-primary);
margin-top: 2rem;
margin-bottom: 0.75rem;
padding-bottom: 0.4rem;
border-bottom: 1px solid var(--color-border);
}
.featured-prose h2:first-child { margin-top: 0; }
.featured-prose h3 {
font-size: 1rem;
font-weight: 600;
color: var(--color-text-primary);
margin-top: 1.25rem;
margin-bottom: 0.5rem;
}
.featured-prose p {
font-size: 0.9rem;
line-height: 1.7;
color: var(--color-text-secondary);
margin-bottom: 0.75rem;
}
.featured-prose strong { color: var(--color-text-primary); }
.featured-prose a { color: var(--color-accent-400); text-decoration: none; }
.featured-prose a:hover { text-decoration: underline; }
.featured-prose ul { margin: 0.75rem 0; padding-left: 1.25rem; }
.featured-prose li {
font-size: 0.9rem;
color: var(--color-text-secondary);
margin-bottom: 0.4rem;
line-height: 1.6;
}
.featured-prose code {
background: var(--color-surface-3);
padding: 0.1rem 0.35rem;
border-radius: 4px;
font-size: 0.8rem;
color: var(--color-accent-400);
font-family: var(--font-mono);
}
/* Premium Animations */
@keyframes slide-up {
from {
transform: translateY(100%);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
@keyframes fade-in {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fade-in-up {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes float {
0%, 100% {
transform: translateY(0) translateX(0);
}
50% {
transform: translateY(-20px) translateX(10px);
}
}
@keyframes float-delayed {
0%, 100% {
transform: translateY(0) translateX(0);
}
50% {
transform: translateY(20px) translateX(-10px);
}
}
@keyframes pulse-subtle {
0%, 100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.9;
transform: scale(1.02);
}
}
.animate-slide-up {
animation: slide-up 0.4s cubic-bezier(0.16, 1, 0.3, 1);
}
.animate-fade-in {
animation: fade-in 0.6s ease-out;
}
.animate-fade-in-up {
animation: fade-in-up 0.8s cubic-bezier(0.16, 1, 0.3, 1);
}
.animate-float {
animation: float 6s ease-in-out infinite;
}
.animate-float-delayed {
animation: float-delayed 8s ease-in-out infinite;
}
.animate-pulse-subtle {
animation: pulse-subtle 3s ease-in-out infinite;
}
/* Gradient background utilities */
.bg-size-200 {
background-size: 200% 100%;
}
.bg-pos-100 {
background-position: 100% 0;
}
/* CTA Button hover effects */
.cta-button {
position: relative;
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
}
.cta-button:hover {
transform: translateY(-2px) scale(1.05);
}
.cta-button:active {
transform: translateY(0) scale(1.02);
}
/* Smooth scroll behavior */
html {
scroll-behavior: smooth;
}
/* Custom scrollbar for webkit browsers */
::-webkit-scrollbar {
width: 10px;
}
::-webkit-scrollbar-track {
background: var(--color-surface-1);
}
::-webkit-scrollbar-thumb {
background: var(--color-surface-3);
border-radius: 5px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--color-accent-400);
}
</style>
<script>
const shareBtn = document.getElementById('shareBtn');
const shareOptions = document.getElementById('shareOptions');
// Share dropdown
shareBtn?.addEventListener('click', (e) => {
e.stopPropagation();
shareOptions?.classList.toggle('hidden');
});
document.addEventListener('click', (e) => {
if (shareOptions && !shareOptions.classList.contains('hidden') && !(e.target as HTMLElement)?.closest('#shareDropdown')) {
shareOptions.classList.add('hidden');
}
});
document.querySelectorAll<HTMLElement>('.share-opt').forEach(btn => {
btn.addEventListener('click', () => {
const platform = btn.dataset.platform;
const title = document.title;
const url = window.location.href;
if (platform === 'x') {
window.open(`https://twitter.com/intent/tweet?text=${encodeURIComponent(title)}&url=${encodeURIComponent(url)}`, '_blank');
} else if (platform === 'threads') {
window.open(`https://www.threads.net/intent/post?text=${encodeURIComponent(title + '\n\n' + url)}`, '_blank');
}
shareOptions?.classList.add('hidden');
});
});
// Copy install command
document.getElementById('copyInstallBtn')?.addEventListener('click', (e) => {
const cmd = document.getElementById('installCmd')?.textContent ?? '';
navigator.clipboard.writeText(cmd);
const btn = e.currentTarget as HTMLElement;
btn.textContent = 'Copied!';
setTimeout(() => { btn.textContent = 'Copy'; }, 2000);
});
// Sticky CTA Bar on scroll
const stickyCtaBar = document.getElementById('stickyCtaBar');
let lastScrollY = window.scrollY;
let ticking = false;
const updateStickyBar = () => {
const scrollY = window.scrollY;
const windowHeight = window.innerHeight;
const documentHeight = document.documentElement.scrollHeight;
// Show sticky bar after scrolling 50% of the page
if (scrollY > windowHeight * 0.5 && scrollY < documentHeight - windowHeight - 100) {
stickyCtaBar?.classList.remove('hidden');
} else {
stickyCtaBar?.classList.add('hidden');
}
lastScrollY = scrollY;
ticking = false;
};
const requestTick = () => {
if (!ticking) {
window.requestAnimationFrame(updateStickyBar);
ticking = true;
}
};
window.addEventListener('scroll', requestTick, { passive: true });
</script>
+50
View File
@@ -0,0 +1,50 @@
---
// GitHub OAuth callback page - opened as a popup
// Exchanges the authorization code for a token and sends it back to the opener
---
<html>
<head><title>Connecting GitHub...</title></head>
<body style="background:#0a0a0a;color:#ccc;font-family:system-ui;display:flex;align-items:center;justify-content:center;height:100vh;margin:0">
<div id="status" style="text-align:center">
<div style="width:24px;height:24px;border:2px solid #333;border-top-color:#fff;border-radius:50%;animation:spin 0.6s linear infinite;margin:0 auto 12px"></div>
<p>Connecting to GitHub...</p>
</div>
<style>@keyframes spin{to{transform:rotate(360deg)}}</style>
<script>
(async () => {
const status = document.getElementById('status');
const params = new URLSearchParams(window.location.search);
const code = params.get('code');
const error = params.get('error');
if (error || !code) {
status.innerHTML = '<p style="color:#f87171">Authorization failed. You can close this window.</p>';
return;
}
try {
const res = await fetch('/api/github/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code }),
});
const data = await res.json();
if (!res.ok || !data.access_token) {
throw new Error(data.error || 'Token exchange failed');
}
if (window.opener) {
window.opener.postMessage({ type: 'github-oauth', token: data.access_token }, window.location.origin);
status.innerHTML = '<p style="color:#4ade80">Connected! This window will close.</p>';
setTimeout(() => window.close(), 1000);
} else {
status.innerHTML = '<p style="color:#f87171">Could not communicate with the opener window.</p>';
}
} catch (err) {
status.innerHTML = `<p style="color:#f87171">${err.message}</p>`;
}
})();
</script>
</body>
</html>
+70
View File
@@ -0,0 +1,70 @@
---
import DashboardLayout from '../layouts/DashboardLayout.astro';
import TopBar from '../components/TopBar.astro';
import FeaturedSection from '../components/FeaturedSection.astro';
import JobsPreview from '../components/JobsPreview.tsx';
import ComponentGrid from '../components/ComponentGrid.tsx';
import SearchModal from '../components/SearchModal.tsx';
import CartSidebar from '../components/CartSidebar.tsx';
export const prerender = true;
---
<DashboardLayout
title="Claude Code Templates: 1000+ Agents, Commands, Skills & MCP Integrations"
description="Browse and install 1000+ pre-built components for Claude Code. AI agents, slash commands, MCP integrations, hooks, and settings. Free, open-source CLI tool."
>
<script type="application/ld+json" set:html={JSON.stringify({
"@context": "https://schema.org",
"@type": "SoftwareApplication",
"name": "Claude Code Templates",
"description": "CLI tool and marketplace for managing Claude Code components. Over 1,000 pre-built agents, commands, skills, settings, hooks, and MCP integrations.",
"url": "https://www.aitmpl.com/",
"applicationCategory": "DeveloperApplication",
"operatingSystem": "Windows, macOS, Linux",
"downloadUrl": "https://www.npmjs.com/package/claude-code-templates",
"installUrl": "https://www.npmjs.com/package/claude-code-templates",
"author": { "@id": "https://www.aitmpl.com/#organization" },
"offers": { "@type": "Offer", "price": "0", "priceCurrency": "USD" },
"softwareRequirements": "Node.js 18+, Claude Code CLI",
"license": "https://opensource.org/licenses/MIT"
})} />
<TopBar />
<!-- Two-column layout: main content + jobs sidebar -->
<div class="flex min-h-0">
<!-- Left: main content -->
<div class="flex-1 min-w-0">
<!-- ASCII hero banner -->
<div class="px-6 pt-5 pb-1">
<div class="hidden md:flex items-center justify-center gap-4 overflow-hidden">
<pre class="ascii-art shrink-0">
█████╗ ██╗ ████████╗███████╗███╗ ███╗██████╗ ██╗ █████╗ ████████╗███████╗███████╗
██╔══██╗██║ ╚══██╔══╝██╔════╝████╗ ████║██╔══██╗██║ ██╔══██╗╚══██╔══╝██╔════╝██╔════╝
███████║██║ ██║ █████╗ ██╔████╔██║██████╔╝██║ ███████║ ██║ █████╗ ███████╗
██╔══██║██║ ██║ ██╔══╝ ██║╚██╔╝██║██╔═══╝ ██║ ██╔══██║ ██║ ██╔══╝ ╚════██║
██║ ██║██║ ██║ ███████╗██║ ╚═╝ ██║██║ ███████╗██║ ██║ ██║ ███████╗███████║
╚═╝ ╚═╝╚═╝ ╚═╝ ╚══════╝╚═╝ ╚═╝╚═╝ ╚══════╝╚═╝ ╚═╝ ╚═╝ ╚══════╝╚══════╝</pre>
<picture>
<source srcset="/claude-code-logo.webp" type="image/webp" />
<img src="/claude-code-logo.png" alt="Claude Code" class="w-20 h-20 shrink-0 object-contain" fetchpriority="high" />
</picture>
</div>
<div class="flex items-center justify-center gap-2 mt-2 mb-1">
<span class="status-dot"></span>
<span class="font-ui-mono text-[12px] text-[var(--color-text-secondary)]">Ready-to-use configurations for your Claude Code projects</span>
</div>
</div>
<FeaturedSection />
<ComponentGrid client:load initialType="skills" />
</div>
<!-- Right: jobs sidebar (sticky, scrolls independently) -->
<aside class="hidden xl:flex flex-col w-[260px] shrink-0 border-l border-[var(--color-border)] sticky top-14 h-[calc(100vh-3.5rem)] overflow-y-auto bg-[var(--color-surface-0)]">
<JobsPreview client:idle variant="sidebar" />
</aside>
</div>
<SearchModal client:idle />
<CartSidebar client:idle />
</DashboardLayout>
+18
View File
@@ -0,0 +1,18 @@
---
import DashboardLayout from '../layouts/DashboardLayout.astro';
import TopBar from '../components/TopBar.astro';
import JobsView from '../components/JobsView.tsx';
import SearchModal from '../components/SearchModal.tsx';
import CartSidebar from '../components/CartSidebar.tsx';
export const prerender = true;
---
<DashboardLayout
title="Jobs Requiring Claude Code | Claude Code Templates"
description="Discover job openings that require Claude Code in their tech stack. Updated daily from HackerNews, RemoteOK, and more."
>
<TopBar />
<JobsView client:load />
<SearchModal client:idle />
<CartSidebar client:idle />
</DashboardLayout>
+18
View File
@@ -0,0 +1,18 @@
---
import DashboardLayout from '../layouts/DashboardLayout.astro';
import TopBar from '../components/TopBar.astro';
import SearchModal from '../components/SearchModal.tsx';
import CartSidebar from '../components/CartSidebar.tsx';
import LiveTaskPanel from '../components/live-task/LiveTaskPanel.tsx';
export const prerender = false;
---
<DashboardLayout
title="Component Review Loop | Claude Code Templates"
description="Monitor the automated component improvement cycle. Track review progress, tool executions, and PR status in real-time."
>
<TopBar />
<LiveTaskPanel client:load />
<SearchModal client:idle />
<CartSidebar client:idle />
</DashboardLayout>
+16
View File
@@ -0,0 +1,16 @@
---
import DashboardLayout from '../layouts/DashboardLayout.astro';
import MyComponentsView from '../components/MyComponentsView.tsx';
import SearchModal from '../components/SearchModal.tsx';
import CartSidebar from '../components/CartSidebar.tsx';
export const prerender = false;
---
<DashboardLayout title="My Components - Claude Code Templates">
<div class="h-full">
<MyComponentsView client:load />
</div>
<SearchModal client:idle />
<CartSidebar client:idle />
</DashboardLayout>
+295
View File
@@ -0,0 +1,295 @@
---
import DashboardLayout from '../../layouts/DashboardLayout.astro';
import TopBar from '../../components/TopBar.astro';
import SearchModal from '../../components/SearchModal.tsx';
import MarketplacePluginsList from '../../components/MarketplacePluginsList.tsx';
export const prerender = true;
import fs from 'node:fs';
import nodePath from 'node:path';
const pluginsFilePath = nodePath.join(process.cwd(), 'public', 'plugins.json');
let allPlugins: any[] = [];
try {
allPlugins = JSON.parse(fs.readFileSync(pluginsFilePath, 'utf-8'));
} catch { allPlugins = []; }
export function getStaticPaths() {
const data = JSON.parse(fs.readFileSync(nodePath.join(process.cwd(), 'public', 'plugins.json'), 'utf-8'));
return data.map((p: any) => ({ params: { slug: p.slug } }));
}
const { slug } = Astro.params;
const plugin = allPlugins.find((p: any) => p.slug === slug);
if (!plugin) return Astro.redirect('/plugins');
function formatStars(stars: number): string {
if (stars >= 1000) return `${(stars / 1000).toFixed(stars >= 10000 ? 0 : 1)}k`;
return String(stars);
}
const TAG_COLORS: Record<string, string> = {
skills: '#f59e0b',
agents: '#3b82f6',
commands: '#10b981',
hooks: '#f97316',
mcps: '#06b6d4',
lsps: '#a78bfa',
settings: '#8b5cf6',
rules: '#ec4899',
};
const isMarketplace = plugin.type === 'marketplace';
const pluginsList: any[] = plugin.plugins_list ?? [];
const manifest = plugin.plugin_manifest ?? {};
const containsObj = plugin.contains ?? {};
const componentsTotals = containsObj.components ?? {};
const pluginCount = containsObj.plugins ?? 0;
// Install commands
const repoPath = plugin.github.replace('https://github.com/', '');
const addMarketplaceCmd = `/plugin marketplace add ${repoPath}`;
const marketplaceName = repoPath.replace('/', '-');
---
<DashboardLayout
title={`${plugin.name} — Claude Code Plugin`}
description={plugin.description}
>
<TopBar />
<!-- Breadcrumb -->
<div class="px-6 py-3 border-b border-[var(--color-border)] bg-[var(--color-surface-0)]">
<nav class="flex items-center gap-2 text-[13px] text-[var(--color-text-tertiary)]">
<a href="/plugins" class="hover:text-[var(--color-text-primary)] transition-colors">Plugins</a>
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" /></svg>
<span class="text-[var(--color-text-primary)] font-medium">{plugin.name}</span>
</nav>
</div>
<!-- Plugin Header -->
<div class="relative overflow-hidden border-b border-[var(--color-border)] bg-gradient-to-b from-[var(--color-surface-0)] to-[var(--color-surface-1)]">
<div class="absolute inset-0 opacity-20">
<div class="absolute inset-0 bg-gradient-to-br from-yellow-500/10 via-transparent to-purple-500/10"></div>
</div>
<div class="absolute top-0 right-0 w-64 h-64 bg-gradient-to-br from-yellow-500/5 to-transparent rounded-full blur-3xl"></div>
<div class="relative px-6 py-10 max-w-6xl mx-auto">
<div class="flex items-start gap-6">
<div
class="w-20 h-20 rounded-2xl flex items-center justify-center shrink-0 shadow-2xl text-[32px] font-bold select-none"
style="background: linear-gradient(135deg, #ffd93d20 0%, #ffd93d10 100%); border: 2px solid #ffd93d30; color: #ffd93d;"
>
{plugin.author.charAt(0).toUpperCase()}
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-3 mb-2 flex-wrap">
<h1 class="text-2xl md:text-3xl font-bold text-[var(--color-text-primary)] tracking-tight">
{plugin.name}
</h1>
<span class="text-[12px] font-semibold px-3 py-1 rounded-full bg-gradient-to-r from-yellow-500/15 to-amber-400/15 text-yellow-400 flex items-center gap-1.5 border border-yellow-500/25">
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/></svg>
{formatStars(plugin.stars)} stars
</span>
{isMarketplace && (
<span class="text-[10px] font-bold uppercase tracking-wider px-2.5 py-1 rounded-md" style="background: rgba(139, 92, 246, 0.15); color: #a78bfa; border: 1px solid rgba(139, 92, 246, 0.25);">
Marketplace
</span>
)}
</div>
<p class="text-[13px] text-[var(--color-text-tertiary)] mb-3">
by <span class="text-[var(--color-text-secondary)] font-medium">{plugin.author}</span>
</p>
<p class="text-[15px] text-[var(--color-text-secondary)] leading-relaxed max-w-2xl mb-4">
{plugin.description}
</p>
<!-- Tags -->
<div class="flex items-center gap-2 flex-wrap mb-5">
{plugin.tags.map((tag: string) => (
<span
class="text-[11px] font-semibold px-3 py-1.5 rounded-full border"
style={`background: ${TAG_COLORS[tag] ?? '#666'}15; color: ${TAG_COLORS[tag] ?? '#666'}; border-color: ${TAG_COLORS[tag] ?? '#666'}30;`}
>
{tag}
</span>
))}
</div>
<!-- CTA buttons -->
<div class="flex items-center gap-3 flex-wrap">
<a
href={plugin.github}
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-2 px-5 py-2.5 rounded-xl bg-[var(--color-text-primary)] text-[var(--color-surface-0)] text-[13px] font-semibold hover:opacity-90 transition-all duration-200 no-underline"
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z"/></svg>
View on GitHub
</a>
{plugin.website && (
<a
href={plugin.website}
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-2 px-5 py-2.5 rounded-xl bg-[var(--color-surface-3)] text-[var(--color-text-primary)] text-[13px] font-semibold border border-[var(--color-border)] hover:bg-[var(--color-surface-4)] transition-all duration-200 no-underline"
>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="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>
Visit Website
</a>
)}
</div>
</div>
</div>
</div>
</div>
<!-- Content -->
<div class="px-6 py-8 max-w-6xl mx-auto space-y-10">
<!-- Install Commands -->
<div>
<h2 class="text-lg font-bold text-[var(--color-text-primary)] mb-4">Install</h2>
<div class="space-y-3">
<!-- Step 1: Add marketplace -->
<div>
<div class="flex items-center gap-2 mb-2">
<span class="w-5 h-5 rounded-full flex items-center justify-center text-[11px] font-bold shrink-0" style="background: #ffd93d25; color: #ffd93d;">1</span>
<span class="text-[13px] text-[var(--color-text-secondary)]">Add the marketplace to Claude Code</span>
</div>
<pre class="p-4 rounded-xl bg-[var(--color-surface-2)] border border-[var(--color-border)] text-[13px] text-[var(--color-text-secondary)] overflow-x-auto font-mono"><code>{addMarketplaceCmd}</code></pre>
</div>
<!-- Step 2: Install plugin -->
<div>
<div class="flex items-center gap-2 mb-2">
<span class="w-5 h-5 rounded-full flex items-center justify-center text-[11px] font-bold shrink-0" style="background: #ffd93d25; color: #ffd93d;">2</span>
<span class="text-[13px] text-[var(--color-text-secondary)]">
{isMarketplace
? 'Install any plugin from the marketplace'
: 'Install the plugin'
}
</span>
</div>
{isMarketplace && pluginsList.length > 0 ? (
<pre class="p-4 rounded-xl bg-[var(--color-surface-2)] border border-[var(--color-border)] text-[13px] text-[var(--color-text-secondary)] overflow-x-auto font-mono"><code>{`/plugin install <plugin-name>@${marketplaceName}`}</code></pre>
) : (
<pre class="p-4 rounded-xl bg-[var(--color-surface-2)] border border-[var(--color-border)] text-[13px] text-[var(--color-text-secondary)] overflow-x-auto font-mono"><code>{`/plugin install ${plugin.slug}@${marketplaceName}`}</code></pre>
)}
</div>
<!-- Tip -->
<div class="flex items-start gap-2 px-4 py-3 rounded-lg text-[12px]" style="background: rgba(59, 130, 246, 0.08); border: 1px solid rgba(59, 130, 246, 0.15); color: rgba(147, 197, 253, 0.9);">
<svg class="w-4 h-4 shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
<span>Or use <code class="font-mono px-1 py-0.5 rounded bg-[var(--color-surface-3)]">/plugin</code> to open the interactive plugin manager and browse the Discover tab.</span>
</div>
</div>
</div>
<!-- Stats Overview (for marketplaces) -->
{isMarketplace && pluginCount > 0 && (
<div>
<h2 class="text-lg font-bold text-[var(--color-text-primary)] mb-4">Overview</h2>
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">
<div class="p-5 rounded-xl bg-[var(--color-card-bg)] border border-[var(--color-border)]" style="box-shadow: var(--shadow-card);">
<div class="text-[12px] text-[var(--color-text-tertiary)] mb-1 uppercase tracking-wider font-medium">Plugins</div>
<div class="text-2xl font-bold" style="color: #ffd93d;">{pluginCount}</div>
</div>
{Object.entries(componentsTotals).map(([type, count]) => (
<div class="p-5 rounded-xl bg-[var(--color-card-bg)] border border-[var(--color-border)]" style="box-shadow: var(--shadow-card);">
<div class="text-[12px] text-[var(--color-text-tertiary)] mb-1 uppercase tracking-wider font-medium">{type}</div>
<div class="text-2xl font-bold" style={`color: ${TAG_COLORS[type] ?? '#888'};`}>{count}</div>
</div>
))}
</div>
</div>
)}
<!-- Plugin Manifest (for individual plugins) -->
{!isMarketplace && Object.keys(manifest).length > 0 && (
<div>
<h2 class="text-lg font-bold text-[var(--color-text-primary)] mb-4">Components</h2>
<div class="space-y-5">
{Object.entries(manifest).map(([type, items]: [string, any]) => (
<div>
<div class="flex items-center gap-2 mb-3">
<span
class="text-[11px] font-semibold px-2.5 py-1 rounded-full border uppercase tracking-wider"
style={`background: ${TAG_COLORS[type] ?? '#666'}15; color: ${TAG_COLORS[type] ?? '#666'}; border-color: ${TAG_COLORS[type] ?? '#666'}30;`}
>
{type}
</span>
<span class="text-[12px] text-[var(--color-text-tertiary)]">
{Array.isArray(items) ? items.length : Object.keys(items).length} items
</span>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2">
{(Array.isArray(items) ? items : Object.keys(items)).map((item: string) => {
const name = item.replace(/^\.\//, '').replace(/\.(md|json|js|ts)$/, '').split('/').pop() ?? item;
return (
<div class="flex items-center gap-2 px-3 py-2.5 rounded-lg bg-[var(--color-surface-2)] border border-[var(--color-border)] text-[13px]">
<span class="w-1.5 h-1.5 rounded-full shrink-0" style={`background: ${TAG_COLORS[type] ?? '#666'};`}></span>
<span class="text-[var(--color-text-secondary)] truncate font-mono text-[12px]">{name}</span>
</div>
);
})}
</div>
</div>
))}
</div>
</div>
)}
<!-- Marketplace Plugins List -->
{isMarketplace && pluginsList.length > 0 && (
<MarketplacePluginsList
client:load
pluginsList={pluginsList}
marketplaceAuthor={plugin.author}
marketplaceName={marketplaceName}
tagColors={TAG_COLORS}
/>
)}
<!-- Highlights -->
{plugin.highlights && plugin.highlights.length > 0 && (
<div>
<h2 class="text-lg font-bold text-[var(--color-text-primary)] mb-4">Highlights</h2>
<div class="space-y-3">
{plugin.highlights.map((highlight: string) => (
<div class="flex items-start gap-3 p-4 rounded-xl bg-[var(--color-card-bg)] border border-[var(--color-border)]" style="box-shadow: var(--shadow-card);">
<div class="w-6 h-6 rounded-full flex items-center justify-center shrink-0 mt-0.5" style="background: #ffd93d20; color: #ffd93d;">
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" /></svg>
</div>
<p class="text-[14px] text-[var(--color-text-secondary)] leading-relaxed">{highlight}</p>
</div>
))}
</div>
</div>
)}
<!-- Back link -->
<div class="pt-4">
<a href="/plugins" class="inline-flex items-center gap-2 text-[13px] text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)] transition-colors no-underline">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M10 19l-7-7m0 0l7-7m-7 7h18" /></svg>
Back to all plugins
</a>
</div>
</div>
<SearchModal client:idle />
<!-- Emit sidebar counts (ComponentGrid not present on this page) -->
<script is:inline>
fetch('/counts.json')
.then(function(r) { return r.json(); })
.then(function(counts) {
window.dispatchEvent(new CustomEvent('component-counts', { detail: counts }));
})
.catch(function() {});
</script>
</DashboardLayout>
+76
View File
@@ -0,0 +1,76 @@
---
import DashboardLayout from '../../layouts/DashboardLayout.astro';
import TopBar from '../../components/TopBar.astro';
import SearchModal from '../../components/SearchModal.tsx';
import PluginsGrid from '../../components/PluginsGrid.tsx';
export const prerender = true;
import fs from 'node:fs';
import nodePath from 'node:path';
// Load plugins data at build time from filesystem
const filePath = nodePath.join(process.cwd(), 'public', 'plugins.json');
let plugins: any[] = [];
try {
plugins = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
} catch { plugins = []; }
---
<DashboardLayout
title="Claude Code Plugins & Marketplaces — Discover the Best Collections"
description="Discover the most popular Claude Code plugin marketplaces, skill collections, and component registries on GitHub. Browse 20+ curated repositories."
>
<TopBar />
<!-- Page Header -->
<div class="relative overflow-hidden border-b border-[var(--color-border)] bg-gradient-to-b from-[var(--color-surface-0)] to-[var(--color-surface-1)]">
<div class="absolute inset-0 opacity-30">
<div class="absolute inset-0 bg-gradient-to-br from-yellow-500/10 via-transparent to-purple-500/10" style="animation: gradient-shimmer 8s ease infinite; background-size: 200% 200%;"></div>
</div>
<div class="absolute top-0 right-0 w-96 h-96 bg-gradient-to-br from-yellow-500/5 to-transparent rounded-full blur-3xl"></div>
<div class="absolute bottom-0 left-0 w-96 h-96 bg-gradient-to-tr from-purple-500/5 to-transparent rounded-full blur-3xl"></div>
<div class="relative px-6 py-12 max-w-7xl mx-auto">
<div class="flex items-center gap-6">
<div
class="w-20 h-20 rounded-2xl flex items-center justify-center shrink-0 shadow-2xl animate-float"
style="background: linear-gradient(135deg, #ffd93d20 0%, #ffd93d10 100%); border: 2px solid #ffd93d30;"
>
<svg class="w-10 h-10" style="color: #ffd93d" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M14.7 6.3a1 1 0 000 1.4l1.6 1.6a1 1 0 001.4 0l3.77-3.77a6 6 0 01-7.94 7.94l-6.91 6.91a2.12 2.12 0 01-3-3l6.91-6.91a6 6 0 017.94-7.94l-3.76 3.76z"/>
</svg>
</div>
<div class="flex-1">
<h1 class="text-3xl font-bold text-[var(--color-text-primary)] mb-2 tracking-tight">
Plugins & Marketplaces
</h1>
<p class="text-[15px] text-[var(--color-text-secondary)] leading-relaxed max-w-2xl">
Discover the most popular Claude Code plugin collections, skill registries, and component marketplaces on GitHub
</p>
</div>
<div class="hidden md:flex items-center gap-2 text-[13px] text-[var(--color-text-tertiary)]">
<span class="px-3 py-1.5 rounded-full bg-[var(--color-surface-3)] border border-[var(--color-border)]">
{plugins.length} collections
</span>
</div>
</div>
</div>
</div>
<PluginsGrid client:load plugins={plugins} />
<SearchModal client:idle />
<!-- Emit sidebar counts (ComponentGrid not present on this page) -->
<script is:inline>
fetch('/counts.json')
.then(function(r) { return r.json(); })
.then(function(counts) {
window.dispatchEvent(new CustomEvent('component-counts', { detail: counts }));
})
.catch(function() {});
</script>
</DashboardLayout>
+90
View File
@@ -0,0 +1,90 @@
import type { APIRoute } from 'astro';
import fs from 'node:fs';
import path from 'node:path';
import { FEATURED_ITEMS } from '../lib/constants';
export const prerender = true;
const SITE = 'https://www.aitmpl.com';
interface UrlOpts {
priority?: string;
changefreq?: string;
lastmod?: string;
}
function buildUrl(loc: string, opts: UrlOpts = {}): string {
const today = new Date().toISOString().split('T')[0];
const {
priority = '0.5',
changefreq = 'weekly',
lastmod = today,
} = opts;
return ` <url>
<loc>${SITE}${loc}</loc>
<lastmod>${lastmod}</lastmod>
<changefreq>${changefreq}</changefreq>
<priority>${priority}</priority>
</url>`;
}
function safeReadJson<T>(relPath: string, fallback: T): T {
try {
const full = path.join(process.cwd(), 'public', relPath);
return JSON.parse(fs.readFileSync(full, 'utf-8')) as T;
} catch {
return fallback;
}
}
export const GET: APIRoute = () => {
const components = safeReadJson<Record<string, Array<{ path: string }>>>(
'components.json',
{}
);
const plugins = safeReadJson<Array<{ slug: string }>>('plugins.json', []);
const urls: string[] = [];
urls.push(buildUrl('/', { priority: '1.0', changefreq: 'daily' }));
urls.push(buildUrl('/plugins', { priority: '0.9', changefreq: 'weekly' }));
urls.push(buildUrl('/trending', { priority: '0.8', changefreq: 'daily' }));
urls.push(buildUrl('/jobs', { priority: '0.6', changefreq: 'weekly' }));
for (const type of ['agents', 'commands', 'skills', 'mcps', 'hooks', 'settings']) {
urls.push(buildUrl(`/${type}`, { priority: '0.9', changefreq: 'daily' }));
}
for (const item of FEATURED_ITEMS) {
urls.push(buildUrl(item.url, { priority: '0.7', changefreq: 'monthly' }));
}
for (const plugin of plugins) {
if (plugin.slug) {
urls.push(buildUrl(`/plugins/${plugin.slug}`, { priority: '0.7', changefreq: 'weekly' }));
}
}
const COMPONENT_TYPES = ['agents', 'commands', 'skills', 'mcps', 'hooks', 'settings'];
for (const type of COMPONENT_TYPES) {
const items = components[type];
if (!Array.isArray(items)) continue;
for (const c of items) {
if (!c.path) continue;
const slug = c.path.replace(/\.(md|json)$/, '');
urls.push(buildUrl(`/component/${type}/${slug}`, { priority: '0.6', changefreq: 'monthly' }));
}
}
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${urls.join('\n')}
</urlset>`;
return new Response(xml, {
headers: {
'Content-Type': 'application/xml; charset=utf-8',
'Cache-Control': 'public, max-age=3600, s-maxage=86400',
},
});
};
+18
View File
@@ -0,0 +1,18 @@
---
import DashboardLayout from '../layouts/DashboardLayout.astro';
import TopBar from '../components/TopBar.astro';
import TrendingView from '../components/TrendingView.tsx';
import SearchModal from '../components/SearchModal.tsx';
import CartSidebar from '../components/CartSidebar.tsx';
export const prerender = true;
---
<DashboardLayout
title="Most Popular Claude Code Components This Week | Claude Code Templates"
description="Discover the most downloaded Claude Code components. See trending agents, commands, MCPs, and skills that developers are installing right now."
>
<TopBar />
<TrendingView client:load />
<SearchModal client:idle />
<CartSidebar client:idle />
</DashboardLayout>
+892
View File
@@ -0,0 +1,892 @@
@import "tailwindcss";
@theme {
--color-primary-50: #eef0fb;
--color-primary-100: #d4d7f2;
--color-primary-200: #a9b0e5;
--color-primary-300: #7e88d8;
--color-primary-400: #5e6ad2;
--color-primary-500: #5e6ad2;
--color-primary-600: #4b55a8;
--color-primary-700: #38407e;
--color-primary-800: #262b54;
--color-primary-900: #13152a;
--color-accent-50: #fdf2ee;
--color-accent-100: #f9dcd2;
--color-accent-200: #f0b5a0;
--color-accent-300: #e38d6e;
--color-accent-400: #d57455;
--color-accent-500: #d57455;
--color-accent-600: #b35c3e;
--color-accent-700: #8c472f;
--color-accent-800: #663320;
--color-accent-900: #3f1f13;
--font-sans: 'Geist', 'Inter', system-ui, -apple-system, sans-serif;
--font-mono: 'Geist Mono', 'JetBrains Mono', 'Fira Code', monospace;
}
/* Dark mode (default) — GitHub-dark terminal palette */
:root,
html.dark {
/* Backgrounds - GitHub-dark terminal */
--color-surface-0: #0d1117;
--color-surface-1: #161b22;
--color-surface-2: #21262d;
--color-surface-3: #2a3038;
--color-surface-4: #30363d;
/* Borders - Sharp terminal borders */
--color-border: #30363d;
--color-border-hover: #d57455;
--color-border-strong: #3d444d;
/* Text colors */
--color-text-primary: #c9d1d9;
--color-text-secondary: #7d8590;
--color-text-tertiary: #6e7681;
/* Accent color */
--color-accent: #d57455;
--color-accent-hover: #e38d6e;
--color-accent-light: rgba(213, 116, 85, 0.1);
/* Terminal green */
--color-success: #3fb950;
--color-success-bg: rgba(63, 185, 80, 0.1);
/* Card backgrounds */
--color-card-bg: #161b22;
--color-card-hover: #1c2330;
/* Card shadows — flat terminal look */
--shadow-card: 0 1px 3px rgba(0, 0, 0, 0.4);
--shadow-card-hover: 0 0 0 1px #d57455, 0 4px 12px rgba(0, 0, 0, 0.4);
}
html.dark .oss-icon {
filter: invert(0);
}
/* Light mode */
html.light {
/* Backgrounds - Clean whites */
--color-surface-0: #ffffff;
--color-surface-1: #fafafa;
--color-surface-2: #f5f5f5;
--color-surface-3: #efefef;
--color-surface-4: #e8e8e8;
/* Borders - Soft grays */
--color-border: #e5e5e5;
--color-border-hover: #d4d4d4;
--color-border-strong: #cbd5e0;
/* Text colors */
--color-text-primary: #0f0f0f;
--color-text-secondary: #525252;
--color-text-tertiary: #8a8a8a;
/* Accent color */
--color-accent: #d57455;
--color-accent-hover: #c5654a;
--color-accent-light: rgba(213, 116, 85, 0.08);
/* Card backgrounds */
--color-card-bg: #ffffff;
--color-card-hover: #fafafa;
/* Card shadows */
--shadow-card: 0 1px 3px rgba(0, 0, 0, 0.08), 0 1px 2px rgba(0, 0, 0, 0.06);
--shadow-card-hover: 0 4px 12px rgba(0, 0, 0, 0.12), 0 2px 4px rgba(0, 0, 0, 0.08);
}
html.light .oss-icon {
filter: invert(1);
}
html {
background-color: var(--color-surface-0);
color: var(--color-text-primary);
font-family: var(--font-sans);
scroll-behavior: smooth;
}
body {
min-height: 100vh;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: var(--color-surface-0);
color: var(--color-text-primary);
transition: background-color 200ms ease, color 200ms ease;
}
/* Scrollbar - Vercel thin style */
::-webkit-scrollbar {
width: 4px;
height: 4px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--color-surface-4);
border-radius: 2px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--color-border-hover);
}
/* Sidebar scrollbar - slightly thicker for better UX */
#sidebar ::-webkit-scrollbar {
width: 6px;
}
#sidebar ::-webkit-scrollbar-thumb {
background: var(--color-surface-3);
border-radius: 3px;
}
#sidebar ::-webkit-scrollbar-thumb:hover {
background: var(--color-surface-4);
}
/* Sidebar collapse animations */
#sidebar {
overflow-y: auto;
}
#sidebar .sidebar-text,
#sidebar .sidebar-nav-count {
transition: opacity 300ms ease-in-out, width 300ms ease-in-out;
white-space: nowrap;
}
#sidebar[data-collapsed="true"] .sidebar-text,
#sidebar[data-collapsed="true"] .sidebar-nav-count {
opacity: 0;
width: 0;
overflow: hidden;
}
/* Tooltip for collapsed sidebar items */
#sidebar[data-collapsed="true"] a,
#sidebar[data-collapsed="true"] button {
position: relative;
}
#sidebar[data-collapsed="true"] a[title]:hover::after,
#sidebar[data-collapsed="true"] button[title]:hover::after {
content: attr(title);
position: absolute;
left: calc(100% + 12px);
top: 50%;
transform: translateY(-50%);
background: var(--color-surface-4);
color: var(--color-text-primary);
padding: 6px 12px;
border-radius: 6px;
font-size: 12px;
white-space: nowrap;
z-index: 1000;
pointer-events: none;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
border: 1px solid var(--color-border);
animation: tooltip-fade-in 150ms ease-out;
}
#sidebar[data-collapsed="true"] a[title]:hover::before,
#sidebar[data-collapsed="true"] button[title]:hover::before {
content: '';
position: absolute;
left: calc(100% + 6px);
top: 50%;
transform: translateY(-50%);
border: 6px solid transparent;
border-right-color: var(--color-surface-4);
z-index: 1001;
pointer-events: none;
animation: tooltip-fade-in 150ms ease-out;
}
@keyframes tooltip-fade-in {
from {
opacity: 0;
transform: translateY(-50%) translateX(-4px);
}
to {
opacity: 1;
transform: translateY(-50%) translateX(0);
}
}
/* Collapsed sidebar - center icons */
#sidebar[data-collapsed="true"] nav {
padding-left: 0.5rem !important;
padding-right: 0.5rem !important;
}
#sidebar[data-collapsed="true"] .sidebar-nav-link,
#sidebar[data-collapsed="true"] nav > a {
justify-content: center !important;
padding-left: 0.75rem !important;
padding-right: 0.75rem !important;
gap: 0 !important;
margin-left: 0 !important;
margin-right: 0 !important;
}
#sidebar[data-collapsed="true"] .sidebar-nav-icon {
margin: 0 !important;
transform-origin: center center !important;
}
#sidebar[data-collapsed="true"] .sidebar-nav-icon > span {
transform-origin: center center !important;
}
/* When collapsed, the count badge uses `ml-auto`; even when width:0 it can
still reserve remaining flex space and shift the icon off-center. */
#sidebar[data-collapsed="true"] .sidebar-nav-count {
margin-left: 0 !important;
}
/* Center header content in collapsed state */
#sidebar[data-collapsed="true"] > div > div:first-child {
justify-content: center !important;
padding-left: 0.5rem !important;
padding-right: 0.5rem !important;
}
#sidebar[data-collapsed="true"] > div > div:first-child > a {
width: auto !important;
min-width: auto !important;
gap: 0 !important;
padding: 0 !important;
}
#sidebar[data-collapsed="true"] > div > div:first-child > button {
margin: 0 auto !important;
}
/* Center bottom section in collapsed state */
#sidebar[data-collapsed="true"] > div > div:last-child {
padding-left: 0.5rem !important;
padding-right: 0.5rem !important;
}
#sidebar[data-collapsed="true"] > div > div:last-child > div {
justify-content: center !important;
padding-left: 0 !important;
padding-right: 0 !important;
gap: 0 !important;
}
/* Hide section headers in collapsed state */
#sidebar[data-collapsed="true"] nav > div {
display: none !important;
}
/* Hide dividers in collapsed state */
#sidebar[data-collapsed="true"] .my-4 {
display: none !important;
}
/* Selection — orange terminal highlight */
::selection {
background: rgba(213, 116, 85, 0.3);
}
/* Terminal focus ring — orange accent */
*:focus-visible {
outline: 1px solid var(--color-accent);
outline-offset: 2px;
}
/* Subtle transition for all interactive elements */
a, button, input, select {
transition: color 150ms ease, background-color 150ms ease, border-color 150ms ease, opacity 150ms ease, transform 150ms ease, box-shadow 150ms ease;
}
/* Keep inline SVG icons vertically centered in links/buttons */
a svg, button svg {
vertical-align: middle;
display: inline-block;
}
/* Smooth scale animation for interactive cards */
@keyframes card-hover {
0% {
transform: scale(1) translateY(0);
}
100% {
transform: scale(1.01) translateY(-2px);
}
}
/* Pulse animation for badges */
@keyframes pulse-subtle {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.8;
}
}
/* Shimmer effect for loading states */
@keyframes shimmer-glow {
0% {
background-position: -200% center;
}
100% {
background-position: 200% center;
}
}
/* Float animation for empty states */
@keyframes float {
0%, 100% {
transform: translateY(0px);
}
50% {
transform: translateY(-10px);
}
}
.animate-float {
animation: float 3s ease-in-out infinite;
}
/* Fade in from bottom */
@keyframes fade-in-up {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-in-up {
animation: fade-in-up 0.5s ease-out;
}
/* Stagger animation for grid items */
@keyframes stagger-fade-in {
from {
opacity: 0;
transform: translateY(10px) scale(0.95);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
/* Gradient shimmer for premium elements */
@keyframes gradient-shimmer {
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}
/* Smooth bounce for interactive elements */
@keyframes bounce-subtle {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-4px);
}
}
/* Glow pulse effect */
@keyframes glow-pulse {
0%, 100% {
box-shadow: 0 0 20px rgba(94, 106, 210, 0.3);
}
50% {
box-shadow: 0 0 30px rgba(94, 106, 210, 0.5);
}
}
/* Skeleton loading animation */
@keyframes shimmer {
0% {
background-position: -200% 0;
}
100% {
background-position: 200% 0;
}
}
/* Slide in animation for file tree */
@keyframes slide-in-left {
from {
opacity: 0;
transform: translateX(-20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.animate-slide-in-left {
animation: slide-in-left 0.2s ease-out;
}
.skeleton {
background: linear-gradient(
90deg,
var(--color-surface-2) 0%,
var(--color-surface-3) 50%,
var(--color-surface-2) 100%
);
background-size: 200% 100%;
animation: shimmer 1.5s ease-in-out infinite;
}
/* Markdown preview styles */
.md-preview {
font-family: var(--font-sans);
line-height: 1.7;
color: var(--color-text-secondary);
font-size: 0.9rem;
}
.md-preview h1,
.md-preview h2,
.md-preview h3,
.md-preview h4,
.md-preview h5,
.md-preview h6 {
color: var(--color-text-primary);
font-weight: 600;
line-height: 1.3;
margin-top: 1.8em;
margin-bottom: 0.6em;
scroll-margin-top: 5rem;
}
.md-preview h1 { font-size: 1.5rem; padding-bottom: 0.4em; border-bottom: 1px solid var(--color-border); }
.md-preview h2 { font-size: 1.25rem; padding-bottom: 0.3em; border-bottom: 1px solid var(--color-border); }
.md-preview h3 { font-size: 1.1rem; }
.md-preview h4 { font-size: 1rem; }
.md-preview h1:first-child,
.md-preview h2:first-child,
.md-preview h3:first-child {
margin-top: 0;
}
.md-preview p {
margin-bottom: 1em;
}
.md-preview a {
color: var(--color-accent-400);
text-decoration: none;
}
.md-preview a:hover {
text-decoration: underline;
}
.md-preview strong {
color: var(--color-text-primary);
font-weight: 600;
}
.md-preview em {
font-style: italic;
}
.md-preview code {
font-family: var(--font-mono);
font-size: 0.85em;
background: rgba(255, 255, 255, 0.06);
padding: 0.15em 0.4em;
border-radius: 4px;
color: var(--color-text-primary);
}
.md-preview .md-code-block {
position: relative;
margin: 1em 0;
border-radius: 8px;
overflow: hidden;
background: var(--color-surface-3);
border: 1px solid var(--color-border);
}
.md-preview .md-code-lang {
font-size: 0.7rem;
font-family: var(--font-mono);
color: var(--color-text-tertiary);
padding: 0.4rem 0.8rem;
border-bottom: 1px solid var(--color-border);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.md-preview .md-code-lang:empty {
display: none;
}
.md-preview .md-code-block pre {
margin: 0;
padding: 0.8rem 1rem;
overflow-x: auto;
}
.md-preview .md-code-block code {
background: none;
padding: 0;
border-radius: 0;
font-size: 0.82rem;
line-height: 1.6;
color: var(--color-text-secondary);
}
.md-preview ul,
.md-preview ol {
margin: 0.8em 0;
padding-left: 1.5em;
}
.md-preview ul { list-style-type: disc; }
.md-preview ol { list-style-type: decimal; }
.md-preview ul ul { list-style-type: circle; }
.md-preview ul ul ul { list-style-type: square; }
.md-preview li {
margin-bottom: 0.3em;
}
.md-preview li > p {
margin-bottom: 0.4em;
}
.md-preview blockquote {
border-left: 3px solid var(--color-accent-400);
padding: 0.5em 1em;
margin: 1em 0;
background: rgba(213, 116, 85, 0.05);
border-radius: 0 6px 6px 0;
color: var(--color-text-secondary);
}
.md-preview blockquote p:last-child {
margin-bottom: 0;
}
.md-preview hr {
border: none;
border-top: 1px solid var(--color-border);
margin: 1.5em 0;
}
.md-preview table {
width: 100%;
border-collapse: collapse;
margin: 1em 0;
font-size: 0.85rem;
}
.md-preview thead th {
text-align: left;
font-weight: 600;
color: var(--color-text-primary);
padding: 0.6em 0.8em;
border-bottom: 2px solid var(--color-border);
background: var(--color-surface-3);
}
.md-preview tbody td {
padding: 0.5em 0.8em;
border-bottom: 1px solid var(--color-border);
}
.md-preview tbody tr:hover {
background: rgba(255, 255, 255, 0.02);
}
.md-preview img {
max-width: 100%;
border-radius: 8px;
margin: 1em 0;
}
.md-preview input[type="checkbox"] {
margin-right: 0.4em;
accent-color: var(--color-accent-400);
}
.md-preview dl dt {
font-weight: 600;
color: var(--color-text-primary);
margin-top: 0.8em;
}
.md-preview dl dd {
padding-left: 1.5em;
margin-bottom: 0.4em;
}
/* Search highlights in markdown preview */
mark.md-search-hl {
background: rgba(213, 116, 85, 0.3);
color: var(--color-text-primary);
border-radius: 2px;
padding: 0 1px;
}
/* Trending page stat icons - theme-aware colors */
.stat-icon {
width: 2rem;
height: 2rem;
border-radius: 0.5rem;
display: flex;
align-items: center;
justify-content: center;
color: white;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
html.dark .stat-blue {
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
}
html.light .stat-blue {
background: linear-gradient(135deg, #60a5fa 0%, #3b82f6 100%);
}
html.dark .stat-purple {
background: linear-gradient(135deg, #a855f7 0%, #9333ea 100%);
}
html.light .stat-purple {
background: linear-gradient(135deg, #c084fc 0%, #a855f7 100%);
}
html.dark .stat-emerald {
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
}
html.light .stat-emerald {
background: linear-gradient(135deg, #34d399 0%, #10b981 100%);
}
html.dark .stat-orange {
background: linear-gradient(135deg, #f97316 0%, #ea580c 100%);
}
html.light .stat-orange {
background: linear-gradient(135deg, #fb923c 0%, #f97316 100%);
}
html.dark .stat-pink {
background: linear-gradient(135deg, #ec4899 0%, #db2777 100%);
}
html.light .stat-pink {
background: linear-gradient(135deg, #f472b6 0%, #ec4899 100%);
}
html.dark .stat-cyan {
background: linear-gradient(135deg, #06b6d4 0%, #0891b2 100%);
}
html.light .stat-cyan {
background: linear-gradient(135deg, #22d3ee 0%, #06b6d4 100%);
}
/* Medal colors for trending ranks - theme-aware */
.medal-gold {
background: linear-gradient(135deg, #fbbf24 0%, #f59e0b 100%);
color: white;
box-shadow: 0 2px 8px rgba(251, 191, 36, 0.3);
}
.medal-silver {
background: linear-gradient(135deg, #d1d5db 0%, #9ca3af 100%);
color: white;
box-shadow: 0 2px 8px rgba(156, 163, 175, 0.3);
}
.medal-bronze {
background: linear-gradient(135deg, #fb923c 0%, #f97316 100%);
color: white;
box-shadow: 0 2px 8px rgba(251, 146, 60, 0.3);
}
html.light .medal-gold {
background: linear-gradient(135deg, #fcd34d 0%, #fbbf24 100%);
box-shadow: 0 2px 8px rgba(251, 191, 36, 0.4);
}
html.light .medal-silver {
background: linear-gradient(135deg, #e5e7eb 0%, #d1d5db 100%);
box-shadow: 0 2px 8px rgba(156, 163, 175, 0.4);
}
html.light .medal-bronze {
background: linear-gradient(135deg, #fdba74 0%, #fb923c 100%);
box-shadow: 0 2px 8px rgba(251, 146, 60, 0.4);
}
.medal-default {
background: var(--color-surface-3);
color: var(--color-text-tertiary);
}
/* Hot badge - theme-aware */
.hot-badge {
display: flex;
align-items: center;
gap: 0.125rem;
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
font-size: 9px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
flex-shrink: 0;
}
html.dark .hot-badge {
background: rgba(16, 185, 129, 0.1);
color: #10b981;
}
html.light .hot-badge {
background: rgba(16, 185, 129, 0.15);
color: #059669;
}
/* Today stats active color - theme-aware */
html.dark .stat-today-active {
color: #10b981;
}
html.light .stat-today-active {
color: #059669;
}
/* ═══════════════════════════════════════════════════════
CLI / TERMINAL UTILITIES
═══════════════════════════════════════════════════════ */
/* Monospace UI font for labels, counts, titles */
.font-ui-mono {
font-family: var(--font-mono);
}
/* Terminal prompt symbol */
.terminal-prompt {
color: var(--color-accent);
font-family: var(--font-mono);
font-weight: bold;
user-select: none;
flex-shrink: 0;
}
/* Terminal green prompt */
.terminal-prompt-green {
color: var(--color-success);
font-family: var(--font-mono);
font-weight: bold;
user-select: none;
flex-shrink: 0;
}
/* Blinking block cursor — hard on/off, no fade */
.terminal-cursor {
display: inline-block;
width: 8px;
height: 16px;
background: var(--color-accent);
animation: terminal-cursor-blink 1s infinite;
vertical-align: middle;
margin-left: 2px;
}
@keyframes terminal-cursor-blink {
0%, 49% { opacity: 1; }
50%, 100% { opacity: 0; }
}
/* Pulsing green status dot */
.status-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--color-success);
animation: status-dot-pulse 2s infinite;
flex-shrink: 0;
}
@keyframes status-dot-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
/* ASCII art hero — orange block characters */
.ascii-art {
color: var(--color-accent);
font-family: var(--font-mono);
font-size: clamp(0.32rem, 0.65vw, 0.6rem);
line-height: 1.1;
white-space: pre;
overflow: hidden;
margin: 0;
text-align: left;
display: block;
}
/* Green download badge (pill) */
.badge-download {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 8px;
border-radius: 20px;
font-size: 11px;
font-weight: 600;
font-family: var(--font-mono);
background: var(--color-success-bg);
border: 1px solid var(--color-success);
color: var(--color-success);
transition: background 0.2s ease;
}
.badge-download:hover {
background: rgba(63, 185, 80, 0.18);
}
/* Terminal-style card border hover */
.card-terminal {
border: 1px solid var(--color-border);
border-radius: 6px;
transition: border-color 150ms ease;
}
.card-terminal:hover {
border-color: var(--color-accent);
}