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

This commit is contained in:
wehub-resource-sync
2026-07-13 12:38:58 +08:00
commit bb5c75ce05
8824 changed files with 1946442 additions and 0 deletions
+228
View File
@@ -0,0 +1,228 @@
import { useState, useEffect, useRef } from 'react';
interface ClerkUser {
fullName?: string | null;
firstName?: string | null;
imageUrl?: string;
email?: string;
}
// Mock user data for local testing
const MOCK_USER: ClerkUser = {
fullName: 'Bitreon',
firstName:'Bitreon',
imageUrl: 'https://avatars.githubusercontent.com/u/207326426?v=4'
};
// Enable mock mode via console: window.enableAuthMock()
// Disable mock mode via console: window.disableAuthMock()
declare global {
interface Window {
enableAuthMock: () => void;
disableAuthMock: () => void;
__authMockEnabled?: boolean;
}
}
function useGlobalAuth() {
const [state, setState] = useState<{ isSignedIn: boolean; isLoaded: boolean; user: ClerkUser | null }>({
isSignedIn: false, isLoaded: false, user: null,
});
const [mockEnabled, setMockEnabled] = useState(false);
useEffect(() => {
// Setup console commands for mock mode
window.enableAuthMock = () => {
window.__authMockEnabled = true;
setMockEnabled(true);
console.log('✅ Auth mock mode enabled! Showing mock user:', MOCK_USER.fullName);
};
window.disableAuthMock = () => {
window.__authMockEnabled = false;
setMockEnabled(false);
console.log('❌ Auth mock mode disabled');
};
// Check if mock was previously enabled
if (window.__authMockEnabled) {
setMockEnabled(true);
}
function check() {
// If mock is enabled and not on Vercel, use mock data
const isVercel = window.location.hostname.includes('vercel.app') ||
window.location.hostname.includes('claude.ai');
if (window.__authMockEnabled && !isVercel) {
setState({
isSignedIn: true,
isLoaded: true,
user: MOCK_USER,
});
return;
}
const clerk = (window as any).Clerk;
if (clerk?.loaded) {
setState({
isSignedIn: !!clerk.user,
isLoaded: true,
user: clerk.user ? {
fullName: clerk.user.fullName,
firstName: clerk.user.firstName,
imageUrl: clerk.user.imageUrl,
email: clerk.user.primaryEmailAddress?.emailAddress || '',
} : null,
});
}
}
check();
const interval = setInterval(check, 500);
const handleChange = () => check();
window.addEventListener('clerk:session', handleChange);
return () => { clearInterval(interval); window.removeEventListener('clerk:session', handleChange); };
}, [mockEnabled]);
return state;
}
function UserMenu({ user }: { user: ClerkUser }) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
}
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, [open]);
const displayName = user.fullName || user.firstName || 'User';
return (
<div ref={ref} className="relative">
<button
onClick={() => setOpen(!open)}
className="flex items-center gap-2.5 px-2.5 py-[6px] rounded-md text-[13px] text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-surface-2)] transition-colors w-full"
>
{user.imageUrl ? (
<img
src={user.imageUrl}
alt={displayName}
className="w-6 h-6 rounded-full shrink-0 ring-2 ring-orange-500"
/>
) : (
<div className="w-6 h-6 rounded-full bg-orange-500 flex items-center justify-center shrink-0 text-[11px] font-bold text-white ring-2 ring-orange-500">
{displayName.charAt(0).toUpperCase()}
</div>
)}
<span className="truncate flex-1 text-left">{displayName}</span>
</button>
{open && (
<div className="fixed bottom-4 left-4 w-56 bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded-lg shadow-xl z-[9999] py-1">
{/* User Info Header */}
<div className="px-3 py-2 border-b border-[var(--color-border)]">
<div className="text-[13px] font-medium text-[var(--color-text-primary)] truncate">
{displayName}
</div>
<div className="text-[11px] text-[var(--color-text-secondary)] truncate">
{user.email || ''}
</div>
</div>
{/* Menu Items */}
<div className="py-1">
<button
onClick={() => {
console.log('Navigate to My Components');
window.location.href = '/my-components';
setOpen(false);
}}
className="flex items-center gap-2 w-full px-3 py-1.5 text-[12px] text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-surface-3)] transition-colors"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="1.5">
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
</svg>
My Components
</button>
<button
onClick={() => {
console.log('Navigate to Settings');
window.location.href = '/settings';
setOpen(false);
}}
className="flex items-center gap-2 w-full px-3 py-1.5 text-[12px] text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-surface-3)] transition-colors"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="1.5">
<path strokeLinecap="round" strokeLinejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
Settings
</button>
</div>
{/* Divider */}
<div className="border-t border-[var(--color-border)] my-1"></div>
{/* Sign Out */}
<div className="py-1">
<button
onClick={() => {
const isVercel = window.location.hostname.includes('vercel.app') ||
window.location.hostname.includes('claude.ai');
if (window.__authMockEnabled && !isVercel) {
console.log('🔓 Mock sign out - disabling mock mode');
window.disableAuthMock?.();
} else {
(window as any).Clerk?.signOut?.();
}
setOpen(false);
}}
className="flex items-center gap-2 w-full px-3 py-1.5 text-[12px] text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-surface-3)] transition-colors"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="1.5">
<path strokeLinecap="round" strokeLinejoin="round" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
Sign Out
</button>
</div>
</div>
)}
</div>
);
}
export default function AuthButton() {
const { isSignedIn, isLoaded, user } = useGlobalAuth();
if (!isLoaded) {
return (
<div className="flex items-center gap-2.5 px-2.5 py-[6px]">
<div className="w-6 h-6 rounded-full bg-[var(--color-surface-3)] animate-pulse" />
<div className="h-3 w-16 bg-[var(--color-surface-3)] rounded animate-pulse" />
</div>
);
}
if (isSignedIn && user) {
return <UserMenu user={user} />;
}
return (
<button
onClick={() => (window as any).Clerk?.openSignIn?.()}
className="flex items-center gap-2.5 px-2.5 py-[6px] rounded-md text-[13px] text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-surface-2)] transition-colors w-full"
>
<svg className="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
<span>Sign In</span>
</button>
);
}
+295
View File
@@ -0,0 +1,295 @@
import { useState, useEffect } from 'react';
import type { Cart } from '../lib/types';
import { TYPE_CONFIG } from '../lib/icons';
const EMPTY_CART: Cart = {
agents: [], commands: [], settings: [], hooks: [], mcps: [], skills: [], templates: [],
};
const TYPE_FLAGS: Record<string, string> = {
agents: '--agent', commands: '--command', settings: '--setting',
hooks: '--hook', mcps: '--mcp', skills: '--skill', templates: '--template',
};
function cleanPath(path: string): string {
return path?.replace(/\.(md|json)$/, '') ?? '';
}
function formatName(name: string): string {
if (!name) return '';
return name.replace(/\.(md|json)$/, '').replace(/[-_]/g, ' ')
.split(' ').map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
}
export default function CartSidebar() {
const [open, setOpen] = useState(false);
const [cart, setCart] = useState<Cart>(EMPTY_CART);
const [copied, setCopied] = useState(false);
// Load cart
useEffect(() => {
function loadCart() {
try {
const saved = localStorage.getItem('claudeCodeCart');
if (saved) setCart({ ...EMPTY_CART, ...JSON.parse(saved) });
} catch {}
}
loadCart();
window.addEventListener('cart-updated', ((e: CustomEvent) => {
setCart({ ...EMPTY_CART, ...e.detail });
}) as EventListener);
window.addEventListener('storage', loadCart);
return () => window.removeEventListener('storage', loadCart);
}, []);
const totalItems = Object.values(cart).reduce((sum, arr) => sum + (arr?.length ?? 0), 0);
// Generate command
function generateCommand(): string {
let cmd = 'npx claude-code-templates@latest';
for (const [type, items] of Object.entries(cart)) {
if (items?.length > 0) {
const flag = TYPE_FLAGS[type];
if (flag) {
const paths = items.map((i: any) => cleanPath(i.path)).join(',');
cmd += ` ${flag} ${paths}`;
}
}
}
return cmd;
}
function removeItem(path: string, type: string) {
setCart((prev) => {
const next = { ...prev, [type]: (prev as any)[type]?.filter((i: any) => i.path !== path) ?? [] };
localStorage.setItem('claudeCodeCart', JSON.stringify(next));
window.dispatchEvent(new CustomEvent('cart-updated', { detail: next }));
return next;
});
}
function clearAll() {
if (!confirm('Clear your entire stack?')) return;
const empty = { ...EMPTY_CART };
setCart(empty);
localStorage.setItem('claudeCodeCart', JSON.stringify(empty));
window.dispatchEvent(new CustomEvent('cart-updated', { detail: empty }));
}
function copyCommand() {
navigator.clipboard.writeText(generateCommand());
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
function shareTwitter() {
const cmd = generateCommand();
const text = `Check out my Claude Code stack!\n\n${cmd}\n\nBuild yours at https://aitmpl.com`;
window.open(`https://twitter.com/intent/tweet?text=${encodeURIComponent(text)}`, '_blank');
}
return (
<>
{/* Floating button */}
{totalItems > 0 && !open && (
<button
onClick={() => setOpen(true)}
className="fixed bottom-6 right-6 z-40 flex items-center gap-2.5 pl-4 pr-3 py-3 bg-gradient-to-br from-[var(--color-accent)] to-[var(--color-accent)]/80 hover:from-[var(--color-accent)]/90 hover:to-[var(--color-accent)]/70 text-white rounded-full shadow-lg shadow-[var(--color-accent)]/30 transition-all duration-200 hover:scale-105 hover:shadow-xl hover:shadow-[var(--color-accent)]/40 active:scale-95"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
<span className="text-[13px] font-semibold">Stack</span>
<span className="min-w-5 h-5 px-1.5 rounded-full bg-white/25 backdrop-blur-sm text-white text-[11px] font-bold flex items-center justify-center border border-white/20">
{totalItems}
</span>
</button>
)}
{/* Overlay */}
{open && (
<div
className="fixed inset-0 bg-black/60 backdrop-blur-sm z-40 animate-in fade-in duration-200"
onClick={() => setOpen(false)}
/>
)}
{/* Sidebar panel */}
<div
className={`fixed top-0 right-0 h-full w-96 max-w-[90vw] bg-[var(--color-surface-0)] border-l border-[var(--color-border)] shadow-2xl z-50 transform transition-transform duration-300 ease-out flex flex-col ${
open ? 'translate-x-0' : 'translate-x-full'
}`}
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-4 border-b border-[var(--color-border)] bg-gradient-to-b from-[var(--color-surface-1)] to-[var(--color-surface-0)] shrink-0">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-lg bg-gradient-to-br from-[var(--color-accent)] to-[var(--color-accent)]/70 flex items-center justify-center shadow-md shadow-[var(--color-accent)]/20">
<svg className="w-4.5 h-4.5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
</div>
<div className="flex flex-col">
<h2 className="text-[15px] font-semibold text-[var(--color-text-primary)] leading-none">Stack Builder</h2>
<span className="text-[11px] text-[var(--color-text-tertiary)] mt-1">
{totalItems} {totalItems === 1 ? 'component' : 'components'}
</span>
</div>
</div>
<div className="flex items-center gap-1.5">
{totalItems > 0 && (
<button
onClick={clearAll}
className="text-xs text-[var(--color-text-tertiary)] hover:text-red-400 hover:bg-red-400/10 px-3 py-1.5 rounded-lg transition-all font-medium"
>
Clear All
</button>
)}
<button
onClick={() => setOpen(false)}
className="p-2 rounded-lg hover:bg-[var(--color-surface-3)] text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] transition-colors"
aria-label="Close sidebar"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto scrollbar-thin scrollbar-thumb-[var(--color-surface-3)] scrollbar-track-transparent min-h-0">
{totalItems === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-center px-6">
<div className="relative w-24 h-24 rounded-2xl bg-gradient-to-br from-[var(--color-surface-2)] to-[var(--color-surface-3)] border border-[var(--color-border)] flex items-center justify-center mb-5 shadow-sm">
<svg className="w-12 h-12 text-[var(--color-text-tertiary)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="1.5">
<path strokeLinecap="round" strokeLinejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
<div className="absolute -top-1.5 -right-1.5 w-7 h-7 rounded-full bg-[var(--color-accent)]/10 border-2 border-[var(--color-accent)]/30 flex items-center justify-center backdrop-blur-sm">
<svg className="w-3.5 h-3.5 text-[var(--color-accent)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2.5">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
</svg>
</div>
</div>
<p className="text-base font-semibold text-[var(--color-text-primary)] mb-2">Your stack is empty</p>
<p className="text-[13px] text-[var(--color-text-secondary)] max-w-xs leading-relaxed">
Browse components and click the <span className="inline-flex items-center justify-center w-4 h-4 rounded bg-[var(--color-accent)]/10 text-[var(--color-accent)] text-[10px] font-bold mx-0.5">+</span> button to add them to your stack for easy installation.
</p>
</div>
) : (
<div className="px-3 py-4">
{Object.entries(cart).filter(([, items]) => items?.length > 0).map(([type, items], idx, arr) => {
const config = TYPE_CONFIG[type];
const isLast = idx === arr.length - 1;
return (
<div key={type} className={!isLast ? 'mb-4 pb-3 border-b border-[var(--color-border)]' : 'mb-2'}>
{/* Folder row */}
<div className="flex items-center gap-2.5 py-2 px-3 rounded-lg bg-[var(--color-surface-1)] border border-[var(--color-border)] mb-2 shadow-sm">
<svg className="w-4 h-4 shrink-0" style={{ color: config?.color ?? 'var(--color-text-tertiary)' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z" />
</svg>
<span className="text-[13px] font-semibold text-[var(--color-text-primary)]">
{config?.label ?? type}
</span>
<span className="ml-auto text-[10px] px-2 py-0.5 rounded-full bg-[var(--color-surface-3)] text-[var(--color-text-tertiary)] font-semibold tabular-nums border border-[var(--color-border)]">
{items.length}
</span>
</div>
{/* File rows with tree lines */}
{items.map((item: any, i: number) => {
const isLastItem = i === items.length - 1;
return (
<div
key={item.path}
className="flex items-center group pl-2 pr-1.5 py-0.5 hover:bg-[var(--color-surface-1)] rounded-md transition-all duration-150"
>
{/* Tree connector */}
<span className="text-[var(--color-border)] text-[13px] font-mono w-7 shrink-0 select-none">
{isLastItem ? '└─' : '├─'}
</span>
{/* File icon */}
<svg className="w-3.5 h-3.5 shrink-0 mr-2.5 text-[var(--color-text-tertiary)] group-hover:text-[var(--color-text-secondary)] transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" />
<polyline points="14 2 14 8 20 8" />
</svg>
{/* Name */}
<span className="text-[12px] text-[var(--color-text-primary)] flex-1 truncate py-2 font-medium">
{formatName(item.name)}
</span>
{/* Remove */}
<button
onClick={() => removeItem(item.path, type)}
className="opacity-0 group-hover:opacity-100 p-1.5 rounded-md hover:bg-red-400/10 text-[var(--color-text-tertiary)] hover:text-red-400 transition-all shrink-0"
title="Remove from stack"
aria-label={`Remove ${formatName(item.name)}`}
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
);
})}
</div>
);
})}
</div>
)}
</div>
{/* Footer */}
{totalItems > 0 && (
<div className="border-t border-[var(--color-border)] bg-gradient-to-t from-[var(--color-surface-1)] to-[var(--color-surface-0)] p-4 space-y-3 shadow-[0_-8px_16px_rgba(0,0,0,0.08)] shrink-0">
{/* Command preview */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<svg className="w-3.5 h-3.5 text-[var(--color-text-tertiary)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<span className="text-[11px] text-[var(--color-text-tertiary)] font-semibold uppercase tracking-wide">Installation Command</span>
</div>
<div className="bg-[var(--color-surface-2)] rounded-lg p-3 text-[11px] font-mono text-[var(--color-text-secondary)] break-all max-h-20 overflow-y-auto border border-[var(--color-border)] shadow-inner hover:border-[var(--color-accent)]/30 transition-colors">
{generateCommand()}
</div>
</div>
{/* Actions */}
<div className="flex gap-2">
<button
onClick={copyCommand}
className="flex-1 flex items-center justify-center gap-2 px-4 py-2.5 bg-gradient-to-br from-[var(--color-accent)] to-[var(--color-accent)]/80 hover:from-[var(--color-accent)]/90 hover:to-[var(--color-accent)]/70 text-white rounded-lg text-sm font-semibold transition-all duration-200 shadow-md shadow-[var(--color-accent)]/20 hover:shadow-lg hover:shadow-[var(--color-accent)]/30 active:scale-95"
>
{copied ? (
<>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2.5">
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
Copied!
</>
) : (
<>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3" />
</svg>
Copy Command
</>
)}
</button>
<button
onClick={shareTwitter}
className="px-3.5 py-2.5 bg-[var(--color-surface-3)] hover:bg-[var(--color-surface-4)] text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] rounded-lg transition-all duration-200 border border-[var(--color-border)] hover:border-[var(--color-accent)]/30 hover:shadow-sm active:scale-95"
title="Share on Twitter"
aria-label="Share on Twitter"
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
</svg>
</button>
</div>
</div>
)}
</div>
</>
);
}
+12
View File
@@ -0,0 +1,12 @@
import { ClerkProvider } from '@clerk/clerk-react';
const clerkKey = import.meta.env.PUBLIC_CLERK_PUBLISHABLE_KEY;
export default function ClerkIsland() {
if (!clerkKey) return null;
return (
<ClerkProvider publishableKey={clerkKey}>
<span />
</ClerkProvider>
);
}
+736
View File
@@ -0,0 +1,736 @@
import { useState, useEffect, useMemo, useCallback } from 'react';
import type { Component } from '../lib/types';
import { TYPE_CONFIG } from '../lib/icons';
import { ITEMS_PER_PAGE } from '../lib/constants';
import { fetchComponentsByType } from '../lib/data';
import SaveToCollectionButton from './SaveToCollectionButton';
interface Props {
initialType: string;
}
interface CartState {
[key: string]: { name: string; path: string; category: string; description: string; icon: string }[];
}
function cleanPath(path: string): string {
return path?.replace(/\.(md|json)$/, '') ?? '';
}
function formatName(name: string): string {
if (!name) return '';
return name
.replace(/\.(md|json)$/, '')
.replace(/[-_]/g, ' ')
.split(' ')
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(' ');
}
import TypeIcon from './TypeIcon';
export default function ComponentGrid({ initialType }: Props) {
// Only the active type's components are loaded, from /components/{type}.json,
// instead of downloading the whole ~1.9MB index up front.
const [typeComponents, setTypeComponents] = useState<Component[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [activeType, setActiveType] = useState<string>(initialType);
const [category, setCategory] = useState('all');
const [search, setSearch] = useState('');
const [sortBy, setSortBy] = useState<'downloads' | 'name'>('downloads');
const [page, setPage] = useState(1);
const [cart, setCart] = useState<CartState>({});
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
const [showFilters, setShowFilters] = useState(false);
const [minDownloads, setMinDownloads] = useState(0);
const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
// Sync activeType when initialType changes (e.g. sidebar navigation)
useEffect(() => {
setActiveType(initialType);
setCategory('all');
setPage(1);
}, [initialType]);
useEffect(() => {
let cancelled = false;
async function load() {
try {
setLoading(true);
setError(null);
const components = await fetchComponentsByType(activeType);
if (!cancelled) { setTypeComponents(components); setLoading(false); }
} catch {
if (!cancelled) { setError('Failed to load components'); setLoading(false); }
}
}
load();
return () => { cancelled = true; };
}, [activeType]);
useEffect(() => {
try {
const saved = localStorage.getItem('claudeCodeCart');
if (saved) setCart(JSON.parse(saved));
} catch {}
}, []);
const categories = useMemo(() => {
const cats = new Set<string>();
for (const c of typeComponents) { if (c.category) cats.add(c.category); }
return Array.from(cats).sort();
}, [typeComponents]);
const filtered = useMemo(() => {
let items = typeComponents;
// Apply either single-category OR multi-category filter, not both
if (selectedCategories.length > 0) {
// Multi-category filter takes precedence
items = items.filter((c) => selectedCategories.includes(c.category ?? ''));
} else if (category !== 'all') {
// Single-category filter
items = items.filter((c) => c.category === category);
}
// Search filter
if (search.trim()) {
const q = search.toLowerCase();
items = items.filter((c) =>
c.name.toLowerCase().includes(q) ||
c.description?.toLowerCase().includes(q) ||
c.category?.toLowerCase().includes(q) ||
c.content?.toLowerCase().includes(q)
);
}
// Downloads filter
if (minDownloads > 0) {
items = items.filter((c) => (c.downloads ?? 0) >= minDownloads);
}
// Sort
const sorted = [...items];
if (sortBy === 'downloads') sorted.sort((a, b) => (b.downloads ?? 0) - (a.downloads ?? 0));
else if (sortBy === 'name') sorted.sort((a, b) => a.name.localeCompare(b.name));
return sorted;
}, [typeComponents, category, selectedCategories, search, sortBy, minDownloads]);
const totalPages = Math.max(1, Math.ceil(filtered.length / ITEMS_PER_PAGE));
const paged = filtered.slice((page - 1) * ITEMS_PER_PAGE, page * ITEMS_PER_PAGE);
useEffect(() => { setPage(1); }, [category, search, activeType, selectedCategories, minDownloads]);
// Sidebar counts come from /counts.json (see Sidebar.astro); the grid no
// longer loads every type, so it can't (and doesn't need to) emit them.
const toggleCategoryFilter = useCallback((cat: string) => {
setSelectedCategories(prev =>
prev.includes(cat) ? prev.filter(c => c !== cat) : [...prev, cat]
);
}, []);
const clearAllFilters = useCallback(() => {
setSearch('');
setCategory('all');
setSelectedCategories([]);
setMinDownloads(0);
}, []);
const hasActiveFilters = search || category !== 'all' || selectedCategories.length > 0 || minDownloads > 0;
const isInCart = useCallback(
(path: string, type: string) => {
const typePlural = type.endsWith('s') ? type : type + 's';
return cart[typePlural]?.some((item) => item.path === path) ?? false;
},
[cart]
);
const toggleCart = useCallback((component: Component) => {
const typePlural = component.type.endsWith('s') ? component.type : component.type + 's';
setCart((prev) => {
const items = prev[typePlural] ?? [];
const exists = items.some((i) => i.path === component.path);
let newItems: CartState;
if (exists) {
newItems = { ...prev, [typePlural]: items.filter((i) => i.path !== component.path) };
} else {
newItems = { ...prev, [typePlural]: [...items, {
name: component.name, path: component.path, category: component.category ?? '',
description: component.description ?? '', icon: typePlural,
}] };
}
localStorage.setItem('claudeCodeCart', JSON.stringify(newItems));
window.dispatchEvent(new CustomEvent('cart-updated', { detail: newItems }));
return newItems;
});
}, []);
if (loading) {
return (
<div className="px-6 py-32">
<div className="flex flex-col items-center gap-6">
<div className="relative w-16 h-16">
{/* Outer ring */}
<div className="absolute inset-0 border-[3px] border-[var(--color-border)] rounded-full opacity-20" />
{/* Spinning gradient ring */}
<div className="absolute inset-0 rounded-full bg-gradient-to-tr from-[var(--color-primary-500)] via-[var(--color-accent-400)] to-[var(--color-primary-500)] opacity-80 animate-spin" style={{ clipPath: 'polygon(50% 50%, 50% 0%, 100% 0%, 100% 100%, 0% 100%, 0% 0%, 50% 0%)' }} />
<div className="absolute inset-[3px] bg-[var(--color-surface-0)] rounded-full" />
{/* Inner pulse */}
<div className="absolute inset-[6px] bg-gradient-to-br from-[var(--color-primary-500)]/20 to-[var(--color-accent-400)]/20 rounded-full animate-pulse" />
</div>
<div className="flex flex-col items-center gap-2">
<span className="text-[14px] font-semibold text-[var(--color-text-primary)] animate-pulse">Loading components</span>
<span className="text-[12px] text-[var(--color-text-tertiary)]">Preparing your experience...</span>
</div>
{/* Skeleton cards preview */}
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 w-full max-w-6xl mt-8">
{[1, 2, 3, 4, 5, 6].map((i) => (
<div key={i} className="p-5 rounded-xl bg-[var(--color-card-bg)] border border-[var(--color-border)] animate-pulse" style={{ animationDelay: `${i * 100}ms` }}>
<div className="flex items-start gap-3.5">
<div className="w-11 h-11 rounded-xl bg-[var(--color-surface-3)] skeleton" />
<div className="flex-1 space-y-3">
<div className="h-4 bg-[var(--color-surface-3)] rounded skeleton w-3/4" />
<div className="space-y-2">
<div className="h-3 bg-[var(--color-surface-3)] rounded skeleton" />
<div className="h-3 bg-[var(--color-surface-3)] rounded skeleton w-5/6" />
</div>
<div className="flex gap-2">
<div className="h-5 w-16 bg-[var(--color-surface-3)] rounded-full skeleton" />
<div className="h-5 w-20 bg-[var(--color-surface-3)] rounded-full skeleton" />
</div>
</div>
</div>
</div>
))}
</div>
</div>
</div>
);
}
if (error) {
return (
<div className="px-6 py-24">
<div className="flex flex-col items-center gap-4 max-w-md mx-auto text-center">
<div className="w-12 h-12 rounded-full bg-red-500/10 border border-red-500/20 flex items-center justify-center">
<svg className="w-6 h-6 text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<div>
<h3 className="text-[14px] font-semibold text-[var(--color-text-primary)] mb-1">Failed to load components</h3>
<p className="text-[13px] text-[var(--color-text-secondary)]">{error}</p>
</div>
<button
onClick={() => window.location.reload()}
className="px-4 py-2 text-[13px] font-medium rounded-lg bg-[var(--color-primary-500)] text-white hover:bg-[var(--color-primary-600)] transition-all active:scale-95"
>
Try again
</button>
</div>
</div>
);
}
return (
<div>
{/* Enhanced Filter bar with view modes */}
<div className="border-b border-[var(--color-border)] bg-[var(--color-surface-0)] backdrop-blur-sm sticky top-14 z-10">
<div className="flex flex-col sm:flex-row sm:flex-wrap sm:items-center gap-2.5 px-6 py-4">
{/* Search */}
<div className="relative w-full sm:w-auto sm:flex-1 sm:min-w-[140px] sm:max-w-md">
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--color-text-tertiary)] pointer-events-none transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
type="text"
placeholder="Search components..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded-lg text-[13px] text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] pl-10 pr-9 py-2.5 outline-none focus:bg-[var(--color-surface-3)] focus:border-[var(--color-primary-500)] focus:ring-2 focus:ring-[var(--color-primary-500)]/20 transition-all hover:border-[var(--color-border-hover)]"
/>
{search && (
<button
onClick={() => setSearch('')}
className="absolute right-2.5 top-1/2 -translate-y-1/2 w-5 h-5 rounded-md flex items-center justify-center text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-surface-3)] transition-all active:scale-95"
aria-label="Clear search"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2.5">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
{/* Category select + advanced filters toggle: share a row on mobile,
flow individually as flex items from sm: up (display:contents
unwraps this grouping div so it doesn't affect desktop layout) */}
<div className="flex items-center gap-2.5 sm:contents">
<select
value={category}
onChange={(e) => setCategory(e.target.value)}
className="flex-1 sm:flex-none min-w-0 bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded-lg text-[13px] text-[var(--color-text-secondary)] font-medium px-3.5 py-2.5 pr-9 outline-none focus:bg-[var(--color-surface-3)] focus:border-[var(--color-primary-500)] focus:ring-2 focus:ring-[var(--color-primary-500)]/20 cursor-pointer transition-all appearance-none hover:border-[var(--color-border-hover)] hover:text-[var(--color-text-primary)] bg-[url('data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20fill%3D%22none%22%20viewBox%3D%220%200%2020%2020%22%3E%3Cpath%20stroke%3D%22%23737373%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%20stroke-width%3D%221.5%22%20d%3D%22m6%208%204%204%204-4%22%2F%3E%3C%2Fsvg%3E')] bg-[length:1.25rem] bg-[right_0.5rem_center] bg-no-repeat"
>
<option value="all">All categories</option>
{categories.map((cat) => (
<option key={cat} value={cat}>{cat}</option>
))}
</select>
{/* Advanced Filters Toggle */}
<button
onClick={() => setShowFilters(!showFilters)}
className={`shrink-0 flex items-center gap-2 px-3 py-2 text-[13px] font-mono font-semibold rounded border transition-all ${
showFilters || hasActiveFilters
? 'bg-[var(--color-accent)] text-white border-[var(--color-accent)]'
: 'bg-[var(--color-surface-2)] text-[var(--color-text-secondary)] border-[var(--color-border)] hover:border-[var(--color-accent)] hover:text-[var(--color-text-primary)]'
}`}
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4" />
</svg>
<span className="hidden sm:inline">Filters</span>
{hasActiveFilters && (
<span className="w-2 h-2 rounded-full bg-white animate-pulse"></span>
)}
</button>
</div>
{/* View Mode Toggle */}
<div className="hidden md:flex items-center gap-1 bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded-lg p-1">
<button
onClick={() => setViewMode('grid')}
className={`p-2 rounded-md transition-all ${
viewMode === 'grid'
? 'bg-[var(--color-surface-4)] text-[var(--color-text-primary)] shadow-sm'
: 'text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)]'
}`}
title="Grid view"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
</svg>
</button>
<button
onClick={() => setViewMode('list')}
className={`p-2 rounded-md transition-all ${
viewMode === 'list'
? 'bg-[var(--color-surface-4)] text-[var(--color-text-primary)] shadow-sm'
: 'text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)]'
}`}
title="List view"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
</div>
{/* Sort */}
<div className="flex items-center gap-2 w-full sm:w-auto sm:ml-auto">
<span className="text-[11px] text-[var(--color-text-tertiary)] font-medium hidden sm:inline">Sort by</span>
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as 'downloads' | 'name')}
className="flex-1 sm:flex-none font-mono bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded text-[13px] text-[var(--color-text-secondary)] font-medium px-3 py-2 pr-9 outline-none focus:border-[var(--color-accent)] cursor-pointer transition-all appearance-none hover:border-[var(--color-accent)] hover:text-[var(--color-text-primary)] bg-[url('data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20fill%3D%22none%22%20viewBox%3D%220%200%2020%2020%22%3E%3Cpath%20stroke%3D%22%237d8590%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%20stroke-width%3D%221.5%22%20d%3D%22m6%208%204%204%204-4%22%2F%3E%3C%2Fsvg%3E')] bg-[length:1.25rem] bg-[right_0.5rem_center] bg-no-repeat"
>
<option value="downloads">Most Popular</option>
<option value="name">Alphabetical</option>
</select>
</div>
</div>
{/* Advanced Filters Panel */}
{showFilters && (
<div className="px-6 py-5 border-t border-[var(--color-border)] bg-[var(--color-surface-1)] animate-fade-in-up">
<div className="flex items-start gap-6">
{/* Multi-category filter */}
<div className="flex-1">
<label className="text-[11px] font-bold text-[var(--color-text-tertiary)] uppercase tracking-wider mb-3 block">
Categories
</label>
<div className="flex flex-wrap gap-2">
{categories.slice(0, 8).map((cat) => (
<button
key={cat}
onClick={() => toggleCategoryFilter(cat)}
className={`px-3 py-1 text-[11px] font-mono font-semibold rounded border transition-all ${
selectedCategories.includes(cat)
? 'bg-[var(--color-accent)] text-white border-[var(--color-accent)]'
: 'bg-[var(--color-surface-2)] text-[var(--color-text-secondary)] border-[var(--color-border)] hover:border-[var(--color-accent)] hover:text-[var(--color-text-primary)]'
}`}
>
{cat}
</button>
))}
</div>
</div>
{/* Downloads filter */}
<div className="w-64">
<label className="text-[11px] font-bold text-[var(--color-text-tertiary)] uppercase tracking-wider mb-3 block">
Min Downloads: {minDownloads}+
</label>
<input
type="range"
min="0"
max="1000"
step="50"
value={minDownloads}
onChange={(e) => setMinDownloads(Number(e.target.value))}
className="w-full h-2 bg-[var(--color-surface-3)] rounded-lg appearance-none cursor-pointer accent-[var(--color-primary-500)]"
/>
<div className="flex justify-between text-[10px] text-[var(--color-text-tertiary)] mt-1">
<span>0</span>
<span>500</span>
<span>1000+</span>
</div>
</div>
{/* Clear filters */}
{hasActiveFilters && (
<button
onClick={clearAllFilters}
className="px-4 py-2 text-[12px] font-semibold text-red-400 hover:text-red-300 bg-red-500/10 hover:bg-red-500/20 border border-red-500/30 rounded-lg transition-all active:scale-95"
>
Clear All
</button>
)}
</div>
</div>
)}
</div>
{/* Results count with quick actions */}
<div className="px-6 pt-5 pb-3 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<span className="text-[13px] text-[var(--color-text-secondary)] font-medium">
{filtered.length} {filtered.length !== 1 ? 'components' : 'component'}
</span>
{search && (
<span className="text-[12px] text-[var(--color-text-tertiary)]">
matching <span className="font-medium text-[var(--color-text-secondary)]">"{search}"</span>
</span>
)}
{category !== 'all' && (
<span className="text-[11px] px-2 py-0.5 rounded-md bg-[var(--color-surface-3)] text-[var(--color-text-tertiary)] border border-[var(--color-border)]">
{category}
</span>
)}
</div>
{/* Quick Actions */}
{filtered.length > 0 && (
<div className="flex items-center gap-2 ml-4">
<button
onClick={() => {
paged.forEach(comp => {
if (!isInCart(comp.path, comp.type)) {
toggleCart(comp);
}
});
}}
className="text-[11px] font-semibold px-3 py-1.5 rounded-lg bg-[var(--color-surface-2)] text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-surface-3)] border border-[var(--color-border)] hover:border-[var(--color-border-hover)] transition-all active:scale-95"
title="Add all visible components to stack"
>
+ Add Page to Stack
</button>
</div>
)}
</div>
{/* Keyboard shortcuts hint */}
<div className="hidden lg:flex items-center gap-2 text-[11px] text-[var(--color-text-tertiary)]">
<kbd className="px-2 py-1 rounded bg-[var(--color-surface-2)] border border-[var(--color-border)] font-mono">K</kbd>
<span>to search</span>
</div>
</div>
{/* Grid or List View */}
<div className={viewMode === 'grid'
? "grid grid-cols-1 md:grid-cols-2 2xl:grid-cols-3 gap-5 px-6 pb-6"
: "flex flex-col gap-4 px-6 pb-6"
}>
{paged.map((component, idx) => {
const inCart = isInCart(component.path, component.type);
const config = TYPE_CONFIG[activeType];
if (viewMode === 'list') {
// List View
return (
<div
key={component.path ?? component.name}
className="group relative flex items-center gap-5 p-4 rounded-md bg-[var(--color-card-bg)] border border-[var(--color-border)] hover:bg-[var(--color-card-hover)] hover:border-[var(--color-accent)] transition-all duration-150 cursor-pointer animate-fade-in-up"
style={{
animationDelay: `${idx * 30}ms`,
animationFillMode: 'both'
}}
onClick={() => {
window.location.href = `/component/${component.type}/${cleanPath(component.path ?? component.name)}`;
}}
>
{/* Icon */}
<div
className="w-11 h-11 rounded flex items-center justify-center shrink-0"
style={{
backgroundColor: `${config?.color ?? '#a1a1a1'}15`,
color: config?.color ?? '#a1a1a1',
border: `1px solid ${config?.color ?? '#a1a1a1'}30`,
}}
>
<TypeIcon type={activeType} size={22} className="[&>svg]:w-[22px] [&>svg]:h-[22px]" />
</div>
{/* Info */}
<div className="flex-1 min-w-0">
<h3 className="font-mono text-[14px] font-bold text-[var(--color-text-primary)] group-hover:text-[var(--color-accent)] transition-colors mb-1">
{formatName(component.name)}
</h3>
<p className="text-[12px] text-[var(--color-text-secondary)] line-clamp-1 leading-relaxed">
{component.description || component.content?.slice(0, 150) || 'No description'}
</p>
</div>
{/* Badges */}
<div className="flex items-center gap-2 shrink-0">
{component.category && (
<span className="font-mono text-[10px] font-semibold px-2 py-0.5 rounded border border-[var(--color-border)] bg-[var(--color-surface-2)] text-[var(--color-text-tertiary)]">
{component.category}
</span>
)}
{(component.downloads ?? 0) > 0 && (
<span className="badge-download text-[10px]">
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2.5">
<path strokeLinecap="round" strokeLinejoin="round" d="M19 14l-7 7m0 0l-7-7m7 7V3" />
</svg>
{component.downloads?.toLocaleString()}
</span>
)}
</div>
{/* Actions */}
<div className="flex items-center gap-2 shrink-0">
<SaveToCollectionButton
componentType={component.type}
componentPath={component.path}
componentName={component.name}
componentCategory={component.category}
/>
<button
onClick={(e) => { e.stopPropagation(); toggleCart(component); }}
className={`w-8 h-8 rounded flex items-center justify-center transition-all duration-150 ${
inCart
? 'bg-[var(--color-accent)] text-white border border-[var(--color-accent)]'
: 'text-[var(--color-text-tertiary)] opacity-0 group-hover:opacity-100 hover:text-[var(--color-accent)] hover:bg-[var(--color-surface-2)] border border-transparent hover:border-[var(--color-border)]'
}`}
title={inCart ? 'Remove from stack' : 'Add to stack'}
>
{inCart ? (
<svg className="w-4.5 h-4.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="3">
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
) : (
<svg className="w-4.5 h-4.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
</svg>
)}
</button>
</div>
</div>
);
}
// Grid View — terminal style
return (
<div
key={component.path ?? component.name}
className="group relative flex items-start gap-4 p-5 rounded-md bg-[var(--color-card-bg)] border border-[var(--color-border)] hover:bg-[var(--color-card-hover)] hover:border-[var(--color-accent)] transition-all duration-150 cursor-pointer animate-fade-in-up"
style={{
animationDelay: `${idx * 50}ms`,
animationFillMode: 'both'
}}
onClick={() => {
window.location.href = `/component/${component.type}/${cleanPath(component.path ?? component.name)}`;
}}
>
{/* Icon — flat, no glow */}
<div
className="w-10 h-10 rounded flex items-center justify-center shrink-0"
style={{
backgroundColor: `${config?.color ?? '#a1a1a1'}15`,
color: config?.color ?? '#a1a1a1',
border: `1px solid ${config?.color ?? '#a1a1a1'}30`,
}}
>
<TypeIcon type={activeType} size={20} className="[&>svg]:w-[20px] [&>svg]:h-[20px]" />
</div>
{/* Info */}
<div className="min-w-0 flex-1">
<h3 className="font-mono text-[14px] font-bold text-[var(--color-text-primary)] group-hover:text-[var(--color-accent)] transition-colors leading-tight mb-1.5">
{formatName(component.name)}
</h3>
<p className="text-[12px] text-[var(--color-text-secondary)] line-clamp-2 leading-[1.6]">
{component.description || component.content?.slice(0, 120) || 'No description'}
</p>
<div className="flex items-center gap-2 mt-3">
{component.category && (
<span className="font-mono text-[10px] font-semibold px-2 py-0.5 rounded border border-[var(--color-border)] bg-[var(--color-surface-2)] text-[var(--color-text-tertiary)]">
{component.category}
</span>
)}
{(component.downloads ?? 0) > 0 && (
<span className="badge-download text-[10px]">
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2.5">
<path strokeLinecap="round" strokeLinejoin="round" d="M19 14l-7 7m0 0l-7-7m7 7V3" />
</svg>
{component.downloads?.toLocaleString()}
</span>
)}
</div>
</div>
{/* Action buttons */}
<div className="flex items-center gap-1.5 shrink-0">
<SaveToCollectionButton
componentType={component.type}
componentPath={component.path}
componentName={component.name}
componentCategory={component.category}
/>
<button
onClick={(e) => { e.stopPropagation(); toggleCart(component); }}
className={`w-8 h-8 rounded flex items-center justify-center transition-all duration-150 ${
inCart
? 'bg-[var(--color-accent)] text-white border border-[var(--color-accent)]'
: 'text-[var(--color-text-tertiary)] opacity-0 group-hover:opacity-100 hover:text-[var(--color-accent)] hover:bg-[var(--color-surface-2)] border border-transparent hover:border-[var(--color-border)]'
}`}
title={inCart ? 'Remove from stack' : 'Add to stack'}
aria-label={inCart ? 'Remove from stack' : 'Add to stack'}
>
{inCart ? (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="3">
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
) : (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
</svg>
)}
</button>
</div>
</div>
);
})}
</div>
{/* Empty state */}
{paged.length === 0 && !loading && (
<div className="flex flex-col items-center justify-center px-6 py-32">
{/* Animated illustration */}
<div className="relative mb-8 animate-float">
<div className="w-24 h-24 rounded-3xl bg-gradient-to-br from-[var(--color-surface-2)] to-[var(--color-surface-3)] border border-[var(--color-border)] flex items-center justify-center shadow-xl">
<svg className="w-12 h-12 text-[var(--color-text-tertiary)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="1.5">
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
{/* Decorative animated elements */}
<div className="absolute -top-2 -right-2 w-4 h-4 rounded-full bg-gradient-to-br from-[var(--color-accent-400)] to-[var(--color-accent-500)] opacity-80 animate-ping" style={{ animationDuration: '2s' }}></div>
<div className="absolute -top-2 -right-2 w-4 h-4 rounded-full bg-[var(--color-accent-400)] opacity-80"></div>
<div className="absolute -bottom-2 -left-2 w-3 h-3 rounded-full bg-gradient-to-br from-[var(--color-primary-400)] to-[var(--color-primary-500)] opacity-80 animate-ping" style={{ animationDuration: '2.5s', animationDelay: '0.5s' }}></div>
<div className="absolute -bottom-2 -left-2 w-3 h-3 rounded-full bg-[var(--color-primary-400)] opacity-80"></div>
{/* Glow effect */}
<div className="absolute inset-0 bg-gradient-to-br from-[var(--color-primary-500)]/10 to-[var(--color-accent-400)]/10 rounded-3xl blur-2xl -z-10"></div>
</div>
{/* Message */}
<h3 className="text-[17px] font-bold text-[var(--color-text-primary)] mb-3">
{search ? `No results for "${search}"` : 'No components found'}
</h3>
<p className="text-[14px] text-[var(--color-text-secondary)] max-w-md text-center mb-8 leading-relaxed">
{search
? "We couldn't find any components matching your search. Try different keywords or browse all categories."
: category !== 'all'
? "No components in this category yet. Try selecting a different category or browse all."
: "No components available at the moment. Check back soon!"}
</p>
{/* Actions */}
<div className="flex items-center gap-3">
{search && (
<button
onClick={() => setSearch('')}
className="px-5 py-2.5 text-[13px] font-semibold rounded-xl bg-[var(--color-surface-2)] text-[var(--color-text-primary)] hover:bg-[var(--color-surface-3)] border border-[var(--color-border)] hover:border-[var(--color-border-hover)] hover:shadow-lg transition-all active:scale-95"
>
Clear search
</button>
)}
{category !== 'all' && (
<button
onClick={() => setCategory('all')}
className="px-5 py-2.5 text-[13px] font-semibold rounded-xl bg-[var(--color-surface-2)] text-[var(--color-text-primary)] hover:bg-[var(--color-surface-3)] border border-[var(--color-border)] hover:border-[var(--color-border-hover)] hover:shadow-lg transition-all active:scale-95"
>
View all categories
</button>
)}
{!search && category === 'all' && (
<a
href="/"
className="px-5 py-2.5 text-[13px] font-semibold rounded-xl bg-gradient-to-r from-[var(--color-primary-500)] to-[var(--color-primary-600)] text-white hover:from-[var(--color-primary-600)] hover:to-[var(--color-primary-700)] shadow-lg hover:shadow-xl transition-all active:scale-95"
>
Browse all components
</a>
)}
</div>
</div>
)}
{/* Pagination - Premium style */}
{totalPages > 1 && (
<div className="flex items-center justify-center gap-3 px-6 pb-10 pt-4">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
className="group flex items-center gap-2 px-4 py-2.5 text-[13px] font-semibold rounded-xl text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-surface-2)] border border-[var(--color-border)] hover:border-[var(--color-border-hover)] disabled:opacity-30 disabled:cursor-not-allowed hover:shadow-md transition-all active:scale-95 disabled:active:scale-100 disabled:hover:shadow-none"
aria-label="Previous page"
>
<svg className="w-4 h-4 transition-transform group-hover:-translate-x-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2.5">
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg>
<span className="hidden sm:inline">Previous</span>
</button>
<div className="flex items-center gap-2 px-5 py-2.5 rounded-xl bg-[var(--color-surface-2)] border border-[var(--color-border)] shadow-sm">
<span className="text-[13px] font-bold text-[var(--color-text-primary)] tabular-nums">
{page}
</span>
<span className="text-[13px] text-[var(--color-text-tertiary)] font-medium">/</span>
<span className="text-[13px] font-semibold text-[var(--color-text-secondary)] tabular-nums">
{totalPages}
</span>
</div>
<button
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
className="group flex items-center gap-2 px-4 py-2.5 text-[13px] font-semibold rounded-xl text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-surface-2)] border border-[var(--color-border)] hover:border-[var(--color-border-hover)] disabled:opacity-30 disabled:cursor-not-allowed hover:shadow-md transition-all active:scale-95 disabled:active:scale-100 disabled:hover:shadow-none"
aria-label="Next page"
>
<span className="hidden sm:inline">Next</span>
<svg className="w-4 h-4 transition-transform group-hover:translate-x-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2.5">
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
)}
</div>
);
}
+188
View File
@@ -0,0 +1,188 @@
import { useId } from 'react';
interface CountryFlagProps {
code: string;
className?: string;
}
// SVG flag path data for top countries (mask applied via unique ID per instance)
const FLAG_DATA: Record<string, (maskId: string) => React.ReactNode> = {
US: (m) => (
<>
<mask id={m}><circle cx="256" cy="256" r="256" fill="#fff"/></mask>
<g mask={`url(#${m})`}>
<path fill="#eee" d="M0 0h512v512H0z"/>
<path fill="#d80027" d="M0 0h512v39.4H0zm0 78.8h512v39.4H0zm0 78.7h512v39.4H0zm0 78.8h512v39.4H0zm0 78.8h512v39.3H0zm0 78.7h512v39.4H0zm0 78.8h512V512H0z"/>
<path fill="#0052b4" d="M0 0h256v275.8H0z"/>
<path fill="#eee" d="m99.8 66.3 4.4 13.5H118l-11.5 8.4 4.4 13.5-11.4-8.3-11.5 8.3 4.4-13.5-11.4-8.4h14.1zm-55 0 4.3 13.5h14.2l-11.5 8.4 4.4 13.5-11.4-8.3-11.5 8.3 4.4-13.5-11.4-8.4h14.1zm110 0 4.4 13.5h14.1l-11.4 8.4 4.4 13.5-11.5-8.3-11.4 8.3 4.4-13.5-11.5-8.4h14.2zm-55 44.5 4.4 13.5h14.1l-11.4 8.3 4.4 13.5-11.5-8.3-11.4 8.3 4.4-13.5-11.5-8.3h14.2zm-55 0 4.3 13.5h14.2l-11.5 8.3 4.4 13.5-11.4-8.3-11.5 8.3 4.4-13.5-11.4-8.3h14.1zm110 0 4.4 13.5h14.1l-11.4 8.3 4.4 13.5-11.5-8.3-11.4 8.3 4.4-13.5-11.5-8.3h14.2zm-55 44.4 4.4 13.5h14.1l-11.4 8.4 4.4 13.5-11.5-8.3-11.4 8.3 4.4-13.5-11.5-8.4h14.2zm-55 0 4.3 13.5h14.2l-11.5 8.4 4.4 13.5-11.4-8.3-11.5 8.3 4.4-13.5-11.4-8.4h14.1zm110 0 4.4 13.5h14.1l-11.4 8.4 4.4 13.5-11.5-8.3-11.4 8.3 4.4-13.5-11.5-8.4h14.2zm-55 44.5 4.4 13.5h14.1l-11.4 8.3 4.4 13.5-11.5-8.3-11.4 8.3 4.4-13.5-11.5-8.3h14.2zm-55 0 4.3 13.5h14.2l-11.5 8.3 4.4 13.5-11.4-8.3-11.5 8.3 4.4-13.5-11.4-8.3h14.1zm110 0 4.4 13.5h14.1l-11.4 8.3 4.4 13.5-11.5-8.3-11.4 8.3 4.4-13.5-11.5-8.3h14.2zm-55 44.5 4.4 13.5h14.1l-11.4 8.3 4.4 13.5-11.5-8.3-11.4 8.3 4.4-13.5-11.5-8.3h14.2zm-55 0 4.3 13.5h14.2l-11.5 8.3 4.4 13.5-11.4-8.3-11.5 8.3 4.4-13.5-11.4-8.3h14.1zm110 0 4.4 13.5h14.1l-11.4 8.3 4.4 13.5-11.5-8.3-11.4 8.3 4.4-13.5-11.5-8.3h14.2z"/>
</g>
</>
),
KR: (m) => (
<>
<mask id={m}><circle cx="256" cy="256" r="256" fill="#fff"/></mask>
<g mask={`url(#${m})`}>
<path fill="#eee" d="M0 0h512v512H0z"/>
<circle cx="256" cy="256" r="111.3" fill="#d80027"/>
<path fill="#0052b4" d="M345.3 256a89.3 89.3 0 0 1-89.3 89.3 111.3 111.3 0 0 0 0-178.6 89.3 89.3 0 0 1 89.3 89.3z"/>
<path fill="#333" d="m350.4 334.7 23.4 17-8.9 28.8 23.4-17 23.4 17-9-28.8 23.5-17h-29l-8.9-28.8-8.9 28.8zm-34.3-22.3-8.9 28.8 23.4-17 23.4 17-8.9-28.8 23.4-17h-29l-8.9-28.8-8.9 28.8h-28.9zm22.3 133.9 8.9-28.8h29l-23.4-17 8.9-28.8-23.4 17-23.4-17 8.9 28.8-23.4 17h29zm-34.3-22.3 23.4-17-8.9-28.8 23.4 17 23.4-17-8.9 28.8 23.4 17h-29l-8.9 28.8-8.9-28.8zM127.7 88.8l23.4 17-8.9 28.8 23.4-17 23.4 17-8.9-28.8 23.4-17h-29l-8.9-28.8-8.9 28.8zm-34.3-22.3-8.9 28.8 23.4-17 23.4 17-8.9-28.8 23.4-17h-29l-8.9-28.8-8.9 28.8H70.1zm22.3 133.9 8.9-28.8h29l-23.4-17 8.9-28.8-23.4 17-23.4-17 8.9 28.8-23.4 17h29zm-34.3-22.3 23.4-17-8.9-28.8 23.4 17 23.4-17-8.9 28.8 23.4 17h-29l-8.9 28.8-8.9-28.8z"/>
</g>
</>
),
BR: (m) => (
<>
<mask id={m}><circle cx="256" cy="256" r="256" fill="#fff"/></mask>
<g mask={`url(#${m})`}>
<path fill="#6da544" d="M0 0h512v512H0z"/>
<path fill="#ffda44" d="M256 100.2 467.5 256 256 411.8 44.5 256z"/>
<circle cx="256" cy="256" r="89" fill="#0052b4"/>
<path fill="#eee" d="M211.2 250.1c-5.4 0-10.8.4-16 1.3 0-2.7-.1-5.4-.1-8.2 0-53.2 18.9-101.3 50.2-138.6-79.6 4.4-143 71-143 152.6 0 84.4 68.5 152.9 152.9 152.9 39.5 0 75.6-15 102.8-39.7-27.8 22.8-63.3 36.5-101.9 36.5-88.4 0-160.2-71.7-160.2-160.1 0-41.2 15.6-78.8 41.3-107.3z"/>
</g>
</>
),
CN: (m) => (
<>
<mask id={m}><circle cx="256" cy="256" r="256" fill="#fff"/></mask>
<g mask={`url(#${m})`}>
<path fill="#d80027" d="M0 0h512v512H0z"/>
<path fill="#ffda44" d="m226.8 157.2 12 37h38.9l-31.5 22.9 12 37-31.4-22.9-31.5 22.9 12-37-31.4-22.9h38.9zm87.1 38.5 3.9 11.9 12.5.1-10.1 7.4 3.8 11.9-10.1-7.3-10.1 7.3 3.9-11.9-10.1-7.4 12.4-.1zm13.6 53.3 11.8 4.3 7.6-9.7-.5 12.5 11.9 4.2-12.3 2-.5 12.4-4.9-11.4-12.4 2 7.7-9.7zm-44.8 52.7 9.4 8.1 10.2-6.5-5.1 11.3 9.4 8-12.4-.9-5.2 11.2-1.1-12.4-12.4-.9 10.2-6.5zm-46.3-29.3 4.3 11.8 9.7 7.6-12.5-.5-4.2 11.9-2-12.3-12.4-.5 11.4-4.9-2-12.4 9.7 7.7z"/>
</g>
</>
),
ES: (m) => (
<>
<mask id={m}><circle cx="256" cy="256" r="256" fill="#fff"/></mask>
<g mask={`url(#${m})`}>
<path fill="#d80027" d="M0 0h512v128l-39.8 130.3L512 384v128H0V384l37.1-124L0 128z"/>
<path fill="#ffda44" d="M0 128h512v256H0z"/>
</g>
</>
),
GB: (m) => (
<>
<mask id={m}><circle cx="256" cy="256" r="256" fill="#fff"/></mask>
<g mask={`url(#${m})`}>
<path fill="#0052b4" d="M0 0h512v512H0z"/>
<path fill="#eee" d="M512 0v64L322 256l190 192v64h-64L258 320 68 512H0v-64l190-192L0 64V0h64l190 192L444 0z"/>
<path fill="#d80027" d="M0 0v32l160 160h32L0 0zm352 192 160 160v-32L384 192h-32zM0 512h32l160-160v-32L0 512zm352-192 160 160h-32L320 320v32z"/>
<path fill="#eee" d="M176 0v512h160V0H176zM0 176v160h512V176H0z"/>
<path fill="#d80027" d="M0 208v96h512v-96H0zM208 0v512h96V0h-96z"/>
</g>
</>
),
DE: (m) => (
<>
<mask id={m}><circle cx="256" cy="256" r="256" fill="#fff"/></mask>
<g mask={`url(#${m})`}>
<path fill="#333" d="M0 0h512v170.7H0z"/>
<path fill="#d80027" d="M0 170.7h512v170.6H0z"/>
<path fill="#ffda44" d="M0 341.3h512V512H0z"/>
</g>
</>
),
FR: (m) => (
<>
<mask id={m}><circle cx="256" cy="256" r="256" fill="#fff"/></mask>
<g mask={`url(#${m})`}>
<path fill="#eee" d="M167 0h178l25.9 252.3L345 512H167l-29.8-253.4z"/>
<path fill="#0052b4" d="M0 0h167v512H0z"/>
<path fill="#d80027" d="M345 0h167v512H345z"/>
</g>
</>
),
IN: (m) => (
<>
<mask id={m}><circle cx="256" cy="256" r="256" fill="#fff"/></mask>
<g mask={`url(#${m})`}>
<path fill="#eee" d="M0 167l253.8-19.3L512 167v178l-254.9 32.3L0 345z"/>
<path fill="#ff9811" d="M0 0h512v167H0z"/>
<path fill="#6da544" d="M0 345h512v167H0z"/>
<circle cx="256" cy="256" r="89" fill="#0052b4"/>
<circle cx="256" cy="256" r="55.7" fill="#eee"/>
<circle cx="256" cy="256" r="22.3" fill="#0052b4"/>
</g>
</>
),
JP: (m) => (
<>
<mask id={m}><circle cx="256" cy="256" r="256" fill="#fff"/></mask>
<g mask={`url(#${m})`}>
<path fill="#eee" d="M0 0h512v512H0z"/>
<circle cx="256" cy="256" r="111.3" fill="#d80027"/>
</g>
</>
),
CA: (m) => (
<>
<mask id={m}><circle cx="256" cy="256" r="256" fill="#fff"/></mask>
<g mask={`url(#${m})`}>
<path fill="#eee" d="M144.7 0h222.6v512H144.7z"/>
<path fill="#d80027" d="M0 0h144.7v512H0zm367.3 0H512v512H367.3z"/>
<path fill="#d80027" d="m300.5 289.4-16.7-4.9-16.6 5 6.2-16.4-10.8-13.6 17.6-.4 6.9-16.1 6.9 16 17.6.5-10.8 13.6zm-89 0 16.7-4.9 16.6 5-6.2-16.4 10.8-13.6-17.6-.4-6.9-16.1-6.9 16-17.6.5 10.8 13.6zm44.5-50.5 4.9 16.7-5 16.6 16.4-6.2 13.6 10.8.4-17.6 16.1-6.9-16-6.9-.5-17.6-13.6 10.8zm0 122.2-4.9-16.7 5-16.6-16.4 6.2-13.6-10.8-.4 17.6-16.1 6.9 16 6.9.5 17.6 13.6-10.8zm-44.5-27.8 16.7 5 16.6-5-6.2 16.4 10.8 13.6-17.6.4-6.9 16.1-6.9-16-17.6-.5 10.8-13.6zm89 0-16.7 5-16.6-5 6.2 16.4-10.8 13.6 17.6.4 6.9 16.1 6.9-16 17.6-.5-10.8-13.6z"/>
</g>
</>
),
AU: (m) => (
<>
<mask id={m}><circle cx="256" cy="256" r="256" fill="#fff"/></mask>
<g mask={`url(#${m})`}>
<path fill="#0052b4" d="M0 0h512v512H0z"/>
<path fill="#eee" d="m154 300.5 8.3 25.5h26.8l-21.7 15.7 8.3 25.5-21.7-15.8-21.7 15.8 8.3-25.5-21.7-15.7h26.8zm206.6-89 4.2 12.7h13.4L367.4 232l4.1 12.8-10.8-7.9-10.9 7.9 4.2-12.8-10.9-7.9h13.4zm0 155.8 4.2 12.8h13.4l-10.8 7.8 4.1 12.8-10.8-7.9-10.9 7.9 4.2-12.8-10.9-7.8h13.4zm-44.5-66.9 4.2 12.8h13.4l-10.8 7.8 4.1 12.8-10.8-7.9-10.9 7.9 4.2-12.8-10.9-7.8h13.4zm88.9 0 4.2 12.8H423l-10.8 7.8 4.1 12.8-10.8-7.9-10.9 7.9 4.2-12.8-10.9-7.8h13.4z"/>
<path fill="#0052b4" d="M256 0v32l-45.3 32H256v64h-46.9L256 154.7V192h-22.1L160 134v58H96v-58l-73.9 58H0v-37.3L54.9 128H0V64h54.9L0 37.3V0h22.1L96 58V0h64v58l73.9-58H256z"/>
<path fill="#eee" d="M144 0v69.3L32 0H0v32l112 80v80h32v-80l112-80V0h-32l-112 69.3V0h-32z"/>
<path fill="#d80027" d="M0 0v15.1L138.7 96H160v-15.1L21.3 0H0zM160 0v15.1L21.3 96H0v-15.1L138.7 0H160z"/>
</g>
</>
),
IT: (m) => (
<>
<mask id={m}><circle cx="256" cy="256" r="256" fill="#fff"/></mask>
<g mask={`url(#${m})`}>
<path fill="#eee" d="M167 0h178l25.9 252.3L345 512H167l-29.8-253.4z"/>
<path fill="#6da544" d="M0 0h167v512H0z"/>
<path fill="#d80027" d="M345 0h167v512H345z"/>
</g>
</>
),
TR: (m) => (
<>
<mask id={m}><circle cx="256" cy="256" r="256" fill="#fff"/></mask>
<g mask={`url(#${m})`}>
<path fill="#d80027" d="M0 0h512v512H0z"/>
<path fill="#eee" d="M199.2 155.4a100.6 100.6 0 1 0 21.6 198.3 122.8 122.8 0 1 1 0-197 100.3 100.3 0 0 0-21.6-1.3z"/>
<path fill="#eee" d="m294.3 202.5 8 34.7 34.8-3.9-27.9 21.8 17.9 29.8-32.2-13.9-13 33 -4.9-35-34.8 4.9 27.9-22.7-17.9-28.8 32.2 12.9z"/>
</g>
</>
),
MX: (m) => (
<>
<mask id={m}><circle cx="256" cy="256" r="256" fill="#fff"/></mask>
<g mask={`url(#${m})`}>
<path fill="#eee" d="M167 0h178l25.9 252.3L345 512H167l-29.8-253.4z"/>
<path fill="#6da544" d="M0 0h167v512H0z"/>
<path fill="#d80027" d="M345 0h167v512H345z"/>
<path fill="#6da544" d="M208 256c0-26.5 21.5-48 48-48s48 21.5 48 48-21.5 48-48 48-48-21.5-48-48z"/>
</g>
</>
),
};
export default function CountryFlag({ code, className }: CountryFlagProps) {
const id = useId();
const maskId = `flag-${code}-${id}`;
const renderFlag = FLAG_DATA[code];
if (!renderFlag) {
return (
<svg className={className || "w-6 h-6"} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
);
}
return (
<svg className={className || "w-6 h-6"} viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
{renderFlag(maskId)}
</svg>
);
}
@@ -0,0 +1,28 @@
---
import type { FeaturedItem } from '../lib/types';
interface Props {
item: FeaturedItem;
}
const { item } = Astro.props;
---
<a
href={item.url}
class="group flex items-center gap-3 p-4 bg-[var(--color-card-bg)] rounded-md border border-[var(--color-border)] hover:bg-[var(--color-card-hover)] hover:border-[var(--color-accent)] transition-all duration-150"
>
<div class="w-11 h-11 rounded-sm bg-[var(--color-surface-2)] border border-[var(--color-border)] flex items-center justify-center shrink-0 p-2">
<img src={item.logo} alt={item.name} class="w-full h-full object-contain" loading="lazy" onerror="this.style.display='none'" />
</div>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span class="font-ui-mono text-[13px] font-bold text-[var(--color-text-primary)] group-hover:text-[var(--color-accent)] transition-colors">{item.name}</span>
<span class="font-ui-mono text-[10px] font-medium px-2 py-0.5 rounded border border-[var(--color-border)] text-[var(--color-text-tertiary)] bg-[var(--color-surface-2)]">
{item.tag}
</span>
</div>
<p class="text-[12px] text-[var(--color-text-secondary)] mt-1 leading-[1.5]">{item.description}</p>
</div>
<span class="terminal-prompt text-[12px] opacity-0 group-hover:opacity-100 transition-opacity shrink-0"></span>
</a>
@@ -0,0 +1,32 @@
---
import FeaturedCard from './FeaturedCard.astro';
import { FEATURED_ITEMS } from '../lib/constants';
const gridColsClass = FEATURED_ITEMS.length % 2 === 0
? 'sm:grid-cols-2'
: 'sm:grid-cols-2 lg:grid-cols-3';
---
<section class="px-6 py-4 mb-2">
<div class="bg-[var(--color-surface-1)] rounded-md p-5 border border-[var(--color-border)]">
<div class="flex flex-wrap items-center justify-between gap-2 mb-4">
<div class="flex items-center gap-2">
<span class="terminal-prompt text-[13px]">★</span>
<h2 class="font-ui-mono text-[13px] font-bold text-[var(--color-text-primary)] tracking-wide uppercase">Featured Integrations</h2>
</div>
<a
href="https://docs.google.com/forms/d/e/1FAIpQLSd7uyNot_YG4vMrt8jJuWiCfb2HNHjGcCZEoVS07SU8OCc-yA/viewform"
target="_blank"
rel="noopener noreferrer"
class="w-full sm:w-auto text-center shrink-0 font-ui-mono text-[12px] font-bold text-[var(--color-accent)] border border-[var(--color-accent)] rounded-md px-3 py-1.5 hover:bg-[var(--color-accent)] hover:text-[var(--color-surface-1)] transition-colors"
>
Promote your component →
</a>
</div>
<div class={`grid grid-cols-1 ${gridColsClass} gap-3`}>
{FEATURED_ITEMS.map((item) => (
<FeaturedCard item={item} />
))}
</div>
</div>
</section>
@@ -0,0 +1,200 @@
import { useState, useMemo } from 'react';
interface FileTreeSidebarProps {
references: string[];
skillName: string;
}
interface TreeNode {
name: string;
path: string;
isFile: boolean;
children: TreeNode[];
ext?: string;
}
const EXT_COLORS: Record<string, string> = {
md: '#60a5fa', js: '#facc15', ts: '#60a5fa', tsx: '#60a5fa', jsx: '#facc15',
py: '#4ade80', html: '#fb923c', json: '#fde047', txt: 'var(--color-text-tertiary)',
sh: '#86efac', yml: '#f472b6', yaml: '#f472b6', css: '#c084fc', toml: '#f472b6',
};
function buildTree(paths: string[]): TreeNode[] {
const root: TreeNode[] = [];
for (const p of paths) {
const parts = p.split('/');
let current = root;
for (let i = 0; i < parts.length; i++) {
const name = parts[i];
const isFile = i === parts.length - 1;
const fullPath = parts.slice(0, i + 1).join('/');
let existing = current.find((n) => n.name === name && n.isFile === isFile);
if (!existing) {
const ext = isFile ? name.split('.').pop()?.toLowerCase() : undefined;
existing = { name, path: fullPath, isFile, children: [], ext };
current.push(existing);
}
current = existing.children;
}
}
// Sort: folders first, then files, alphabetically
function sortNodes(nodes: TreeNode[]) {
nodes.sort((a, b) => {
if (a.isFile !== b.isFile) return a.isFile ? 1 : -1;
return a.name.localeCompare(b.name);
});
for (const n of nodes) {
if (!n.isFile) sortNodes(n.children);
}
}
sortNodes(root);
return root;
}
function countFiles(nodes: TreeNode[]): number {
let count = 0;
for (const n of nodes) {
if (n.isFile) count++;
else count += countFiles(n.children);
}
return count;
}
export default function FileTreeSidebar({ references, skillName }: FileTreeSidebarProps) {
const tree = useMemo(() => buildTree(references), [references]);
const [search, setSearch] = useState('');
const filteredRefs = useMemo(() => {
if (!search.trim()) return null;
const q = search.toLowerCase();
return references.filter((r) => r.toLowerCase().includes(q));
}, [search, references]);
const filteredTree = useMemo(() => {
if (!filteredRefs) return tree;
return buildTree(filteredRefs);
}, [filteredRefs, tree]);
return (
<div className="w-64 shrink-0 bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded-lg overflow-hidden flex flex-col" style={{ maxHeight: 'calc(100vh - 8rem)' }}>
{/* Header */}
<div className="px-3 py-2.5 border-b border-[var(--color-border)] flex items-center justify-between">
<div className="flex items-center gap-2 min-w-0">
<svg className="w-4 h-4 text-[var(--color-text-tertiary)] shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 12.75V12A2.25 2.25 0 014.5 9.75h15A2.25 2.25 0 0121.75 12v.75m-8.69-6.44l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-5.379a1.5 1.5 0 01-1.06-.44z" />
</svg>
<span className="text-xs font-semibold text-[var(--color-text-primary)] truncate">{skillName}</span>
</div>
<span className="text-[10px] text-[var(--color-text-tertiary)] shrink-0">{references.length} files</span>
</div>
{/* Search */}
<div className="px-2 py-1.5 border-b border-[var(--color-border)]">
<div className="relative">
<svg className="absolute left-2 top-1/2 -translate-y-1/2 w-3 h-3 text-[var(--color-text-tertiary)] pointer-events-none" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
</svg>
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Filter files..."
className="w-full bg-[var(--color-surface-3)] border-none rounded pl-7 pr-2 py-1 text-[11px] text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] focus:outline-none focus:ring-1 focus:ring-accent-400/30"
/>
{search && (
<button
onClick={() => setSearch('')}
className="absolute right-1.5 top-1/2 -translate-y-1/2 text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)]"
>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
</div>
{/* Tree */}
<div className="flex-1 overflow-y-auto py-1" style={{ scrollbarWidth: 'thin' }}>
{filteredTree.length === 0 && search ? (
<div className="px-3 py-4 text-center text-[11px] text-[var(--color-text-tertiary)]">No matching files</div>
) : (
<TreeNodes nodes={filteredTree} depth={0} forceOpen={!!search} />
)}
</div>
</div>
);
}
function TreeNodes({ nodes, depth, forceOpen }: { nodes: TreeNode[]; depth: number; forceOpen?: boolean }) {
return (
<>
{nodes.map((node) =>
node.isFile ? (
<FileNode key={node.path} node={node} depth={depth} />
) : (
<FolderNode key={node.path} node={node} depth={depth} forceOpen={forceOpen} />
)
)}
</>
);
}
function FolderNode({ node, depth, forceOpen }: { node: TreeNode; depth: number; forceOpen?: boolean }) {
const [open, setOpen] = useState(forceOpen || depth < 1);
const fileCount = useMemo(() => countFiles(node.children), [node.children]);
// If forceOpen changes to true, open the folder
const isOpen = forceOpen || open;
return (
<div>
<button
onClick={() => setOpen(!open)}
className="w-full flex items-center gap-1 py-[3px] pr-2 hover:bg-white/[0.04] transition-colors group/folder"
style={{ paddingLeft: `${depth * 12 + 8}px` }}
>
<svg
className={`w-3 h-3 text-[var(--color-text-tertiary)] shrink-0 transition-transform duration-100 ${isOpen ? 'rotate-90' : ''}`}
fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
<svg className="w-3.5 h-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke={isOpen ? '#facc15' : 'var(--color-text-tertiary)'} strokeWidth={1.5}>
{isOpen ? (
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 9.776c.112-.017.227-.026.344-.026h15.812c.117 0 .232.009.344.026m-16.5 0a2.25 2.25 0 00-1.883 2.542l.857 6a2.25 2.25 0 002.227 1.932H19.05a2.25 2.25 0 002.227-1.932l.857-6a2.25 2.25 0 00-1.883-2.542m-16.5 0V6A2.25 2.25 0 016 3.75h3.879a1.5 1.5 0 011.06.44l2.122 2.12a1.5 1.5 0 001.06.44H18A2.25 2.25 0 0120.25 9v.776" />
) : (
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 12.75V12A2.25 2.25 0 014.5 9.75h15A2.25 2.25 0 0121.75 12v.75m-8.69-6.44l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-5.379a1.5 1.5 0 01-1.06-.44z" />
)}
</svg>
<span className="text-[11px] text-[var(--color-text-secondary)] truncate">{node.name}</span>
<span className="text-[9px] text-[var(--color-text-tertiary)] ml-auto opacity-0 group-hover/folder:opacity-100 transition-opacity">{fileCount}</span>
</button>
{isOpen && <TreeNodes nodes={node.children} depth={depth + 1} forceOpen={forceOpen} />}
</div>
);
}
function FileNode({ node, depth }: { node: TreeNode; depth: number }) {
const color = EXT_COLORS[node.ext ?? ''] ?? 'var(--color-text-tertiary)';
return (
<div
className="flex items-center gap-1.5 py-[3px] pr-2 hover:bg-white/[0.04] cursor-default transition-colors"
style={{ paddingLeft: `${depth * 12 + 20}px` }}
title={node.path}
>
<svg className="w-3.5 h-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke={color} strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
</svg>
<span className="text-[11px] text-[var(--color-text-primary)] truncate font-mono">{node.name}</span>
{node.ext && (
<span className="text-[8px] ml-auto shrink-0 font-mono uppercase" style={{ color }}>{node.ext}</span>
)}
</div>
);
}
+264
View File
@@ -0,0 +1,264 @@
import { useState, useEffect } from 'react';
interface Job {
id: string;
company: string;
position: string;
location: string;
remote: boolean;
salary: string;
applyUrl: string;
source: string;
tags: string[];
companyIcon: string;
}
interface JobsData {
totalJobs: number;
jobs: Job[];
}
const SOURCE_COLORS: Record<string, string> = {
HackerNews: 'bg-orange-500/15 text-orange-400 border-orange-500/20',
RemoteOK: 'bg-blue-500/15 text-blue-400 border-blue-500/20',
WeWorkRemotely: 'bg-emerald-500/15 text-emerald-400 border-emerald-500/20',
Anthropic: 'bg-purple-500/15 text-purple-400 border-purple-500/20',
};
function useGlobalAuth() {
const [state, setState] = useState<{ isSignedIn: boolean; isLoaded: boolean }>({
isSignedIn: false, isLoaded: false,
});
useEffect(() => {
function check() {
const clerk = (window as any).Clerk;
if (clerk?.loaded) {
const signedIn = !!clerk.user;
setState((prev) => {
if (prev.isLoaded && prev.isSignedIn === signedIn) return prev;
return { isSignedIn: signedIn, isLoaded: true };
});
}
}
check();
const interval = setInterval(check, 500);
const handleChange = () => check();
window.addEventListener('clerk:session', handleChange);
return () => { clearInterval(interval); window.removeEventListener('clerk:session', handleChange); };
}, []);
return state;
}
function safeUrl(url: string): string {
return /^https?:\/\//i.test(url) ? url : '#';
}
function getCompanyLogoUrl(companyName: string, existingIcon?: string): string {
// If icon exists in data, use it
if (existingIcon) return existingIcon;
// Otherwise, try to fetch from Clearbit Logo API
const domain = companyName.toLowerCase()
.replace(/\s+/g, '')
.replace(/[^a-z0-9]/g, '');
return `https://logo.clearbit.com/${domain}.com`;
}
interface Props {
variant?: 'preview' | 'sidebar';
}
export default function JobsPreview({ variant = 'preview' }: Props) {
const [data, setData] = useState<JobsData | null>(null);
const { isSignedIn, isLoaded } = useGlobalAuth();
useEffect(() => {
fetch('/claude-jobs.json')
.then((r) => r.json())
.then((d) => setData(d))
.catch(() => {});
}, []);
if (!data || data.jobs.length === 0) return null;
const jobs = variant === 'sidebar' ? data.jobs.slice(0, 12) : data.jobs.slice(0, 5);
const showAuthGate = isLoaded && !isSignedIn;
function handleJobClick(e: React.MouseEvent) {
if (!isLoaded) { e.preventDefault(); return; }
if (!isSignedIn) {
e.preventDefault();
(window as any).Clerk?.openSignIn?.();
}
}
if (variant === 'sidebar') {
return (
<div className="flex flex-col h-full">
{/* Sticky header */}
<div className="px-4 py-3 border-b border-[var(--color-border)] bg-[var(--color-surface-0)] sticky top-0 z-10">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="font-mono text-[11px] font-bold text-[var(--color-text-primary)] uppercase tracking-wider">Jobs</span>
<span className="font-mono text-[9px] font-medium bg-orange-500/15 text-orange-400 px-1.5 py-0.5 rounded border border-orange-500/20">
{data.totalJobs}
</span>
</div>
<a
href="/jobs"
className="font-mono text-[10px] text-[var(--color-text-tertiary)] hover:text-[var(--color-accent)] transition-colors"
>
view all
</a>
</div>
<p className="font-mono text-[9px] text-[var(--color-text-tertiary)] mt-0.5">Using Claude Code</p>
</div>
{/* Vertical job list */}
<div className="flex-1 overflow-y-auto px-3 py-2 space-y-1.5">
{jobs.map((job) => (
<a
key={job.id}
href={isSignedIn ? safeUrl(job.applyUrl) : '#'}
target={isSignedIn ? '_blank' : undefined}
rel={isSignedIn ? 'noopener noreferrer' : undefined}
onClick={showAuthGate ? handleJobClick : undefined}
className="flex items-start gap-2.5 p-2.5 rounded border border-[var(--color-border)] hover:border-[var(--color-accent)] bg-[var(--color-card-bg)] hover:bg-[var(--color-card-hover)] transition-all group relative overflow-hidden block"
>
{showAuthGate && (
<div className="absolute inset-0 bg-[var(--color-surface-0)]/80 backdrop-blur-[1px] flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
<span className="font-mono text-[9px] font-medium text-[var(--color-text-secondary)] bg-[var(--color-surface-2)] px-2 py-1 rounded border border-[var(--color-border)]">
Sign in to view
</span>
</div>
)}
{/* Logo */}
<div className="w-6 h-6 rounded shrink-0 bg-[var(--color-surface-2)] border border-[var(--color-border)] flex items-center justify-center overflow-hidden mt-0.5">
<img
src={getCompanyLogoUrl(job.company, job.companyIcon)}
alt={job.company}
className="w-3.5 h-3.5 object-contain"
onError={(e) => {
const t = e.target as HTMLImageElement;
t.style.display = 'none';
const fb = t.nextElementSibling as HTMLElement;
if (fb) fb.style.display = 'flex';
}}
/>
<span className="text-[9px] font-bold text-[var(--color-text-tertiary)]" style={{ display: 'none' }}>
{job.company.charAt(0).toUpperCase()}
</span>
</div>
{/* Info */}
<div className="min-w-0 flex-1">
<p className="font-mono text-[10px] font-bold text-[var(--color-text-primary)] group-hover:text-[var(--color-accent)] transition-colors line-clamp-2 leading-tight mb-1">
{job.position}
</p>
<p className="font-mono text-[9px] text-[var(--color-text-tertiary)] truncate mb-1.5">{job.company}</p>
<div className="flex items-center gap-1 flex-wrap">
{job.remote && (
<span className="text-[8px] font-medium bg-blue-500/15 text-blue-400 px-1 py-0.5 rounded border border-blue-500/20">Remote</span>
)}
{job.salary && (
<span className="text-[8px] font-medium bg-emerald-500/15 text-emerald-400 px-1 py-0.5 rounded border border-emerald-500/20">{job.salary}</span>
)}
<span className={`text-[8px] font-medium px-1 py-0.5 rounded border ${SOURCE_COLORS[job.source] || 'bg-[var(--color-surface-3)] text-[var(--color-text-secondary)] border-[var(--color-border)]'}`}>
{job.source}
</span>
</div>
</div>
</a>
))}
</div>
</div>
);
}
return (
<section className="px-6 py-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<h2 className="text-sm font-semibold text-[var(--color-text-primary)]">Jobs Using Claude Code</h2>
<span className="text-[10px] font-medium bg-orange-500/15 text-orange-400 px-1.5 py-0.5 rounded">
{data.totalJobs} jobs
</span>
</div>
<a
href="/jobs"
className="text-[12px] text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)] transition-colors"
>
View all &rarr;
</a>
</div>
<div className="grid grid-cols-1 md:grid-cols-5 gap-3">
{jobs.map((job) => (
<a
key={job.id}
href={isSignedIn ? safeUrl(job.applyUrl) : '#'}
target={isSignedIn ? '_blank' : undefined}
rel={isSignedIn ? 'noopener noreferrer' : undefined}
onClick={showAuthGate ? handleJobClick : undefined}
className="bg-[var(--color-card-bg)] border border-[var(--color-border)] rounded-lg p-3.5 hover:border-[var(--color-border-hover)] hover:shadow-md transition-all group relative overflow-hidden"
>
{showAuthGate && (
<div className="absolute inset-0 bg-gradient-to-t from-[var(--color-surface-0)]/80 to-transparent backdrop-blur-[1px] flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
<div className="flex items-center gap-1.5 text-[11px] font-medium text-[var(--color-text-primary)] bg-[var(--color-surface-2)] px-3 py-1.5 rounded-full border border-[var(--color-border)]">
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0110 0v4" />
</svg>
Sign in to view
</div>
</div>
)}
<div className="flex items-center gap-2 mb-2.5">
<div className="w-7 h-7 rounded bg-[var(--color-surface-2)] border border-[var(--color-border)] flex items-center justify-center shrink-0 overflow-hidden">
<img
src={getCompanyLogoUrl(job.company, job.companyIcon)}
alt={job.company}
className="w-4 h-4 object-contain"
onError={(e) => {
const target = e.target as HTMLImageElement;
target.style.display = 'none';
const fallback = target.nextElementSibling as HTMLElement;
if (fallback) fallback.style.display = 'flex';
}}
/>
<span className="text-[10px] font-bold text-[var(--color-text-tertiary)]" style={{ display: 'none' }}>
{job.company.charAt(0).toUpperCase()}
</span>
</div>
<span className="text-[11px] font-medium text-[var(--color-text-tertiary)] truncate">{job.company}</span>
</div>
<h3 className="text-[12px] font-medium text-[var(--color-text-primary)] group-hover:text-[var(--color-text-primary)] transition-colors line-clamp-2 leading-tight mb-2.5 min-h-[2.5rem]">
{job.position}
</h3>
<div className="flex items-center gap-1.5 flex-wrap">
{job.remote && (
<span className="text-[9px] font-medium bg-blue-500/15 text-blue-400 px-1.5 py-0.5 rounded border border-blue-500/20">
Remote
</span>
)}
{job.salary && (
<span className="text-[9px] font-medium bg-emerald-500/15 text-emerald-400 px-1.5 py-0.5 rounded border border-emerald-500/20">
{job.salary}
</span>
)}
<span className={`text-[9px] font-medium px-1.5 py-0.5 rounded border ${SOURCE_COLORS[job.source] || 'bg-[var(--color-surface-3)] text-[var(--color-text-secondary)] border-[var(--color-border)]'}`}>
{job.source}
</span>
</div>
</a>
))}
</div>
</section>
);
}
+750
View File
@@ -0,0 +1,750 @@
import { useState, useEffect, useMemo } from 'react';
interface Job {
id: string;
company: string;
position: string;
location: string;
remote: boolean;
salary: string;
description: string;
applyUrl: string;
source: string;
sourceUrl: string;
postedAt: string;
tags: string[];
companyIcon: string;
}
interface JobsData {
lastUpdated: string;
totalJobs: number;
sources: string[];
jobs: Job[];
}
type LocationFilter = 'all' | 'remote' | 'onsite';
type SortOption = 'recent' | 'company' | 'salary';
const SOURCE_COLORS: Record<string, string> = {
HackerNews: 'bg-orange-500/15 text-orange-400 border-orange-500/20',
RemoteOK: 'bg-blue-500/15 text-blue-400 border-blue-500/20',
WeWorkRemotely: 'bg-emerald-500/15 text-emerald-400 border-emerald-500/20',
Anthropic: 'bg-purple-500/15 text-purple-400 border-purple-500/20',
};
function useGlobalAuth() {
const [state, setState] = useState<{ isSignedIn: boolean; isLoaded: boolean }>({
isSignedIn: false, isLoaded: false,
});
useEffect(() => {
function check() {
const clerk = (window as any).Clerk;
if (clerk?.loaded) {
const signedIn = !!clerk.user;
setState((prev) => {
if (prev.isLoaded && prev.isSignedIn === signedIn) return prev;
return { isSignedIn: signedIn, isLoaded: true };
});
}
}
check();
const interval = setInterval(check, 500);
const handleChange = () => check();
window.addEventListener('clerk:session', handleChange);
return () => { clearInterval(interval); window.removeEventListener('clerk:session', handleChange); };
}, []);
return state;
}
function timeAgo(dateStr: string): string {
if (!dateStr) return '';
try {
const date = new Date(dateStr);
if (isNaN(date.getTime())) return '';
const now = new Date();
const days = Math.floor((now.getTime() - date.getTime()) / 86400000);
if (days < 0 || isNaN(days)) return '';
if (days === 0) return 'Today';
if (days === 1) return '1d ago';
if (days < 30) return `${days}d ago`;
if (days < 60) return '1mo ago';
return `${Math.floor(days / 30)}mo ago`;
} catch {
return '';
}
}
function safeUrl(url: string): string {
return /^https?:\/\//i.test(url) ? url : '#';
}
export default function JobsView() {
const [data, setData] = useState<JobsData | null>(null);
const [loading, setLoading] = useState(true);
const [locationFilter, setLocationFilter] = useState<LocationFilter>('all');
const [sourceFilter, setSourceFilter] = useState<string>('all');
const [companyFilter, setCompanyFilter] = useState<string>('all');
const [selectedTag, setSelectedTag] = useState<string>('all');
const [sortBy, setSortBy] = useState<SortOption>('recent');
const [searchQuery, setSearchQuery] = useState('');
const { isSignedIn, isLoaded } = useGlobalAuth();
useEffect(() => {
fetch('/claude-jobs.json')
.then((r) => r.json())
.then((d) => { setData(d); setLoading(false); })
.catch(() => setLoading(false));
}, []);
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
const target = e.target as HTMLElement;
if (!target.closest('.company-dropdown')) {
setIsCompanyDropdownOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const filteredJobs = useMemo(() => {
if (!data) return [];
let filtered = data.jobs.filter((job) => {
if (locationFilter === 'remote' && !job.remote) return false;
if (locationFilter === 'onsite' && job.remote) return false;
if (sourceFilter !== 'all' && job.source !== sourceFilter) return false;
if (companyFilter !== 'all' && job.company !== companyFilter) return false;
if (selectedTag !== 'all' && !job.tags.includes(selectedTag)) return false;
if (searchQuery) {
const q = searchQuery.toLowerCase();
const text = `${job.company} ${job.position} ${job.location} ${job.tags.join(' ')}`.toLowerCase();
if (!text.includes(q)) return false;
}
return true;
});
// Sort jobs
filtered.sort((a, b) => {
if (sortBy === 'recent') {
return new Date(b.postedAt || 0).getTime() - new Date(a.postedAt || 0).getTime();
} else if (sortBy === 'company') {
return a.company.localeCompare(b.company);
} else if (sortBy === 'salary') {
// Sort by salary (jobs with salary first)
if (a.salary && !b.salary) return -1;
if (!a.salary && b.salary) return 1;
return 0;
}
return 0;
});
return filtered;
}, [data, locationFilter, sourceFilter, companyFilter, selectedTag, sortBy, searchQuery]);
// Get unique companies and tags for filters
const companies = useMemo(() => {
if (!data) return [];
const uniqueCompanies = Array.from(new Set(data.jobs.map(j => j.company))).sort();
return uniqueCompanies;
}, [data]);
// Get company logo for a given company name
const getCompanyLogo = (companyName: string): string => {
if (!data) return '';
const job = data.jobs.find(j => j.company === companyName);
// If companyIcon exists in data, use it
if (job?.companyIcon) return job.companyIcon;
// Otherwise, try to fetch from Clearbit Logo API
// Format: https://logo.clearbit.com/{domain}
const domain = companyName.toLowerCase()
.replace(/\s+/g, '')
.replace(/[^a-z0-9]/g, '');
return `https://logo.clearbit.com/${domain}.com`;
};
const allTags = useMemo(() => {
if (!data) return [];
const tagSet = new Set<string>();
data.jobs.forEach(job => job.tags.forEach(tag => tagSet.add(tag)));
return Array.from(tagSet).sort();
}, [data]);
// Custom dropdown state
const [isCompanyDropdownOpen, setIsCompanyDropdownOpen] = useState(false);
function handleJobClick(e: React.MouseEvent, job: Job) {
if (!isLoaded) {
e.preventDefault();
return;
}
if (!isSignedIn) {
e.preventDefault();
(window as any).Clerk?.openSignIn?.();
}
// If signed in, let the <a> navigate normally
}
if (loading) {
return (
<div className="px-6 py-20 flex flex-col items-center gap-3">
<div className="w-5 h-5 border-2 border-[var(--color-text-tertiary)] border-t-transparent rounded-full animate-spin" />
<span className="text-[13px] text-[var(--color-text-tertiary)]">Loading jobs...</span>
</div>
);
}
if (!data || data.jobs.length === 0) {
return (
<div className="flex flex-col items-center justify-center px-6 py-24">
{/* Icon */}
<div className="w-20 h-20 rounded-2xl bg-[var(--color-surface-2)] border border-[var(--color-border)] flex items-center justify-center mb-6">
<svg className="w-10 h-10 text-[var(--color-text-tertiary)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="1.5">
<rect x="2" y="7" width="20" height="14" rx="2" ry="2" />
<path d="M16 7V5a2 2 0 00-2-2h-4a2 2 0 00-2 2v2" />
<line x1="12" y1="12" x2="12" y2="12.01" />
</svg>
</div>
{/* Message */}
<h3 className="text-[15px] font-semibold text-[var(--color-text-primary)] mb-2">No jobs available yet</h3>
<p className="text-[13px] text-[var(--color-text-secondary)] text-center max-w-md leading-relaxed">
We're currently curating exciting opportunities for AI developers. Check back soon for new job postings!
</p>
</div>
);
}
const remoteCount = data.jobs.filter((j) => j.remote).length;
const onsiteCount = data.jobs.length - remoteCount;
const showAuthGate = isLoaded && !isSignedIn;
return (
<div>
{/* Header */}
<div className="px-6 pt-5 pb-2">
<h1 className="text-lg font-semibold text-[var(--color-text-primary)] flex items-center gap-2">
Jobs Requiring Claude Code
<picture>
<source srcSet="/claude-code-logo.webp" type="image/webp" />
<img src="/claude-code-logo.png" alt="Claude Code" className="w-6 h-6 inline-block" loading="lazy" />
</picture>
</h1>
<p className="text-[13px] text-[var(--color-text-tertiary)] mt-1">
Companies actively hiring developers who use Claude Code in their workflow.
Updated daily from HackerNews "Who is Hiring", RemoteOK, and more.
</p>
</div>
{/* Auth banner for non-signed-in users */}
{showAuthGate && (
<div className="mx-6 mt-2 mb-1 bg-gradient-to-r from-purple-500/10 via-blue-500/10 to-emerald-500/10 border border-[var(--color-border)] rounded-lg px-4 py-3 flex items-center gap-3">
<svg className="w-5 h-5 text-purple-400 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="1.5">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0110 0v4" />
</svg>
<div className="flex-1">
<p className="text-[13px] text-[var(--color-text-primary)]">
<span className="font-medium">Sign in</span>
<span className="text-[var(--color-text-secondary)]"> to view job details and apply to positions</span>
</p>
</div>
<button
onClick={() => (window as any).Clerk?.openSignIn?.()}
className="px-3 py-1.5 bg-[var(--color-surface-3)] hover:bg-[var(--color-surface-4)] text-[12px] font-medium text-[var(--color-text-primary)] rounded-md transition-colors"
>
Sign In
</button>
</div>
)}
{/* Stats cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 px-6 py-4">
{[
{ label: 'Total Jobs', value: String(data.totalJobs) },
{ label: 'Remote', value: String(remoteCount) },
{ label: 'On-site', value: String(onsiteCount) },
{ label: 'Sources', value: String(data.sources.length) },
].map((s) => (
<div key={s.label} className="bg-[var(--color-card-bg)] border border-[var(--color-border)] rounded-lg px-4 py-3">
<div className="text-[18px] font-semibold text-[var(--color-text-primary)] tabular-nums">{s.value}</div>
<div className="text-[11px] text-[var(--color-text-tertiary)] mt-0.5">{s.label}</div>
</div>
))}
</div>
{/* Divider */}
<div className="border-t border-[var(--color-border)]" />
{/* Filter bar - Enhanced */}
<div className="px-6 py-4 space-y-3">
{/* Top row: Location, Source, Company, Sort */}
<div className="flex flex-wrap items-center gap-2">
{/* Location filter */}
<div className="flex items-center gap-1 bg-[var(--color-surface-2)] rounded-lg p-0.5">
{(['all', 'remote', 'onsite'] as LocationFilter[]).map((loc) => (
<button
key={loc}
onClick={() => setLocationFilter(loc)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-[12px] font-medium transition-all ${
locationFilter === loc
? 'bg-[var(--color-surface-3)] text-[var(--color-text-primary)] shadow-sm'
: 'text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)]'
}`}
>
{loc === 'all' ? (
<>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
All Locations
</>
) : loc === 'remote' ? (
<>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="10" />
<path strokeLinecap="round" strokeLinejoin="round" d="M2 12h20M12 2a15.3 15.3 0 014 10 15.3 15.3 0 01-4 10 15.3 15.3 0 01-4-10 15.3 15.3 0 014-10z" />
</svg>
Remote
</>
) : (
<>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
</svg>
On-site
</>
)}
</button>
))}
</div>
{/* Source filter dropdown */}
<div className="relative">
<select
value={sourceFilter}
onChange={(e) => setSourceFilter(e.target.value)}
className="appearance-none bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded-lg text-[12px] font-medium text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] px-3 py-1.5 pr-8 outline-none cursor-pointer transition-colors"
>
<option value="all">All Sources</option>
{data.sources.map((src) => (
<option key={src} value={src}>{src}</option>
))}
</select>
<svg className="absolute right-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-[var(--color-text-tertiary)] pointer-events-none" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</div>
{/* Company filter dropdown - Custom with logos */}
<div className="relative company-dropdown">
<button
onClick={() => setIsCompanyDropdownOpen(!isCompanyDropdownOpen)}
className="flex items-center gap-2 bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded-lg text-[12px] font-medium text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] pl-3 pr-8 py-1.5 outline-none cursor-pointer transition-colors min-w-[180px]"
>
{companyFilter === 'all' ? (
<>
<svg className="w-3.5 h-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
</svg>
<span>All Companies ({companies.length})</span>
</>
) : (
<>
<div className="w-4 h-4 rounded bg-[var(--color-surface-3)] border border-[var(--color-border)] flex items-center justify-center shrink-0 overflow-hidden">
<img
src={getCompanyLogo(companyFilter)}
alt=""
className="w-3 h-3 object-contain"
onError={(e) => {
const target = e.target as HTMLImageElement;
target.style.display = 'none';
const fallback = target.nextElementSibling as HTMLElement;
if (fallback) fallback.style.display = 'flex';
}}
/>
<span className="text-[8px] font-bold text-[var(--color-text-tertiary)]" style={{ display: 'none' }}>
{companyFilter.charAt(0).toUpperCase()}
</span>
</div>
<span className="truncate">{companyFilter}</span>
</>
)}
<svg className="absolute right-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-[var(--color-text-tertiary)] pointer-events-none" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
{/* Dropdown menu */}
{isCompanyDropdownOpen && (
<div className="absolute top-full left-0 mt-1 w-72 max-h-80 overflow-y-auto bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded-lg shadow-lg z-50">
<button
onClick={() => {
setCompanyFilter('all');
setIsCompanyDropdownOpen(false);
}}
className={`w-full flex items-center gap-2.5 px-3 py-2 text-[12px] text-left transition-colors ${
companyFilter === 'all'
? 'bg-[var(--color-surface-3)] text-[var(--color-text-primary)] font-medium'
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-primary)]'
}`}
>
<svg className="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
</svg>
<span>All Companies ({companies.length})</span>
</button>
<div className="border-t border-[var(--color-border)] my-1" />
{companies.map((company) => (
<button
key={company}
onClick={() => {
setCompanyFilter(company);
setIsCompanyDropdownOpen(false);
}}
className={`w-full flex items-center gap-2.5 px-3 py-2 text-[12px] text-left transition-colors ${
companyFilter === company
? 'bg-[var(--color-surface-3)] text-[var(--color-text-primary)] font-medium'
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-primary)]'
}`}
>
<div className="w-5 h-5 rounded bg-[var(--color-surface-3)] border border-[var(--color-border)] flex items-center justify-center shrink-0 overflow-hidden">
<img
src={getCompanyLogo(company)}
alt=""
className="w-4 h-4 object-contain"
onError={(e) => {
const target = e.target as HTMLImageElement;
target.style.display = 'none';
const fallback = target.nextElementSibling as HTMLElement;
if (fallback) fallback.style.display = 'flex';
}}
/>
<span className="text-[9px] font-bold text-[var(--color-text-tertiary)]" style={{ display: 'none' }}>
{company.charAt(0).toUpperCase()}
</span>
</div>
<span className="truncate">{company}</span>
</button>
))}
</div>
)}
</div>
{/* Sort dropdown */}
<div className="relative">
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as SortOption)}
className="appearance-none bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded-lg text-[12px] font-medium text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] pl-9 pr-8 py-1.5 outline-none cursor-pointer transition-colors"
>
<option value="recent">Most Recent</option>
<option value="company">Company A-Z</option>
<option value="salary">With Salary</option>
</select>
<svg className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-[var(--color-text-tertiary)] pointer-events-none" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
{sortBy === 'recent' ? (
<path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
) : sortBy === 'company' ? (
<path strokeLinecap="round" strokeLinejoin="round" d="M3 4h13M3 8h9m-9 4h6m4 0l4-4m0 0l4 4m-4-4v12" />
) : (
<path strokeLinecap="round" strokeLinejoin="round" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
)}
</svg>
<svg className="absolute right-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-[var(--color-text-tertiary)] pointer-events-none" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</div>
{/* Search */}
<div className="ml-auto relative">
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-[var(--color-text-tertiary)] pointer-events-none" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
<circle cx="11" cy="11" r="8" />
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-4.35-4.35" />
</svg>
<input
type="text"
placeholder="Search jobs..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded-lg text-[12px] text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] pl-9 pr-3 py-1.5 w-56 outline-none focus:border-[var(--color-border-hover)] transition-colors"
/>
{searchQuery && (
<button
onClick={() => setSearchQuery('')}
className="absolute right-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)] transition-colors"
>
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
</div>
{/* Bottom row: Tags filter */}
{allTags.length > 0 && (
<div className="flex items-center gap-2">
<span className="text-[11px] font-medium text-[var(--color-text-tertiary)] uppercase tracking-wide">Tags:</span>
<div className="flex items-center gap-1.5 flex-wrap">
<button
onClick={() => setSelectedTag('all')}
className={`px-2.5 py-1 rounded-md text-[11px] font-medium transition-all ${
selectedTag === 'all'
? 'bg-[var(--color-surface-3)] text-[var(--color-text-primary)] border border-[var(--color-border)]'
: 'bg-[var(--color-surface-2)] text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-3)]'
}`}
>
All
</button>
{allTags.slice(0, 12).map((tag) => (
<button
key={tag}
onClick={() => setSelectedTag(tag)}
className={`px-2.5 py-1 rounded-md text-[11px] font-medium transition-all ${
selectedTag === tag
? 'bg-blue-500/15 text-blue-400 border border-blue-500/30'
: 'bg-[var(--color-surface-2)] text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-3)]'
}`}
>
{tag}
</button>
))}
{allTags.length > 12 && (
<span className="text-[11px] text-[var(--color-text-tertiary)] px-2">
+{allTags.length - 12} more
</span>
)}
</div>
</div>
)}
{/* Active filters indicator */}
{(locationFilter !== 'all' || sourceFilter !== 'all' || companyFilter !== 'all' || selectedTag !== 'all' || searchQuery) && (
<div className="flex items-center gap-2 pt-1">
<span className="text-[11px] text-[var(--color-text-tertiary)]">Active filters:</span>
<div className="flex items-center gap-1.5 flex-wrap">
{locationFilter !== 'all' && (
<span className="inline-flex items-center gap-1 text-[11px] bg-blue-500/15 text-blue-400 px-2 py-0.5 rounded-full">
{locationFilter}
<button onClick={() => setLocationFilter('all')} className="hover:text-blue-300">×</button>
</span>
)}
{sourceFilter !== 'all' && (
<span className="inline-flex items-center gap-1 text-[11px] bg-orange-500/15 text-orange-400 px-2 py-0.5 rounded-full">
{sourceFilter}
<button onClick={() => setSourceFilter('all')} className="hover:text-orange-300">×</button>
</span>
)}
{companyFilter !== 'all' && (
<span className="inline-flex items-center gap-1 text-[11px] bg-purple-500/15 text-purple-400 px-2 py-0.5 rounded-full">
{companyFilter}
<button onClick={() => setCompanyFilter('all')} className="hover:text-purple-300">×</button>
</span>
)}
{selectedTag !== 'all' && (
<span className="inline-flex items-center gap-1 text-[11px] bg-emerald-500/15 text-emerald-400 px-2 py-0.5 rounded-full">
{selectedTag}
<button onClick={() => setSelectedTag('all')} className="hover:text-emerald-300">×</button>
</span>
)}
{searchQuery && (
<span className="inline-flex items-center gap-1 text-[11px] bg-pink-500/15 text-pink-400 px-2 py-0.5 rounded-full">
"{searchQuery}"
<button onClick={() => setSearchQuery('')} className="hover:text-pink-300">×</button>
</span>
)}
<button
onClick={() => {
setLocationFilter('all');
setSourceFilter('all');
setCompanyFilter('all');
setSelectedTag('all');
setSearchQuery('');
}}
className="text-[11px] text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)] underline"
>
Clear all
</button>
</div>
</div>
)}
</div>
{/* Results count */}
<div className="px-6 pb-3 flex items-center justify-between">
<span className="text-[12px] font-medium text-[var(--color-text-secondary)]">
Showing {filteredJobs.length} of {data.totalJobs} job{filteredJobs.length !== 1 ? 's' : ''}
</span>
{filteredJobs.length === 0 && (
<button
onClick={() => {
setLocationFilter('all');
setSourceFilter('all');
setCompanyFilter('all');
setSelectedTag('all');
setSearchQuery('');
}}
className="text-[12px] text-blue-400 hover:text-blue-300 underline"
>
Reset filters
</button>
)}
</div>
{/* Job cards */}
{filteredJobs.length === 0 ? (
<div className="px-6 pb-8 flex flex-col items-center justify-center py-12">
<div className="w-16 h-16 rounded-full bg-[var(--color-surface-2)] border border-[var(--color-border)] flex items-center justify-center mb-4">
<svg className="w-8 h-8 text-[var(--color-text-tertiary)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="1.5">
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
<h3 className="text-[14px] font-semibold text-[var(--color-text-primary)] mb-1">No jobs found</h3>
<p className="text-[12px] text-[var(--color-text-tertiary)] text-center max-w-sm">
Try adjusting your filters or search query to find more opportunities.
</p>
</div>
) : (
<div className="px-6 pb-8 grid grid-cols-1 md:grid-cols-2 gap-3">
{filteredJobs.map((job) => (
<a
key={job.id}
href={isSignedIn ? safeUrl(job.applyUrl) : '#'}
target={isSignedIn ? '_blank' : undefined}
rel={isSignedIn ? 'noopener noreferrer' : undefined}
onClick={(e) => handleJobClick(e, job)}
className="block bg-[var(--color-card-bg)] border border-[var(--color-border)] rounded-lg p-4 hover:border-[var(--color-border-hover)] hover:bg-[var(--color-card-hover)] hover:shadow-lg transition-all group relative"
>
<div className="flex items-start gap-3">
{/* Company icon - Enhanced with internet fallback */}
<div className="w-12 h-12 rounded-lg bg-[var(--color-surface-2)] border border-[var(--color-border)] flex items-center justify-center shrink-0 overflow-hidden group-hover:border-[var(--color-border-hover)] transition-colors">
<img
src={job.companyIcon || getCompanyLogo(job.company)}
alt={job.company}
className="w-7 h-7 object-contain"
style={{ display: 'block' }}
onError={(e) => {
const target = e.target as HTMLImageElement;
target.style.display = 'none';
const fallback = target.nextElementSibling as HTMLElement;
if (fallback) fallback.style.display = 'flex';
}}
/>
<span
className="text-[16px] font-bold text-[var(--color-text-tertiary)]"
style={{ display: 'none' }}
>
{job.company.charAt(0).toUpperCase()}
</span>
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-start gap-2 flex-wrap">
<h3 className="text-[14px] font-semibold text-[var(--color-text-primary)] group-hover:text-[var(--color-text-primary)] transition-colors flex-1">
{job.position}
</h3>
{job.salary && (
<span className="flex items-center gap-1 text-[11px] font-semibold bg-emerald-500/15 text-emerald-400 px-2 py-1 rounded border border-emerald-500/20">
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{job.salary}
</span>
)}
</div>
<div className="flex items-center gap-2 mt-1.5 flex-wrap">
<span className="text-[13px] font-medium text-[var(--color-text-secondary)]">{job.company}</span>
<span className="text-[10px] text-[var(--color-text-tertiary)]">•</span>
<span className="text-[12px] text-[var(--color-text-tertiary)]">{job.location}</span>
</div>
<div className="flex items-center gap-1.5 mt-2 flex-wrap">
{job.remote && (
<span className="flex items-center gap-1 text-[10px] font-semibold bg-blue-500/15 text-blue-400 px-2 py-1 rounded border border-blue-500/20">
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="10" />
<path strokeLinecap="round" strokeLinejoin="round" d="M2 12h20M12 2a15.3 15.3 0 014 10 15.3 15.3 0 01-4 10 15.3 15.3 0 01-4-10 15.3 15.3 0 014-10z" />
</svg>
Remote
</span>
)}
<span className={`text-[10px] font-semibold px-2 py-1 rounded border ${SOURCE_COLORS[job.source] || 'bg-[var(--color-surface-3)] text-[var(--color-text-secondary)]'}`}>
{job.source}
</span>
{job.postedAt && (
<span className="flex items-center gap-1 text-[11px] text-[var(--color-text-tertiary)] ml-auto">
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{timeAgo(job.postedAt)}
</span>
)}
</div>
{job.description && (
<p className="text-[12px] text-[var(--color-text-secondary)] mt-2.5 line-clamp-2 leading-relaxed">{job.description}</p>
)}
{job.tags.length > 0 && (
<div className="flex items-center gap-1.5 mt-2.5 flex-wrap">
{job.tags.slice(0, 6).map((tag) => (
<span
key={tag}
className="text-[10px] text-[var(--color-text-secondary)] bg-[var(--color-surface-2)] px-2 py-0.5 rounded border border-[var(--color-border)] hover:border-[var(--color-border-hover)] transition-colors cursor-pointer"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setSelectedTag(tag);
}}
>
{tag}
</span>
))}
{job.tags.length > 6 && (
<span className="text-[10px] text-[var(--color-text-tertiary)]">
+{job.tags.length - 6}
</span>
)}
</div>
)}
</div>
{/* Right side: apply arrow or lock icon */}
{showAuthGate ? (
<svg
className="w-4 h-4 text-[var(--color-text-tertiary)] shrink-0 mt-1"
fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}
>
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0110 0v4" />
</svg>
) : (
<svg
className="w-4 h-4 text-[var(--color-text-tertiary)] group-hover:text-[var(--color-text-secondary)] group-hover:translate-x-0.5 group-hover:-translate-y-0.5 shrink-0 mt-1 transition-all"
fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
)}
</div>
</a>
))}
</div>
)}
{/* Footer */}
<div className="px-6 pb-6 text-center">
<p className="text-[11px] text-[var(--color-text-tertiary)]">
Last updated: {new Date(data.lastUpdated).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
{' | '}Data sourced from HackerNews "Who is Hiring", RemoteOK, WeWorkRemotely, and Anthropic Careers
</p>
</div>
</div>
);
}
+255
View File
@@ -0,0 +1,255 @@
import { useState, useCallback } from 'react';
interface JsonViewerProps {
content: string;
}
type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };
function parseJson(content: string): JsonValue | null {
try {
return JSON.parse(content);
} catch {
return null;
}
}
export default function JsonViewer({ content }: JsonViewerProps) {
const parsed = parseJson(content);
const handleCopy = () => {
navigator.clipboard.writeText(content);
};
if (parsed === null) {
return (
<div className="bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded-lg p-6 text-sm text-red-400 font-mono whitespace-pre-wrap">
Invalid JSON
<pre className="mt-4 text-[var(--color-text-tertiary)]">{content}</pre>
</div>
);
}
return (
<div>
<div className="flex items-center justify-between mb-3">
<span className="text-[10px] font-semibold text-[var(--color-text-tertiary)] uppercase tracking-wider">JSON</span>
<button
onClick={handleCopy}
className="text-[11px] px-2.5 py-1 rounded-md bg-white/[0.06] hover:bg-white/[0.12] text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)] transition-colors"
>
Copy
</button>
</div>
<div className="bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded-lg py-3 px-1 font-mono text-sm overflow-x-auto">
<JsonNode value={parsed} path="" depth={0} defaultOpen />
</div>
</div>
);
}
function JsonNode({
value,
path,
depth,
defaultOpen = false,
keyName,
}: {
value: JsonValue;
path: string;
depth: number;
defaultOpen?: boolean;
keyName?: string;
}) {
if (value === null) return <JsonPrimitive keyName={keyName} depth={depth} value="null" className="text-[var(--color-text-tertiary)] italic" />;
if (typeof value === 'boolean') return <JsonPrimitive keyName={keyName} depth={depth} value={String(value)} className="text-purple-400" />;
if (typeof value === 'number') return <JsonPrimitive keyName={keyName} depth={depth} value={String(value)} className="text-cyan-400" />;
if (typeof value === 'string') return <JsonString keyName={keyName} depth={depth} value={value} />;
if (Array.isArray(value)) return <JsonArray keyName={keyName} depth={depth} value={value} path={path} defaultOpen={defaultOpen} />;
if (typeof value === 'object') return <JsonObject keyName={keyName} depth={depth} value={value as Record<string, JsonValue>} path={path} defaultOpen={defaultOpen} />;
return null;
}
function JsonPrimitive({ keyName, depth, value, className }: { keyName?: string; depth: number; value: string; className: string }) {
return (
<div className="flex items-start" style={{ paddingLeft: `${depth * 1.25 + 0.75}rem` }}>
{keyName !== undefined && (
<span className="text-accent-400 shrink-0">&quot;{keyName}&quot;<span className="text-[var(--color-text-tertiary)]">: </span></span>
)}
<span className={className}>{value}</span>
</div>
);
}
function JsonString({ keyName, depth, value }: { keyName?: string; depth: number; value: string }) {
const isLong = value.length > 120;
const [expanded, setExpanded] = useState(!isLong);
const display = expanded ? value : value.slice(0, 80) + '...';
return (
<div className="flex items-start group/str" style={{ paddingLeft: `${depth * 1.25 + 0.75}rem` }}>
{keyName !== undefined && (
<span className="text-accent-400 shrink-0">&quot;{keyName}&quot;<span className="text-[var(--color-text-tertiary)]">: </span></span>
)}
<span className="text-green-400 break-all">
&quot;{display}&quot;
{isLong && (
<button
onClick={() => setExpanded(!expanded)}
className="ml-1.5 text-[10px] px-1.5 py-0.5 rounded bg-white/[0.06] hover:bg-white/[0.1] text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)] transition-colors align-middle"
>
{expanded ? 'less' : `+${value.length - 80} chars`}
</button>
)}
</span>
</div>
);
}
function JsonObject({
keyName,
depth,
value,
path,
defaultOpen,
}: {
keyName?: string;
depth: number;
value: Record<string, JsonValue>;
path: string;
defaultOpen?: boolean;
}) {
const keys = Object.keys(value);
const [open, setOpen] = useState(defaultOpen || depth < 2);
const handleCopySection = useCallback(() => {
navigator.clipboard.writeText(JSON.stringify(value, null, 2));
}, [value]);
if (keys.length === 0) {
return (
<div style={{ paddingLeft: `${depth * 1.25 + 0.75}rem` }}>
{keyName !== undefined && (
<span className="text-accent-400">&quot;{keyName}&quot;<span className="text-[var(--color-text-tertiary)]">: </span></span>
)}
<span className="text-[var(--color-text-tertiary)]">{'{}'}</span>
</div>
);
}
return (
<div>
<div
className="flex items-center gap-1 group/obj cursor-pointer hover:bg-white/[0.02] rounded py-0.5 transition-colors"
style={{ paddingLeft: `${depth * 1.25 + 0.25}rem` }}
onClick={() => setOpen(!open)}
>
<svg
className={`w-3 h-3 text-[var(--color-text-tertiary)] shrink-0 transition-transform duration-150 ${open ? 'rotate-90' : ''}`}
fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
{keyName !== undefined && (
<span className="text-accent-400">&quot;{keyName}&quot;<span className="text-[var(--color-text-tertiary)]">: </span></span>
)}
<span className="text-[var(--color-text-tertiary)]">{'{'}</span>
{!open && (
<span className="text-[var(--color-text-tertiary)] text-xs ml-1">
{keys.length} key{keys.length !== 1 ? 's' : ''}
</span>
)}
{!open && <span className="text-[var(--color-text-tertiary)]">{'}'}</span>}
<button
onClick={(e) => { e.stopPropagation(); handleCopySection(); }}
className="ml-auto mr-2 opacity-0 group-hover/obj:opacity-100 text-[10px] px-1.5 py-0.5 rounded bg-white/[0.06] hover:bg-white/[0.1] text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)] transition-all"
title="Copy this section"
>
Copy
</button>
</div>
{open && (
<>
{keys.map((k) => (
<JsonNode key={k} keyName={k} value={value[k]} path={`${path}.${k}`} depth={depth + 1} />
))}
<div className="text-[var(--color-text-tertiary)]" style={{ paddingLeft: `${depth * 1.25 + 0.75}rem` }}>{'}'}</div>
</>
)}
</div>
);
}
function JsonArray({
keyName,
depth,
value,
path,
defaultOpen,
}: {
keyName?: string;
depth: number;
value: JsonValue[];
path: string;
defaultOpen?: boolean;
}) {
const [open, setOpen] = useState(defaultOpen || depth < 2);
const handleCopySection = useCallback(() => {
navigator.clipboard.writeText(JSON.stringify(value, null, 2));
}, [value]);
if (value.length === 0) {
return (
<div style={{ paddingLeft: `${depth * 1.25 + 0.75}rem` }}>
{keyName !== undefined && (
<span className="text-accent-400">&quot;{keyName}&quot;<span className="text-[var(--color-text-tertiary)]">: </span></span>
)}
<span className="text-[var(--color-text-tertiary)]">{'[]'}</span>
</div>
);
}
return (
<div>
<div
className="flex items-center gap-1 group/arr cursor-pointer hover:bg-white/[0.02] rounded py-0.5 transition-colors"
style={{ paddingLeft: `${depth * 1.25 + 0.25}rem` }}
onClick={() => setOpen(!open)}
>
<svg
className={`w-3 h-3 text-[var(--color-text-tertiary)] shrink-0 transition-transform duration-150 ${open ? 'rotate-90' : ''}`}
fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
{keyName !== undefined && (
<span className="text-accent-400">&quot;{keyName}&quot;<span className="text-[var(--color-text-tertiary)]">: </span></span>
)}
<span className="text-[var(--color-text-tertiary)]">{'['}</span>
{!open && (
<span className="text-[var(--color-text-tertiary)] text-xs ml-1">
{value.length} item{value.length !== 1 ? 's' : ''}
</span>
)}
{!open && <span className="text-[var(--color-text-tertiary)]">{']'}</span>}
<button
onClick={(e) => { e.stopPropagation(); handleCopySection(); }}
className="ml-auto mr-2 opacity-0 group-hover/arr:opacity-100 text-[10px] px-1.5 py-0.5 rounded bg-white/5 hover:bg-white/10 text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)] transition-all"
title="Copy this section"
>
Copy
</button>
</div>
{open && (
<>
{value.map((item, i) => (
<JsonNode key={i} value={item} path={`${path}[${i}]`} depth={depth + 1} />
))}
<div className="text-[var(--color-text-tertiary)]" style={{ paddingLeft: `${depth * 1.25 + 0.75}rem` }}>{']'}</div>
</>
)}
</div>
);
}
+521
View File
@@ -0,0 +1,521 @@
import { useState, useEffect, useRef, useMemo, useCallback } from 'react';
import { marked } from 'marked';
interface Heading {
level: number;
text: string;
id: string;
}
interface SearchMatch {
type: 'heading' | 'text';
text: string;
id: string; // heading id to scroll to
context?: string; // surrounding text for text matches
}
interface MarkdownViewerProps {
content: string;
headings: Heading[];
}
function renderMarkdown(content: string): string {
const stripped = content.replace(/^---\s*\n[\s\S]*?\n---\s*\n?/, '');
marked.setOptions({ gfm: true, breaks: false });
const renderer = new marked.Renderer();
renderer.heading = ({ text, depth }: { text: string; depth: number }) => {
const id = text.toLowerCase().replace(/[^\w]+/g, '-').replace(/^-|-$/g, '');
return `<h${depth} id="${id}" class="md-heading">${text}</h${depth}>`;
};
renderer.code = ({ text, lang }: { text: string; lang?: string }) => {
return `<div class="md-code-block"><div class="md-code-lang">${lang || ''}</div><pre><code class="language-${lang || ''}">${text.replace(/</g, '&lt;').replace(/>/g, '&gt;')}</code></pre></div>`;
};
return marked.parse(stripped, { renderer }) as string;
}
/** Build a searchable index: map each paragraph/line to its nearest preceding heading */
function buildSearchIndex(content: string, headings: Heading[]): { text: string; headingId: string }[] {
const stripped = content.replace(/^---\s*\n[\s\S]*?\n---\s*\n?/, '');
const lines = stripped.split('\n');
const entries: { text: string; headingId: string }[] = [];
let currentHeadingId = headings[0]?.id ?? '';
let paragraph = '';
for (const line of lines) {
const headingMatch = line.match(/^(#{1,4})\s+(.+)$/);
if (headingMatch) {
if (paragraph.trim()) {
entries.push({ text: paragraph.trim(), headingId: currentHeadingId });
paragraph = '';
}
const text = headingMatch[2].replace(/[*_`\[\]]/g, '').trim();
currentHeadingId = text.toLowerCase().replace(/[^\w]+/g, '-').replace(/^-|-$/g, '');
continue;
}
if (line.trim() === '') {
if (paragraph.trim()) {
entries.push({ text: paragraph.trim(), headingId: currentHeadingId });
paragraph = '';
}
} else {
paragraph += (paragraph ? ' ' : '') + line.trim();
}
}
if (paragraph.trim()) {
entries.push({ text: paragraph.trim(), headingId: currentHeadingId });
}
return entries;
}
export default function MarkdownViewer({ content, headings }: MarkdownViewerProps) {
const [mode, setMode] = useState<'code' | 'preview'>('preview');
const [expanded, setExpanded] = useState(false);
const [activeHeading, setActiveHeading] = useState('');
const [searchQuery, setSearchQuery] = useState('');
const [searchOpen, setSearchOpen] = useState(false);
const [selectedIdx, setSelectedIdx] = useState(0);
const [highlightCount, setHighlightCount] = useState(0);
const previewRef = useRef<HTMLDivElement>(null);
const codeRef = useRef<HTMLDivElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
const html = useMemo(() => renderMarkdown(content), [content]);
const searchIndex = useMemo(() => buildSearchIndex(content, headings), [content, headings]);
// Search results
const searchResults = useMemo((): SearchMatch[] => {
if (!searchQuery.trim() || searchQuery.length < 2) return [];
const q = searchQuery.toLowerCase();
const results: SearchMatch[] = [];
const seen = new Set<string>();
// Search headings first
for (const h of headings) {
if (h.text.toLowerCase().includes(q)) {
results.push({ type: 'heading', text: h.text, id: h.id });
seen.add(h.id);
}
}
// Search content paragraphs
for (const entry of searchIndex) {
if (entry.text.toLowerCase().includes(q) && !seen.has(entry.headingId)) {
const idx = entry.text.toLowerCase().indexOf(q);
const start = Math.max(0, idx - 40);
const end = Math.min(entry.text.length, idx + q.length + 40);
const context = (start > 0 ? '...' : '') +
entry.text.slice(start, end) +
(end < entry.text.length ? '...' : '');
results.push({ type: 'text', text: entry.text.slice(idx, idx + q.length), id: entry.headingId, context });
seen.add(entry.headingId);
}
}
return results.slice(0, 15);
}, [searchQuery, headings, searchIndex]);
// Highlight matches inside preview
useEffect(() => {
if (!previewRef.current || mode !== 'preview') return;
// Remove old highlights
previewRef.current.querySelectorAll('mark.md-search-hl').forEach((m) => {
const parent = m.parentNode;
if (parent) {
parent.replaceChild(document.createTextNode(m.textContent || ''), m);
parent.normalize();
}
});
if (!searchQuery.trim() || searchQuery.length < 2) {
setHighlightCount(0);
return;
}
const walker = document.createTreeWalker(previewRef.current, NodeFilter.SHOW_TEXT);
const q = searchQuery.toLowerCase();
const nodesToProcess: { node: Text; indices: number[] }[] = [];
let node: Text | null;
while ((node = walker.nextNode() as Text | null)) {
const text = node.textContent || '';
const lower = text.toLowerCase();
const indices: number[] = [];
let pos = 0;
while ((pos = lower.indexOf(q, pos)) !== -1) {
indices.push(pos);
pos += q.length;
}
if (indices.length > 0) {
nodesToProcess.push({ node, indices });
}
}
let count = 0;
for (const { node: textNode, indices } of nodesToProcess) {
const text = textNode.textContent || '';
const parent = textNode.parentNode;
if (!parent || parent.nodeName === 'CODE' || parent.nodeName === 'PRE') continue;
const frag = document.createDocumentFragment();
let lastIdx = 0;
for (const idx of indices) {
if (idx > lastIdx) frag.appendChild(document.createTextNode(text.slice(lastIdx, idx)));
const mark = document.createElement('mark');
mark.className = 'md-search-hl';
mark.textContent = text.slice(idx, idx + q.length);
frag.appendChild(mark);
lastIdx = idx + q.length;
count++;
}
if (lastIdx < text.length) frag.appendChild(document.createTextNode(text.slice(lastIdx)));
parent.replaceChild(frag, textNode);
}
setHighlightCount(count);
}, [searchQuery, mode, html]);
// Track active heading on scroll
useEffect(() => {
if (mode !== 'preview' || !previewRef.current) return;
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) setActiveHeading(entry.target.id);
}
},
{ rootMargin: '-80px 0px -70% 0px', threshold: 0 }
);
const headingEls = previewRef.current.querySelectorAll('.md-heading');
headingEls.forEach((el) => observer.observe(el));
return () => observer.disconnect();
}, [mode, html]);
// Keyboard shortcut: Cmd/Ctrl+F to open search
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'f') {
// Only intercept if the viewer is visible
if (previewRef.current || codeRef.current) {
e.preventDefault();
setSearchOpen(true);
setTimeout(() => searchInputRef.current?.focus(), 50);
}
}
if (e.key === 'Escape' && searchOpen) {
setSearchOpen(false);
setSearchQuery('');
}
};
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [searchOpen]);
// Reset selection when results change
useEffect(() => { setSelectedIdx(0); }, [searchResults]);
const handleCopy = () => {
navigator.clipboard.writeText(content);
};
const scrollToHeading = useCallback((id: string) => {
if (!expanded) setExpanded(true);
// Small delay to allow expand animation
setTimeout(() => {
const container = previewRef.current || codeRef.current;
const el = container?.querySelector(`#${CSS.escape(id)}`);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
setActiveHeading(id);
}
}, expanded ? 0 : 100);
}, [expanded]);
const navigateToResult = useCallback((result: SearchMatch) => {
if (mode !== 'preview') setMode('preview');
scrollToHeading(result.id);
setSearchOpen(false);
}, [mode, scrollToHeading]);
const handleSearchKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
setSelectedIdx((i) => Math.min(i + 1, searchResults.length - 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setSelectedIdx((i) => Math.max(i - 1, 0));
} else if (e.key === 'Enter' && searchResults[selectedIdx]) {
e.preventDefault();
navigateToResult(searchResults[selectedIdx]);
}
};
return (
<div className="flex gap-4">
{/* Main content */}
<div className="flex-1 min-w-0">
{/* Toolbar */}
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<div className="flex items-center gap-1 bg-[var(--color-surface-3)] rounded-lg p-0.5">
<button
onClick={() => setMode('code')}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${
mode === 'code'
? 'bg-[var(--color-surface-2)] text-[var(--color-text-primary)] shadow-sm'
: 'text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)]'
}`}
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M17.25 6.75L22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3l-4.5 16.5" />
</svg>
Code
</button>
<button
onClick={() => setMode('preview')}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${
mode === 'preview'
? 'bg-[var(--color-surface-2)] text-[var(--color-text-primary)] shadow-sm'
: 'text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)]'
}`}
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
Preview
</button>
</div>
{/* Search toggle */}
<button
onClick={() => {
setSearchOpen(!searchOpen);
if (!searchOpen) setTimeout(() => searchInputRef.current?.focus(), 50);
}}
className={`flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs transition-colors ${
searchOpen
? 'bg-accent-500/15 text-accent-400'
: 'text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)] hover:bg-white/[0.04]'
}`}
title="Search in document (Cmd+F)"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
</svg>
<span className="hidden sm:inline">Search</span>
<kbd className="hidden sm:inline text-[9px] px-1 py-0.5 rounded bg-white/[0.06] text-[var(--color-text-tertiary)] font-mono ml-1">
{navigator.platform?.includes('Mac') ? '\u2318' : 'Ctrl'}F
</kbd>
</button>
</div>
<button
onClick={handleCopy}
className="text-[11px] px-2.5 py-1 rounded-md bg-white/[0.06] hover:bg-white/[0.12] text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)] transition-colors"
>
Copy
</button>
</div>
{/* Search bar */}
{searchOpen && (
<div className="mb-3 relative">
<div className="relative">
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-[var(--color-text-tertiary)] pointer-events-none" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
</svg>
<input
ref={searchInputRef}
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={handleSearchKeyDown}
placeholder="Search sections and content..."
className="w-full bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded-lg pl-9 pr-20 py-2 text-sm text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] focus:outline-none focus:border-accent-400/50 focus:ring-1 focus:ring-accent-400/20 font-sans"
autoFocus
/>
<div className="absolute right-3 top-1/2 -translate-y-1/2 flex items-center gap-2">
{searchQuery.length >= 2 && (
<span className="text-[10px] text-[var(--color-text-tertiary)]">
{highlightCount > 0 ? `${highlightCount} match${highlightCount !== 1 ? 'es' : ''}` : 'No matches'}
</span>
)}
<button
onClick={() => { setSearchOpen(false); setSearchQuery(''); }}
className="text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)]"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
{/* Search results dropdown */}
{searchResults.length > 0 && (
<div className="absolute z-20 top-full left-0 right-0 mt-1 bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded-lg shadow-xl overflow-hidden max-h-72 overflow-y-auto">
{searchResults.map((result, i) => (
<button
key={`${result.id}-${i}`}
onClick={() => navigateToResult(result)}
onMouseEnter={() => setSelectedIdx(i)}
className={`w-full text-left px-3 py-2.5 flex items-start gap-2.5 transition-colors ${
i === selectedIdx ? 'bg-white/[0.06]' : 'hover:bg-white/[0.03]'
}`}
>
{result.type === 'heading' ? (
<svg className="w-3.5 h-3.5 mt-0.5 shrink-0 text-accent-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5.25 7.5h13.5m-13.5 4.5h7.5m-7.5 4.5h13.5" />
</svg>
) : (
<svg className="w-3.5 h-3.5 mt-0.5 shrink-0 text-[var(--color-text-tertiary)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
</svg>
)}
<div className="flex-1 min-w-0">
{result.type === 'heading' ? (
<HighlightText text={result.text} query={searchQuery} className="text-sm text-[var(--color-text-primary)] font-medium" />
) : (
<>
<div className="text-[11px] text-[var(--color-text-tertiary)] mb-0.5">
{headings.find((h) => h.id === result.id)?.text ?? ''}
</div>
<HighlightText
text={result.context || ''}
query={searchQuery}
className="text-xs text-[var(--color-text-secondary)] leading-relaxed"
/>
</>
)}
</div>
{i === selectedIdx && (
<kbd className="text-[9px] px-1 py-0.5 rounded bg-white/[0.06] text-[var(--color-text-tertiary)] font-mono self-center shrink-0">
Enter
</kbd>
)}
</button>
))}
</div>
)}
</div>
)}
{/* Content area */}
<div className="relative">
{mode === 'code' ? (
<div
ref={codeRef}
className={`bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded-lg p-6 text-sm text-[var(--color-text-primary)] leading-relaxed whitespace-pre-wrap font-mono overflow-hidden transition-[max-height] duration-300 ${
expanded ? '' : 'max-h-[32rem]'
}`}
>
{content || 'No content available'}
</div>
) : (
<div
ref={previewRef}
className={`md-preview bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded-lg p-6 overflow-hidden transition-[max-height] duration-300 ${
expanded ? '' : 'max-h-[32rem]'
}`}
dangerouslySetInnerHTML={{ __html: html }}
/>
)}
{!expanded && (
<div className="mt-4 flex justify-center">
<button
onClick={() => setExpanded(true)}
className="text-[13px] font-medium text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] flex items-center gap-2 px-4 py-2 rounded-lg hover:bg-[var(--color-surface-3)] border border-[var(--color-border)] hover:border-[var(--color-primary-500)]/40 transition-all duration-150 active:scale-95"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
Show full {mode === 'preview' ? 'document' : 'source'}
</button>
</div>
)}
{expanded && (
<div className="flex justify-center mt-4">
<button
onClick={() => setExpanded(false)}
className="text-[13px] font-medium text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] flex items-center gap-2 px-4 py-2 rounded-lg hover:bg-[var(--color-surface-3)] border border-[var(--color-border)] hover:border-[var(--color-primary-500)]/40 transition-all duration-150 active:scale-95"
>
<svg className="w-4 h-4 rotate-180" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
Collapse
</button>
</div>
)}
</div>
</div>
{/* Table of Contents sidebar */}
{headings.length > 1 && mode === 'preview' && (
<nav className="hidden xl:block w-56 shrink-0 ml-6">
<div className="sticky top-28">
<h4 className="text-[10px] font-bold text-[var(--color-text-tertiary)] uppercase tracking-[0.08em] mb-3 px-3">
On this page
</h4>
<ul className="space-y-1 text-[12px] border-l-2 border-[var(--color-border)] max-h-[calc(100vh-12rem)] overflow-y-auto">
{headings.map((h) => (
<li key={h.id}>
<button
onClick={() => scrollToHeading(h.id)}
className={`block w-full text-left py-1.5 transition-all duration-150 border-l-2 -ml-0.5 ${
h.level === 1 ? 'pl-3' : h.level === 2 ? 'pl-5' : 'pl-7'
} ${
activeHeading === h.id
? 'border-[var(--color-primary-400)] text-[var(--color-primary-400)] font-medium'
: 'border-transparent text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)] hover:border-[var(--color-border)]'
}`}
title={h.text}
>
<span className="line-clamp-2 leading-snug">{h.text}</span>
</button>
</li>
))}
</ul>
</div>
</nav>
)}
</div>
);
}
/** Highlights query matches within text */
function HighlightText({ text, query, className }: { text: string; query: string; className?: string }) {
if (!query.trim()) return <span className={className}>{text}</span>;
const parts: { text: string; match: boolean }[] = [];
const lower = text.toLowerCase();
const q = query.toLowerCase();
let lastIdx = 0;
let pos = 0;
while ((pos = lower.indexOf(q, lastIdx)) !== -1) {
if (pos > lastIdx) parts.push({ text: text.slice(lastIdx, pos), match: false });
parts.push({ text: text.slice(pos, pos + q.length), match: true });
lastIdx = pos + q.length;
}
if (lastIdx < text.length) parts.push({ text: text.slice(lastIdx), match: false });
return (
<span className={className}>
{parts.map((p, i) =>
p.match ? (
<mark key={i} className="bg-accent-400/25 text-accent-300 rounded-sm px-px">{p.text}</mark>
) : (
<span key={i}>{p.text}</span>
)
)}
</span>
);
}
@@ -0,0 +1,354 @@
import { useState, useMemo, useRef, useEffect } from 'react';
interface ComponentItem {
name: string;
description?: string;
}
interface MarketplacePlugin {
name: string;
description?: string;
source?: string;
source_path?: string;
author?: string;
tags?: string[];
components?: Record<string, number>;
components_items?: Record<string, ComponentItem[]>;
}
interface Props {
pluginsList: MarketplacePlugin[];
marketplaceAuthor: string;
marketplaceName: string;
tagColors: Record<string, string>;
}
function getSourceLabel(p: MarketplacePlugin): 'local' | 'github' {
const src = p.source ?? '';
if (typeof src === 'string') {
if (src.startsWith('./') || src.startsWith('../')) return 'local';
if (src.includes('github.com') || src.includes('.git')) return 'github';
if (src.includes('/')) return 'github';
}
return 'local';
}
export default function MarketplacePluginsList({
pluginsList,
marketplaceAuthor,
marketplaceName,
tagColors,
}: Props) {
const [search, setSearch] = useState('');
const [selected, setSelected] = useState<MarketplacePlugin | null>(null);
const backdropRef = useRef<HTMLDivElement>(null);
const filtered = useMemo(() => {
if (!search.trim()) return pluginsList;
const q = search.toLowerCase().trim();
return pluginsList.filter(
(p) =>
p.name?.toLowerCase().includes(q) ||
p.description?.toLowerCase().includes(q) ||
p.author?.toLowerCase().includes(q) ||
p.tags?.some((t) => t.toLowerCase().includes(q)) ||
Object.keys(p.components ?? {}).some((t) => t.toLowerCase().includes(q))
);
}, [pluginsList, search]);
useEffect(() => {
if (!selected) return;
function handleKey(e: KeyboardEvent) {
if (e.key === 'Escape') setSelected(null);
}
window.addEventListener('keydown', handleKey);
return () => window.removeEventListener('keydown', handleKey);
}, [selected]);
function handleBackdropClick(e: React.MouseEvent) {
if (e.target === backdropRef.current) setSelected(null);
}
const namedTypes = new Set(Object.keys(selected?.components_items ?? {}));
const countOnlyTypes = Object.entries(selected?.components ?? {}).filter(
([type]) => !namedTypes.has(type)
);
return (
<div>
<div className="flex items-center justify-between gap-3 flex-wrap mb-4">
<h2 className="text-lg font-bold text-[var(--color-text-primary)]">
Plugins ({filtered.length}
{filtered.length !== pluginsList.length ? ` of ${pluginsList.length}` : ''})
</h2>
<div className="relative w-full sm:w-72">
<svg
className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 pointer-events-none"
style={{ color: 'var(--color-text-tertiary)' }}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.35-4.35" />
</svg>
<input
type="text"
placeholder="Search plugins in this marketplace..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full pl-9 pr-8 py-2 rounded-lg text-[12.5px] outline-none transition-all duration-200"
style={{
background: 'var(--color-surface-2)',
border: '1px solid var(--color-border)',
color: 'var(--color-text-primary)',
}}
onFocus={(e) => {
e.currentTarget.style.borderColor = 'var(--color-border-hover)';
e.currentTarget.style.boxShadow = '0 0 0 3px rgba(255,217,61,0.1)';
}}
onBlur={(e) => {
e.currentTarget.style.borderColor = 'var(--color-border)';
e.currentTarget.style.boxShadow = 'none';
}}
/>
{search && (
<button
onClick={() => setSearch('')}
className="absolute right-2.5 top-1/2 -translate-y-1/2 w-4 h-4 rounded-full flex items-center justify-center transition-colors hover:opacity-80"
style={{ background: 'var(--color-surface-3)', color: 'var(--color-text-tertiary)' }}
aria-label="Clear search"
>
<svg className="w-2.5 h-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path d="M18 6 6 18M6 6l12 12" />
</svg>
</button>
)}
</div>
</div>
{filtered.length === 0 ? (
<div
className="flex flex-col items-center justify-center py-14 rounded-2xl"
style={{ background: 'var(--color-surface-1)', border: '1px dashed var(--color-border)' }}
>
<p className="text-[13px] font-medium" style={{ color: 'var(--color-text-secondary)' }}>
No plugins match "{search}"
</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{filtered.map((p, idx) => {
const sourceType = getSourceLabel(p);
const hasAuthor = p.author && p.author !== marketplaceAuthor;
const hasComponents = p.components && Object.keys(p.components).length > 0;
return (
<button
key={`${p.name}-${idx}`}
type="button"
onClick={() => setSelected(p)}
className="text-left flex flex-col p-4 rounded-xl bg-[var(--color-card-bg)] border border-[var(--color-border)] hover:border-[var(--color-border-hover)] transition-all duration-200 cursor-pointer"
style={{ boxShadow: 'var(--shadow-card)' }}
>
<div className="flex items-start gap-3 mb-2">
<div
className="w-8 h-8 rounded-lg flex items-center justify-center shrink-0 text-[12px] font-bold select-none"
style={{
background: 'linear-gradient(135deg, #ffd93d12 0%, #ffd93d08 100%)',
border: '1px solid #ffd93d20',
color: '#ffd93d99',
}}
>
{(p.name ?? '?').charAt(0).toUpperCase()}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h3 className="text-[13px] font-semibold text-[var(--color-text-primary)] truncate">
{p.name}
</h3>
{sourceType === 'github' && (
<span className="text-[9px] font-medium px-1.5 py-0.5 rounded bg-[var(--color-surface-3)] text-[var(--color-text-tertiary)] border border-[var(--color-border)]">
external
</span>
)}
</div>
{hasAuthor && (
<p className="text-[11px] text-[var(--color-text-tertiary)]">by {p.author}</p>
)}
</div>
</div>
{p.description && (
<p className="text-[12px] text-[var(--color-text-secondary)] leading-[1.5] line-clamp-2 mb-2 flex-1">
{p.description}
</p>
)}
{hasComponents && (
<div className="flex items-center gap-1.5 flex-wrap mt-auto">
{Object.entries(p.components!).map(([type, count]) => (
<span
key={type}
className="text-[9px] font-semibold px-2 py-0.5 rounded-full border"
style={{
background: `${tagColors[type] ?? '#666'}10`,
color: tagColors[type] ?? '#666',
borderColor: `${tagColors[type] ?? '#666'}20`,
}}
>
{count} {type}
</span>
))}
</div>
)}
{p.tags && p.tags.length > 0 && !hasComponents && (
<div className="flex items-center gap-1.5 flex-wrap mt-auto">
{p.tags.slice(0, 4).map((tag) => (
<span
key={tag}
className="text-[9px] font-medium px-2 py-0.5 rounded-full bg-[var(--color-surface-3)] text-[var(--color-text-tertiary)] border border-[var(--color-border)]"
>
{tag}
</span>
))}
</div>
)}
</button>
);
})}
</div>
)}
{selected && (
<div
ref={backdropRef}
onClick={handleBackdropClick}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4"
>
<div className="w-full max-w-xl bg-[var(--color-surface-1)] border border-[var(--color-border)] rounded-xl shadow-2xl overflow-hidden max-h-[85vh] flex flex-col">
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-[var(--color-border)] shrink-0">
<div className="flex items-center gap-2.5 min-w-0">
<div
className="w-8 h-8 rounded-lg flex items-center justify-center shrink-0 text-[13px] font-bold select-none"
style={{
background: 'linear-gradient(135deg, #ffd93d18 0%, #ffd93d08 100%)',
border: '1.5px solid #ffd93d30',
color: '#ffd93d',
}}
>
{(selected.name ?? '?').charAt(0).toUpperCase()}
</div>
<h3 className="text-sm font-semibold text-[var(--color-text-primary)] truncate">
{selected.name}
</h3>
</div>
<button
onClick={() => setSelected(null)}
className="p-1 rounded hover:bg-white/10 text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)] transition-colors shrink-0"
aria-label="Close"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Body */}
<div className="px-5 py-4 overflow-y-auto space-y-4">
{selected.description && (
<p className="text-[13px] text-[var(--color-text-secondary)] leading-relaxed">
{selected.description}
</p>
)}
{selected.author && selected.author !== marketplaceAuthor && (
<p className="text-[12px] text-[var(--color-text-tertiary)]">by {selected.author}</p>
)}
{/* Components with resolved item names */}
{Object.entries(selected.components_items ?? {}).map(([type, items]) => (
<div key={type}>
<div className="flex items-center gap-2 mb-2">
<span
className="text-[10px] font-semibold px-2.5 py-1 rounded-full border uppercase tracking-wider"
style={{
background: `${tagColors[type] ?? '#666'}15`,
color: tagColors[type] ?? '#666',
borderColor: `${tagColors[type] ?? '#666'}30`,
}}
>
{type}
</span>
<span className="text-[11px] text-[var(--color-text-tertiary)]">{items.length} items</span>
</div>
<div className="space-y-1.5">
{items.map((item) => (
<div
key={item.name}
className="flex items-start gap-2 px-2.5 py-2 rounded-lg bg-[var(--color-surface-2)] border border-[var(--color-border)] text-[12px]"
>
<span
className="w-1.5 h-1.5 rounded-full shrink-0 mt-1.5"
style={{ background: tagColors[type] ?? '#666' }}
/>
<div className="min-w-0">
<div className="text-[var(--color-text-primary)] font-mono text-[11.5px] font-medium">
{item.name}
</div>
{item.description && (
<p className="text-[var(--color-text-tertiary)] text-[11.5px] leading-snug mt-0.5">
{item.description}
</p>
)}
</div>
</div>
))}
</div>
</div>
))}
{/* Component types where only a count is known (names not resolved) */}
{countOnlyTypes.length > 0 && (
<div className="flex items-center gap-1.5 flex-wrap">
{countOnlyTypes.map(([type, count]) => (
<span
key={type}
className="text-[10px] font-semibold px-2.5 py-1 rounded-full border"
style={{
background: `${tagColors[type] ?? '#666'}12`,
color: tagColors[type] ?? '#666',
borderColor: `${tagColors[type] ?? '#666'}25`,
}}
>
{count} {type}
</span>
))}
</div>
)}
{!selected.components || Object.keys(selected.components).length === 0 ? (
<p className="text-[12px] text-[var(--color-text-tertiary)] italic">
No component breakdown available for this plugin yet.
</p>
) : null}
{/* Install command */}
<div>
<div className="text-[11px] uppercase tracking-wider text-[var(--color-text-tertiary)] mb-1.5 font-medium">
Install this plugin
</div>
<pre className="p-3 rounded-lg bg-[var(--color-surface-2)] border border-[var(--color-border)] text-[12px] text-[var(--color-text-secondary)] overflow-x-auto font-mono">
<code>{`/plugin install ${selected.name}@${marketplaceName}`}</code>
</pre>
</div>
</div>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,78 @@
import { useState, useEffect } from 'react';
function useGlobalAuth() {
const [state, setState] = useState({ isSignedIn: false, isLoaded: false });
useEffect(() => {
function check() {
const clerk = (window as any).Clerk;
if (clerk?.loaded) {
setState({ isSignedIn: !!clerk.user, isLoaded: true });
}
}
check();
const interval = setInterval(check, 500);
const handleChange = () => check();
window.addEventListener('clerk:session', handleChange);
return () => { clearInterval(interval); window.removeEventListener('clerk:session', handleChange); };
}, []);
return state;
}
export function NavContent({ isActive }: { isActive: boolean }) {
const { isSignedIn, isLoaded } = useGlobalAuth();
if (!isLoaded) {
return (
<div className="flex items-center gap-3 px-3 py-2 rounded-lg">
<div className="w-[18px] h-[18px] flex items-center justify-center shrink-0">
<div className="w-[18px] h-[18px] rounded bg-[var(--color-surface-3)] animate-pulse" />
</div>
<div className="sidebar-text w-24 h-3.5 rounded bg-[var(--color-surface-3)] animate-pulse" />
</div>
);
}
const icon = (
<svg className="w-[18px] h-[18px] flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="1.5">
<path strokeLinecap="round" strokeLinejoin="round" d="M2 17V7a2 2 0 012-2h5l2 2h9a2 2 0 012 2v8a2 2 0 01-2 2H4a2 2 0 01-2-2z" />
</svg>
);
if (isSignedIn) {
return (
<a
href="/my-components"
data-tooltip="My Components"
className={`flex items-center gap-3 px-3 py-2 rounded-lg text-[13px] transition-all duration-150 group ${
isActive
? 'bg-[var(--color-surface-3)] text-[var(--color-text-primary)] font-medium shadow-sm'
: 'text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-surface-2)] hover:shadow-sm'
}`}
>
<span className={`w-[18px] h-[18px] shrink-0 flex items-center justify-center transition-all duration-150 ${isActive ? 'text-[var(--color-text-primary)] scale-105' : 'text-[var(--color-text-tertiary)] group-hover:text-[var(--color-text-secondary)] group-hover:scale-105'}`}>
{icon}
</span>
<span className="sidebar-text truncate transition-all duration-300">My Components</span>
</a>
);
}
return (
<button
onClick={() => (window as any).Clerk?.openSignIn?.()}
data-tooltip="My Components"
className="flex items-center gap-3 px-3 py-2 rounded-lg text-[13px] text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-surface-2)] hover:shadow-sm transition-all duration-150 group w-full text-left"
>
<span className="w-[18px] h-[18px] shrink-0 flex items-center justify-center text-[var(--color-text-tertiary)] group-hover:text-[var(--color-text-secondary)] transition-all duration-150 group-hover:scale-105">
{icon}
</span>
<span className="sidebar-text truncate transition-all duration-300">My Components</span>
</button>
);
}
export default function MyComponentsSidebarItem({ isActive = false }: { isActive?: boolean }) {
return <NavContent isActive={isActive} />;
}
File diff suppressed because it is too large Load Diff
+37
View File
@@ -0,0 +1,37 @@
---
interface Props {
href: string;
label: string;
icon?: string;
active?: boolean;
external?: boolean;
}
const { href, label, icon, active = false, external = false } = Astro.props;
const Tag = external ? 'a' : 'a';
---
<a
href={href}
class:list={[
'flex items-center gap-2.5 px-3 py-1.5 rounded-md text-sm transition-colors',
active
? 'bg-primary-500/15 text-primary-400 font-medium'
: 'text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-surface-3)]',
]}
{...external ? { target: '_blank', rel: 'noopener noreferrer' } : {}}
>
{icon && (
<span
class="w-5 h-5 shrink-0 inline-flex items-center justify-center leading-none text-center [&>svg]:w-full [&>svg]:h-full [&>svg]:block [&>svg]:m-auto"
>
{icon}
</span>
)}
<span class="truncate">{label}</span>
{external && (
<svg class="w-3 h-3 ml-auto opacity-40 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
)}
</a>
+278
View File
@@ -0,0 +1,278 @@
import { useState, useMemo } from 'react';
interface Plugin {
slug: string;
name: string;
author: string;
description: string;
github: string;
website?: string;
stars: number;
type: string;
tags: string[];
contains: Record<string, number>;
highlights: string[];
}
const TAG_COLORS: Record<string, string> = {
skills: '#f59e0b',
agents: '#3b82f6',
commands: '#10b981',
hooks: '#f97316',
mcps: '#06b6d4',
settings: '#8b5cf6',
rules: '#ec4899',
};
function formatStars(stars: number): string {
if (stars >= 1000) return `${(stars / 1000).toFixed(stars >= 10000 ? 0 : 1)}k`;
return String(stars);
}
type SortMode = 'stars' | 'alpha';
export default function PluginsGrid({ plugins }: { plugins: Plugin[] }) {
const [search, setSearch] = useState('');
const [sort, setSort] = useState<SortMode>('stars');
const filtered = useMemo(() => {
let result = plugins;
if (search.trim()) {
const q = search.toLowerCase().trim();
result = result.filter(
(p) =>
p.name.toLowerCase().includes(q) ||
p.author.toLowerCase().includes(q) ||
p.description.toLowerCase().includes(q) ||
p.tags.some((t) => t.toLowerCase().includes(q))
);
}
if (sort === 'stars') {
result = [...result].sort((a, b) => b.stars - a.stars);
} else {
result = [...result].sort((a, b) => a.name.localeCompare(b.name));
}
return result;
}, [plugins, search, sort]);
return (
<div className="px-6 py-8 max-w-7xl mx-auto">
{/* Controls */}
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-3 mb-6">
{/* Search */}
<div className="relative flex-1">
<svg
className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 pointer-events-none"
style={{ color: 'var(--color-text-tertiary)' }}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.35-4.35" />
</svg>
<input
type="text"
placeholder="Search plugins by name, author, or tag..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full pl-10 pr-4 py-2.5 rounded-xl text-[13px] outline-none transition-all duration-200"
style={{
background: 'var(--color-surface-2)',
border: '1px solid var(--color-border)',
color: 'var(--color-text-primary)',
}}
onFocus={(e) => {
e.currentTarget.style.borderColor = 'var(--color-border-hover)';
e.currentTarget.style.boxShadow = '0 0 0 3px rgba(255,217,61,0.1)';
}}
onBlur={(e) => {
e.currentTarget.style.borderColor = 'var(--color-border)';
e.currentTarget.style.boxShadow = 'none';
}}
/>
{search && (
<button
onClick={() => setSearch('')}
className="absolute right-3 top-1/2 -translate-y-1/2 w-5 h-5 rounded-full flex items-center justify-center transition-colors hover:opacity-80"
style={{
background: 'var(--color-surface-3)',
color: 'var(--color-text-tertiary)',
}}
>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path d="M18 6 6 18M6 6l12 12" />
</svg>
</button>
)}
</div>
{/* Sort selector */}
<div
className="flex items-center rounded-xl overflow-hidden shrink-0"
style={{
background: 'var(--color-surface-2)',
border: '1px solid var(--color-border)',
}}
>
<button
onClick={() => setSort('stars')}
className="flex items-center gap-1.5 px-4 py-2.5 text-[12px] font-semibold transition-all duration-200"
style={{
background: sort === 'stars' ? 'var(--color-surface-3)' : 'transparent',
color: sort === 'stars' ? '#ffd93d' : 'var(--color-text-tertiary)',
}}
>
<svg className="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
</svg>
Stars
</button>
<div style={{ width: '1px', height: '20px', background: 'var(--color-border)' }} />
<button
onClick={() => setSort('alpha')}
className="flex items-center gap-1.5 px-4 py-2.5 text-[12px] font-semibold transition-all duration-200"
style={{
background: sort === 'alpha' ? 'var(--color-surface-3)' : 'transparent',
color: sort === 'alpha' ? '#ffd93d' : 'var(--color-text-tertiary)',
}}
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path d="M3 6h7M3 12h10M3 18h5M16 6l3 3m0 0l3-3m-3 3V3m0 18l-3-3m3 3l3-3m-3 3V12" strokeLinecap="round" strokeLinejoin="round" />
</svg>
A-Z
</button>
</div>
</div>
{/* Results count */}
{search && (
<p className="text-[12px] mb-4" style={{ color: 'var(--color-text-tertiary)' }}>
{filtered.length} {filtered.length === 1 ? 'result' : 'results'} for "{search}"
</p>
)}
{/* Grid */}
{filtered.length === 0 ? (
<div
className="flex flex-col items-center justify-center py-20 rounded-2xl"
style={{
background: 'var(--color-surface-1)',
border: '1px dashed var(--color-border)',
}}
>
<svg
className="w-12 h-12 mb-3"
style={{ color: 'var(--color-text-tertiary)', opacity: 0.4 }}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.35-4.35" strokeLinecap="round" />
</svg>
<p className="text-[14px] font-medium" style={{ color: 'var(--color-text-secondary)' }}>
No plugins found
</p>
<p className="text-[12px] mt-1" style={{ color: 'var(--color-text-tertiary)' }}>
Try a different search term
</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-5">
{filtered.map((plugin, idx) => (
<a
key={plugin.slug}
href={`/plugins/${plugin.slug}`}
className="group relative flex flex-col p-6 rounded-2xl transition-all duration-300 cursor-pointer no-underline"
style={{
background: 'var(--color-card-bg)',
border: '1px solid var(--color-border)',
boxShadow: 'var(--shadow-card)',
animationDelay: `${idx * 50}ms`,
animationFillMode: 'both',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'var(--color-card-hover)';
e.currentTarget.style.borderColor = 'var(--color-border-hover)';
e.currentTarget.style.transform = 'scale(1.02) translateY(-4px)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'var(--color-card-bg)';
e.currentTarget.style.borderColor = 'var(--color-border)';
e.currentTarget.style.transform = 'scale(1) translateY(0)';
}}
>
{/* Gradient overlay on hover */}
<div className="absolute inset-0 rounded-2xl bg-gradient-to-br from-yellow-500/0 to-purple-500/0 group-hover:from-yellow-500/5 group-hover:to-purple-500/5 transition-all duration-300 pointer-events-none" />
{/* Header */}
<div className="relative z-10 flex items-start gap-4 mb-4">
<div
className="w-12 h-12 rounded-xl flex items-center justify-center shrink-0 transition-all duration-300 group-hover:scale-110 group-hover:rotate-3 text-[22px] font-bold select-none"
style={{
background: 'linear-gradient(135deg, #ffd93d18 0%, #ffd93d08 100%)',
border: '1.5px solid #ffd93d30',
color: '#ffd93d',
}}
>
{plugin.author.charAt(0).toUpperCase()}
</div>
<div className="min-w-0 flex-1">
<h3
className="text-[15px] font-bold leading-tight mb-1 transition-colors"
style={{ color: 'var(--color-text-primary)' }}
>
{plugin.name}
</h3>
<p className="text-[12px]" style={{ color: 'var(--color-text-tertiary)' }}>
by {plugin.author}
</p>
</div>
{/* Stars badge */}
<span className="shrink-0 text-[11px] font-semibold px-2.5 py-1 rounded-full flex items-center gap-1 transition-all bg-gradient-to-r from-yellow-500/15 to-amber-400/15 text-yellow-400 border border-yellow-500/25 group-hover:from-yellow-500/25 group-hover:to-amber-400/25 group-hover:border-yellow-500/40">
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
</svg>
{formatStars(plugin.stars)}
</span>
</div>
{/* Description */}
<p
className="relative z-10 text-[13px] line-clamp-2 leading-[1.6] mb-4 flex-1"
style={{ color: 'var(--color-text-secondary)' }}
>
{plugin.description}
</p>
{/* Tags */}
<div className="relative z-10 flex items-center gap-1.5 flex-wrap">
{plugin.tags.slice(0, 5).map((tag) => (
<span
key={tag}
className="text-[10px] font-semibold px-2.5 py-1 rounded-full border transition-all"
style={{
background: `${TAG_COLORS[tag] ?? '#666'}12`,
color: TAG_COLORS[tag] ?? '#666',
borderColor: `${TAG_COLORS[tag] ?? '#666'}25`,
}}
>
{tag}
</span>
))}
</div>
</a>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,296 @@
import { useState, useEffect, useRef } from 'react';
import { collectionsApi } from '../lib/collections-api';
import type { Collection } from '../lib/types';
// Hook that reads auth state from Clerk's global instance (window.Clerk)
// Works without needing a ClerkProvider in the same React tree
function useGlobalAuth() {
const [state, setState] = useState({ isSignedIn: false, isLoaded: false });
useEffect(() => {
function check() {
const clerk = (window as any).Clerk;
if (clerk?.loaded) {
setState({ isSignedIn: !!clerk.user, isLoaded: true });
}
}
check();
// Re-check periodically until Clerk loads
const interval = setInterval(check, 500);
// Also listen for clerk state changes
const handleChange = () => check();
window.addEventListener('clerk:session', handleChange);
return () => { clearInterval(interval); window.removeEventListener('clerk:session', handleChange); };
}, []);
const getToken = async () => {
const clerk = (window as any).Clerk;
return clerk?.session?.getToken() ?? null;
};
return { ...state, getToken };
}
interface Props {
componentType: string;
componentPath: string;
componentName: string;
componentCategory?: string;
}
function SaveButton({ componentType, componentPath, componentName, componentCategory }: Props) {
const { isSignedIn, isLoaded, getToken } = useGlobalAuth();
const [open, setOpen] = useState(false);
const [collections, setCollections] = useState<Collection[]>([]);
const [loading, setLoading] = useState(false);
const [newName, setNewName] = useState('');
const buttonRef = useRef<HTMLButtonElement>(null);
const [dropdownPos, setDropdownPos] = useState({ top: 0, left: 0 });
const [creating, setCreating] = useState(false);
const [savedIn, setSavedIn] = useState<Set<string>>(new Set());
const dropdownRef = useRef<HTMLDivElement>(null);
// Close dropdown on outside click
useEffect(() => {
if (!open) return;
function handleClick(e: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
setOpen(false);
}
}
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, [open]);
async function loadCollections() {
setLoading(true);
try {
const token = await getToken();
if (!token) return;
const cols = await collectionsApi.list(token);
setCollections(cols);
// Track which collections already contain this component
const saved = new Set<string>();
for (const col of cols) {
if (col.collection_items?.some((i) => i.component_path === componentPath && i.component_type === componentType)) {
saved.add(col.id);
}
}
setSavedIn(saved);
} catch (err) {
console.error('Failed to load collections:', err);
} finally {
setLoading(false);
}
}
// Elevate the parent card's z-index when dropdown is open
useEffect(() => {
const card = dropdownRef.current?.closest('.group') as HTMLElement | null;
if (!card) return;
if (open) {
card.style.zIndex = '50';
card.style.position = 'relative';
} else {
card.style.zIndex = '';
card.style.position = '';
}
return () => { card.style.zIndex = ''; card.style.position = ''; };
}, [open]);
async function handleToggle(e: React.MouseEvent) {
e.stopPropagation();
if (!open) {
setOpen(true);
await loadCollections();
} else {
setOpen(false);
}
}
async function handleToggleCollection(collectionId: string) {
const token = await getToken();
if (!token) return;
try {
if (savedIn.has(collectionId)) {
// Remove from collection
const col = collections.find((c) => c.id === collectionId);
const item = col?.collection_items?.find(
(i) => i.component_path === componentPath && i.component_type === componentType
);
if (item) {
await collectionsApi.removeItem(token, item.id, collectionId);
setSavedIn((prev) => {
const next = new Set(prev);
next.delete(collectionId);
return next;
});
// Update local state
setCollections((prev) =>
prev.map((c) =>
c.id === collectionId
? { ...c, collection_items: c.collection_items.filter((i) => i.id !== item.id) }
: c
)
);
}
} else {
// Add to collection
const newItem = await collectionsApi.addItem(token, collectionId, {
type: componentType,
path: componentPath,
name: componentName,
category: componentCategory,
});
setSavedIn((prev) => new Set(prev).add(collectionId));
setCollections((prev) =>
prev.map((c) =>
c.id === collectionId
? { ...c, collection_items: [...(c.collection_items ?? []), newItem] }
: c
)
);
}
} catch (err: any) {
console.error('Failed to toggle collection item:', err);
}
}
async function handleCreate() {
if (!newName.trim()) return;
const token = await getToken();
if (!token) return;
setCreating(true);
try {
const col = await collectionsApi.create(token, newName.trim());
// Add item to the new collection immediately
const newItem = await collectionsApi.addItem(token, col.id, {
type: componentType,
path: componentPath,
name: componentName,
category: componentCategory,
});
col.collection_items = [newItem];
setCollections((prev) => [...prev, col]);
setSavedIn((prev) => new Set(prev).add(col.id));
setNewName('');
} catch (err: any) {
console.error('Failed to create collection:', err);
} finally {
setCreating(false);
}
}
if (!isLoaded) return null;
const isSaved = savedIn.size > 0;
// Not signed in - open Clerk modal via global instance
if (!isSignedIn) {
return (
<button
onClick={(e) => { e.stopPropagation(); (window as any).Clerk?.openSignIn?.(); }}
className="w-7 h-7 rounded-lg flex items-center justify-center shrink-0 mt-0.5 text-[var(--color-text-tertiary)] opacity-0 group-hover:opacity-100 hover:text-white hover:bg-white/10 transition-all"
title="Sign in to save"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="1.5">
<path strokeLinecap="round" strokeLinejoin="round" d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z" />
</svg>
</button>
);
}
return (
<div className="relative" ref={dropdownRef}>
<button
ref={buttonRef}
onClick={handleToggle}
className={`w-7 h-7 rounded-lg flex items-center justify-center shrink-0 mt-0.5 transition-all ${
isSaved
? 'text-blue-400'
: 'text-[var(--color-text-tertiary)] opacity-0 group-hover:opacity-100 hover:text-white hover:bg-white/10'
}`}
title={isSaved ? 'Saved to collection' : 'Save to collection'}
>
<svg className="w-3.5 h-3.5" fill={isSaved ? 'currentColor' : 'none'} viewBox="0 0 24 24" stroke="currentColor" strokeWidth="1.5">
<path strokeLinecap="round" strokeLinejoin="round" d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z" />
</svg>
</button>
{open && (
<div className="absolute right-0 top-full mt-1 w-56 bg-[var(--color-surface-1)] border border-[var(--color-border)] rounded-lg shadow-xl z-[9999] py-1">
{loading ? (
<div className="px-3 py-4 text-center">
<div className="w-4 h-4 border-2 border-[var(--color-text-tertiary)] border-t-transparent rounded-full animate-spin mx-auto" />
</div>
) : (
<>
<div className="px-3 py-1.5 text-[11px] font-medium text-[var(--color-text-tertiary)] uppercase tracking-wide">
Save to collection
</div>
{collections.length === 0 && (
<div className="px-3 py-2 text-[12px] text-[var(--color-text-tertiary)]">
No collections yet
</div>
)}
<div className="max-h-40 overflow-y-auto">
{collections.map((col) => (
<button
key={col.id}
onClick={(e) => { e.stopPropagation(); handleToggleCollection(col.id); }}
className="flex items-center gap-2 w-full px-3 py-1.5 text-[12px] text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-white/[0.06] transition-colors"
>
<span className={`w-3.5 h-3.5 rounded border flex items-center justify-center shrink-0 ${
savedIn.has(col.id) ? 'bg-blue-500 border-blue-500' : 'border-[var(--color-border-hover)]'
}`}>
{savedIn.has(col.id) && (
<svg className="w-2.5 h-2.5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="3">
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
)}
</span>
<span className="truncate">{col.name}</span>
<span className="ml-auto text-[10px] text-[var(--color-text-tertiary)]">
{col.collection_items?.length ?? 0}
</span>
</button>
))}
</div>
<div className="border-t border-[var(--color-border)] mt-1 pt-1 px-2 pb-1">
<div className="flex items-center gap-1">
<input
type="text"
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') handleCreate(); }}
onClick={(e) => e.stopPropagation()}
placeholder="New collection..."
className="flex-1 bg-transparent border-none text-[12px] text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] px-1 py-1 outline-none"
maxLength={100}
/>
<button
onClick={(e) => { e.stopPropagation(); handleCreate(); }}
disabled={!newName.trim() || creating}
className="text-[11px] px-2 py-0.5 rounded bg-blue-600 hover:bg-blue-500 text-white disabled:opacity-40 transition-colors"
>
{creating ? '...' : 'Add'}
</button>
</div>
</div>
</>
)}
</div>
)}
</div>
);
}
export default function SaveToCollectionButton(props: Props) {
return <SaveButton {...props} />;
}
+282
View File
@@ -0,0 +1,282 @@
import { useState, useEffect, useRef, useMemo } from 'react';
import { TYPE_CONFIG } from '../lib/icons';
import TypeIcon from './TypeIcon';
import { fetchSearchIndex, type SearchIndexEntry } from '../lib/data';
function cleanPath(path: string): string {
return path?.replace(/\.(md|json)$/, '') ?? '';
}
function formatName(name: string): string {
if (!name) return '';
return name
.replace(/\.(md|json)$/, '')
.replace(/[-_]/g, ' ')
.split(' ')
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(' ');
}
export default function SearchModal() {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
const [data, setData] = useState<SearchIndexEntry[] | null>(null);
const [selectedIndex, setSelectedIndex] = useState(0);
const [isAnimating, setIsAnimating] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const resultsRef = useRef<HTMLDivElement>(null);
const closeTimeoutRef = useRef<number | null>(null);
// Load the lightweight search index lazily on first open
useEffect(() => {
if (!open || data) return;
fetchSearchIndex()
.then((entries) => setData(entries))
.catch(() => {});
}, [open, data]);
// Cmd+K listener
useEffect(() => {
function handleKey(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault();
if (open) {
closeModal();
} else {
openModal();
}
}
if (e.key === 'Escape' && open) closeModal();
}
const handleTriggerClick = (e: Event) => {
e.preventDefault();
openModal();
};
window.addEventListener('keydown', handleKey);
// Use MutationObserver to watch for the trigger button
let observer: MutationObserver | null = null;
let timeoutId: number | null = null;
const trigger = document.getElementById('searchTrigger');
if (trigger) {
trigger.addEventListener('click', handleTriggerClick);
} else {
// Watch for the button to be added to the DOM
observer = new MutationObserver(() => {
const btn = document.getElementById('searchTrigger');
if (btn) {
btn.addEventListener('click', handleTriggerClick);
observer?.disconnect();
observer = null;
}
});
observer.observe(document.body, { childList: true, subtree: true });
// Cleanup observer after 5 seconds if button never appears
timeoutId = window.setTimeout(() => {
observer?.disconnect();
observer = null;
}, 5000);
}
return () => {
window.removeEventListener('keydown', handleKey);
const trigger = document.getElementById('searchTrigger');
trigger?.removeEventListener('click', handleTriggerClick);
if (observer) {
observer.disconnect();
}
if (timeoutId !== null) {
clearTimeout(timeoutId);
}
};
}, [open]);
function openModal() {
// Clear any pending close timeout to prevent race conditions
if (closeTimeoutRef.current !== null) {
clearTimeout(closeTimeoutRef.current);
closeTimeoutRef.current = null;
}
setOpen(true);
setIsAnimating(true);
}
function closeModal() {
setIsAnimating(false);
// Clear any existing timeout before setting a new one
if (closeTimeoutRef.current !== null) {
clearTimeout(closeTimeoutRef.current);
}
closeTimeoutRef.current = window.setTimeout(() => {
setOpen(false);
closeTimeoutRef.current = null;
}, 200); // Match animation duration
}
// Focus input when opened
useEffect(() => {
if (open) {
setQuery('');
setSelectedIndex(0);
setTimeout(() => inputRef.current?.focus(), 50);
}
}, [open]);
// Search results
const results = useMemo(() => {
if (!data || !query.trim()) return [];
const q = query.toLowerCase();
return data
.filter(
(c) =>
c.name.toLowerCase().includes(q) ||
c.description?.toLowerCase().includes(q) ||
c.category?.toLowerCase().includes(q)
)
.slice(0, 20);
}, [data, query]);
// Keyboard navigation
useEffect(() => {
setSelectedIndex(0);
}, [query]);
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === 'ArrowDown') {
e.preventDefault();
setSelectedIndex((i) => Math.min(results.length - 1, i + 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setSelectedIndex((i) => Math.max(0, i - 1));
} else if (e.key === 'Enter' && results[selectedIndex]) {
const c = results[selectedIndex];
navigate(c);
}
}
function navigate(component: SearchIndexEntry) {
closeModal();
window.location.href = `/component/${component.type}/${cleanPath(component.path ?? component.name)}`;
}
// Scroll selected into view
useEffect(() => {
const container = resultsRef.current;
const selected = container?.children[selectedIndex] as HTMLElement;
selected?.scrollIntoView({ block: 'nearest' });
}, [selectedIndex]);
if (!open) return null;
return (
<div
className="fixed inset-0 z-50 flex items-start justify-center pt-[20vh]"
onClick={closeModal}
>
{/* Backdrop */}
<div
className={`absolute inset-0 bg-black/60 backdrop-blur-sm transition-opacity duration-200 ${
isAnimating ? 'opacity-100' : 'opacity-0'
}`}
/>
{/* Modal */}
<div
className={`relative w-full max-w-xl bg-[var(--color-surface-1)] border border-[var(--color-border)] rounded-xl shadow-2xl overflow-hidden transition-all duration-200 ${
isAnimating
? 'opacity-100 scale-100 translate-y-0'
: 'opacity-0 scale-95 translate-y-4'
}`}
onClick={(e) => e.stopPropagation()}
>
{/* Input */}
<div className={`flex items-center gap-3 px-4 py-3 border-b border-[var(--color-border)] transition-all duration-200 delay-[75ms] ${
isAnimating ? 'opacity-100 translate-x-0' : 'opacity-0 translate-x-4'
}`}>
<svg className="w-5 h-5 text-[var(--color-text-tertiary)] shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
ref={inputRef}
type="text"
placeholder="Search all components..."
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
className="flex-1 bg-transparent text-sm text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] outline-none"
/>
<kbd className="px-1.5 py-0.5 text-[10px] bg-[var(--color-surface-3)] border border-[var(--color-border)] rounded text-[var(--color-text-tertiary)]">
ESC
</kbd>
</div>
{/* Results */}
<div ref={resultsRef} className={`max-h-80 overflow-y-auto transition-all duration-200 delay-100 ${
isAnimating ? 'opacity-100' : 'opacity-0'
}`}>
{query.trim() && results.length === 0 && (
<div className="flex flex-col items-center justify-center px-4 py-12">
{/* Icon */}
<div className="w-16 h-16 rounded-xl bg-[var(--color-surface-2)] border border-[var(--color-border)] flex items-center justify-center mb-4">
<svg className="w-8 h-8 text-[var(--color-text-tertiary)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="1.5">
<path strokeLinecap="round" strokeLinejoin="round" d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M12 12h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
{/* Message */}
<p className="text-[14px] font-medium text-[var(--color-text-primary)] mb-1">No results found</p>
<p className="text-[13px] text-[var(--color-text-secondary)] text-center max-w-xs">
We couldn't find any components for "<span className="font-medium text-[var(--color-text-primary)]">{query}</span>". Try different keywords.
</p>
</div>
)}
{results.map((component, i) => {
const typePlural = component.type.endsWith('s') ? component.type : component.type + 's';
const config = TYPE_CONFIG[typePlural];
return (
<button
key={component.path ?? `${component.name}-${i}`}
onClick={() => navigate(component)}
className={`w-full flex items-center gap-3 px-4 py-2.5 text-left transition-colors ${
i === selectedIndex
? 'bg-[var(--color-primary-500)]/10'
: 'hover:bg-[var(--color-surface-2)]'
}`}
>
<TypeIcon type={typePlural} size={16} className="w-4 h-4 shrink-0 [&>svg]:w-4 [&>svg]:h-4 text-[var(--color-text-tertiary)]" />
<div className="min-w-0 flex-1">
<span className="text-sm text-[var(--color-text-primary)]">{formatName(component.name)}</span>
<span className="text-xs text-[var(--color-text-tertiary)] ml-2">{config?.label ?? component.type}</span>
</div>
{component.category && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-[var(--color-surface-3)] text-[var(--color-text-tertiary)] shrink-0">
{component.category}
</span>
)}
</button>
);
})}
</div>
{/* Footer */}
{query.trim() && results.length > 0 && (
<div className={`flex items-center gap-4 px-4 py-2 border-t border-[var(--color-border)] text-[10px] text-[var(--color-text-tertiary)] transition-all duration-200 delay-[150ms] ${
isAnimating ? 'opacity-100 translate-y-0' : 'opacity-0 -translate-y-2'
}`}>
<span>↑↓ navigate</span>
<span>↵ open</span>
<span>esc close</span>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,396 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { listRepos, createPR, type GitHubRepo, type PRResult } from '../lib/github-api';
import type { CollectionItem } from '../lib/types';
import { fetchComponentContent } from '../lib/data';
type Step = 'connect' | 'select-repo' | 'creating' | 'done' | 'error';
interface Props {
items: CollectionItem[];
collectionName: string;
onClose: () => void;
}
const GITHUB_CLIENT_ID = import.meta.env.PUBLIC_GITHUB_CLIENT_ID ?? '';
function pluralType(type: string): string {
return type.endsWith('s') ? type : type + 's';
}
function cleanPath(path: string): string {
return path?.replace(/\.(md|json)$/, '') ?? '';
}
// Map collection items to file paths + content.
// Content lives in per-component files (/component-content/{type}/{slug}.json),
// not in the index, so we fetch each item's content on demand.
async function buildFileMap(
items: CollectionItem[]
): Promise<Record<string, string>> {
const files: Record<string, string> = {};
await Promise.all(
items.map(async (item) => {
const type = pluralType(item.component_type);
const cleanItemPath = cleanPath(item.component_path);
const content = await fetchComponentContent(item.component_type, cleanItemPath);
if (!content) return;
const name = item.component_name?.replace(/\.(md|json)$/, '') ?? '';
switch (type) {
case 'agents':
files[`.claude/agents/${name}.md`] = content;
break;
case 'commands':
files[`.claude/commands/${name}.md`] = content;
break;
case 'skills': {
// Skills may have multiple files; content is the main SKILL.md
const skillPath = cleanItemPath.replace(/^[^/]+\//, '');
files[`.claude/skills/${skillPath}/SKILL.md`] = content;
break;
}
case 'hooks':
files[`.claude/hooks/${name}.json`] = content;
break;
case 'settings':
// Settings content is JSON that should be merged into settings.json
files[`.claude/settings/${name}.json`] = content;
break;
case 'mcps':
files[`.mcp-components/${name}.json`] = content;
break;
}
})
);
return files;
}
export default function SendToRepoModal({ items, collectionName, onClose }: Props) {
const [step, setStep] = useState<Step>(() => {
const token = sessionStorage.getItem('github_token');
return token ? 'select-repo' : 'connect';
});
const [token, setToken] = useState(() => sessionStorage.getItem('github_token') ?? '');
const [repos, setRepos] = useState<GitHubRepo[]>([]);
const [filteredRepos, setFilteredRepos] = useState<GitHubRepo[]>([]);
const [search, setSearch] = useState('');
const [loadingRepos, setLoadingRepos] = useState(false);
const [selectedRepo, setSelectedRepo] = useState<GitHubRepo | null>(null);
const [prResult, setPrResult] = useState<PRResult | null>(null);
const [error, setError] = useState('');
const backdropRef = useRef<HTMLDivElement>(null);
// Listen for OAuth callback
useEffect(() => {
function handleMessage(event: MessageEvent) {
if (event.origin !== window.location.origin) return;
if (event.data?.type === 'github-oauth' && event.data.token) {
sessionStorage.setItem('github_token', event.data.token);
setToken(event.data.token);
setStep('select-repo');
}
}
window.addEventListener('message', handleMessage);
return () => window.removeEventListener('message', handleMessage);
}, []);
// Load repos when we have a token
useEffect(() => {
if (step === 'select-repo' && token && repos.length === 0) {
loadRepos();
}
}, [step, token]);
// Filter repos on search
useEffect(() => {
if (!search.trim()) {
setFilteredRepos(repos);
} else {
const q = search.toLowerCase();
setFilteredRepos(repos.filter((r) => r.full_name.toLowerCase().includes(q)));
}
}, [search, repos]);
async function loadRepos() {
setLoadingRepos(true);
try {
const data = await listRepos(token);
setRepos(data);
setFilteredRepos(data);
} catch (err: any) {
if (err.message?.includes('401')) {
sessionStorage.removeItem('github_token');
setToken('');
setStep('connect');
} else {
setError(err.message);
setStep('error');
}
} finally {
setLoadingRepos(false);
}
}
function startOAuth() {
if (!GITHUB_CLIENT_ID) {
setError('GitHub OAuth is not configured (missing PUBLIC_GITHUB_CLIENT_ID)');
setStep('error');
return;
}
const redirectUri = `${window.location.origin}/github-callback`;
const url = `https://github.com/login/oauth/authorize?client_id=${GITHUB_CLIENT_ID}&redirect_uri=${encodeURIComponent(redirectUri)}&scope=repo`;
const w = 600, h = 700;
const left = window.screenX + (window.outerWidth - w) / 2;
const top = window.screenY + (window.outerHeight - h) / 2;
window.open(url, 'github-oauth', `width=${w},height=${h},left=${left},top=${top}`);
}
const handleCreatePR = useCallback(async () => {
if (!selectedRepo || !token) return;
setStep('creating');
try {
// Fetch per-component content on demand and assemble the PR file map.
const files = await buildFileMap(items);
if (Object.keys(files).length === 0) {
throw new Error('No component content found to include in PR');
}
const branchName = `claude-components/${collectionName.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`;
const timestamp = new Date().toISOString().slice(0, 10);
const pr = await createPR(
token,
selectedRepo.full_name,
files,
`${branchName}-${timestamp}`,
`Add Claude Code components: ${collectionName}`,
`Add Claude Code components: ${collectionName}`,
[
`## Claude Code Components`,
'',
`This PR adds **${items.length}** component${items.length !== 1 ? 's' : ''} from the "${collectionName}" collection.`,
'',
'### Files',
'',
...Object.keys(files).map((f) => `- \`${f}\``),
'',
'---',
`Sent from [AI Templates](https://app.aitmpl.com/my-components)`,
].join('\n')
);
setPrResult(pr);
setStep('done');
} catch (err: any) {
setError(err.message);
setStep('error');
}
}, [selectedRepo, token, items, collectionName]);
function handleBackdropClick(e: React.MouseEvent) {
if (e.target === backdropRef.current) onClose();
}
return (
<div
ref={backdropRef}
onClick={handleBackdropClick}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
>
<div className="w-full max-w-lg mx-4 bg-[var(--color-surface-1)] border border-[var(--color-border)] rounded-xl shadow-2xl overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-[var(--color-border)]">
<div className="flex items-center gap-2.5">
<svg className="w-5 h-5 text-[var(--color-text-primary)]" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z" />
</svg>
<h3 className="text-sm font-semibold text-white">Send to GitHub</h3>
</div>
<button
onClick={onClose}
className="p-1 rounded hover:bg-white/10 text-[var(--color-text-tertiary)] hover:text-white transition-colors"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Body */}
<div className="px-5 py-4">
{/* Step: Connect GitHub */}
{step === 'connect' && (
<div className="text-center py-6">
<p className="text-sm text-[var(--color-text-secondary)] mb-4">
Connect your GitHub account to create a PR with your components.
</p>
<button
onClick={startOAuth}
className="inline-flex items-center gap-2 px-5 py-2.5 bg-white text-black rounded-lg text-sm font-medium hover:bg-gray-100 transition-colors"
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z" />
</svg>
Connect GitHub
</button>
</div>
)}
{/* Step: Select repo */}
{step === 'select-repo' && (
<div>
<div className="mb-3">
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search repositories..."
className="w-full bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded-lg px-3 py-2 text-sm text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] outline-none focus:border-[var(--color-border-hover)] transition-colors"
autoFocus
/>
</div>
{loadingRepos ? (
<div className="flex items-center justify-center py-8">
<div className="w-5 h-5 border-2 border-[var(--color-text-tertiary)] border-t-transparent rounded-full animate-spin" />
</div>
) : (
<div className="max-h-72 overflow-y-auto space-y-0.5 -mx-1 px-1">
{filteredRepos.map((repo) => (
<button
key={repo.id}
onClick={() => setSelectedRepo(repo)}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-left transition-colors ${
selectedRepo?.id === repo.id
? 'bg-blue-500/15 border border-blue-500/30'
: 'hover:bg-white/[0.04] border border-transparent'
}`}
>
<svg className="w-4 h-4 shrink-0 text-[var(--color-text-tertiary)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="1.5">
<path strokeLinecap="round" strokeLinejoin="round" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
</svg>
<div className="min-w-0 flex-1">
<div className="text-[13px] text-white truncate">{repo.full_name}</div>
{repo.description && (
<div className="text-[11px] text-[var(--color-text-tertiary)] truncate mt-0.5">
{repo.description}
</div>
)}
</div>
{repo.private && (
<svg className="w-3.5 h-3.5 shrink-0 text-[var(--color-text-tertiary)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="1.5">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0110 0v4" />
</svg>
)}
</button>
))}
{filteredRepos.length === 0 && !loadingRepos && (
<div className="flex flex-col items-center justify-center py-12 px-4">
<div className="w-16 h-16 rounded-xl bg-[var(--color-surface-2)] border border-[var(--color-border)] flex items-center justify-center mb-4">
<svg className="w-8 h-8 text-[var(--color-text-tertiary)]" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" />
</svg>
</div>
<p className="text-[14px] font-medium text-[var(--color-text-primary)] mb-1">
{search ? 'No repositories found' : 'No repositories available'}
</p>
<p className="text-[13px] text-[var(--color-text-secondary)] text-center max-w-xs">
{search ? 'Try a different search term' : 'Connect your GitHub account to see your repositories'}
</p>
</div>
)}
</div>
)}
{/* Footer with create PR button */}
{selectedRepo && (
<div className="mt-4 pt-3 border-t border-[var(--color-border)] flex items-center justify-between">
<div className="text-[12px] text-[var(--color-text-tertiary)]">
{items.length} component{items.length !== 1 ? 's' : ''} <span className="text-[var(--color-text-primary)]">{selectedRepo.full_name}</span>
</div>
<button
onClick={handleCreatePR}
className="flex items-center gap-2 px-4 py-2 bg-green-600 hover:bg-green-500 text-white rounded-lg text-[13px] font-medium transition-colors"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
<circle cx="18" cy="18" r="3" />
<circle cx="6" cy="6" r="3" />
<path d="M13 6h3a2 2 0 012 2v7" />
<line x1="6" y1="9" x2="6" y2="21" />
</svg>
Create PR
</button>
</div>
)}
</div>
)}
{/* Step: Creating */}
{step === 'creating' && (
<div className="text-center py-8">
<div className="w-6 h-6 border-2 border-[var(--color-text-tertiary)] border-t-[var(--color-text-primary)] rounded-full animate-spin mx-auto mb-3" />
<p className="text-sm text-[var(--color-text-secondary)]">Creating pull request...</p>
<p className="text-[11px] text-[var(--color-text-tertiary)] mt-1">
Adding {items.length} component{items.length !== 1 ? 's' : ''} to {selectedRepo?.full_name}
</p>
</div>
)}
{/* Step: Done */}
{step === 'done' && prResult && (
<div className="text-center py-6">
<div className="w-10 h-10 rounded-full bg-green-500/15 flex items-center justify-center mx-auto mb-3">
<svg className="w-5 h-5 text-green-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2.5">
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
</div>
<p className="text-sm font-medium text-white mb-1">Pull Request Created!</p>
<p className="text-[12px] text-[var(--color-text-tertiary)] mb-4">
PR #{prResult.number} in {selectedRepo?.full_name}
</p>
<a
href={prResult.html_url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-5 py-2.5 bg-white text-black rounded-lg text-sm font-medium hover:bg-gray-100 transition-colors"
>
View on GitHub
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
</div>
)}
{/* Step: Error */}
{step === 'error' && (
<div className="text-center py-6">
<div className="w-10 h-10 rounded-full bg-red-500/15 flex items-center justify-center mx-auto mb-3">
<svg className="w-5 h-5 text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
<p className="text-sm font-medium text-white mb-1">Something went wrong</p>
<p className="text-[12px] text-[var(--color-text-tertiary)] mb-4 max-w-sm mx-auto">{error}</p>
<button
onClick={() => {
setError('');
setStep(token ? 'select-repo' : 'connect');
}}
className="px-4 py-2 bg-[var(--color-surface-3)] hover:bg-[var(--color-surface-4)] rounded-lg text-sm text-[var(--color-text-secondary)] hover:text-white transition-colors"
>
Try Again
</button>
</div>
)}
</div>
</div>
</div>
);
}
+369
View File
@@ -0,0 +1,369 @@
---
import { NAV_LINKS } from '../lib/constants';
import { TYPE_CONFIG, ICONS } from '../lib/icons';
import AuthButton from './AuthButton';
import MyComponentsSidebarItem from './MyComponentsSidebarItem';
const currentPath = Astro.url.pathname;
function isActive(path: string): boolean {
if (path === '/') return currentPath === '/';
return currentPath.startsWith(path);
}
---
<div class="flex flex-col h-full bg-[var(--color-surface-0)] relative">
<!-- Logo -->
<div class="px-4 py-4 border-b border-[var(--color-border)] flex items-center justify-between min-h-[65px] transition-all duration-300 bg-[var(--color-surface-0)]">
<a href="/" class="flex items-center gap-2.5 group min-w-0 overflow-hidden transition-all duration-300">
<span
class="w-8 h-8 shrink-0 rounded-md border border-[var(--color-accent)]/40 bg-[var(--color-accent)]/10 flex items-center justify-center transition-all duration-200 group-hover:scale-110 group-hover:border-[var(--color-accent)]"
aria-hidden="true"
>
<svg class="w-4 h-4 text-[var(--color-accent)]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="4 17 10 11 4 5" />
<line x1="12" y1="19" x2="20" y2="19" />
</svg>
</span>
<span class="sidebar-text text-[14px] font-bold text-[var(--color-text-primary)] tracking-tight whitespace-nowrap transition-all duration-300">AI Templates</span>
</a>
<button
id="sidebar-toggle"
class="hidden md:flex shrink-0 w-8 h-8 items-center justify-center rounded-lg hover:bg-[var(--color-surface-2)] transition-all duration-200 text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)] active:scale-95 hover:rotate-180"
aria-label="Toggle sidebar"
title="Collapse sidebar"
>
<svg class="w-4 h-4 transition-transform duration-300 ease-in-out" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M11 19l-7-7 7-7m8 14l-7-7 7-7" />
</svg>
</button>
<button
id="mobile-sidebar-close"
class="md:hidden shrink-0 w-8 h-8 flex items-center justify-center rounded-lg hover:bg-[var(--color-surface-2)] transition-all duration-200 text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)] active:scale-95"
onclick="window.toggleSidebar && window.toggleSidebar()"
aria-label="Close menu"
>
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Navigation -->
<nav class="flex-1 overflow-y-auto overflow-x-hidden px-3 py-3 space-y-1 scrollbar-thin scrollbar-thumb-[var(--color-surface-3)] scrollbar-track-transparent transition-all duration-300">
<!-- Workplace section -->
<div class="px-2 pt-1 pb-2 overflow-hidden">
<span class="sidebar-text font-ui-mono text-[10px] font-semibold text-[var(--color-text-tertiary)] uppercase tracking-[0.1em] select-none transition-all duration-300 block">Workplace</span>
</div>
<!-- My Components (React island) -->
<MyComponentsSidebarItem client:load isActive={isActive('/my-components')} />
<!-- Divider -->
<div class="my-4 mx-2 border-t border-[var(--color-border)] opacity-60"></div>
<!-- Browse section -->
<div class="px-2 pt-1 pb-2 overflow-hidden">
<span class="sidebar-text font-ui-mono text-[10px] font-semibold text-[var(--color-text-tertiary)] uppercase tracking-[0.1em] select-none transition-all duration-300 block">Browse</span>
</div>
{Object.entries(TYPE_CONFIG).map(([type, config]) => (
<a
href={`/${type}`}
data-nav-type={type}
data-tooltip={config.label}
class:list={[
'sidebar-nav-link flex items-center gap-3 px-3 py-2 rounded text-[13px] transition-all duration-150 group relative overflow-hidden font-ui-mono',
isActive(`/${type}`)
? 'bg-[var(--color-surface-2)] text-[var(--color-text-primary)] font-semibold border border-[var(--color-accent)]/40'
: 'text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-surface-2)] border border-transparent hover:border-[var(--color-border)]',
]}
>
{isActive(`/${type}`) && (
<span class="absolute left-0 top-1/2 -translate-y-1/2 w-1 h-6 bg-[var(--color-accent)] rounded-r-full"></span>
)}
<span
class="sidebar-nav-icon w-[18px] h-[18px] shrink-0 inline-flex items-center justify-center"
>
<span
class:list={[
'w-full h-full inline-flex items-center justify-center [&>svg]:w-full [&>svg]:h-full [&>svg]:flex-shrink-0 [&>svg]:block [&>svg]:m-auto transition-all duration-200',
isActive(`/${type}`)
? 'text-[var(--color-accent)] scale-110'
: 'text-[var(--color-text-tertiary)] group-hover:text-[var(--color-text-secondary)] group-hover:scale-105',
]}
set:html={ICONS[type]}
/>
</span>
<span class="sidebar-text truncate transition-all duration-300">{config.label}</span>
<span class="sidebar-nav-count font-ui-mono ml-auto text-[11px] font-semibold tabular-nums text-[var(--color-text-tertiary)] bg-[var(--color-surface-2)] border border-[var(--color-border)] px-2 py-0.5 rounded min-w-[24px] text-center transition-all duration-300 group-hover:bg-[var(--color-surface-3)]" data-count-type={type}></span>
</a>
))}
<!-- Plugins (external marketplaces) -->
<a
href="/plugins"
data-nav-type="plugins"
data-tooltip="Plugins"
class:list={[
'sidebar-nav-link flex items-center gap-3 px-3 py-2 rounded text-[13px] transition-all duration-150 group relative overflow-hidden font-ui-mono',
isActive('/plugins')
? 'bg-[var(--color-surface-2)] text-[var(--color-text-primary)] font-semibold border border-[var(--color-accent)]/40'
: 'text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-surface-2)] border border-transparent hover:border-[var(--color-border)]',
]}
>
{isActive('/plugins') && (
<span class="absolute left-0 top-1/2 -translate-y-1/2 w-1 h-6 bg-[var(--color-accent)] rounded-r-full"></span>
)}
<span class="sidebar-nav-icon w-[18px] h-[18px] shrink-0 inline-flex items-center justify-center">
<span
class:list={[
'w-full h-full inline-flex items-center justify-center [&>svg]:w-full [&>svg]:h-full [&>svg]:flex-shrink-0 [&>svg]:block [&>svg]:m-auto transition-all duration-200',
isActive('/plugins')
? 'text-[var(--color-accent)] scale-110'
: 'text-[var(--color-text-tertiary)] group-hover:text-[var(--color-text-secondary)] group-hover:scale-105',
]}
>
<svg class="w-full h-full" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M14.7 6.3a1 1 0 000 1.4l1.6 1.6a1 1 0 001.4 0l3.77-3.77a6 6 0 01-7.94 7.94l-6.91 6.91a2.12 2.12 0 01-3-3l6.91-6.91a6 6 0 017.94-7.94l-3.76 3.76z"/>
</svg>
</span>
</span>
<span class="sidebar-text truncate transition-all duration-300">Plugins</span>
<span class="sidebar-nav-count font-ui-mono ml-auto text-[11px] font-semibold tabular-nums text-[var(--color-text-tertiary)] bg-[var(--color-surface-2)] border border-[var(--color-border)] px-2 py-0.5 rounded min-w-[24px] text-center transition-all duration-300 group-hover:bg-[var(--color-surface-3)]" data-count-type="plugins"></span>
</a>
<!-- Divider -->
<div class="my-4 mx-2 border-t border-[var(--color-border)] opacity-60"></div>
<!-- Resources -->
<div class="px-2 pt-1 pb-2 overflow-hidden">
<span class="sidebar-text font-ui-mono text-[10px] font-semibold text-[var(--color-text-tertiary)] uppercase tracking-[0.1em] select-none transition-all duration-300 block">Resources</span>
</div>
<a href="/trending"
data-tooltip="Trending"
class:list={[
'flex items-center gap-3 px-3 py-2 rounded-lg text-[13px] transition-all duration-200 group overflow-hidden',
isActive('/trending')
? 'bg-[var(--color-surface-3)] text-[var(--color-text-primary)] font-medium shadow-sm'
: 'text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-surface-2)] hover:shadow-sm',
]}>
<span class:list={[
'w-[18px] h-[18px] shrink-0 inline-flex items-center justify-center transition-all duration-200',
isActive('/trending') ? 'text-[var(--color-text-primary)] scale-105' : 'text-[var(--color-text-tertiary)] group-hover:text-[var(--color-text-secondary)] group-hover:scale-105',
]}>
<svg class="w-full h-full flex-shrink-0 block m-auto" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12" />
</svg>
</span>
<span class="sidebar-text transition-all duration-300">Trending</span>
</a>
<a href="/jobs"
data-tooltip="Jobs"
class:list={[
'flex items-center gap-3 px-3 py-2 rounded-lg text-[13px] transition-all duration-200 group overflow-hidden',
isActive('/jobs')
? 'bg-[var(--color-surface-3)] text-[var(--color-text-primary)] font-medium shadow-sm'
: 'text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-surface-2)] hover:shadow-sm',
]}>
<span class:list={[
'w-[18px] h-[18px] shrink-0 inline-flex items-center justify-center transition-all duration-200',
isActive('/jobs') ? 'text-[var(--color-text-primary)] scale-105' : 'text-[var(--color-text-tertiary)] group-hover:text-[var(--color-text-secondary)] group-hover:scale-105',
]}>
<svg class="w-full h-full flex-shrink-0 block m-auto" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="7" width="20" height="14" rx="2" ry="2" /><path d="M16 7V5a2 2 0 00-2-2h-4a2 2 0 00-2 2v2" /><line x1="12" y1="12" x2="12" y2="12.01" />
</svg>
</span>
<span class="sidebar-text transition-all duration-300">Jobs</span>
<span class="sidebar-text ml-auto text-[9px] font-bold uppercase tracking-wider bg-orange-500/15 text-orange-400 px-1.5 py-0.5 rounded-md shadow-sm transition-all duration-300">New</span>
</a>
<a href={NAV_LINKS.blog} target="_blank" rel="noopener noreferrer"
data-tooltip="Blog"
class="flex items-center gap-3 px-3 py-2 rounded-lg text-[13px] text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-surface-2)] hover:shadow-sm transition-all duration-200 group overflow-hidden">
<span class="w-[18px] h-[18px] shrink-0 inline-flex items-center justify-center text-[var(--color-text-tertiary)] group-hover:text-[var(--color-text-secondary)] transition-all duration-200 group-hover:scale-105">
<svg class="w-full h-full flex-shrink-0 block m-auto" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 19.5v-15A2.5 2.5 0 016.5 2H20v20H6.5a2.5 2.5 0 010-5H20" />
</svg>
</span>
<span class="sidebar-text transition-all duration-300">Blog</span>
<svg class="sidebar-text w-3 h-3 ml-auto opacity-0 group-hover:opacity-50 transition-all duration-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" /></svg>
</a>
<a href={NAV_LINKS.docs} target="_blank" rel="noopener noreferrer"
data-tooltip="Docs"
class="flex items-center gap-3 px-3 py-2 rounded-lg text-[13px] text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-surface-2)] hover:shadow-sm transition-all duration-150 group">
<span class="w-[18px] h-[18px] shrink-0 inline-flex items-center justify-center text-[var(--color-text-tertiary)] group-hover:text-[var(--color-text-secondary)] transition-transform duration-150 group-hover:scale-105">
<svg class="w-full h-full flex-shrink-0 block m-auto" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" /><polyline points="14 2 14 8 20 8" /><line x1="16" y1="13" x2="8" y2="13" /><line x1="16" y1="17" x2="8" y2="17" /><polyline points="10 9 9 9 8 9" />
</svg>
</span>
<span class="sidebar-text transition-all duration-300">Docs</span>
<svg class="sidebar-text w-3 h-3 ml-auto opacity-0 group-hover:opacity-50 transition-opacity" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" /></svg>
</a>
<a href={NAV_LINKS.github} target="_blank" rel="noopener noreferrer"
data-tooltip="GitHub"
class="flex items-center gap-3 px-3 py-2 rounded-lg text-[13px] text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-surface-2)] hover:shadow-sm transition-all duration-200 group overflow-hidden">
<span class="w-[18px] h-[18px] shrink-0 inline-flex items-center justify-center text-[var(--color-text-tertiary)] group-hover:text-[var(--color-text-secondary)] transition-all duration-200 group-hover:scale-105">
<svg class="w-full h-full flex-shrink-0 block m-auto" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" />
</svg>
</span>
<span class="sidebar-text transition-all duration-300">GitHub</span>
<svg class="sidebar-text w-3 h-3 ml-auto opacity-0 group-hover:opacity-50 transition-all duration-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" /></svg>
</a>
</nav>
<!-- Bottom -->
<div class="px-3 py-3 border-t border-[var(--color-border)] space-y-2.5 bg-[var(--color-surface-0)] overflow-hidden">
<AuthButton client:idle />
</div>
</div>
<script>
function updateSidebarActive() {
const path = window.location.pathname.replace(/^\//, '').split('/')[0] || 'skills';
document.querySelectorAll('.sidebar-nav-link').forEach((link) => {
const type = (link as HTMLElement).dataset.navType;
const iconWrapper = link.querySelector('.sidebar-nav-icon');
const iconSpan = iconWrapper?.querySelector('span');
if (type === path) {
link.classList.remove('text-[var(--color-text-secondary)]', 'hover:text-[var(--color-text-primary)]', 'hover:bg-[var(--color-surface-2)]', 'hover:shadow-sm');
link.classList.add('bg-[var(--color-surface-3)]', 'text-[var(--color-text-primary)]', 'font-medium', 'shadow-sm');
iconSpan?.classList.remove('text-[var(--color-text-tertiary)]', 'group-hover:text-[var(--color-text-secondary)]', 'group-hover:scale-105');
iconSpan?.classList.add('text-[var(--color-accent)]', 'scale-110');
} else {
link.classList.remove('bg-[var(--color-surface-3)]', 'text-[var(--color-text-primary)]', 'font-medium', 'shadow-sm');
link.classList.add('text-[var(--color-text-secondary)]', 'hover:text-[var(--color-text-primary)]', 'hover:bg-[var(--color-surface-2)]', 'hover:shadow-sm');
iconSpan?.classList.remove('text-[var(--color-accent)]', 'scale-110');
iconSpan?.classList.add('text-[var(--color-text-tertiary)]', 'group-hover:text-[var(--color-text-secondary)]', 'group-hover:scale-105');
}
});
}
// Patch pushState to emit events for sidebar sync
const origPushState = history.pushState.bind(history);
history.pushState = function(...args: Parameters<typeof origPushState>) {
origPushState(...args);
updateSidebarActive();
};
window.addEventListener('popstate', updateSidebarActive);
// Load component counts directly — no dependency on page components
function applyCounts(counts: Record<string, number>) {
document.querySelectorAll('.sidebar-nav-count').forEach((el) => {
const type = (el as HTMLElement).dataset.countType;
if (type && counts[type]) {
el.textContent = String(counts[type]);
}
});
}
// Fetch counts on load (tiny counts.json, not the full index)
fetch('/counts.json')
.then((r) => r.json())
.then((counts: Record<string, number>) => {
applyCounts(counts);
})
.catch(() => {});
// Plugins isn't part of the component catalog (counts.json), it's a
// curated list of external marketplaces — count comes from plugins.json.
fetch('/plugins.json')
.then((r) => r.json())
.then((plugins: unknown[]) => {
applyCounts({ plugins: plugins.length });
})
.catch(() => {});
// Also listen for counts from React grid (may arrive with more accurate data)
window.addEventListener('component-counts', ((e: CustomEvent) => {
applyCounts(e.detail as Record<string, number>);
}) as EventListener);
// Custom tooltip functionality
let tooltipEl: HTMLElement | null = null;
let hideTimeout: number | null = null;
function createTooltip() {
if (tooltipEl) return tooltipEl;
tooltipEl = document.createElement('div');
tooltipEl.className = 'sidebar-tooltip';
tooltipEl.style.cssText = `
position: fixed;
background: var(--color-surface-3);
color: var(--color-text-primary);
padding: 6px 10px;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
pointer-events: none;
z-index: 9999;
opacity: 0;
transition: opacity 0.15s ease;
white-space: nowrap;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
border: 1px solid var(--color-border);
`;
document.body.appendChild(tooltipEl);
return tooltipEl;
}
function showTooltip(element: HTMLElement, text: string) {
const sidebar = document.getElementById('sidebar');
const isCollapsed = sidebar?.dataset.collapsed === 'true';
// Only show tooltip when sidebar is collapsed
if (!isCollapsed) return;
if (hideTimeout) {
clearTimeout(hideTimeout);
hideTimeout = null;
}
const tooltip = createTooltip();
tooltip.textContent = text;
const rect = element.getBoundingClientRect();
tooltip.style.left = `${rect.right + 12}px`;
tooltip.style.top = `${rect.top + rect.height / 2}px`;
tooltip.style.transform = 'translateY(-50%)';
// Force reflow
tooltip.offsetHeight;
tooltip.style.opacity = '1';
}
function hideTooltip() {
if (!tooltipEl) return;
hideTimeout = window.setTimeout(() => {
if (tooltipEl) {
tooltipEl.style.opacity = '0';
}
}, 100);
}
function initTooltips() {
document.querySelectorAll('[data-tooltip]').forEach((el) => {
const element = el as HTMLElement;
const text = element.dataset.tooltip || '';
// Check if already bound to prevent duplicate listeners
if (element.dataset.tooltipBound === 'true') return;
element.addEventListener('mouseenter', () => showTooltip(element, text));
element.addEventListener('mouseleave', hideTooltip);
element.dataset.tooltipBound = 'true';
});
}
// Initialize tooltips
initTooltips();
document.addEventListener('astro:page-load', initTooltips);
</script>
+628
View File
@@ -0,0 +1,628 @@
import { useState, useEffect, useRef, useMemo, useCallback } from 'react';
import { marked } from 'marked';
import SkillSlideView from './SkillSlideView';
interface Heading {
level: number;
text: string;
id: string;
}
interface SearchMatch {
type: 'heading' | 'text';
text: string;
id: string;
context?: string;
}
interface TreeNode {
name: string;
path: string;
isFile: boolean;
children: TreeNode[];
ext?: string;
}
interface SkillExplorerProps {
skillContent: string;
skillName: string;
skillPath: string;
references: string[];
headings: Heading[];
}
const GITHUB_RAW_BASE = 'https://raw.githubusercontent.com/davila7/claude-code-templates/main/cli-tool/components/skills';
const EXT_COLORS: Record<string, string> = {
md: '#60a5fa', js: '#facc15', ts: '#60a5fa', tsx: '#60a5fa', jsx: '#facc15',
py: '#4ade80', html: '#fb923c', json: '#fde047', txt: 'var(--color-text-tertiary)',
sh: '#86efac', yml: '#f472b6', yaml: '#f472b6', css: '#c084fc', toml: '#f472b6',
};
// ─── Markdown rendering ────────────────────────────────────────────
function renderMarkdown(content: string): string {
const stripped = content.replace(/^---\s*\n[\s\S]*?\n---\s*\n?/, '');
marked.setOptions({ gfm: true, breaks: false });
const renderer = new marked.Renderer();
renderer.heading = ({ text, depth }: { text: string; depth: number }) => {
const id = text.toLowerCase().replace(/[^\w]+/g, '-').replace(/^-|-$/g, '');
return `<h${depth} id="${id}" class="md-heading">${text}</h${depth}>`;
};
renderer.code = ({ text, lang }: { text: string; lang?: string }) => {
return `<div class="md-code-block"><div class="md-code-lang">${lang || ''}</div><pre><code class="language-${lang || ''}">${text.replace(/</g, '&lt;').replace(/>/g, '&gt;')}</code></pre></div>`;
};
return marked.parse(stripped, { renderer }) as string;
}
function extractHeadings(content: string): Heading[] {
const stripped = content.replace(/^---\s*\n[\s\S]*?\n---\s*\n?/, '');
const headings: Heading[] = [];
const regex = /^(#{1,4})\s+(.+)$/gm;
let m;
while ((m = regex.exec(stripped)) !== null) {
const text = m[2].replace(/[*_`\[\]]/g, '').trim();
const id = text.toLowerCase().replace(/[^\w]+/g, '-').replace(/^-|-$/g, '');
headings.push({ level: m[1].length, text, id });
}
return headings;
}
function buildSearchIndex(content: string, headings: Heading[]): { text: string; headingId: string }[] {
const stripped = content.replace(/^---\s*\n[\s\S]*?\n---\s*\n?/, '');
const lines = stripped.split('\n');
const entries: { text: string; headingId: string }[] = [];
let currentHeadingId = headings[0]?.id ?? '';
let paragraph = '';
for (const line of lines) {
const headingMatch = line.match(/^(#{1,4})\s+(.+)$/);
if (headingMatch) {
if (paragraph.trim()) { entries.push({ text: paragraph.trim(), headingId: currentHeadingId }); paragraph = ''; }
const text = headingMatch[2].replace(/[*_`\[\]]/g, '').trim();
currentHeadingId = text.toLowerCase().replace(/[^\w]+/g, '-').replace(/^-|-$/g, '');
continue;
}
if (line.trim() === '') {
if (paragraph.trim()) { entries.push({ text: paragraph.trim(), headingId: currentHeadingId }); paragraph = ''; }
} else {
paragraph += (paragraph ? ' ' : '') + line.trim();
}
}
if (paragraph.trim()) entries.push({ text: paragraph.trim(), headingId: currentHeadingId });
return entries;
}
// ─── Tree building ─────────────────────────────────────────────────
function buildTree(paths: string[], skillName: string): TreeNode[] {
const root: TreeNode[] = [];
root.push({ name: 'SKILL.md', path: 'SKILL.md', isFile: true, children: [], ext: 'md' });
for (const p of paths) {
const parts = p.split('/');
let current = root;
for (let i = 0; i < parts.length; i++) {
const name = parts[i];
const isFile = i === parts.length - 1;
const fullPath = parts.slice(0, i + 1).join('/');
let existing = current.find((n) => n.name === name && n.isFile === isFile);
if (!existing) {
const ext = isFile ? name.split('.').pop()?.toLowerCase() : undefined;
existing = { name, path: fullPath, isFile, children: [], ext };
current.push(existing);
}
current = existing.children;
}
}
function sortNodes(nodes: TreeNode[]) {
nodes.sort((a, b) => {
if (a.name === 'SKILL.md') return -1;
if (b.name === 'SKILL.md') return 1;
if (a.isFile !== b.isFile) return a.isFile ? 1 : -1;
return a.name.localeCompare(b.name);
});
for (const n of nodes) if (!n.isFile) sortNodes(n.children);
}
sortNodes(root);
return root;
}
function countFiles(nodes: TreeNode[]): number {
let c = 0;
for (const n of nodes) { if (n.isFile) c++; else c += countFiles(n.children); }
return c;
}
// ─── Main Component ────────────────────────────────────────────────
export default function SkillExplorer({ skillContent, skillName, skillPath, references, headings: initialHeadings }: SkillExplorerProps) {
const [selectedFile, setSelectedFile] = useState('SKILL.md');
const [fileContent, setFileContent] = useState(skillContent);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [mode, setMode] = useState<'code' | 'preview' | 'slides'>('preview');
const [expanded, setExpanded] = useState(false);
const [activeHeading, setActiveHeading] = useState('');
const [searchQuery, setSearchQuery] = useState('');
const [searchOpen, setSearchOpen] = useState(false);
const [selectedIdx, setSelectedIdx] = useState(0);
const [highlightCount, setHighlightCount] = useState(0);
const [treeOpen, setTreeOpen] = useState(false);
const [treeFilter, setTreeFilter] = useState('');
const previewRef = useRef<HTMLDivElement>(null);
const codeRef = useRef<HTMLDivElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
const isMarkdown = selectedFile.endsWith('.md');
const html = useMemo(() => isMarkdown ? renderMarkdown(fileContent) : '', [fileContent, isMarkdown]);
const currentHeadings = useMemo(() => {
if (selectedFile === 'SKILL.md') return initialHeadings;
if (isMarkdown) return extractHeadings(fileContent);
return [];
}, [selectedFile, fileContent, isMarkdown, initialHeadings]);
const searchIndex = useMemo(() => buildSearchIndex(fileContent, currentHeadings), [fileContent, currentHeadings]);
const tree = useMemo(() => buildTree(references, skillName), [references, skillName]);
const filteredTree = useMemo(() => {
if (!treeFilter.trim()) return tree;
const q = treeFilter.toLowerCase();
const filtered = references.filter((r) => r.toLowerCase().includes(q));
const t = buildTree(filtered, skillName);
if (!('SKILL.md').toLowerCase().includes(q)) {
const idx = t.findIndex((n) => n.name === 'SKILL.md');
if (idx >= 0) t.splice(idx, 1);
}
return t;
}, [treeFilter, references, tree, skillName]);
// Fetch file content
useEffect(() => {
if (selectedFile === 'SKILL.md') { setFileContent(skillContent); setError(''); return; }
setLoading(true); setError('');
fetch(`${GITHUB_RAW_BASE}/${skillPath}/${selectedFile}`)
.then((res) => { if (!res.ok) throw new Error(`HTTP ${res.status}`); return res.text(); })
.then((text) => setFileContent(text))
.catch((e) => { setError(`Failed to load file: ${e.message}`); setFileContent(''); })
.finally(() => setLoading(false));
}, [selectedFile, skillPath, skillContent]);
// Search results
const searchResults = useMemo((): SearchMatch[] => {
if (!searchQuery.trim() || searchQuery.length < 2) return [];
const q = searchQuery.toLowerCase();
const results: SearchMatch[] = [];
const seen = new Set<string>();
for (const h of currentHeadings) {
if (h.text.toLowerCase().includes(q)) { results.push({ type: 'heading', text: h.text, id: h.id }); seen.add(h.id); }
}
for (const entry of searchIndex) {
if (entry.text.toLowerCase().includes(q) && !seen.has(entry.headingId)) {
const idx = entry.text.toLowerCase().indexOf(q);
const start = Math.max(0, idx - 40);
const end = Math.min(entry.text.length, idx + q.length + 40);
const context = (start > 0 ? '...' : '') + entry.text.slice(start, end) + (end < entry.text.length ? '...' : '');
results.push({ type: 'text', text: entry.text.slice(idx, idx + q.length), id: entry.headingId, context });
seen.add(entry.headingId);
}
}
return results.slice(0, 15);
}, [searchQuery, currentHeadings, searchIndex]);
// Highlight matches
useEffect(() => {
if (!previewRef.current || mode !== 'preview') return;
previewRef.current.querySelectorAll('mark.md-search-hl').forEach((m) => {
const p = m.parentNode; if (p) { p.replaceChild(document.createTextNode(m.textContent || ''), m); p.normalize(); }
});
if (!searchQuery.trim() || searchQuery.length < 2) { setHighlightCount(0); return; }
const walker = document.createTreeWalker(previewRef.current, NodeFilter.SHOW_TEXT);
const q = searchQuery.toLowerCase();
const nodesToProcess: { node: Text; indices: number[] }[] = [];
let node: Text | null;
while ((node = walker.nextNode() as Text | null)) {
const text = (node.textContent || '').toLowerCase();
const indices: number[] = []; let pos = 0;
while ((pos = text.indexOf(q, pos)) !== -1) { indices.push(pos); pos += q.length; }
if (indices.length) nodesToProcess.push({ node, indices });
}
let count = 0;
for (const { node: textNode, indices } of nodesToProcess) {
const text = textNode.textContent || '';
const parent = textNode.parentNode;
if (!parent || parent.nodeName === 'CODE' || parent.nodeName === 'PRE') continue;
const frag = document.createDocumentFragment();
let lastIdx = 0;
for (const idx of indices) {
if (idx > lastIdx) frag.appendChild(document.createTextNode(text.slice(lastIdx, idx)));
const mark = document.createElement('mark'); mark.className = 'md-search-hl';
mark.textContent = text.slice(idx, idx + q.length); frag.appendChild(mark);
lastIdx = idx + q.length; count++;
}
if (lastIdx < text.length) frag.appendChild(document.createTextNode(text.slice(lastIdx)));
parent.replaceChild(frag, textNode);
}
setHighlightCount(count);
}, [searchQuery, mode, html]);
// Heading observer
useEffect(() => {
if (mode !== 'preview' || !previewRef.current) return;
const observer = new IntersectionObserver(
(entries) => { for (const e of entries) if (e.isIntersecting) setActiveHeading(e.target.id); },
{ rootMargin: '-80px 0px -70% 0px', threshold: 0 }
);
previewRef.current.querySelectorAll('.md-heading').forEach((el) => observer.observe(el));
return () => observer.disconnect();
}, [mode, html]);
// Keyboard shortcuts
useEffect(() => {
const h = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'f' && (previewRef.current || codeRef.current)) {
e.preventDefault(); setSearchOpen(true);
setTimeout(() => searchInputRef.current?.focus(), 50);
}
if (e.key === 'Escape' && searchOpen) { setSearchOpen(false); setSearchQuery(''); }
};
document.addEventListener('keydown', h);
return () => document.removeEventListener('keydown', h);
}, [searchOpen]);
useEffect(() => { setSelectedIdx(0); }, [searchResults]);
const handleCopy = () => navigator.clipboard.writeText(fileContent);
const scrollToHeading = useCallback((id: string) => {
if (!expanded) setExpanded(true);
setTimeout(() => {
const container = previewRef.current || codeRef.current;
container?.querySelector(`#${CSS.escape(id)}`)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
setActiveHeading(id);
}, expanded ? 0 : 100);
}, [expanded]);
const navigateToResult = useCallback((result: SearchMatch) => {
if (mode !== 'preview') setMode('preview');
scrollToHeading(result.id);
setSearchOpen(false);
}, [mode, scrollToHeading]);
const handleSearchKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'ArrowDown') { e.preventDefault(); setSelectedIdx((i) => Math.min(i + 1, searchResults.length - 1)); }
else if (e.key === 'ArrowUp') { e.preventDefault(); setSelectedIdx((i) => Math.max(i - 1, 0)); }
else if (e.key === 'Enter' && searchResults[selectedIdx]) { e.preventDefault(); navigateToResult(searchResults[selectedIdx]); }
};
const handleFileClick = (path: string) => {
setSelectedFile(path);
if (mode === 'slides') setMode('preview');
setSearchOpen(false);
setSearchQuery('');
setExpanded(false);
};
const fileTreePanel = treeOpen ? (
<div className="w-56 shrink-0 bg-[var(--color-surface-2)] border border-r-0 border-[var(--color-border)] rounded-l-lg overflow-hidden flex flex-col animate-slide-in-left">
<div className="px-3 py-2.5 border-b border-[var(--color-border)] flex items-center gap-2">
<button
onClick={() => setTreeOpen(false)}
className="text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-surface-3)] p-1 rounded-md shrink-0 transition-all duration-150 active:scale-95"
title="Close file tree"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
</button>
<span className="text-[11px] font-bold text-[var(--color-text-primary)] truncate flex-1">{skillName}</span>
<span className="text-[10px] font-semibold text-[var(--color-text-tertiary)] bg-[var(--color-surface-3)] px-1.5 py-0.5 rounded-md shrink-0">{references.length + 1}</span>
</div>
<div className="px-2.5 py-2 border-b border-[var(--color-border)]">
<div className="relative">
<svg className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-[var(--color-text-tertiary)] pointer-events-none" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
</svg>
<input type="text" value={treeFilter} onChange={(e) => setTreeFilter(e.target.value)}
placeholder="Filter files..." className="w-full bg-[var(--color-surface-3)] border border-[var(--color-border)] rounded-lg pl-8 pr-2.5 py-1.5 text-[11px] text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] focus:outline-none focus:border-[var(--color-primary-500)]/40 focus:ring-1 focus:ring-[var(--color-primary-500)]/20 transition-all" />
</div>
</div>
<div className="flex-1 overflow-y-auto py-1" style={{ scrollbarWidth: 'thin' }}>
<TreeNodes nodes={filteredTree} depth={0} forceOpen={!!treeFilter} selectedFile={selectedFile} onSelect={handleFileClick} />
</div>
</div>
) : (
<button
onClick={() => setTreeOpen(true)}
className="shrink-0 w-10 bg-[var(--color-surface-2)] border border-r-0 border-[var(--color-border)] rounded-l-lg flex flex-col items-center justify-start pt-4 gap-2.5 hover:bg-[var(--color-surface-3)] hover:w-11 hover:shadow-sm transition-all duration-200 group active:scale-95"
title={`Browse files (${references.length + 1})`}
>
<svg className="w-5 h-5 text-[var(--color-text-tertiary)] group-hover:text-[var(--color-text-secondary)] transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 12.75V12A2.25 2.25 0 014.5 9.75h15A2.25 2.25 0 0121.75 12v.75m-8.69-6.44l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-5.379a1.5 1.5 0 01-1.06-.44z" />
</svg>
<span className="text-[10px] font-semibold text-[var(--color-text-tertiary)] group-hover:text-[var(--color-text-secondary)] transition-colors" style={{ writingMode: 'vertical-lr' }}>Files</span>
</button>
);
return (
<div className="flex-1 min-w-0">
{/* File indicator when viewing non-SKILL file */}
{selectedFile !== 'SKILL.md' && (
<div className="flex items-center gap-2 mb-2 text-[12px] text-[var(--color-text-secondary)]">
<svg className="w-3.5 h-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke={EXT_COLORS[selectedFile.split('.').pop() || ''] || 'var(--color-text-tertiary)'} strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
</svg>
<span className="font-mono">{selectedFile}</span>
<button onClick={() => handleFileClick('SKILL.md')} className="text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)] ml-1">
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
)}
{/* Toolbar — identical to MarkdownViewer */}
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
{isMarkdown && (
<div className="flex items-center gap-1 bg-[var(--color-surface-3)] rounded-lg p-0.5">
<button onClick={() => setMode('code')}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${mode === 'code' ? 'bg-[var(--color-surface-2)] text-[var(--color-text-primary)] shadow-sm' : 'text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)]'}`}>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M17.25 6.75L22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3l-4.5 16.5" />
</svg>
Code
</button>
<button onClick={() => setMode('preview')}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${mode === 'preview' ? 'bg-[var(--color-surface-2)] text-[var(--color-text-primary)] shadow-sm' : 'text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)]'}`}>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
Preview
</button>
{selectedFile === 'SKILL.md' && (
<button onClick={() => setMode('slides')}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${mode === 'slides' ? 'bg-surface-2 text-text-primary shadow-sm' : 'text-text-tertiary hover:text-text-secondary'}`}>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 3v11.25A2.25 2.25 0 006 16.5h2.25M3.75 3h-1.5m1.5 0h16.5m0 0h1.5m-1.5 0v11.25A2.25 2.25 0 0118 16.5h-2.25m-7.5 0h7.5m-7.5 0l-1 3m8.5-3l1 3m0 0l.5 1.5m-.5-1.5h-9.5m0 0l-.5 1.5" />
</svg>
Slides
</button>
)}
</div>
)}
{isMarkdown && (
<button onClick={() => { setSearchOpen(!searchOpen); if (!searchOpen) setTimeout(() => searchInputRef.current?.focus(), 50); }}
className={`flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs transition-colors ${searchOpen ? 'bg-accent-500/15 text-accent-400' : 'text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)] hover:bg-white/[0.04]'}`}
title="Search in document (Cmd+F)">
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
</svg>
<span className="hidden sm:inline">Search</span>
<kbd className="hidden sm:inline text-[9px] px-1 py-0.5 rounded bg-white/[0.06] text-[var(--color-text-tertiary)] font-mono ml-1">
{typeof navigator !== 'undefined' && navigator.platform?.includes('Mac') ? '\u2318' : 'Ctrl'}F
</kbd>
</button>
)}
</div>
<button onClick={handleCopy}
className="text-[11px] px-2.5 py-1 rounded-md bg-white/[0.06] hover:bg-white/[0.12] text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)] transition-colors">
Copy
</button>
</div>
{/* Search bar */}
{searchOpen && isMarkdown && mode !== 'slides' && (
<div className="mb-3 relative">
<div className="relative">
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-[var(--color-text-tertiary)] pointer-events-none" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
</svg>
<input ref={searchInputRef} type="text" value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={handleSearchKeyDown}
placeholder="Search sections and content..."
className="w-full bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded-lg pl-9 pr-20 py-2 text-sm text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] focus:outline-none focus:border-accent-400/50 focus:ring-1 focus:ring-accent-400/20 font-sans" autoFocus />
<div className="absolute right-3 top-1/2 -translate-y-1/2 flex items-center gap-2">
{searchQuery.length >= 2 && (
<span className="text-[10px] text-[var(--color-text-tertiary)]">
{highlightCount > 0 ? `${highlightCount} match${highlightCount !== 1 ? 'es' : ''}` : 'No matches'}
</span>
)}
<button onClick={() => { setSearchOpen(false); setSearchQuery(''); }} className="text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)]">
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
{searchResults.length > 0 && (
<div className="absolute z-20 top-full left-0 right-0 mt-1 bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded-lg shadow-xl overflow-hidden max-h-72 overflow-y-auto">
{searchResults.map((result, i) => (
<button key={`${result.id}-${i}`} onClick={() => navigateToResult(result)} onMouseEnter={() => setSelectedIdx(i)}
className={`w-full text-left px-3 py-2.5 flex items-start gap-2.5 transition-colors ${i === selectedIdx ? 'bg-white/[0.06]' : 'hover:bg-white/[0.03]'}`}>
{result.type === 'heading' ? (
<svg className="w-3.5 h-3.5 mt-0.5 shrink-0 text-accent-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5.25 7.5h13.5m-13.5 4.5h7.5m-7.5 4.5h13.5" />
</svg>
) : (
<svg className="w-3.5 h-3.5 mt-0.5 shrink-0 text-[var(--color-text-tertiary)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
</svg>
)}
<div className="flex-1 min-w-0">
{result.type === 'heading' ? (
<HighlightText text={result.text} query={searchQuery} className="text-sm text-[var(--color-text-primary)] font-medium" />
) : (
<>
<div className="text-[11px] text-[var(--color-text-tertiary)] mb-0.5">{currentHeadings.find((h) => h.id === result.id)?.text ?? ''}</div>
<HighlightText text={result.context || ''} query={searchQuery} className="text-xs text-[var(--color-text-secondary)] leading-relaxed" />
</>
)}
</div>
{i === selectedIdx && <kbd className="text-[9px] px-1 py-0.5 rounded bg-white/[0.06] text-[var(--color-text-tertiary)] font-mono self-center shrink-0">Enter</kbd>}
</button>
))}
</div>
)}
</div>
)}
{/* Slides mode */}
{mode === 'slides' && selectedFile === 'SKILL.md' ? (
<SkillSlideView content={skillContent} skillName={skillName} />
) : (
<>
{/* Content + TOC — identical layout to MarkdownViewer */}
<div className="flex gap-0 items-stretch">
{/* File tree panel */}
{fileTreePanel}
<div className="flex-1 min-w-0 flex gap-4">
<div className="flex-1 min-w-0 relative">
{loading && (
<div className="absolute inset-0 bg-[var(--color-surface-2)]/80 z-10 flex items-center justify-center rounded-r-lg">
<div className="flex items-center gap-2 text-sm text-[var(--color-text-secondary)]">
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24"><circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" /><path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" /></svg>
Loading...
</div>
</div>
)}
{error ? (
<div className="bg-[var(--color-surface-2)] border border-l-0 border-[var(--color-border)] rounded-lg rounded-l-none p-6 text-sm text-red-400">{error}</div>
) : isMarkdown && mode === 'preview' ? (
<div ref={previewRef}
className={`md-preview bg-[var(--color-surface-2)] border border-l-0 border-[var(--color-border)] rounded-lg rounded-l-none p-6 overflow-hidden transition-[max-height] duration-300 ${expanded ? '' : 'max-h-[32rem]'}`}
dangerouslySetInnerHTML={{ __html: html }} />
) : (
<div ref={codeRef}
className={`bg-[var(--color-surface-2)] border border-l-0 border-[var(--color-border)] rounded-lg rounded-l-none p-6 text-sm text-[var(--color-text-primary)] leading-relaxed whitespace-pre-wrap font-mono overflow-hidden transition-[max-height] duration-300 ${expanded ? '' : 'max-h-[32rem]'}`}>
{fileContent || 'No content available'}
</div>
)}
{/* Gradient fade + expand */}
{!expanded && (
<div className="absolute bottom-0 left-0 right-0 rounded-br-lg overflow-hidden">
<div className="h-24 bg-gradient-to-t from-[var(--color-surface-2)] to-transparent pointer-events-none" />
<div className="bg-[var(--color-surface-2)] px-4 pb-3 flex justify-center border-r border-b border-[var(--color-border)] rounded-br-lg -mt-px">
<button onClick={() => setExpanded(true)}
className="text-[13px] text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] flex items-center gap-1.5 px-3 py-1.5 rounded-md hover:bg-[var(--color-surface-3)] transition-colors">
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
Show full {mode === 'preview' ? 'document' : 'source'}
</button>
</div>
</div>
)}
{expanded && (
<div className="flex justify-center py-2 bg-[var(--color-surface-2)] border border-l-0 border-t-0 border-[var(--color-border)] rounded-br-lg">
<button onClick={() => setExpanded(false)}
className="text-[13px] text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] flex items-center gap-1.5 px-3 py-1.5 rounded-md hover:bg-[var(--color-surface-3)] transition-colors">
<svg className="w-3.5 h-3.5 rotate-180" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
Collapse
</button>
</div>
)}
</div>
{/* TOC sidebar — identical to MarkdownViewer */}
{isMarkdown && currentHeadings.length >= 1 && mode === 'preview' && (
<nav className="hidden lg:block w-52 shrink-0">
<div className="sticky top-24">
<h4 className="text-[10px] font-semibold text-[var(--color-text-tertiary)] uppercase tracking-wider mb-2">On this page</h4>
<ul className="space-y-0.5 text-[12px] border-l border-[var(--color-border)] max-h-[calc(100vh-10rem)] overflow-y-auto">
{currentHeadings.map((h) => (
<li key={h.id}>
<button onClick={() => scrollToHeading(h.id)}
className={`block w-full text-left py-1 transition-colors border-l-2 -ml-px ${
h.level === 1 ? 'pl-3' : h.level === 2 ? 'pl-5' : 'pl-7'
} ${activeHeading === h.id ? 'border-accent-400 text-accent-400' : 'border-transparent text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)] hover:border-[var(--color-border)]'}`}
title={h.text}>
<span className="line-clamp-1">{h.text}</span>
</button>
</li>
))}
</ul>
</div>
</nav>
)}
</div>
</div>
</>
)}
</div>
);
}
// ─── Tree components ───────────────────────────────────────────────
function TreeNodes({ nodes, depth, forceOpen, selectedFile, onSelect }: { nodes: TreeNode[]; depth: number; forceOpen?: boolean; selectedFile: string; onSelect: (path: string) => void }) {
return (
<>
{nodes.map((node) =>
node.isFile ? (
<FileNode key={node.path} node={node} depth={depth} selected={selectedFile === node.path} onSelect={onSelect} />
) : (
<FolderNode key={node.path} node={node} depth={depth} forceOpen={forceOpen} selectedFile={selectedFile} onSelect={onSelect} />
)
)}
</>
);
}
function FolderNode({ node, depth, forceOpen, selectedFile, onSelect }: { node: TreeNode; depth: number; forceOpen?: boolean; selectedFile: string; onSelect: (path: string) => void }) {
const [open, setOpen] = useState(depth < 1);
const isOpen = forceOpen || open;
const fc = useMemo(() => countFiles(node.children), [node.children]);
return (
<div>
<button onClick={() => setOpen(!open)}
className="w-full flex items-center gap-1 py-[3px] pr-2 hover:bg-white/[0.04] transition-colors group/f"
style={{ paddingLeft: `${depth * 10 + 6}px` }}>
<svg className={`w-2.5 h-2.5 text-[var(--color-text-tertiary)] shrink-0 transition-transform duration-100 ${isOpen ? 'rotate-90' : ''}`}
fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
<svg className="w-3.5 h-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke={isOpen ? '#facc15' : 'var(--color-text-tertiary)'} strokeWidth={1.5}>
{isOpen
? <path strokeLinecap="round" strokeLinejoin="round" d="M3.75 9.776c.112-.017.227-.026.344-.026h15.812c.117 0 .232.009.344.026m-16.5 0a2.25 2.25 0 00-1.883 2.542l.857 6a2.25 2.25 0 002.227 1.932H19.05a2.25 2.25 0 002.227-1.932l.857-6a2.25 2.25 0 00-1.883-2.542m-16.5 0V6A2.25 2.25 0 016 3.75h3.879a1.5 1.5 0 011.06.44l2.122 2.12a1.5 1.5 0 001.06.44H18A2.25 2.25 0 0120.25 9v.776" />
: <path strokeLinecap="round" strokeLinejoin="round" d="M2.25 12.75V12A2.25 2.25 0 014.5 9.75h15A2.25 2.25 0 0121.75 12v.75m-8.69-6.44l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-5.379a1.5 1.5 0 01-1.06-.44z" />}
</svg>
<span className="text-[11px] text-[var(--color-text-secondary)] truncate">{node.name}</span>
<span className="text-[8px] text-[var(--color-text-tertiary)] ml-auto opacity-0 group-hover/f:opacity-100">{fc}</span>
</button>
{isOpen && <TreeNodes nodes={node.children} depth={depth + 1} forceOpen={forceOpen} selectedFile={selectedFile} onSelect={onSelect} />}
</div>
);
}
function FileNode({ node, depth, selected, onSelect }: { node: TreeNode; depth: number; selected: boolean; onSelect: (path: string) => void }) {
const color = EXT_COLORS[node.ext ?? ''] ?? 'var(--color-text-tertiary)';
return (
<button onClick={() => onSelect(node.path)}
className={`w-full flex items-center gap-1.5 py-[3px] pr-2 transition-colors ${selected ? 'bg-accent-400/10 text-accent-300' : 'hover:bg-white/[0.04] text-[var(--color-text-primary)]'}`}
style={{ paddingLeft: `${depth * 10 + 16}px` }} title={node.path}>
<svg className="w-3.5 h-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke={selected ? 'currentColor' : color} strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
</svg>
<span className="text-[11px] truncate font-mono">{node.name}</span>
</button>
);
}
function HighlightText({ text, query, className }: { text: string; query: string; className?: string }) {
if (!query.trim()) return <span className={className}>{text}</span>;
const parts: { text: string; match: boolean }[] = [];
const lower = text.toLowerCase();
const q = query.toLowerCase();
let lastIdx = 0, pos = 0;
while ((pos = lower.indexOf(q, lastIdx)) !== -1) {
if (pos > lastIdx) parts.push({ text: text.slice(lastIdx, pos), match: false });
parts.push({ text: text.slice(pos, pos + q.length), match: true });
lastIdx = pos + q.length;
}
if (lastIdx < text.length) parts.push({ text: text.slice(lastIdx), match: false });
return (
<span className={className}>
{parts.map((p, i) => p.match ? <mark key={i} className="bg-accent-400/25 text-accent-300 rounded-sm px-px">{p.text}</mark> : <span key={i}>{p.text}</span>)}
</span>
);
}
+492
View File
@@ -0,0 +1,492 @@
import { useState, useEffect, useRef, useMemo, useCallback } from 'react';
import { marked } from 'marked';
// Lightweight token-count approximation: ~4 characters per token for English prose.
// Avoids shipping the heavy gpt-tokenizer BPE table to the browser.
function estimateTokens(text: string): number {
return Math.ceil(text.length / 4);
}
interface Slide {
type: 'title' | 'content';
title: string;
body: string;
html: string;
metadata?: Record<string, string>;
}
interface SkillSlideViewProps {
content: string;
skillName: string;
}
// ─── YAML frontmatter parsing ─────────────────────────────────────
function parseFrontmatter(content: string): { meta: Record<string, string>; body: string } {
const match = content.match(/^---\s*\n([\s\S]*?)\n---\s*\n?([\s\S]*)$/);
if (!match) return { meta: {}, body: content };
const meta: Record<string, string> = {};
let currentKey = '';
let currentValue = '';
for (const line of match[1].split('\n')) {
const kv = line.match(/^(\S[\w-]*)\s*:\s*(.*)/);
if (kv) {
if (currentKey) meta[currentKey] = currentValue.trim();
currentKey = kv[1];
currentValue = kv[2].replace(/^["']|["']$/g, '');
} else if (currentKey && (line.startsWith(' ') || line.startsWith('\t'))) {
currentValue += ' ' + line.trim();
}
}
if (currentKey) meta[currentKey] = currentValue.trim();
return { meta, body: match[2] || '' };
}
// ─── Markdown rendering ───────────────────────────────────────────
function renderSlideMarkdown(md: string): string {
marked.setOptions({ gfm: true, breaks: false });
const renderer = new marked.Renderer();
renderer.heading = ({ text, depth }: { text: string; depth: number }) => {
const tag = `h${Math.min(depth + 1, 6)}`;
return `<${tag} class="slide-heading">${text}</${tag}>`;
};
renderer.code = ({ text, lang }: { text: string; lang?: string }) => {
return `<div class="slide-code-block"><div class="slide-code-lang">${lang || ''}</div><pre><code class="language-${lang || ''}">${text.replace(/</g, '&lt;').replace(/>/g, '&gt;')}</code></pre></div>`;
};
return marked.parse(md, { renderer }) as string;
}
// ─── Slide building ───────────────────────────────────────────────
function buildSlides(content: string, skillName: string): Slide[] {
const { meta, body } = parseFrontmatter(content);
const slides: Slide[] = [];
// Title slide
const titleName = meta.name || skillName.replace(/[-_]/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
// Get the first H1 for subtitle if available
const h1Match = body.match(/^#\s+(.+)$/m);
const subtitle = h1Match ? h1Match[1] : '';
// Build description - truncate if too long
let desc = meta.description || '';
if (desc.length > 300) {
desc = desc.slice(0, 300).replace(/\s+\S*$/, '') + '...';
}
// Remove <example> blocks from description for cleaner display
desc = desc.replace(/<example>[\s\S]*?<\/example>/g, '').trim();
const metaFields: Record<string, string> = {};
if (meta.version) metaFields['Version'] = meta.version;
if (meta.tools) metaFields['Tools'] = meta.tools;
if (meta['allowed-tools']) metaFields['Tools'] = meta['allowed-tools'];
if (meta.model) metaFields['Model'] = meta.model;
if (meta.color) metaFields['Color'] = meta.color;
if (meta.author) metaFields['Author'] = meta.author;
if (meta.license) metaFields['License'] = meta.license;
slides.push({
type: 'title',
title: titleName,
body: subtitle ? `${subtitle}\n\n${desc}` : desc,
html: '',
metadata: metaFields,
});
// Split body by H2 sections
const bodyWithoutH1 = body.replace(/^#\s+.+$/m, '').trim();
const sections = bodyWithoutH1.split(/(?=^##\s)/m).filter((s) => s.trim());
for (const section of sections) {
const headingMatch = section.match(/^##\s+(.+)$/m);
if (!headingMatch) {
// Content before first H2 — add as intro slide if substantial
if (section.trim().length > 50) {
slides.push({
type: 'content',
title: 'Introduction',
body: section.trim(),
html: renderSlideMarkdown(section.trim()),
});
}
continue;
}
const title = headingMatch[1].replace(/[*_`\[\]]/g, '').trim();
const sectionBody = section.replace(/^##\s+.+$/m, '').trim();
slides.push({
type: 'content',
title,
body: sectionBody,
html: renderSlideMarkdown(sectionBody),
});
}
// If no H2 sections found, create a single content slide with full body
if (slides.length === 1 && bodyWithoutH1.trim()) {
slides.push({
type: 'content',
title: 'Content',
body: bodyWithoutH1.trim(),
html: renderSlideMarkdown(bodyWithoutH1.trim()),
});
}
return slides;
}
// ─── Main Component ───────────────────────────────────────────────
const SLIDE_HEIGHT = '32rem';
export default function SkillSlideView({ content, skillName }: SkillSlideViewProps) {
const [currentSlide, setCurrentSlide] = useState(0);
const [isFullscreen, setIsFullscreen] = useState(false);
const [expanded, setExpanded] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const slides = useMemo(() => buildSlides(content, skillName), [content, skillName]);
const totalSlides = slides.length;
// Token counting (approximation: ~4 chars per token)
const totalTokens = useMemo(() => estimateTokens(content), [content]);
const slideTokens = useMemo(() => slides.map((s) => estimateTokens(s.body)), [slides]);
const goTo = useCallback((idx: number) => {
setCurrentSlide(Math.max(0, Math.min(idx, totalSlides - 1)));
}, [totalSlides]);
const goNext = useCallback(() => goTo(currentSlide + 1), [currentSlide, goTo]);
const goPrev = useCallback(() => goTo(currentSlide - 1), [currentSlide, goTo]);
// Keyboard navigation
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { e.preventDefault(); goNext(); }
else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') { e.preventDefault(); goPrev(); }
else if (e.key === 'Home') { e.preventDefault(); goTo(0); }
else if (e.key === 'End') { e.preventDefault(); goTo(totalSlides - 1); }
else if (e.key === 'Escape' && isFullscreen) { toggleFullscreen(); }
};
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [goNext, goPrev, goTo, totalSlides, isFullscreen]);
// Fullscreen API
const toggleFullscreen = useCallback(() => {
if (!containerRef.current) return;
if (!document.fullscreenElement) {
containerRef.current.requestFullscreen().then(() => setIsFullscreen(true)).catch(() => {});
} else {
document.exitFullscreen().then(() => setIsFullscreen(false)).catch(() => {});
}
}, []);
useEffect(() => {
const handler = () => setIsFullscreen(!!document.fullscreenElement);
document.addEventListener('fullscreenchange', handler);
return () => document.removeEventListener('fullscreenchange', handler);
}, []);
const slide = slides[currentSlide];
if (!slide) return null;
return (
<div
ref={containerRef}
className={`flex flex-col ${isFullscreen ? 'bg-[#0d0d0f] h-screen' : ''}`}
>
{/* Slide area */}
<div className={`relative bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded-xl overflow-hidden flex flex-col ${isFullscreen ? 'flex-1 rounded-none border-0' : ''}`}>
{/* Slide content */}
<div
className={`${isFullscreen ? 'flex-1 px-16 py-12 overflow-y-auto' : 'px-8 py-8'}`}
style={!isFullscreen && !expanded ? { height: SLIDE_HEIGHT, overflow: 'hidden' } : undefined}
>
{slide.type === 'title' ? (
<TitleSlide slide={slide} isFullscreen={isFullscreen} totalTokens={totalTokens} />
) : (
<ContentSlide slide={slide} isFullscreen={isFullscreen} />
)}
</div>
{/* Show more / Collapse */}
{!isFullscreen && (
<button
onClick={() => setExpanded(!expanded)}
className="flex items-center justify-center gap-1.5 w-full py-2 text-xs font-medium text-[var(--color-accent-400)] hover:text-[var(--color-text-primary)] border-t border-[var(--color-border)] hover:bg-[var(--color-surface-3)] transition-colors"
>
{expanded ? (
<>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 15.75l7.5-7.5 7.5 7.5" />
</svg>
Collapse
</>
) : (
<>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
</svg>
Show full document
</>
)}
</button>
)}
{/* Navigation bar */}
<div className="flex items-center justify-between px-4 py-3 border-t border-[var(--color-border)] bg-[var(--color-surface-1)]/50 backdrop-blur-sm">
{/* Left: prev button + token info */}
<div className="flex items-center gap-3">
<button
onClick={goPrev}
disabled={currentSlide === 0}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors disabled:opacity-30 disabled:cursor-not-allowed text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-white/[0.06]"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg>
Prev
</button>
<div className="hidden sm:flex items-center gap-1.5 font-mono">
<span title="Tokens in this slide" className="flex items-center gap-1 px-2 py-0.5 rounded-md bg-[var(--color-accent-400)]/10 text-[var(--color-accent-400)] text-[10px] font-bold">
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M17.25 6.75L22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3l-4.5 16.5" />
</svg>
{slideTokens[currentSlide]?.toLocaleString()}
</span>
<span className="text-[var(--color-text-tertiary)] text-[9px]">/</span>
<span title="Total tokens" className="text-[10px] text-[var(--color-text-tertiary)] font-medium">{totalTokens.toLocaleString()}</span>
</div>
</div>
{/* Center: slide dots + counter */}
<div className="flex items-center gap-3">
<div className="flex items-center gap-1">
{slides.map((_, i) => (
<button
key={i}
onClick={() => goTo(i)}
className={`rounded-full transition-all duration-200 ${
i === currentSlide
? 'w-6 h-1.5 bg-[var(--color-accent-400)]'
: 'w-1.5 h-1.5 bg-[var(--color-text-tertiary)]/40 hover:bg-[var(--color-text-tertiary)]'
}`}
title={`Slide ${i + 1}: ${slides[i].title}`}
/>
))}
</div>
<span className="text-[11px] text-[var(--color-text-tertiary)] font-mono tabular-nums">
{currentSlide + 1} / {totalSlides}
</span>
</div>
{/* Right: next + fullscreen */}
<div className="flex items-center gap-1">
<button
onClick={goNext}
disabled={currentSlide === totalSlides - 1}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors disabled:opacity-30 disabled:cursor-not-allowed text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-white/[0.06]"
>
Next
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</button>
<button
onClick={toggleFullscreen}
className="p-1.5 rounded-lg text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)] hover:bg-white/[0.06] transition-colors"
title={isFullscreen ? 'Exit fullscreen (Esc)' : 'Fullscreen'}
>
{isFullscreen ? (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 9V4.5M9 9H4.5M9 9L3.75 3.75M9 15v4.5M9 15H4.5M9 15l-5.25 5.25M15 9h4.5M15 9V4.5M15 9l5.25-5.25M15 15h4.5M15 15v4.5m0-4.5l5.25 5.25" />
</svg>
) : (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 3.75v4.5m0-4.5h4.5m-4.5 0L9 9M3.75 20.25v-4.5m0 4.5h4.5m-4.5 0L9 15M20.25 3.75h-4.5m4.5 0v4.5m0-4.5L15 9m5.25 11.25h-4.5m4.5 0v-4.5m0 4.5L15 15" />
</svg>
)}
</button>
</div>
</div>
</div>
{/* Keyboard hints */}
<div className="flex justify-center gap-3 mt-3 text-[10px] text-[var(--color-text-tertiary)]">
<span className="flex items-center gap-1">
<kbd className="px-1 py-0.5 rounded bg-[var(--color-surface-3)] font-mono text-[9px]">&larr;</kbd>
<kbd className="px-1 py-0.5 rounded bg-[var(--color-surface-3)] font-mono text-[9px]">&rarr;</kbd>
Navigate
</span>
<span className="flex items-center gap-1">
<kbd className="px-1 py-0.5 rounded bg-[var(--color-surface-3)] font-mono text-[9px]">Home</kbd>
<kbd className="px-1 py-0.5 rounded bg-[var(--color-surface-3)] font-mono text-[9px]">End</kbd>
First / Last
</span>
</div>
</div>
);
}
// ─── Title Slide ──────────────────────────────────────────────────
function TitleSlide({ slide, isFullscreen, totalTokens }: { slide: Slide; isFullscreen: boolean; totalTokens: number }) {
const metaEntries = Object.entries(slide.metadata || {});
const allBadges = [...metaEntries, ['Tokens', totalTokens.toLocaleString()]];
return (
<div className={`flex flex-col items-center justify-center text-center h-full min-h-[20rem] ${isFullscreen ? 'min-h-[60vh]' : ''}`}>
{/* Icon */}
<div className="w-16 h-16 rounded-2xl flex items-center justify-center mb-6" style={{ backgroundColor: 'rgba(213, 116, 85, 0.15)' }}>
<svg className="w-8 h-8" style={{ color: 'var(--color-accent-400)' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.455 2.456L21.75 6l-1.036.259a3.375 3.375 0 00-2.455 2.456z" />
</svg>
</div>
{/* Title */}
<h1 className={`font-bold text-[var(--color-text-primary)] mb-3 leading-tight ${isFullscreen ? 'text-4xl' : 'text-2xl'}`}>
{slide.title}
</h1>
{/* Description */}
{slide.body && (
<p className={`text-[var(--color-text-secondary)] leading-relaxed max-w-2xl ${isFullscreen ? 'text-lg' : 'text-sm'}`}>
{slide.body}
</p>
)}
{/* Metadata badges */}
{allBadges.length > 0 && (
<div className="flex flex-wrap justify-center gap-2.5 mt-8">
{allBadges.map(([key, value]) => {
const isTokens = key === 'Tokens';
return (
<div
key={key}
className={`flex flex-col items-center px-4 py-2.5 rounded-xl border text-center min-w-[5rem] ${
isTokens
? 'bg-[var(--color-accent-400)]/10 border-[var(--color-accent-400)]/25'
: 'bg-[var(--color-surface-3)]/60 border-[var(--color-border)]'
}`}
>
<span className={`text-[10px] uppercase tracking-widest font-semibold mb-0.5 ${
isTokens ? 'text-[var(--color-accent-400)]' : 'text-[var(--color-text-tertiary)]'
}`}>{key}</span>
<span className={`text-sm font-bold font-mono ${
isTokens ? 'text-[var(--color-accent-400)]' : 'text-[var(--color-text-primary)]'
}`}>{value}</span>
</div>
);
})}
</div>
)}
</div>
);
}
// ─── Content Slide ────────────────────────────────────────────────
function ContentSlide({ slide, isFullscreen }: { slide: Slide; isFullscreen: boolean }) {
return (
<div className={`h-full ${isFullscreen ? 'max-w-4xl mx-auto' : ''}`}>
{/* Slide title */}
<h2 className={`font-bold text-[var(--color-text-primary)] mb-6 pb-3 border-b border-[var(--color-border)] ${isFullscreen ? 'text-3xl' : 'text-xl'}`}>
{slide.title}
</h2>
{/* Rendered markdown content */}
<div
className={`slide-md-content ${isFullscreen ? 'text-base' : 'text-sm'}`}
dangerouslySetInnerHTML={{ __html: slide.html }}
/>
{/* Inline styles for slide markdown content */}
<style>{`
.slide-md-content {
color: var(--color-text-secondary);
line-height: 1.7;
}
.slide-md-content .slide-heading {
color: var(--color-text-primary);
font-weight: 600;
margin-top: 1.25rem;
margin-bottom: 0.5rem;
}
.slide-md-content h3.slide-heading { font-size: 1.1em; }
.slide-md-content h4.slide-heading { font-size: 1em; }
.slide-md-content p { margin-bottom: 0.75rem; }
.slide-md-content ul, .slide-md-content ol {
margin-bottom: 0.75rem;
padding-left: 1.5rem;
}
.slide-md-content li { margin-bottom: 0.25rem; }
.slide-md-content ul li { list-style: disc; }
.slide-md-content ol li { list-style: decimal; }
.slide-md-content strong { color: var(--color-text-primary); font-weight: 600; }
.slide-md-content code {
background: var(--color-surface-3);
padding: 0.15em 0.4em;
border-radius: 4px;
font-size: 0.9em;
font-family: ui-monospace, monospace;
color: var(--color-accent-400, #f97316);
}
.slide-md-content .slide-code-block {
background: var(--color-surface-1, #0d0d0f);
border: 1px solid var(--color-border);
border-radius: 8px;
margin: 0.75rem 0;
overflow: hidden;
}
.slide-md-content .slide-code-lang {
padding: 0.35rem 0.75rem;
font-size: 0.7em;
color: var(--color-text-tertiary);
border-bottom: 1px solid var(--color-border);
font-family: ui-monospace, monospace;
}
.slide-md-content .slide-code-lang:empty { display: none; }
.slide-md-content pre {
padding: 0.75rem;
overflow-x: auto;
font-size: 0.85em;
line-height: 1.6;
}
.slide-md-content pre code {
background: none;
padding: 0;
border-radius: 0;
color: var(--color-text-primary);
}
.slide-md-content a {
color: var(--color-accent-400, #f97316);
text-decoration: underline;
text-underline-offset: 2px;
}
.slide-md-content blockquote {
border-left: 3px solid var(--color-border);
padding-left: 1rem;
margin: 0.75rem 0;
color: var(--color-text-tertiary);
font-style: italic;
}
.slide-md-content table {
width: 100%;
border-collapse: collapse;
margin: 0.75rem 0;
font-size: 0.9em;
}
.slide-md-content th, .slide-md-content td {
border: 1px solid var(--color-border);
padding: 0.4rem 0.75rem;
text-align: left;
}
.slide-md-content th {
background: var(--color-surface-3);
color: var(--color-text-primary);
font-weight: 600;
}
`}</style>
</div>
);
}
+63
View File
@@ -0,0 +1,63 @@
import { useState, useEffect } from 'react';
import { toggleTheme, getTheme } from '../lib/theme';
export default function ThemeToggle() {
const [theme, setTheme] = useState<'light' | 'dark'>('dark');
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
setTheme(getTheme());
}, []);
const handleToggle = () => {
const newTheme = toggleTheme();
setTheme(newTheme);
};
if (!mounted) return null;
return (
<button
onClick={handleToggle}
className="relative group p-2 rounded-lg transition-all duration-200 hover:bg-[var(--color-surface-2)] active:scale-95 flex items-center justify-center"
aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
title={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
>
<div className="relative w-5 h-5">
{/* Sun icon for light mode */}
<svg
className={`absolute inset-0 w-5 h-5 transition-all duration-300 ease-out ${
theme === 'light'
? 'opacity-100 rotate-0 scale-100'
: 'opacity-0 rotate-90 scale-75'
}`}
style={{ color: 'var(--color-text-primary)' }}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2m0 16v2M4.93 4.93l1.41 1.41m11.32 11.32l1.41 1.41M2 12h2m16 0h2M4.93 19.07l1.41-1.41m11.32-11.32l1.41-1.41" />
</svg>
{/* Moon icon for dark mode */}
<svg
className={`absolute inset-0 w-5 h-5 transition-all duration-300 ease-out ${
theme === 'dark'
? 'opacity-100 rotate-0 scale-100'
: 'opacity-0 -rotate-90 scale-75'
}`}
style={{ color: 'var(--color-text-primary)' }}
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M21.64 13a1 1 0 00-1.05-.14 8.05 8.05 0 01-3.37.73 8.15 8.15 0 01-8.14-8.1 8.59 8.59 0 01.25-2A1 1 0 008 2.36a10.14 10.14 0 1014 11.69 1 1 0 00-.36-1.05z" />
</svg>
</div>
</button>
);
}
+48
View File
@@ -0,0 +1,48 @@
---
import ThemeToggle from './ThemeToggle.tsx';
---
<header class="sticky top-0 z-20 bg-[var(--color-surface-0)] border-b border-[var(--color-border)] transition-all duration-200">
<div class="flex items-center gap-3 justify-between px-6 h-14">
<!-- Mobile menu button -->
<button
class="md:hidden shrink-0 p-2 rounded border border-[var(--color-border)] hover:bg-[var(--color-surface-2)] text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)] transition-all duration-150 flex items-center justify-center"
onclick="toggleSidebar()"
aria-label="Toggle menu"
>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
<!-- Search trigger — terminal style -->
<button
id="searchTrigger"
class="flex items-center gap-2 px-4 py-2 border border-[var(--color-border)] rounded text-[13px] text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:border-[var(--color-accent)] hover:bg-[var(--color-surface-1)] transition-all duration-150 flex-1 min-w-0 max-w-md group font-ui-mono"
>
<span class="terminal-prompt text-[13px]">&gt;</span>
<span class="text-[var(--color-text-tertiary)] group-hover:text-[var(--color-text-secondary)]">Search components...</span>
<kbd class="hidden sm:inline-flex ml-auto items-center gap-1 px-2 py-0.5 text-[10px] font-mono font-bold border border-[var(--color-border)] rounded bg-[var(--color-surface-2)] text-[var(--color-text-tertiary)] group-hover:border-[var(--color-accent)] transition-all">
<span class="text-[11px]">⌘</span>K
</kbd>
</button>
<!-- Right actions -->
<div class="flex items-center gap-2 ml-4">
<a
href="https://github.com/davila7/claude-code-templates"
target="_blank"
rel="noopener noreferrer"
class="flex items-center gap-2 px-3 py-2 rounded border border-[var(--color-border)] text-[13px] font-ui-mono font-semibold text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:border-[var(--color-accent)] hover:bg-[var(--color-surface-1)] transition-all duration-150 group"
aria-label="View on GitHub"
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" />
</svg>
<span class="hidden sm:inline">GitHub</span>
</a>
<ThemeToggle client:load />
<div id="cart-button-mount"></div>
</div>
</div>
</header>
+446
View File
@@ -0,0 +1,446 @@
import { useState, useEffect, useMemo } from 'react';
import { TYPE_CONFIG } from '../lib/icons';
import TypeIcon from './TypeIcon';
import CountryFlag from './CountryFlag';
interface TrendingItem {
id: string;
name: string;
category: string;
downloadsToday: number;
downloadsWeek: number;
downloadsMonth: number;
downloadsTotal: number;
}
interface GlobalStats {
totalComponents: number;
totalDownloads: number;
monthlyDownloads: number;
weeklyDownloads: number;
todayDownloads: number;
totalCountries: number;
}
interface TopCountry {
code: string;
name: string;
flag: string;
downloads: number;
percentage: number;
}
interface TrendingData {
lastUpdated: string;
globalStats: GlobalStats;
topCountries: TopCountry[];
trending: Record<string, TrendingItem[]>;
}
function formatName(name: string): string {
return name.replace(/[-_]/g, ' ').split(' ').map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
}
function formatNumber(n: number): string {
if (n >= 1000000) return (n / 1000000).toFixed(1).replace(/\.0$/, '') + 'M';
if (n >= 1000) return (n / 1000).toFixed(1).replace(/\.0$/, '') + 'k';
return String(n);
}
const TRENDING_TYPES = ['all', 'skills', 'agents', 'commands', 'settings', 'hooks', 'mcps'] as const;
export default function TrendingView() {
const [data, setData] = useState<TrendingData | null>(null);
const [loading, setLoading] = useState(true);
const [activeType, setActiveType] = useState<string>('all');
const [period, setPeriod] = useState<'downloadsWeek' | 'downloadsMonth' | 'downloadsTotal'>('downloadsWeek');
useEffect(() => {
fetch('/trending-data.json')
.then((r) => r.json())
.then((d) => { setData(d); setLoading(false); })
.catch(() => setLoading(false));
}, []);
const items = useMemo(() => {
if (!data) return [];
const list = data.trending[activeType] ?? [];
return [...list].sort((a, b) => (b[period] ?? 0) - (a[period] ?? 0));
}, [data, activeType, period]);
if (loading) {
return (
<div className="px-6 py-20 flex flex-col items-center gap-3">
<div className="w-5 h-5 border-2 border-[var(--color-text-tertiary)] border-t-transparent rounded-full animate-spin" />
<span className="text-[13px] text-[var(--color-text-tertiary)]">Loading trending data...</span>
</div>
);
}
if (!data) {
return (
<div className="px-6 py-20 text-center">
<p className="text-[13px] text-red-400">Failed to load trending data</p>
</div>
);
}
const stats = data.globalStats;
return (
<div className="pb-8">
{/* Hero Section with Gradient */}
<div className="relative px-6 pt-6 pb-8 overflow-hidden">
<div className="absolute inset-0 bg-gradient-to-br from-[var(--color-accent)]/5 via-transparent to-transparent pointer-events-none" />
<div className="relative">
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--color-accent)] to-[var(--color-accent)]/70 flex items-center justify-center shadow-lg shadow-[var(--color-accent)]/20">
<svg className="w-5 h-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
</svg>
</div>
<div>
<h1 className="text-[20px] font-semibold text-[var(--color-text-primary)]">Trending Components</h1>
<p className="text-[12px] text-[var(--color-text-tertiary)] mt-0.5">Most popular downloads this week</p>
</div>
</div>
{/* Stats cards with enhanced design */}
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3">
{[
{
label: 'Total Downloads',
value: formatNumber(stats.totalDownloads),
icon: (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M9 19l3 3m0 0l3-3m-3 3V10" />
</svg>
),
colorClass: 'stat-blue'
},
{
label: 'This Month',
value: formatNumber(stats.monthlyDownloads),
icon: (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
),
colorClass: 'stat-purple'
},
{
label: 'This Week',
value: formatNumber(stats.weeklyDownloads),
icon: (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
</svg>
),
colorClass: 'stat-emerald'
},
{
label: 'Today',
value: formatNumber(stats.todayDownloads),
icon: (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
),
colorClass: 'stat-orange'
},
{
label: 'Components',
value: formatNumber(stats.totalComponents),
icon: (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
),
colorClass: 'stat-pink'
},
{
label: 'Countries',
value: String(stats.totalCountries),
icon: (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
),
colorClass: 'stat-cyan'
},
].map((s) => (
<div
key={s.label}
className="group relative bg-[var(--color-surface-1)] border border-[var(--color-border)] rounded-xl px-4 py-3.5 hover:border-[var(--color-accent)]/30 transition-all duration-200 hover:shadow-lg hover:shadow-[var(--color-accent)]/5 hover:-translate-y-0.5"
>
<div className="flex items-start justify-between mb-2">
<div className={`stat-icon ${s.colorClass}`}>
{s.icon}
</div>
</div>
<div className="text-[22px] font-bold text-[var(--color-text-primary)] tabular-nums leading-none mb-1.5">{s.value}</div>
<div className="text-[11px] text-[var(--color-text-tertiary)] font-medium">{s.label}</div>
</div>
))}
</div>
</div>
</div>
{/* Top countries with icons */}
<div className="px-6 pb-6">
<div className="bg-[var(--color-surface-1)] border border-[var(--color-border)] rounded-xl p-4">
<div className="flex items-center gap-2 mb-3">
<svg className="w-4 h-4 text-[var(--color-accent)]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span className="text-[12px] text-[var(--color-text-secondary)] font-semibold uppercase tracking-wider">Top Countries</span>
</div>
<div className="flex items-center gap-3 overflow-x-auto pb-1">
{data.topCountries.map((c, idx) => (
<div
key={c.code}
className="flex items-center gap-2.5 shrink-0 bg-[var(--color-surface-2)] rounded-lg px-3 py-2.5 border border-[var(--color-border)] hover:border-[var(--color-accent)]/30 transition-all duration-200 hover:shadow-md"
>
<div className="flex items-center justify-center w-6 h-6 rounded-full bg-gradient-to-br from-[var(--color-accent)]/20 to-[var(--color-accent)]/10 text-[10px] font-bold text-[var(--color-accent)]">
{idx + 1}
</div>
<div className="w-7 h-7 rounded-md overflow-hidden shadow-sm border border-[var(--color-border)]">
<CountryFlag code={c.code} className="w-full h-full object-cover" />
</div>
<div className="flex flex-col">
<span className="text-[12px] text-[var(--color-text-primary)] font-medium">{c.name}</span>
<span className="text-[10px] text-[var(--color-text-tertiary)] tabular-nums">{formatNumber(c.downloads)} downloads</span>
</div>
<span className="text-[11px] text-[var(--color-accent)] font-semibold tabular-nums ml-1">{c.percentage}%</span>
</div>
))}
</div>
</div>
</div>
{/* Divider */}
<div className="border-t border-[var(--color-border)] mx-6" />
{/* Filter bar with enhanced design */}
<div className="px-6 py-4">
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-3">
{/* Type filter */}
<div className="flex items-center gap-1.5 flex-wrap">
{TRENDING_TYPES.map((type) => {
const config = TYPE_CONFIG[type];
return (
<button
key={type}
onClick={() => setActiveType(type)}
className={`group flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[12px] font-medium transition-all duration-200 ${
activeType === type
? 'bg-[var(--color-accent)] text-white shadow-md shadow-[var(--color-accent)]/20'
: 'bg-[var(--color-surface-2)] text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-surface-3)] border border-[var(--color-border)]'
}`}
>
{type !== 'all' && (
<div className={`w-4 h-4 flex items-center justify-center ${activeType === type ? 'text-white' : ''}`}>
<TypeIcon type={type} size={14} />
</div>
)}
{type === 'all' ? 'All' : TYPE_CONFIG[type]?.label ?? type}
</button>
);
})}
</div>
{/* Period selector with icon */}
<div className="flex items-center gap-2 ml-auto">
<svg className="w-4 h-4 text-[var(--color-text-tertiary)]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" />
</svg>
<select
value={period}
onChange={(e) => setPeriod(e.target.value as any)}
className="bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded-lg text-[12px] text-[var(--color-text-primary)] font-medium px-3 py-1.5 outline-none cursor-pointer hover:border-[var(--color-accent)]/30 transition-colors"
>
<option value="downloadsWeek">This Week</option>
<option value="downloadsMonth">This Month</option>
<option value="downloadsTotal">All Time</option>
</select>
</div>
</div>
{/* Results count with icon */}
<div className="flex items-center gap-2 mt-3">
<svg className="w-3.5 h-3.5 text-[var(--color-text-tertiary)]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
<span className="text-[11px] text-[var(--color-text-tertiary)] font-medium">
{items.length} trending component{items.length !== 1 ? 's' : ''}
</span>
</div>
</div>
{/* Trending list with enhanced design */}
<div className="px-6 pb-8">
<div className="bg-[var(--color-surface-1)] border border-[var(--color-border)] rounded-xl overflow-hidden shadow-sm">
{/* Table header */}
<div className="hidden md:grid grid-cols-[50px_1fr_110px_110px_110px_110px] gap-3 px-5 py-3 bg-[var(--color-surface-2)] border-b border-[var(--color-border)]">
<div className="flex items-center gap-1.5 text-[11px] text-[var(--color-text-tertiary)] font-semibold uppercase tracking-wider">
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 20l4-16m2 16l4-16M6 9h14M4 15h14" />
</svg>
Rank
</div>
<span className="text-[11px] text-[var(--color-text-tertiary)] font-semibold uppercase tracking-wider">Component</span>
<div className="flex items-center justify-end gap-1.5 text-[11px] text-[var(--color-text-tertiary)] font-semibold uppercase tracking-wider">
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Today
</div>
<div className="flex items-center justify-end gap-1.5 text-[11px] text-[var(--color-text-tertiary)] font-semibold uppercase tracking-wider">
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
Week
</div>
<div className="flex items-center justify-end gap-1.5 text-[11px] text-[var(--color-text-tertiary)] font-semibold uppercase tracking-wider">
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
Month
</div>
<div className="flex items-center justify-end gap-1.5 text-[11px] text-[var(--color-text-tertiary)] font-semibold uppercase tracking-wider">
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
Total
</div>
</div>
{/* Table rows */}
<div className="divide-y divide-[var(--color-border)]">
{items.map((item, idx) => {
// Extract type from id (e.g. "command-generate-tests" -> "commands")
const typeKey = item.id.split('-')[0];
const typePlural = typeKey === 'mcp' ? 'mcps' : typeKey + 's';
const config = TYPE_CONFIG[typePlural];
// Medal colors for top 3
const getMedalClass = (rank: number) => {
if (rank === 1) return 'medal-gold';
if (rank === 2) return 'medal-silver';
if (rank === 3) return 'medal-bronze';
return 'medal-default';
};
return (
<div
key={item.id}
className="grid md:grid-cols-[50px_1fr_110px_110px_110px_110px] gap-3 px-5 py-3.5 hover:bg-[var(--color-surface-2)] transition-all duration-150 group"
>
{/* Rank with medal for top 3 */}
<div className="flex items-center">
<div className={`w-8 h-8 rounded-lg flex items-center justify-center text-[13px] font-bold ${getMedalClass(idx + 1)}`}>
{idx + 1}
</div>
</div>
{/* Component info */}
<div className="flex items-center gap-3 min-w-0">
<div
className="w-9 h-9 rounded-lg flex items-center justify-center shrink-0 shadow-sm group-hover:scale-110 transition-transform duration-200"
style={{
backgroundColor: config?.color ? `${config.color}15` : 'rgba(115, 115, 115, 0.15)',
color: config?.color ?? 'var(--color-text-tertiary)'
}}
>
<TypeIcon type={typePlural} size={16} />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-[13px] text-[var(--color-text-primary)] font-medium truncate block group-hover:text-[var(--color-accent)] transition-colors">
{formatName(item.name)}
</span>
{item.downloadsToday > 0 && (
<span className="hot-badge">
<svg className="w-2.5 h-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 10l7-7m0 0l7 7m-7-7v18" />
</svg>
Hot
</span>
)}
</div>
<div className="flex items-center gap-1.5 mt-0.5">
<span className="text-[10px] text-[var(--color-text-tertiary)] px-1.5 py-0.5 bg-[var(--color-surface-2)] rounded">{item.category}</span>
</div>
</div>
</div>
{/* Stats - Desktop */}
<div className="hidden md:flex items-center justify-end">
<span className={`text-[13px] font-semibold tabular-nums ${item.downloadsToday > 0 ? 'stat-today-active' : 'text-[var(--color-text-tertiary)]'}`}>
{item.downloadsToday > 0 ? `+${item.downloadsToday}` : '—'}
</span>
</div>
<div className="hidden md:flex items-center justify-end">
<span className="text-[13px] font-medium tabular-nums text-[var(--color-text-secondary)]">
{formatNumber(item.downloadsWeek)}
</span>
</div>
<div className="hidden md:flex items-center justify-end">
<span className="text-[13px] font-medium tabular-nums text-[var(--color-text-secondary)]">
{formatNumber(item.downloadsMonth)}
</span>
</div>
<div className="hidden md:flex items-center justify-end">
<span className="text-[13px] font-bold tabular-nums text-[var(--color-text-primary)]">
{formatNumber(item.downloadsTotal)}
</span>
</div>
{/* Stats - Mobile */}
<div className="md:hidden col-span-full grid grid-cols-4 gap-2 mt-2 pt-2 border-t border-[var(--color-border)]">
<div className="text-center">
<div className="text-[10px] text-[var(--color-text-tertiary)] mb-0.5">Today</div>
<div className={`text-[12px] font-semibold tabular-nums ${item.downloadsToday > 0 ? 'stat-today-active' : 'text-[var(--color-text-tertiary)]'}`}>
{item.downloadsToday > 0 ? `+${item.downloadsToday}` : '—'}
</div>
</div>
<div className="text-center">
<div className="text-[10px] text-[var(--color-text-tertiary)] mb-0.5">Week</div>
<div className="text-[12px] font-medium tabular-nums text-[var(--color-text-secondary)]">
{formatNumber(item.downloadsWeek)}
</div>
</div>
<div className="text-center">
<div className="text-[10px] text-[var(--color-text-tertiary)] mb-0.5">Month</div>
<div className="text-[12px] font-medium tabular-nums text-[var(--color-text-secondary)]">
{formatNumber(item.downloadsMonth)}
</div>
</div>
<div className="text-center">
<div className="text-[10px] text-[var(--color-text-tertiary)] mb-0.5">Total</div>
<div className="text-[12px] font-bold tabular-nums text-[var(--color-text-primary)]">
{formatNumber(item.downloadsTotal)}
</div>
</div>
</div>
</div>
);
})}
</div>
</div>
</div>
{/* Last updated with icon */}
<div className="px-6 pb-6">
<div className="flex items-center justify-center gap-2 text-[11px] text-[var(--color-text-tertiary)]">
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>
Last updated: {new Date(data.lastUpdated.replace(/\+00:00Z$/, 'Z')).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: '2-digit', minute: '2-digit' })}
</span>
</div>
</div>
</div>
);
}
+15
View File
@@ -0,0 +1,15 @@
import { ICONS } from '../lib/icons';
/**
* Renders a type icon SVG inline.
* Safe to use dangerouslySetInnerHTML here because ICONS are hardcoded
* SVG strings defined in icons.ts — not user-supplied data.
*/
export default function TypeIcon({ type, size = 16, className = '' }: { type: string; size?: number; className?: string }) {
const svg = ICONS[type];
if (!svg) return <span className={className} />;
const sized = svg.replace(/width="16"/, `width="${size}"`).replace(/height="16"/, `height="${size}"`);
return <span className={className} dangerouslySetInnerHTML={{ __html: sized }} />;
}
+44
View File
@@ -0,0 +1,44 @@
---
import { TYPE_CONFIG } from '../lib/icons';
interface Props {
activeType: string;
counts?: Record<string, number>;
}
const { activeType, counts = {} } = Astro.props;
---
<nav class="flex items-center gap-1 px-6 py-2 border-b border-[var(--color-border)] overflow-x-auto">
{Object.entries(TYPE_CONFIG).map(([type, config]) => {
const isActive = type === activeType;
const count = counts[type] ?? 0;
return (
<a
href={`/${type}`}
class:list={[
'flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm whitespace-nowrap transition-colors',
isActive
? 'bg-primary-500/15 text-primary-400 font-medium'
: 'text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-surface-3)]',
]}
data-astro-prefetch
>
<span>{config.icon}</span>
<span>{config.label}</span>
{count > 0 && (
<span
class:list={[
'text-xs px-1.5 py-0.5 rounded-full',
isActive
? 'bg-primary-500/20 text-primary-400'
: 'bg-[var(--color-surface-3)] text-[var(--color-text-tertiary)]',
]}
>
{count}
</span>
)}
</a>
);
})}
</nav>
@@ -0,0 +1,161 @@
import type { ReviewCycle } from '../../lib/live-task/types';
const statusColors: Record<string, string> = {
active: '#22c55e',
completed: '#6b7280',
merged: '#3b82f6',
failed: '#ef4444',
};
const phaseLabels: Record<string, string> = {
selection: 'Selecting',
research: 'Researching',
improve: 'Improving',
pr_created: 'PR Created',
reporting: 'Reporting',
merging: 'Merging',
};
function timeAgo(dateStr: string): string {
const diff = Date.now() - new Date(dateStr).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return 'just now';
if (mins < 60) return `${mins}m ago`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h ago`;
return `${Math.floor(hours / 24)}d ago`;
}
function duration(start: string, end: string | null): string {
const endTime = end ? new Date(end).getTime() : Date.now();
const diff = endTime - new Date(start).getTime();
const mins = Math.floor(diff / 60000);
const secs = Math.floor((diff % 60000) / 1000);
if (mins === 0) return `${secs}s`;
return `${mins}m ${secs}s`;
}
function shortPath(path: string): string {
const parts = path.split('/');
return parts.slice(-2).join('/');
}
interface Props {
cycles: ReviewCycle[];
selectedId: string | null;
onSelect: (id: string) => void;
}
export default function CycleTable({ cycles, selectedId, onSelect }: Props) {
if (cycles.length === 0) {
return (
<div style={{
padding: '32px 16px',
textAlign: 'center',
color: '#6b7280',
fontSize: 14,
fontFamily: 'monospace',
}}>
No review cycles yet. The daily scheduled task will create the first one.
</div>
);
}
return (
<div style={{ overflowX: 'auto' }}>
<table style={{
width: '100%',
borderCollapse: 'collapse',
fontSize: 13,
fontFamily: "'Geist Mono', 'JetBrains Mono', monospace",
}}>
<thead>
<tr style={{ borderBottom: '1px solid #2a2a2a' }}>
{['Status', 'Component', 'Type', 'Phase', 'PR', 'Started', 'Duration'].map(h => (
<th key={h} style={{
padding: '8px 12px',
textAlign: 'left',
color: '#6b7280',
fontWeight: 500,
fontSize: 11,
textTransform: 'uppercase',
letterSpacing: '0.05em',
}}>{h}</th>
))}
</tr>
</thead>
<tbody>
{cycles.map(cycle => {
const isSelected = cycle.id === selectedId;
const isActive = cycle.status === 'active';
return (
<tr
key={cycle.id}
onClick={() => onSelect(cycle.id)}
style={{
borderBottom: '1px solid #1a1a1a',
cursor: 'pointer',
background: isSelected ? '#1a1a2e' : 'transparent',
borderLeft: isSelected ? '2px solid #3b82f6' : '2px solid transparent',
transition: 'background 0.15s',
}}
onMouseEnter={e => { if (!isSelected) e.currentTarget.style.background = '#111'; }}
onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = 'transparent'; }}
>
<td style={{ padding: '10px 12px' }}>
<span style={{
display: 'inline-block',
width: 8,
height: 8,
borderRadius: '50%',
background: statusColors[cycle.status] || '#6b7280',
animation: isActive ? 'pulse-dot 2s infinite' : 'none',
}} />
<style>{`@keyframes pulse-dot { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }`}</style>
</td>
<td style={{ padding: '10px 12px', color: '#e5e7eb' }} title={cycle.component_path}>
{shortPath(cycle.component_path)}
</td>
<td style={{ padding: '10px 12px', color: '#9ca3af' }}>
{cycle.component_type}
</td>
<td style={{ padding: '10px 12px' }}>
<span style={{
padding: '2px 8px',
borderRadius: 4,
fontSize: 11,
background: isActive ? '#1e3a5f' : '#1a1a1a',
color: isActive ? '#60a5fa' : '#6b7280',
}}>
{phaseLabels[cycle.phase] || cycle.phase}
</span>
</td>
<td style={{ padding: '10px 12px' }}>
{cycle.pr_url && cycle.pr_url.startsWith('https://') ? (
<a
href={cycle.pr_url}
target="_blank"
rel="noopener noreferrer"
onClick={e => e.stopPropagation()}
style={{ color: '#3b82f6', textDecoration: 'none', fontSize: 12 }}
>
#{cycle.pr_number}
</a>
) : (
<span style={{ color: '#4b5563' }}>-</span>
)}
</td>
<td style={{ padding: '10px 12px', color: '#6b7280', fontSize: 12 }}>
{timeAgo(cycle.started_at)}
</td>
<td style={{ padding: '10px 12px', color: '#6b7280', fontSize: 12 }}>
{duration(cycle.started_at, cycle.completed_at)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}
@@ -0,0 +1,358 @@
import { useState, useEffect, useCallback } from 'react';
import type { ReviewCycle, ToolExecution, CycleControl } from '../../lib/live-task/types';
import CycleTable from './CycleTable';
import ToolTimeline from './ToolTimeline';
const POLL_INTERVAL = 5000;
type FilterMode = 'open' | 'all' | 'done';
const FILTER_LABELS: Record<FilterMode, string> = {
open: 'Open',
all: 'All',
done: 'Done',
};
const OPEN_STATUSES = new Set(['active', 'completed']);
const DONE_STATUSES = new Set(['merged', 'failed']);
export default function LiveTaskPanel() {
const [cycles, setCycles] = useState<ReviewCycle[]>([]);
const [selectedCycleId, setSelectedCycleId] = useState<string | null>(null);
const [tools, setTools] = useState<ToolExecution[]>([]);
const [control, setControl] = useState<CycleControl | null>(null);
const [loading, setLoading] = useState(true);
const [toggling, setToggling] = useState(false);
const [filter, setFilter] = useState<FilterMode>('open');
const fetchCycles = useCallback(async () => {
try {
const res = await fetch('/api/live-task/cycles?limit=20');
const data = await res.json();
setCycles(data.cycles || []);
} catch { /* silent */ }
}, []);
const fetchControl = useCallback(async () => {
try {
const res = await fetch('/api/live-task/control');
const data = await res.json();
setControl(data.control || null);
} catch { /* silent */ }
}, []);
const fetchTools = useCallback(async (cycleId: string) => {
try {
const res = await fetch(`/api/live-task/tools?cycle_id=${cycleId}`);
const data = await res.json();
setTools(data.tools || []);
} catch { /* silent */ }
}, []);
// Initial load
useEffect(() => {
Promise.all([fetchCycles(), fetchControl()]).then(() => setLoading(false));
}, [fetchCycles, fetchControl]);
// Polling
useEffect(() => {
const interval = setInterval(() => {
fetchCycles();
fetchControl();
if (selectedCycleId) fetchTools(selectedCycleId);
}, POLL_INTERVAL);
return () => clearInterval(interval);
}, [fetchCycles, fetchControl, fetchTools, selectedCycleId]);
// Fetch tools when selection changes
useEffect(() => {
if (selectedCycleId) {
fetchTools(selectedCycleId);
} else {
setTools([]);
}
}, [selectedCycleId, fetchTools]);
const [authError, setAuthError] = useState<string | null>(null);
const [isAdmin, setIsAdmin] = useState(false);
// Check if current user is the authorized admin
useEffect(() => {
const checkAdmin = () => {
const clerk = (window as any).Clerk;
if (!clerk?.user) {
setIsAdmin(false);
return;
}
const email = clerk.user.primaryEmailAddress?.emailAddress;
setIsAdmin(email === 'dan.avila7@gmail.com');
};
// Clerk may not be loaded yet
const interval = setInterval(() => {
if ((window as any).Clerk?.loaded) {
checkAdmin();
clearInterval(interval);
}
}, 500);
return () => clearInterval(interval);
}, []);
const handleTogglePause = async () => {
if (!control) return;
setToggling(true);
setAuthError(null);
try {
const clerk = (window as any).Clerk;
if (!clerk?.session) {
setAuthError('Sign in required');
setToggling(false);
return;
}
const token = await clerk.session.getToken();
const res = await fetch('/api/live-task/control', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({
is_paused: !control.is_paused,
reason: control.is_paused ? 'Resumed from dashboard' : 'Paused from dashboard',
}),
});
if (res.status === 401) {
setAuthError('Sign in required');
} else if (res.status === 403) {
setAuthError('Only the project admin can control the review cycle');
} else if (!res.ok) {
setAuthError('Failed to update control');
}
await fetchControl();
} catch { setAuthError('Network error'); }
setToggling(false);
};
const selectedCycle = cycles.find(c => c.id === selectedCycleId) || null;
const activeCycles = cycles.filter(c => c.status === 'active').length;
const filteredCycles = cycles.filter(c => {
if (filter === 'open') return OPEN_STATUSES.has(c.status);
if (filter === 'done') return DONE_STATUSES.has(c.status);
return true;
});
const filterCounts: Record<FilterMode, number> = {
open: cycles.filter(c => OPEN_STATUSES.has(c.status)).length,
all: cycles.length,
done: cycles.filter(c => DONE_STATUSES.has(c.status)).length,
};
return (
<div style={{
maxWidth: 1200,
margin: '0 auto',
padding: '24px 16px',
fontFamily: "'Geist', -apple-system, sans-serif",
}}>
{/* Header */}
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 24,
}}>
<div>
<h1 style={{
fontSize: 22,
fontWeight: 600,
color: '#e5e7eb',
margin: 0,
fontFamily: "'Geist Mono', monospace",
}}>
Component Review Loop
</h1>
<p style={{ color: '#6b7280', fontSize: 13, margin: '4px 0 0' }}>
{loading ? 'Loading...' : `${cycles.length} cycles total | ${activeCycles} active`}
</p>
<p style={{ color: '#4b5563', fontSize: 11, margin: '2px 0 0', fontFamily: 'monospace' }}>
{loading ? '' : `showing ${filteredCycles.length} of ${cycles.length}`}
</p>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
{control && (
<>
<span style={{
fontSize: 12,
color: control.is_paused ? '#f59e0b' : '#22c55e',
fontFamily: 'monospace',
}}>
{control.is_paused ? 'PAUSED' : 'RUNNING'}
</span>
{isAdmin && (
<button
onClick={handleTogglePause}
disabled={toggling}
style={{
padding: '6px 16px',
borderRadius: 6,
border: '1px solid #2a2a2a',
background: control.is_paused ? '#1e3a5f' : '#1a1a1a',
color: control.is_paused ? '#60a5fa' : '#9ca3af',
fontSize: 13,
cursor: toggling ? 'wait' : 'pointer',
fontFamily: "'Geist Mono', monospace",
opacity: toggling ? 0.6 : 1,
}}
>
{control.is_paused ? 'Resume' : 'Pause'}
</button>
)}
</>
)}
{authError && (
<span style={{ fontSize: 12, color: '#ef4444', fontFamily: 'monospace' }}>
{authError}
</span>
)}
</div>
</div>
{/* Cycles Table */}
<div style={{
background: '#0a0a0a',
border: '1px solid #1a1a1a',
borderRadius: 8,
marginBottom: 16,
overflow: 'hidden',
}}>
<div style={{
padding: '8px 16px',
borderBottom: '1px solid #1a1a1a',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}>
<span style={{
fontSize: 12,
color: '#6b7280',
fontFamily: "'Geist Mono', monospace",
textTransform: 'uppercase',
letterSpacing: '0.05em',
}}>
Review Cycles
</span>
<div style={{ display: 'flex', gap: 4 }}>
{(['open', 'all', 'done'] as FilterMode[]).map(f => (
<button
key={f}
onClick={() => setFilter(f)}
style={{
padding: '3px 10px',
borderRadius: 4,
border: '1px solid',
borderColor: filter === f ? '#3b82f6' : '#2a2a2a',
background: filter === f ? '#1e3a5f' : 'transparent',
color: filter === f ? '#60a5fa' : '#6b7280',
fontSize: 11,
fontFamily: "'Geist Mono', monospace",
cursor: 'pointer',
transition: 'all 0.15s',
}}
>
{FILTER_LABELS[f]}
<span style={{
marginLeft: 5,
padding: '0 4px',
borderRadius: 3,
background: filter === f ? '#2563eb33' : '#ffffff0d',
color: filter === f ? '#93c5fd' : '#4b5563',
fontSize: 10,
}}>
{filterCounts[f]}
</span>
</button>
))}
</div>
</div>
<CycleTable
cycles={filteredCycles}
selectedId={selectedCycleId}
onSelect={setSelectedCycleId}
/>
</div>
{/* Tool Timeline */}
<div style={{
background: '#0a0a0a',
border: '1px solid #1a1a1a',
borderRadius: 8,
overflow: 'hidden',
}}>
<div style={{
padding: '10px 16px',
borderBottom: '1px solid #1a1a1a',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}>
<span style={{
fontSize: 12,
color: '#6b7280',
fontFamily: "'Geist Mono', monospace",
textTransform: 'uppercase',
letterSpacing: '0.05em',
}}>
Tool Executions
</span>
{selectedCycle && (
<span style={{
fontSize: 11,
color: '#4b5563',
fontFamily: 'monospace',
}}>
{tools.length} tools | {selectedCycle.component_path.split('/').pop()}
</span>
)}
</div>
<ToolTimeline
tools={tools}
cycleStartTime={selectedCycle?.started_at || null}
/>
</div>
{/* Error display for selected cycle */}
{selectedCycle?.error_message && (
<div style={{
marginTop: 16,
padding: '12px 16px',
background: '#1a0a0a',
border: '1px solid #3f1515',
borderRadius: 8,
fontSize: 13,
color: '#ef4444',
fontFamily: "'Geist Mono', monospace",
}}>
Error: {selectedCycle.error_message}
</div>
)}
{/* Improvements summary for selected cycle */}
{selectedCycle?.improvements_summary && (
<div style={{
marginTop: 16,
padding: '12px 16px',
background: '#0a1a0a',
border: '1px solid #153f15',
borderRadius: 8,
fontSize: 13,
color: '#9ca3af',
fontFamily: "'Geist Mono', monospace",
whiteSpace: 'pre-wrap',
}}>
{selectedCycle.improvements_summary}
</div>
)}
</div>
);
}
@@ -0,0 +1,169 @@
import { useEffect, useRef } from 'react';
import type { ToolExecution } from '../../lib/live-task/types';
const toolColors: Record<string, string> = {
Read: '#22c55e',
Write: '#f59e0b',
Edit: '#f59e0b',
Bash: '#ef4444',
Grep: '#8b5cf6',
Glob: '#8b5cf6',
WebSearch: '#3b82f6',
WebFetch: '#3b82f6',
Agent: '#ec4899',
};
function sanitizePaths(s: string): string {
return s
.replace(/\/Users\/[^/]+\/[^"' ]*(Proyectos|Projects|github|repos)\/[^/]+\//gi, '')
.replace(/\/Users\/[^/]+\//g, '~/');
}
function relativeTime(baseTime: string, eventTime: string): string {
const diff = new Date(eventTime).getTime() - new Date(baseTime).getTime();
const totalSecs = Math.floor(diff / 1000);
if (totalSecs < 0) return '+0s';
const mins = Math.floor(totalSecs / 60);
const secs = totalSecs % 60;
if (mins === 0) return `+${secs}s`;
return `+${mins}m ${secs}s`;
}
interface Props {
tools: ToolExecution[];
cycleStartTime: string | null;
}
export default function ToolTimeline({ tools, cycleStartTime }: Props) {
const bottomRef = useRef<HTMLDivElement>(null);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [tools.length, cycleStartTime]);
if (!cycleStartTime) {
return (
<div style={{
padding: '32px 16px',
textAlign: 'center',
color: '#6b7280',
fontSize: 14,
fontFamily: 'monospace',
}}>
Select a cycle to view tool executions
</div>
);
}
if (tools.length === 0) {
return (
<div style={{
padding: '32px 16px',
textAlign: 'center',
color: '#6b7280',
fontSize: 14,
fontFamily: 'monospace',
}}>
No tool executions recorded yet for this cycle
</div>
);
}
let currentPhase = '';
return (
<div style={{
maxHeight: 400,
overflowY: 'auto',
padding: '8px 0',
}}>
{tools.map((tool, i) => {
const showPhaseSeparator = tool.phase && tool.phase !== currentPhase;
if (tool.phase) currentPhase = tool.phase;
return (
<div key={tool.id}>
{showPhaseSeparator && (
<div style={{
display: 'flex',
alignItems: 'center',
gap: 8,
padding: '12px 16px 4px',
}}>
<div style={{ height: 1, flex: 1, background: '#2a2a2a' }} />
<span style={{
fontSize: 10,
color: '#6b7280',
textTransform: 'uppercase',
letterSpacing: '0.1em',
fontFamily: 'monospace',
}}>
{tool.phase}
</span>
<div style={{ height: 1, flex: 1, background: '#2a2a2a' }} />
</div>
)}
<div style={{
display: 'flex',
alignItems: 'flex-start',
gap: 12,
padding: '6px 16px',
fontSize: 13,
fontFamily: "'Geist Mono', 'JetBrains Mono', monospace",
}}>
{/* Timestamp */}
<span style={{ color: '#4b5563', fontSize: 11, minWidth: 70, textAlign: 'right', flexShrink: 0 }}>
{relativeTime(cycleStartTime, tool.created_at)}
</span>
{/* Timeline dot + line */}
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', flexShrink: 0 }}>
<div style={{
width: 8,
height: 8,
borderRadius: '50%',
background: tool.result_status === 'error' ? '#ef4444' : (toolColors[tool.tool_name] || '#6b7280'),
marginTop: 4,
}} />
{i < tools.length - 1 && (
<div style={{ width: 1, height: 20, background: '#2a2a2a', marginTop: 2 }} />
)}
</div>
{/* Tool info */}
<div style={{ flex: 1, minWidth: 0 }}>
<span style={{
display: 'inline-block',
padding: '1px 6px',
borderRadius: 3,
fontSize: 11,
fontWeight: 600,
background: `${toolColors[tool.tool_name] || '#6b7280'}20`,
color: toolColors[tool.tool_name] || '#6b7280',
marginRight: 8,
}}>
{tool.tool_name}
</span>
<span style={{
color: '#9ca3af',
fontSize: 12,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}>
{sanitizePaths(tool.tool_args_summary || '')}
</span>
</div>
{/* Status icon */}
<span style={{ fontSize: 12, flexShrink: 0 }}>
{tool.result_status === 'error' ? '!' : ''}
</span>
</div>
</div>
);
})}
<div ref={bottomRef} />
</div>
);
}