chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 →
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">"{keyName}"<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">"{keyName}"<span className="text-[var(--color-text-tertiary)]">: </span></span>
|
||||
)}
|
||||
<span className="text-green-400 break-all">
|
||||
"{display}"
|
||||
{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">"{keyName}"<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">"{keyName}"<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">"{keyName}"<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">"{keyName}"<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>
|
||||
);
|
||||
}
|
||||
@@ -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, '<').replace(/>/g, '>')}</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
@@ -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>
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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, '<').replace(/>/g, '>')}</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>
|
||||
);
|
||||
}
|
||||
@@ -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, '<').replace(/>/g, '>')}</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]">←</kbd>
|
||||
<kbd className="px-1 py-0.5 rounded bg-[var(--color-surface-3)] font-mono text-[9px]">→</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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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]">></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>
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 }} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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',
|
||||
};
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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' },
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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();
|
||||
@@ -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>
|
||||
@@ -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">💡</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">🗺</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">📋</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">⚡</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">🛡</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">🔍</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">🌐</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">📊</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">⚡</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">📚</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">🎨</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">🔎</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">{
|
||||
"mcpServers": {
|
||||
"brightdata": {
|
||||
"url": "https://mcp.brightdata.com/mcp?token=YOUR_TOKEN&pro=1"
|
||||
}
|
||||
}
|
||||
}</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">📈</span><div><p class="font-medium text-[var(--color-text-primary)] text-sm">Research & 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">🎯</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">👥</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">📱</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">🛒</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">🖥</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 & 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user