chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,228 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
|
||||
interface ClerkUser {
|
||||
fullName?: string | null;
|
||||
firstName?: string | null;
|
||||
imageUrl?: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
// Mock user data for local testing
|
||||
const MOCK_USER: ClerkUser = {
|
||||
fullName: 'Bitreon',
|
||||
firstName:'Bitreon',
|
||||
imageUrl: 'https://avatars.githubusercontent.com/u/207326426?v=4'
|
||||
};
|
||||
|
||||
// Enable mock mode via console: window.enableAuthMock()
|
||||
// Disable mock mode via console: window.disableAuthMock()
|
||||
declare global {
|
||||
interface Window {
|
||||
enableAuthMock: () => void;
|
||||
disableAuthMock: () => void;
|
||||
__authMockEnabled?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
function useGlobalAuth() {
|
||||
const [state, setState] = useState<{ isSignedIn: boolean; isLoaded: boolean; user: ClerkUser | null }>({
|
||||
isSignedIn: false, isLoaded: false, user: null,
|
||||
});
|
||||
const [mockEnabled, setMockEnabled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Setup console commands for mock mode
|
||||
window.enableAuthMock = () => {
|
||||
window.__authMockEnabled = true;
|
||||
setMockEnabled(true);
|
||||
console.log('✅ Auth mock mode enabled! Showing mock user:', MOCK_USER.fullName);
|
||||
};
|
||||
|
||||
window.disableAuthMock = () => {
|
||||
window.__authMockEnabled = false;
|
||||
setMockEnabled(false);
|
||||
console.log('❌ Auth mock mode disabled');
|
||||
};
|
||||
|
||||
// Check if mock was previously enabled
|
||||
if (window.__authMockEnabled) {
|
||||
setMockEnabled(true);
|
||||
}
|
||||
|
||||
function check() {
|
||||
// If mock is enabled and not on Vercel, use mock data
|
||||
const isVercel = window.location.hostname.includes('vercel.app') ||
|
||||
window.location.hostname.includes('claude.ai');
|
||||
|
||||
if (window.__authMockEnabled && !isVercel) {
|
||||
setState({
|
||||
isSignedIn: true,
|
||||
isLoaded: true,
|
||||
user: MOCK_USER,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const clerk = (window as any).Clerk;
|
||||
if (clerk?.loaded) {
|
||||
setState({
|
||||
isSignedIn: !!clerk.user,
|
||||
isLoaded: true,
|
||||
user: clerk.user ? {
|
||||
fullName: clerk.user.fullName,
|
||||
firstName: clerk.user.firstName,
|
||||
imageUrl: clerk.user.imageUrl,
|
||||
email: clerk.user.primaryEmailAddress?.emailAddress || '',
|
||||
} : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
check();
|
||||
const interval = setInterval(check, 500);
|
||||
const handleChange = () => check();
|
||||
window.addEventListener('clerk:session', handleChange);
|
||||
return () => { clearInterval(interval); window.removeEventListener('clerk:session', handleChange); };
|
||||
}, [mockEnabled]);
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
function UserMenu({ user }: { user: ClerkUser }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
}
|
||||
document.addEventListener('mousedown', handleClick);
|
||||
return () => document.removeEventListener('mousedown', handleClick);
|
||||
}, [open]);
|
||||
|
||||
const displayName = user.fullName || user.firstName || 'User';
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
className="flex items-center gap-2.5 px-2.5 py-[6px] rounded-md text-[13px] text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-surface-2)] transition-colors w-full"
|
||||
>
|
||||
{user.imageUrl ? (
|
||||
<img
|
||||
src={user.imageUrl}
|
||||
alt={displayName}
|
||||
className="w-6 h-6 rounded-full shrink-0 ring-2 ring-orange-500"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-6 h-6 rounded-full bg-orange-500 flex items-center justify-center shrink-0 text-[11px] font-bold text-white ring-2 ring-orange-500">
|
||||
{displayName.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<span className="truncate flex-1 text-left">{displayName}</span>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="fixed bottom-4 left-4 w-56 bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded-lg shadow-xl z-[9999] py-1">
|
||||
{/* User Info Header */}
|
||||
<div className="px-3 py-2 border-b border-[var(--color-border)]">
|
||||
<div className="text-[13px] font-medium text-[var(--color-text-primary)] truncate">
|
||||
{displayName}
|
||||
</div>
|
||||
<div className="text-[11px] text-[var(--color-text-secondary)] truncate">
|
||||
{user.email || ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Menu Items */}
|
||||
<div className="py-1">
|
||||
<button
|
||||
onClick={() => {
|
||||
console.log('Navigate to My Components');
|
||||
window.location.href = '/my-components';
|
||||
setOpen(false);
|
||||
}}
|
||||
className="flex items-center gap-2 w-full px-3 py-1.5 text-[12px] text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-surface-3)] transition-colors"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="1.5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
|
||||
</svg>
|
||||
My Components
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
console.log('Navigate to Settings');
|
||||
window.location.href = '/settings';
|
||||
setOpen(false);
|
||||
}}
|
||||
className="flex items-center gap-2 w-full px-3 py-1.5 text-[12px] text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-surface-3)] transition-colors"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="1.5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
Settings
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="border-t border-[var(--color-border)] my-1"></div>
|
||||
|
||||
{/* Sign Out */}
|
||||
<div className="py-1">
|
||||
<button
|
||||
onClick={() => {
|
||||
const isVercel = window.location.hostname.includes('vercel.app') ||
|
||||
window.location.hostname.includes('claude.ai');
|
||||
|
||||
if (window.__authMockEnabled && !isVercel) {
|
||||
console.log('🔓 Mock sign out - disabling mock mode');
|
||||
window.disableAuthMock?.();
|
||||
} else {
|
||||
(window as any).Clerk?.signOut?.();
|
||||
}
|
||||
setOpen(false);
|
||||
}}
|
||||
className="flex items-center gap-2 w-full px-3 py-1.5 text-[12px] text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-surface-3)] transition-colors"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="1.5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||||
</svg>
|
||||
Sign Out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AuthButton() {
|
||||
const { isSignedIn, isLoaded, user } = useGlobalAuth();
|
||||
|
||||
if (!isLoaded) {
|
||||
return (
|
||||
<div className="flex items-center gap-2.5 px-2.5 py-[6px]">
|
||||
<div className="w-6 h-6 rounded-full bg-[var(--color-surface-3)] animate-pulse" />
|
||||
<div className="h-3 w-16 bg-[var(--color-surface-3)] rounded animate-pulse" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isSignedIn && user) {
|
||||
return <UserMenu user={user} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => (window as any).Clerk?.openSignIn?.()}
|
||||
className="flex items-center gap-2.5 px-2.5 py-[6px] rounded-md text-[13px] text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-surface-2)] transition-colors w-full"
|
||||
>
|
||||
<svg className="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
<span>Sign In</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import type { Cart } from '../lib/types';
|
||||
import { TYPE_CONFIG } from '../lib/icons';
|
||||
|
||||
const EMPTY_CART: Cart = {
|
||||
agents: [], commands: [], settings: [], hooks: [], mcps: [], skills: [], templates: [],
|
||||
};
|
||||
|
||||
const TYPE_FLAGS: Record<string, string> = {
|
||||
agents: '--agent', commands: '--command', settings: '--setting',
|
||||
hooks: '--hook', mcps: '--mcp', skills: '--skill', templates: '--template',
|
||||
};
|
||||
|
||||
function cleanPath(path: string): string {
|
||||
return path?.replace(/\.(md|json)$/, '') ?? '';
|
||||
}
|
||||
|
||||
function formatName(name: string): string {
|
||||
if (!name) return '';
|
||||
return name.replace(/\.(md|json)$/, '').replace(/[-_]/g, ' ')
|
||||
.split(' ').map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
|
||||
}
|
||||
|
||||
export default function CartSidebar() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [cart, setCart] = useState<Cart>(EMPTY_CART);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
// Load cart
|
||||
useEffect(() => {
|
||||
function loadCart() {
|
||||
try {
|
||||
const saved = localStorage.getItem('claudeCodeCart');
|
||||
if (saved) setCart({ ...EMPTY_CART, ...JSON.parse(saved) });
|
||||
} catch {}
|
||||
}
|
||||
|
||||
loadCart();
|
||||
window.addEventListener('cart-updated', ((e: CustomEvent) => {
|
||||
setCart({ ...EMPTY_CART, ...e.detail });
|
||||
}) as EventListener);
|
||||
window.addEventListener('storage', loadCart);
|
||||
|
||||
return () => window.removeEventListener('storage', loadCart);
|
||||
}, []);
|
||||
|
||||
const totalItems = Object.values(cart).reduce((sum, arr) => sum + (arr?.length ?? 0), 0);
|
||||
|
||||
// Generate command
|
||||
function generateCommand(): string {
|
||||
let cmd = 'npx claude-code-templates@latest';
|
||||
for (const [type, items] of Object.entries(cart)) {
|
||||
if (items?.length > 0) {
|
||||
const flag = TYPE_FLAGS[type];
|
||||
if (flag) {
|
||||
const paths = items.map((i: any) => cleanPath(i.path)).join(',');
|
||||
cmd += ` ${flag} ${paths}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
return cmd;
|
||||
}
|
||||
|
||||
function removeItem(path: string, type: string) {
|
||||
setCart((prev) => {
|
||||
const next = { ...prev, [type]: (prev as any)[type]?.filter((i: any) => i.path !== path) ?? [] };
|
||||
localStorage.setItem('claudeCodeCart', JSON.stringify(next));
|
||||
window.dispatchEvent(new CustomEvent('cart-updated', { detail: next }));
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
if (!confirm('Clear your entire stack?')) return;
|
||||
const empty = { ...EMPTY_CART };
|
||||
setCart(empty);
|
||||
localStorage.setItem('claudeCodeCart', JSON.stringify(empty));
|
||||
window.dispatchEvent(new CustomEvent('cart-updated', { detail: empty }));
|
||||
}
|
||||
|
||||
function copyCommand() {
|
||||
navigator.clipboard.writeText(generateCommand());
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
|
||||
function shareTwitter() {
|
||||
const cmd = generateCommand();
|
||||
const text = `Check out my Claude Code stack!\n\n${cmd}\n\nBuild yours at https://aitmpl.com`;
|
||||
window.open(`https://twitter.com/intent/tweet?text=${encodeURIComponent(text)}`, '_blank');
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Floating button */}
|
||||
{totalItems > 0 && !open && (
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className="fixed bottom-6 right-6 z-40 flex items-center gap-2.5 pl-4 pr-3 py-3 bg-gradient-to-br from-[var(--color-accent)] to-[var(--color-accent)]/80 hover:from-[var(--color-accent)]/90 hover:to-[var(--color-accent)]/70 text-white rounded-full shadow-lg shadow-[var(--color-accent)]/30 transition-all duration-200 hover:scale-105 hover:shadow-xl hover:shadow-[var(--color-accent)]/40 active:scale-95"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
||||
</svg>
|
||||
<span className="text-[13px] font-semibold">Stack</span>
|
||||
<span className="min-w-5 h-5 px-1.5 rounded-full bg-white/25 backdrop-blur-sm text-white text-[11px] font-bold flex items-center justify-center border border-white/20">
|
||||
{totalItems}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Overlay */}
|
||||
{open && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/60 backdrop-blur-sm z-40 animate-in fade-in duration-200"
|
||||
onClick={() => setOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Sidebar panel */}
|
||||
<div
|
||||
className={`fixed top-0 right-0 h-full w-96 max-w-[90vw] bg-[var(--color-surface-0)] border-l border-[var(--color-border)] shadow-2xl z-50 transform transition-transform duration-300 ease-out flex flex-col ${
|
||||
open ? 'translate-x-0' : 'translate-x-full'
|
||||
}`}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-4 border-b border-[var(--color-border)] bg-gradient-to-b from-[var(--color-surface-1)] to-[var(--color-surface-0)] shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-lg bg-gradient-to-br from-[var(--color-accent)] to-[var(--color-accent)]/70 flex items-center justify-center shadow-md shadow-[var(--color-accent)]/20">
|
||||
<svg className="w-4.5 h-4.5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<h2 className="text-[15px] font-semibold text-[var(--color-text-primary)] leading-none">Stack Builder</h2>
|
||||
<span className="text-[11px] text-[var(--color-text-tertiary)] mt-1">
|
||||
{totalItems} {totalItems === 1 ? 'component' : 'components'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{totalItems > 0 && (
|
||||
<button
|
||||
onClick={clearAll}
|
||||
className="text-xs text-[var(--color-text-tertiary)] hover:text-red-400 hover:bg-red-400/10 px-3 py-1.5 rounded-lg transition-all font-medium"
|
||||
>
|
||||
Clear All
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setOpen(false)}
|
||||
className="p-2 rounded-lg hover:bg-[var(--color-surface-3)] text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] transition-colors"
|
||||
aria-label="Close sidebar"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto scrollbar-thin scrollbar-thumb-[var(--color-surface-3)] scrollbar-track-transparent min-h-0">
|
||||
{totalItems === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-center px-6">
|
||||
<div className="relative w-24 h-24 rounded-2xl bg-gradient-to-br from-[var(--color-surface-2)] to-[var(--color-surface-3)] border border-[var(--color-border)] flex items-center justify-center mb-5 shadow-sm">
|
||||
<svg className="w-12 h-12 text-[var(--color-text-tertiary)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="1.5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
||||
</svg>
|
||||
<div className="absolute -top-1.5 -right-1.5 w-7 h-7 rounded-full bg-[var(--color-accent)]/10 border-2 border-[var(--color-accent)]/30 flex items-center justify-center backdrop-blur-sm">
|
||||
<svg className="w-3.5 h-3.5 text-[var(--color-accent)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2.5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-base font-semibold text-[var(--color-text-primary)] mb-2">Your stack is empty</p>
|
||||
<p className="text-[13px] text-[var(--color-text-secondary)] max-w-xs leading-relaxed">
|
||||
Browse components and click the <span className="inline-flex items-center justify-center w-4 h-4 rounded bg-[var(--color-accent)]/10 text-[var(--color-accent)] text-[10px] font-bold mx-0.5">+</span> button to add them to your stack for easy installation.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-3 py-4">
|
||||
{Object.entries(cart).filter(([, items]) => items?.length > 0).map(([type, items], idx, arr) => {
|
||||
const config = TYPE_CONFIG[type];
|
||||
const isLast = idx === arr.length - 1;
|
||||
return (
|
||||
<div key={type} className={!isLast ? 'mb-4 pb-3 border-b border-[var(--color-border)]' : 'mb-2'}>
|
||||
{/* Folder row */}
|
||||
<div className="flex items-center gap-2.5 py-2 px-3 rounded-lg bg-[var(--color-surface-1)] border border-[var(--color-border)] mb-2 shadow-sm">
|
||||
<svg className="w-4 h-4 shrink-0" style={{ color: config?.color ?? 'var(--color-text-tertiary)' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z" />
|
||||
</svg>
|
||||
<span className="text-[13px] font-semibold text-[var(--color-text-primary)]">
|
||||
{config?.label ?? type}
|
||||
</span>
|
||||
<span className="ml-auto text-[10px] px-2 py-0.5 rounded-full bg-[var(--color-surface-3)] text-[var(--color-text-tertiary)] font-semibold tabular-nums border border-[var(--color-border)]">
|
||||
{items.length}
|
||||
</span>
|
||||
</div>
|
||||
{/* File rows with tree lines */}
|
||||
{items.map((item: any, i: number) => {
|
||||
const isLastItem = i === items.length - 1;
|
||||
return (
|
||||
<div
|
||||
key={item.path}
|
||||
className="flex items-center group pl-2 pr-1.5 py-0.5 hover:bg-[var(--color-surface-1)] rounded-md transition-all duration-150"
|
||||
>
|
||||
{/* Tree connector */}
|
||||
<span className="text-[var(--color-border)] text-[13px] font-mono w-7 shrink-0 select-none">
|
||||
{isLastItem ? '└─' : '├─'}
|
||||
</span>
|
||||
{/* File icon */}
|
||||
<svg className="w-3.5 h-3.5 shrink-0 mr-2.5 text-[var(--color-text-tertiary)] group-hover:text-[var(--color-text-secondary)] transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
</svg>
|
||||
{/* Name */}
|
||||
<span className="text-[12px] text-[var(--color-text-primary)] flex-1 truncate py-2 font-medium">
|
||||
{formatName(item.name)}
|
||||
</span>
|
||||
{/* Remove */}
|
||||
<button
|
||||
onClick={() => removeItem(item.path, type)}
|
||||
className="opacity-0 group-hover:opacity-100 p-1.5 rounded-md hover:bg-red-400/10 text-[var(--color-text-tertiary)] hover:text-red-400 transition-all shrink-0"
|
||||
title="Remove from stack"
|
||||
aria-label={`Remove ${formatName(item.name)}`}
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
{totalItems > 0 && (
|
||||
<div className="border-t border-[var(--color-border)] bg-gradient-to-t from-[var(--color-surface-1)] to-[var(--color-surface-0)] p-4 space-y-3 shadow-[0_-8px_16px_rgba(0,0,0,0.08)] shrink-0">
|
||||
{/* Command preview */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg className="w-3.5 h-3.5 text-[var(--color-text-tertiary)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<span className="text-[11px] text-[var(--color-text-tertiary)] font-semibold uppercase tracking-wide">Installation Command</span>
|
||||
</div>
|
||||
<div className="bg-[var(--color-surface-2)] rounded-lg p-3 text-[11px] font-mono text-[var(--color-text-secondary)] break-all max-h-20 overflow-y-auto border border-[var(--color-border)] shadow-inner hover:border-[var(--color-accent)]/30 transition-colors">
|
||||
{generateCommand()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={copyCommand}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-4 py-2.5 bg-gradient-to-br from-[var(--color-accent)] to-[var(--color-accent)]/80 hover:from-[var(--color-accent)]/90 hover:to-[var(--color-accent)]/70 text-white rounded-lg text-sm font-semibold transition-all duration-200 shadow-md shadow-[var(--color-accent)]/20 hover:shadow-lg hover:shadow-[var(--color-accent)]/30 active:scale-95"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2.5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
Copied!
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3" />
|
||||
</svg>
|
||||
Copy Command
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={shareTwitter}
|
||||
className="px-3.5 py-2.5 bg-[var(--color-surface-3)] hover:bg-[var(--color-surface-4)] text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] rounded-lg transition-all duration-200 border border-[var(--color-border)] hover:border-[var(--color-accent)]/30 hover:shadow-sm active:scale-95"
|
||||
title="Share on Twitter"
|
||||
aria-label="Share on Twitter"
|
||||
>
|
||||
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { ClerkProvider } from '@clerk/clerk-react';
|
||||
|
||||
const clerkKey = import.meta.env.PUBLIC_CLERK_PUBLISHABLE_KEY;
|
||||
|
||||
export default function ClerkIsland() {
|
||||
if (!clerkKey) return null;
|
||||
return (
|
||||
<ClerkProvider publishableKey={clerkKey}>
|
||||
<span />
|
||||
</ClerkProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,736 @@
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import type { Component } from '../lib/types';
|
||||
import { TYPE_CONFIG } from '../lib/icons';
|
||||
import { ITEMS_PER_PAGE } from '../lib/constants';
|
||||
import { fetchComponentsByType } from '../lib/data';
|
||||
import SaveToCollectionButton from './SaveToCollectionButton';
|
||||
|
||||
interface Props {
|
||||
initialType: string;
|
||||
}
|
||||
|
||||
interface CartState {
|
||||
[key: string]: { name: string; path: string; category: string; description: string; icon: string }[];
|
||||
}
|
||||
|
||||
function cleanPath(path: string): string {
|
||||
return path?.replace(/\.(md|json)$/, '') ?? '';
|
||||
}
|
||||
|
||||
function formatName(name: string): string {
|
||||
if (!name) return '';
|
||||
return name
|
||||
.replace(/\.(md|json)$/, '')
|
||||
.replace(/[-_]/g, ' ')
|
||||
.split(' ')
|
||||
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
import TypeIcon from './TypeIcon';
|
||||
|
||||
export default function ComponentGrid({ initialType }: Props) {
|
||||
// Only the active type's components are loaded, from /components/{type}.json,
|
||||
// instead of downloading the whole ~1.9MB index up front.
|
||||
const [typeComponents, setTypeComponents] = useState<Component[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [activeType, setActiveType] = useState<string>(initialType);
|
||||
const [category, setCategory] = useState('all');
|
||||
const [search, setSearch] = useState('');
|
||||
const [sortBy, setSortBy] = useState<'downloads' | 'name'>('downloads');
|
||||
const [page, setPage] = useState(1);
|
||||
const [cart, setCart] = useState<CartState>({});
|
||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const [minDownloads, setMinDownloads] = useState(0);
|
||||
const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
|
||||
|
||||
// Sync activeType when initialType changes (e.g. sidebar navigation)
|
||||
useEffect(() => {
|
||||
setActiveType(initialType);
|
||||
setCategory('all');
|
||||
setPage(1);
|
||||
}, [initialType]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const components = await fetchComponentsByType(activeType);
|
||||
if (!cancelled) { setTypeComponents(components); setLoading(false); }
|
||||
} catch {
|
||||
if (!cancelled) { setError('Failed to load components'); setLoading(false); }
|
||||
}
|
||||
}
|
||||
|
||||
load();
|
||||
return () => { cancelled = true; };
|
||||
}, [activeType]);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem('claudeCodeCart');
|
||||
if (saved) setCart(JSON.parse(saved));
|
||||
} catch {}
|
||||
}, []);
|
||||
|
||||
|
||||
const categories = useMemo(() => {
|
||||
const cats = new Set<string>();
|
||||
for (const c of typeComponents) { if (c.category) cats.add(c.category); }
|
||||
return Array.from(cats).sort();
|
||||
}, [typeComponents]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
let items = typeComponents;
|
||||
|
||||
// Apply either single-category OR multi-category filter, not both
|
||||
if (selectedCategories.length > 0) {
|
||||
// Multi-category filter takes precedence
|
||||
items = items.filter((c) => selectedCategories.includes(c.category ?? ''));
|
||||
} else if (category !== 'all') {
|
||||
// Single-category filter
|
||||
items = items.filter((c) => c.category === category);
|
||||
}
|
||||
|
||||
// Search filter
|
||||
if (search.trim()) {
|
||||
const q = search.toLowerCase();
|
||||
items = items.filter((c) =>
|
||||
c.name.toLowerCase().includes(q) ||
|
||||
c.description?.toLowerCase().includes(q) ||
|
||||
c.category?.toLowerCase().includes(q) ||
|
||||
c.content?.toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
|
||||
// Downloads filter
|
||||
if (minDownloads > 0) {
|
||||
items = items.filter((c) => (c.downloads ?? 0) >= minDownloads);
|
||||
}
|
||||
|
||||
// Sort
|
||||
const sorted = [...items];
|
||||
if (sortBy === 'downloads') sorted.sort((a, b) => (b.downloads ?? 0) - (a.downloads ?? 0));
|
||||
else if (sortBy === 'name') sorted.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
return sorted;
|
||||
}, [typeComponents, category, selectedCategories, search, sortBy, minDownloads]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(filtered.length / ITEMS_PER_PAGE));
|
||||
const paged = filtered.slice((page - 1) * ITEMS_PER_PAGE, page * ITEMS_PER_PAGE);
|
||||
|
||||
useEffect(() => { setPage(1); }, [category, search, activeType, selectedCategories, minDownloads]);
|
||||
|
||||
// Sidebar counts come from /counts.json (see Sidebar.astro); the grid no
|
||||
// longer loads every type, so it can't (and doesn't need to) emit them.
|
||||
|
||||
const toggleCategoryFilter = useCallback((cat: string) => {
|
||||
setSelectedCategories(prev =>
|
||||
prev.includes(cat) ? prev.filter(c => c !== cat) : [...prev, cat]
|
||||
);
|
||||
}, []);
|
||||
|
||||
const clearAllFilters = useCallback(() => {
|
||||
setSearch('');
|
||||
setCategory('all');
|
||||
setSelectedCategories([]);
|
||||
setMinDownloads(0);
|
||||
}, []);
|
||||
|
||||
const hasActiveFilters = search || category !== 'all' || selectedCategories.length > 0 || minDownloads > 0;
|
||||
|
||||
const isInCart = useCallback(
|
||||
(path: string, type: string) => {
|
||||
const typePlural = type.endsWith('s') ? type : type + 's';
|
||||
return cart[typePlural]?.some((item) => item.path === path) ?? false;
|
||||
},
|
||||
[cart]
|
||||
);
|
||||
|
||||
const toggleCart = useCallback((component: Component) => {
|
||||
const typePlural = component.type.endsWith('s') ? component.type : component.type + 's';
|
||||
setCart((prev) => {
|
||||
const items = prev[typePlural] ?? [];
|
||||
const exists = items.some((i) => i.path === component.path);
|
||||
let newItems: CartState;
|
||||
if (exists) {
|
||||
newItems = { ...prev, [typePlural]: items.filter((i) => i.path !== component.path) };
|
||||
} else {
|
||||
newItems = { ...prev, [typePlural]: [...items, {
|
||||
name: component.name, path: component.path, category: component.category ?? '',
|
||||
description: component.description ?? '', icon: typePlural,
|
||||
}] };
|
||||
}
|
||||
localStorage.setItem('claudeCodeCart', JSON.stringify(newItems));
|
||||
window.dispatchEvent(new CustomEvent('cart-updated', { detail: newItems }));
|
||||
return newItems;
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="px-6 py-32">
|
||||
<div className="flex flex-col items-center gap-6">
|
||||
<div className="relative w-16 h-16">
|
||||
{/* Outer ring */}
|
||||
<div className="absolute inset-0 border-[3px] border-[var(--color-border)] rounded-full opacity-20" />
|
||||
{/* Spinning gradient ring */}
|
||||
<div className="absolute inset-0 rounded-full bg-gradient-to-tr from-[var(--color-primary-500)] via-[var(--color-accent-400)] to-[var(--color-primary-500)] opacity-80 animate-spin" style={{ clipPath: 'polygon(50% 50%, 50% 0%, 100% 0%, 100% 100%, 0% 100%, 0% 0%, 50% 0%)' }} />
|
||||
<div className="absolute inset-[3px] bg-[var(--color-surface-0)] rounded-full" />
|
||||
{/* Inner pulse */}
|
||||
<div className="absolute inset-[6px] bg-gradient-to-br from-[var(--color-primary-500)]/20 to-[var(--color-accent-400)]/20 rounded-full animate-pulse" />
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<span className="text-[14px] font-semibold text-[var(--color-text-primary)] animate-pulse">Loading components</span>
|
||||
<span className="text-[12px] text-[var(--color-text-tertiary)]">Preparing your experience...</span>
|
||||
</div>
|
||||
{/* Skeleton cards preview */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 w-full max-w-6xl mt-8">
|
||||
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||||
<div key={i} className="p-5 rounded-xl bg-[var(--color-card-bg)] border border-[var(--color-border)] animate-pulse" style={{ animationDelay: `${i * 100}ms` }}>
|
||||
<div className="flex items-start gap-3.5">
|
||||
<div className="w-11 h-11 rounded-xl bg-[var(--color-surface-3)] skeleton" />
|
||||
<div className="flex-1 space-y-3">
|
||||
<div className="h-4 bg-[var(--color-surface-3)] rounded skeleton w-3/4" />
|
||||
<div className="space-y-2">
|
||||
<div className="h-3 bg-[var(--color-surface-3)] rounded skeleton" />
|
||||
<div className="h-3 bg-[var(--color-surface-3)] rounded skeleton w-5/6" />
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="h-5 w-16 bg-[var(--color-surface-3)] rounded-full skeleton" />
|
||||
<div className="h-5 w-20 bg-[var(--color-surface-3)] rounded-full skeleton" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="px-6 py-24">
|
||||
<div className="flex flex-col items-center gap-4 max-w-md mx-auto text-center">
|
||||
<div className="w-12 h-12 rounded-full bg-red-500/10 border border-red-500/20 flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-[14px] font-semibold text-[var(--color-text-primary)] mb-1">Failed to load components</h3>
|
||||
<p className="text-[13px] text-[var(--color-text-secondary)]">{error}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="px-4 py-2 text-[13px] font-medium rounded-lg bg-[var(--color-primary-500)] text-white hover:bg-[var(--color-primary-600)] transition-all active:scale-95"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Enhanced Filter bar with view modes */}
|
||||
<div className="border-b border-[var(--color-border)] bg-[var(--color-surface-0)] backdrop-blur-sm sticky top-14 z-10">
|
||||
<div className="flex flex-col sm:flex-row sm:flex-wrap sm:items-center gap-2.5 px-6 py-4">
|
||||
{/* Search */}
|
||||
<div className="relative w-full sm:w-auto sm:flex-1 sm:min-w-[140px] sm:max-w-md">
|
||||
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--color-text-tertiary)] pointer-events-none transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search components..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded-lg text-[13px] text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] pl-10 pr-9 py-2.5 outline-none focus:bg-[var(--color-surface-3)] focus:border-[var(--color-primary-500)] focus:ring-2 focus:ring-[var(--color-primary-500)]/20 transition-all hover:border-[var(--color-border-hover)]"
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
onClick={() => setSearch('')}
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 w-5 h-5 rounded-md flex items-center justify-center text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-surface-3)] transition-all active:scale-95"
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2.5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Category select + advanced filters toggle: share a row on mobile,
|
||||
flow individually as flex items from sm: up (display:contents
|
||||
unwraps this grouping div so it doesn't affect desktop layout) */}
|
||||
<div className="flex items-center gap-2.5 sm:contents">
|
||||
<select
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value)}
|
||||
className="flex-1 sm:flex-none min-w-0 bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded-lg text-[13px] text-[var(--color-text-secondary)] font-medium px-3.5 py-2.5 pr-9 outline-none focus:bg-[var(--color-surface-3)] focus:border-[var(--color-primary-500)] focus:ring-2 focus:ring-[var(--color-primary-500)]/20 cursor-pointer transition-all appearance-none hover:border-[var(--color-border-hover)] hover:text-[var(--color-text-primary)] bg-[url('data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20fill%3D%22none%22%20viewBox%3D%220%200%2020%2020%22%3E%3Cpath%20stroke%3D%22%23737373%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%20stroke-width%3D%221.5%22%20d%3D%22m6%208%204%204%204-4%22%2F%3E%3C%2Fsvg%3E')] bg-[length:1.25rem] bg-[right_0.5rem_center] bg-no-repeat"
|
||||
>
|
||||
<option value="all">All categories</option>
|
||||
{categories.map((cat) => (
|
||||
<option key={cat} value={cat}>{cat}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Advanced Filters Toggle */}
|
||||
<button
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
className={`shrink-0 flex items-center gap-2 px-3 py-2 text-[13px] font-mono font-semibold rounded border transition-all ${
|
||||
showFilters || hasActiveFilters
|
||||
? 'bg-[var(--color-accent)] text-white border-[var(--color-accent)]'
|
||||
: 'bg-[var(--color-surface-2)] text-[var(--color-text-secondary)] border-[var(--color-border)] hover:border-[var(--color-accent)] hover:text-[var(--color-text-primary)]'
|
||||
}`}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4" />
|
||||
</svg>
|
||||
<span className="hidden sm:inline">Filters</span>
|
||||
{hasActiveFilters && (
|
||||
<span className="w-2 h-2 rounded-full bg-white animate-pulse"></span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* View Mode Toggle */}
|
||||
<div className="hidden md:flex items-center gap-1 bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded-lg p-1">
|
||||
<button
|
||||
onClick={() => setViewMode('grid')}
|
||||
className={`p-2 rounded-md transition-all ${
|
||||
viewMode === 'grid'
|
||||
? 'bg-[var(--color-surface-4)] text-[var(--color-text-primary)] shadow-sm'
|
||||
: 'text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)]'
|
||||
}`}
|
||||
title="Grid view"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('list')}
|
||||
className={`p-2 rounded-md transition-all ${
|
||||
viewMode === 'list'
|
||||
? 'bg-[var(--color-surface-4)] text-[var(--color-text-primary)] shadow-sm'
|
||||
: 'text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)]'
|
||||
}`}
|
||||
title="List view"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Sort */}
|
||||
<div className="flex items-center gap-2 w-full sm:w-auto sm:ml-auto">
|
||||
<span className="text-[11px] text-[var(--color-text-tertiary)] font-medium hidden sm:inline">Sort by</span>
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={(e) => setSortBy(e.target.value as 'downloads' | 'name')}
|
||||
className="flex-1 sm:flex-none font-mono bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded text-[13px] text-[var(--color-text-secondary)] font-medium px-3 py-2 pr-9 outline-none focus:border-[var(--color-accent)] cursor-pointer transition-all appearance-none hover:border-[var(--color-accent)] hover:text-[var(--color-text-primary)] bg-[url('data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20fill%3D%22none%22%20viewBox%3D%220%200%2020%2020%22%3E%3Cpath%20stroke%3D%22%237d8590%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%20stroke-width%3D%221.5%22%20d%3D%22m6%208%204%204%204-4%22%2F%3E%3C%2Fsvg%3E')] bg-[length:1.25rem] bg-[right_0.5rem_center] bg-no-repeat"
|
||||
>
|
||||
<option value="downloads">Most Popular</option>
|
||||
<option value="name">Alphabetical</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Advanced Filters Panel */}
|
||||
{showFilters && (
|
||||
<div className="px-6 py-5 border-t border-[var(--color-border)] bg-[var(--color-surface-1)] animate-fade-in-up">
|
||||
<div className="flex items-start gap-6">
|
||||
{/* Multi-category filter */}
|
||||
<div className="flex-1">
|
||||
<label className="text-[11px] font-bold text-[var(--color-text-tertiary)] uppercase tracking-wider mb-3 block">
|
||||
Categories
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{categories.slice(0, 8).map((cat) => (
|
||||
<button
|
||||
key={cat}
|
||||
onClick={() => toggleCategoryFilter(cat)}
|
||||
className={`px-3 py-1 text-[11px] font-mono font-semibold rounded border transition-all ${
|
||||
selectedCategories.includes(cat)
|
||||
? 'bg-[var(--color-accent)] text-white border-[var(--color-accent)]'
|
||||
: 'bg-[var(--color-surface-2)] text-[var(--color-text-secondary)] border-[var(--color-border)] hover:border-[var(--color-accent)] hover:text-[var(--color-text-primary)]'
|
||||
}`}
|
||||
>
|
||||
{cat}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Downloads filter */}
|
||||
<div className="w-64">
|
||||
<label className="text-[11px] font-bold text-[var(--color-text-tertiary)] uppercase tracking-wider mb-3 block">
|
||||
Min Downloads: {minDownloads}+
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1000"
|
||||
step="50"
|
||||
value={minDownloads}
|
||||
onChange={(e) => setMinDownloads(Number(e.target.value))}
|
||||
className="w-full h-2 bg-[var(--color-surface-3)] rounded-lg appearance-none cursor-pointer accent-[var(--color-primary-500)]"
|
||||
/>
|
||||
<div className="flex justify-between text-[10px] text-[var(--color-text-tertiary)] mt-1">
|
||||
<span>0</span>
|
||||
<span>500</span>
|
||||
<span>1000+</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Clear filters */}
|
||||
{hasActiveFilters && (
|
||||
<button
|
||||
onClick={clearAllFilters}
|
||||
className="px-4 py-2 text-[12px] font-semibold text-red-400 hover:text-red-300 bg-red-500/10 hover:bg-red-500/20 border border-red-500/30 rounded-lg transition-all active:scale-95"
|
||||
>
|
||||
Clear All
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Results count with quick actions */}
|
||||
<div className="px-6 pt-5 pb-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[13px] text-[var(--color-text-secondary)] font-medium">
|
||||
{filtered.length} {filtered.length !== 1 ? 'components' : 'component'}
|
||||
</span>
|
||||
{search && (
|
||||
<span className="text-[12px] text-[var(--color-text-tertiary)]">
|
||||
matching <span className="font-medium text-[var(--color-text-secondary)]">"{search}"</span>
|
||||
</span>
|
||||
)}
|
||||
{category !== 'all' && (
|
||||
<span className="text-[11px] px-2 py-0.5 rounded-md bg-[var(--color-surface-3)] text-[var(--color-text-tertiary)] border border-[var(--color-border)]">
|
||||
{category}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
{filtered.length > 0 && (
|
||||
<div className="flex items-center gap-2 ml-4">
|
||||
<button
|
||||
onClick={() => {
|
||||
paged.forEach(comp => {
|
||||
if (!isInCart(comp.path, comp.type)) {
|
||||
toggleCart(comp);
|
||||
}
|
||||
});
|
||||
}}
|
||||
className="text-[11px] font-semibold px-3 py-1.5 rounded-lg bg-[var(--color-surface-2)] text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-surface-3)] border border-[var(--color-border)] hover:border-[var(--color-border-hover)] transition-all active:scale-95"
|
||||
title="Add all visible components to stack"
|
||||
>
|
||||
+ Add Page to Stack
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Keyboard shortcuts hint */}
|
||||
<div className="hidden lg:flex items-center gap-2 text-[11px] text-[var(--color-text-tertiary)]">
|
||||
<kbd className="px-2 py-1 rounded bg-[var(--color-surface-2)] border border-[var(--color-border)] font-mono">⌘K</kbd>
|
||||
<span>to search</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grid or List View */}
|
||||
<div className={viewMode === 'grid'
|
||||
? "grid grid-cols-1 md:grid-cols-2 2xl:grid-cols-3 gap-5 px-6 pb-6"
|
||||
: "flex flex-col gap-4 px-6 pb-6"
|
||||
}>
|
||||
{paged.map((component, idx) => {
|
||||
const inCart = isInCart(component.path, component.type);
|
||||
const config = TYPE_CONFIG[activeType];
|
||||
|
||||
if (viewMode === 'list') {
|
||||
// List View
|
||||
return (
|
||||
<div
|
||||
key={component.path ?? component.name}
|
||||
className="group relative flex items-center gap-5 p-4 rounded-md bg-[var(--color-card-bg)] border border-[var(--color-border)] hover:bg-[var(--color-card-hover)] hover:border-[var(--color-accent)] transition-all duration-150 cursor-pointer animate-fade-in-up"
|
||||
style={{
|
||||
animationDelay: `${idx * 30}ms`,
|
||||
animationFillMode: 'both'
|
||||
}}
|
||||
onClick={() => {
|
||||
window.location.href = `/component/${component.type}/${cleanPath(component.path ?? component.name)}`;
|
||||
}}
|
||||
>
|
||||
{/* Icon */}
|
||||
<div
|
||||
className="w-11 h-11 rounded flex items-center justify-center shrink-0"
|
||||
style={{
|
||||
backgroundColor: `${config?.color ?? '#a1a1a1'}15`,
|
||||
color: config?.color ?? '#a1a1a1',
|
||||
border: `1px solid ${config?.color ?? '#a1a1a1'}30`,
|
||||
}}
|
||||
>
|
||||
<TypeIcon type={activeType} size={22} className="[&>svg]:w-[22px] [&>svg]:h-[22px]" />
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-mono text-[14px] font-bold text-[var(--color-text-primary)] group-hover:text-[var(--color-accent)] transition-colors mb-1">
|
||||
{formatName(component.name)}
|
||||
</h3>
|
||||
<p className="text-[12px] text-[var(--color-text-secondary)] line-clamp-1 leading-relaxed">
|
||||
{component.description || component.content?.slice(0, 150) || 'No description'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Badges */}
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{component.category && (
|
||||
<span className="font-mono text-[10px] font-semibold px-2 py-0.5 rounded border border-[var(--color-border)] bg-[var(--color-surface-2)] text-[var(--color-text-tertiary)]">
|
||||
{component.category}
|
||||
</span>
|
||||
)}
|
||||
{(component.downloads ?? 0) > 0 && (
|
||||
<span className="badge-download text-[10px]">
|
||||
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2.5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 14l-7 7m0 0l-7-7m7 7V3" />
|
||||
</svg>
|
||||
{component.downloads?.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<SaveToCollectionButton
|
||||
componentType={component.type}
|
||||
componentPath={component.path}
|
||||
componentName={component.name}
|
||||
componentCategory={component.category}
|
||||
/>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); toggleCart(component); }}
|
||||
className={`w-8 h-8 rounded flex items-center justify-center transition-all duration-150 ${
|
||||
inCart
|
||||
? 'bg-[var(--color-accent)] text-white border border-[var(--color-accent)]'
|
||||
: 'text-[var(--color-text-tertiary)] opacity-0 group-hover:opacity-100 hover:text-[var(--color-accent)] hover:bg-[var(--color-surface-2)] border border-transparent hover:border-[var(--color-border)]'
|
||||
}`}
|
||||
title={inCart ? 'Remove from stack' : 'Add to stack'}
|
||||
>
|
||||
{inCart ? (
|
||||
<svg className="w-4.5 h-4.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="3">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-4.5 h-4.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Grid View — terminal style
|
||||
return (
|
||||
<div
|
||||
key={component.path ?? component.name}
|
||||
className="group relative flex items-start gap-4 p-5 rounded-md bg-[var(--color-card-bg)] border border-[var(--color-border)] hover:bg-[var(--color-card-hover)] hover:border-[var(--color-accent)] transition-all duration-150 cursor-pointer animate-fade-in-up"
|
||||
style={{
|
||||
animationDelay: `${idx * 50}ms`,
|
||||
animationFillMode: 'both'
|
||||
}}
|
||||
onClick={() => {
|
||||
window.location.href = `/component/${component.type}/${cleanPath(component.path ?? component.name)}`;
|
||||
}}
|
||||
>
|
||||
{/* Icon — flat, no glow */}
|
||||
<div
|
||||
className="w-10 h-10 rounded flex items-center justify-center shrink-0"
|
||||
style={{
|
||||
backgroundColor: `${config?.color ?? '#a1a1a1'}15`,
|
||||
color: config?.color ?? '#a1a1a1',
|
||||
border: `1px solid ${config?.color ?? '#a1a1a1'}30`,
|
||||
}}
|
||||
>
|
||||
<TypeIcon type={activeType} size={20} className="[&>svg]:w-[20px] [&>svg]:h-[20px]" />
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="font-mono text-[14px] font-bold text-[var(--color-text-primary)] group-hover:text-[var(--color-accent)] transition-colors leading-tight mb-1.5">
|
||||
{formatName(component.name)}
|
||||
</h3>
|
||||
<p className="text-[12px] text-[var(--color-text-secondary)] line-clamp-2 leading-[1.6]">
|
||||
{component.description || component.content?.slice(0, 120) || 'No description'}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mt-3">
|
||||
{component.category && (
|
||||
<span className="font-mono text-[10px] font-semibold px-2 py-0.5 rounded border border-[var(--color-border)] bg-[var(--color-surface-2)] text-[var(--color-text-tertiary)]">
|
||||
{component.category}
|
||||
</span>
|
||||
)}
|
||||
{(component.downloads ?? 0) > 0 && (
|
||||
<span className="badge-download text-[10px]">
|
||||
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2.5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 14l-7 7m0 0l-7-7m7 7V3" />
|
||||
</svg>
|
||||
{component.downloads?.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
<SaveToCollectionButton
|
||||
componentType={component.type}
|
||||
componentPath={component.path}
|
||||
componentName={component.name}
|
||||
componentCategory={component.category}
|
||||
/>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); toggleCart(component); }}
|
||||
className={`w-8 h-8 rounded flex items-center justify-center transition-all duration-150 ${
|
||||
inCart
|
||||
? 'bg-[var(--color-accent)] text-white border border-[var(--color-accent)]'
|
||||
: 'text-[var(--color-text-tertiary)] opacity-0 group-hover:opacity-100 hover:text-[var(--color-accent)] hover:bg-[var(--color-surface-2)] border border-transparent hover:border-[var(--color-border)]'
|
||||
}`}
|
||||
title={inCart ? 'Remove from stack' : 'Add to stack'}
|
||||
aria-label={inCart ? 'Remove from stack' : 'Add to stack'}
|
||||
>
|
||||
{inCart ? (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="3">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Empty state */}
|
||||
{paged.length === 0 && !loading && (
|
||||
<div className="flex flex-col items-center justify-center px-6 py-32">
|
||||
{/* Animated illustration */}
|
||||
<div className="relative mb-8 animate-float">
|
||||
<div className="w-24 h-24 rounded-3xl bg-gradient-to-br from-[var(--color-surface-2)] to-[var(--color-surface-3)] border border-[var(--color-border)] flex items-center justify-center shadow-xl">
|
||||
<svg className="w-12 h-12 text-[var(--color-text-tertiary)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="1.5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
{/* Decorative animated elements */}
|
||||
<div className="absolute -top-2 -right-2 w-4 h-4 rounded-full bg-gradient-to-br from-[var(--color-accent-400)] to-[var(--color-accent-500)] opacity-80 animate-ping" style={{ animationDuration: '2s' }}></div>
|
||||
<div className="absolute -top-2 -right-2 w-4 h-4 rounded-full bg-[var(--color-accent-400)] opacity-80"></div>
|
||||
<div className="absolute -bottom-2 -left-2 w-3 h-3 rounded-full bg-gradient-to-br from-[var(--color-primary-400)] to-[var(--color-primary-500)] opacity-80 animate-ping" style={{ animationDuration: '2.5s', animationDelay: '0.5s' }}></div>
|
||||
<div className="absolute -bottom-2 -left-2 w-3 h-3 rounded-full bg-[var(--color-primary-400)] opacity-80"></div>
|
||||
{/* Glow effect */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-[var(--color-primary-500)]/10 to-[var(--color-accent-400)]/10 rounded-3xl blur-2xl -z-10"></div>
|
||||
</div>
|
||||
|
||||
{/* Message */}
|
||||
<h3 className="text-[17px] font-bold text-[var(--color-text-primary)] mb-3">
|
||||
{search ? `No results for "${search}"` : 'No components found'}
|
||||
</h3>
|
||||
<p className="text-[14px] text-[var(--color-text-secondary)] max-w-md text-center mb-8 leading-relaxed">
|
||||
{search
|
||||
? "We couldn't find any components matching your search. Try different keywords or browse all categories."
|
||||
: category !== 'all'
|
||||
? "No components in this category yet. Try selecting a different category or browse all."
|
||||
: "No components available at the moment. Check back soon!"}
|
||||
</p>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-3">
|
||||
{search && (
|
||||
<button
|
||||
onClick={() => setSearch('')}
|
||||
className="px-5 py-2.5 text-[13px] font-semibold rounded-xl bg-[var(--color-surface-2)] text-[var(--color-text-primary)] hover:bg-[var(--color-surface-3)] border border-[var(--color-border)] hover:border-[var(--color-border-hover)] hover:shadow-lg transition-all active:scale-95"
|
||||
>
|
||||
Clear search
|
||||
</button>
|
||||
)}
|
||||
{category !== 'all' && (
|
||||
<button
|
||||
onClick={() => setCategory('all')}
|
||||
className="px-5 py-2.5 text-[13px] font-semibold rounded-xl bg-[var(--color-surface-2)] text-[var(--color-text-primary)] hover:bg-[var(--color-surface-3)] border border-[var(--color-border)] hover:border-[var(--color-border-hover)] hover:shadow-lg transition-all active:scale-95"
|
||||
>
|
||||
View all categories
|
||||
</button>
|
||||
)}
|
||||
{!search && category === 'all' && (
|
||||
<a
|
||||
href="/"
|
||||
className="px-5 py-2.5 text-[13px] font-semibold rounded-xl bg-gradient-to-r from-[var(--color-primary-500)] to-[var(--color-primary-600)] text-white hover:from-[var(--color-primary-600)] hover:to-[var(--color-primary-700)] shadow-lg hover:shadow-xl transition-all active:scale-95"
|
||||
>
|
||||
Browse all components
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination - Premium style */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-3 px-6 pb-10 pt-4">
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
className="group flex items-center gap-2 px-4 py-2.5 text-[13px] font-semibold rounded-xl text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-surface-2)] border border-[var(--color-border)] hover:border-[var(--color-border-hover)] disabled:opacity-30 disabled:cursor-not-allowed hover:shadow-md transition-all active:scale-95 disabled:active:scale-100 disabled:hover:shadow-none"
|
||||
aria-label="Previous page"
|
||||
>
|
||||
<svg className="w-4 h-4 transition-transform group-hover:-translate-x-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2.5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
<span className="hidden sm:inline">Previous</span>
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-2 px-5 py-2.5 rounded-xl bg-[var(--color-surface-2)] border border-[var(--color-border)] shadow-sm">
|
||||
<span className="text-[13px] font-bold text-[var(--color-text-primary)] tabular-nums">
|
||||
{page}
|
||||
</span>
|
||||
<span className="text-[13px] text-[var(--color-text-tertiary)] font-medium">/</span>
|
||||
<span className="text-[13px] font-semibold text-[var(--color-text-secondary)] tabular-nums">
|
||||
{totalPages}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={page === totalPages}
|
||||
className="group flex items-center gap-2 px-4 py-2.5 text-[13px] font-semibold rounded-xl text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-surface-2)] border border-[var(--color-border)] hover:border-[var(--color-border-hover)] disabled:opacity-30 disabled:cursor-not-allowed hover:shadow-md transition-all active:scale-95 disabled:active:scale-100 disabled:hover:shadow-none"
|
||||
aria-label="Next page"
|
||||
>
|
||||
<span className="hidden sm:inline">Next</span>
|
||||
<svg className="w-4 h-4 transition-transform group-hover:translate-x-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2.5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import { useId } from 'react';
|
||||
|
||||
interface CountryFlagProps {
|
||||
code: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// SVG flag path data for top countries (mask applied via unique ID per instance)
|
||||
const FLAG_DATA: Record<string, (maskId: string) => React.ReactNode> = {
|
||||
US: (m) => (
|
||||
<>
|
||||
<mask id={m}><circle cx="256" cy="256" r="256" fill="#fff"/></mask>
|
||||
<g mask={`url(#${m})`}>
|
||||
<path fill="#eee" d="M0 0h512v512H0z"/>
|
||||
<path fill="#d80027" d="M0 0h512v39.4H0zm0 78.8h512v39.4H0zm0 78.7h512v39.4H0zm0 78.8h512v39.4H0zm0 78.8h512v39.3H0zm0 78.7h512v39.4H0zm0 78.8h512V512H0z"/>
|
||||
<path fill="#0052b4" d="M0 0h256v275.8H0z"/>
|
||||
<path fill="#eee" d="m99.8 66.3 4.4 13.5H118l-11.5 8.4 4.4 13.5-11.4-8.3-11.5 8.3 4.4-13.5-11.4-8.4h14.1zm-55 0 4.3 13.5h14.2l-11.5 8.4 4.4 13.5-11.4-8.3-11.5 8.3 4.4-13.5-11.4-8.4h14.1zm110 0 4.4 13.5h14.1l-11.4 8.4 4.4 13.5-11.5-8.3-11.4 8.3 4.4-13.5-11.5-8.4h14.2zm-55 44.5 4.4 13.5h14.1l-11.4 8.3 4.4 13.5-11.5-8.3-11.4 8.3 4.4-13.5-11.5-8.3h14.2zm-55 0 4.3 13.5h14.2l-11.5 8.3 4.4 13.5-11.4-8.3-11.5 8.3 4.4-13.5-11.4-8.3h14.1zm110 0 4.4 13.5h14.1l-11.4 8.3 4.4 13.5-11.5-8.3-11.4 8.3 4.4-13.5-11.5-8.3h14.2zm-55 44.4 4.4 13.5h14.1l-11.4 8.4 4.4 13.5-11.5-8.3-11.4 8.3 4.4-13.5-11.5-8.4h14.2zm-55 0 4.3 13.5h14.2l-11.5 8.4 4.4 13.5-11.4-8.3-11.5 8.3 4.4-13.5-11.4-8.4h14.1zm110 0 4.4 13.5h14.1l-11.4 8.4 4.4 13.5-11.5-8.3-11.4 8.3 4.4-13.5-11.5-8.4h14.2zm-55 44.5 4.4 13.5h14.1l-11.4 8.3 4.4 13.5-11.5-8.3-11.4 8.3 4.4-13.5-11.5-8.3h14.2zm-55 0 4.3 13.5h14.2l-11.5 8.3 4.4 13.5-11.4-8.3-11.5 8.3 4.4-13.5-11.4-8.3h14.1zm110 0 4.4 13.5h14.1l-11.4 8.3 4.4 13.5-11.5-8.3-11.4 8.3 4.4-13.5-11.5-8.3h14.2zm-55 44.5 4.4 13.5h14.1l-11.4 8.3 4.4 13.5-11.5-8.3-11.4 8.3 4.4-13.5-11.5-8.3h14.2zm-55 0 4.3 13.5h14.2l-11.5 8.3 4.4 13.5-11.4-8.3-11.5 8.3 4.4-13.5-11.4-8.3h14.1zm110 0 4.4 13.5h14.1l-11.4 8.3 4.4 13.5-11.5-8.3-11.4 8.3 4.4-13.5-11.5-8.3h14.2z"/>
|
||||
</g>
|
||||
</>
|
||||
),
|
||||
KR: (m) => (
|
||||
<>
|
||||
<mask id={m}><circle cx="256" cy="256" r="256" fill="#fff"/></mask>
|
||||
<g mask={`url(#${m})`}>
|
||||
<path fill="#eee" d="M0 0h512v512H0z"/>
|
||||
<circle cx="256" cy="256" r="111.3" fill="#d80027"/>
|
||||
<path fill="#0052b4" d="M345.3 256a89.3 89.3 0 0 1-89.3 89.3 111.3 111.3 0 0 0 0-178.6 89.3 89.3 0 0 1 89.3 89.3z"/>
|
||||
<path fill="#333" d="m350.4 334.7 23.4 17-8.9 28.8 23.4-17 23.4 17-9-28.8 23.5-17h-29l-8.9-28.8-8.9 28.8zm-34.3-22.3-8.9 28.8 23.4-17 23.4 17-8.9-28.8 23.4-17h-29l-8.9-28.8-8.9 28.8h-28.9zm22.3 133.9 8.9-28.8h29l-23.4-17 8.9-28.8-23.4 17-23.4-17 8.9 28.8-23.4 17h29zm-34.3-22.3 23.4-17-8.9-28.8 23.4 17 23.4-17-8.9 28.8 23.4 17h-29l-8.9 28.8-8.9-28.8zM127.7 88.8l23.4 17-8.9 28.8 23.4-17 23.4 17-8.9-28.8 23.4-17h-29l-8.9-28.8-8.9 28.8zm-34.3-22.3-8.9 28.8 23.4-17 23.4 17-8.9-28.8 23.4-17h-29l-8.9-28.8-8.9 28.8H70.1zm22.3 133.9 8.9-28.8h29l-23.4-17 8.9-28.8-23.4 17-23.4-17 8.9 28.8-23.4 17h29zm-34.3-22.3 23.4-17-8.9-28.8 23.4 17 23.4-17-8.9 28.8 23.4 17h-29l-8.9 28.8-8.9-28.8z"/>
|
||||
</g>
|
||||
</>
|
||||
),
|
||||
BR: (m) => (
|
||||
<>
|
||||
<mask id={m}><circle cx="256" cy="256" r="256" fill="#fff"/></mask>
|
||||
<g mask={`url(#${m})`}>
|
||||
<path fill="#6da544" d="M0 0h512v512H0z"/>
|
||||
<path fill="#ffda44" d="M256 100.2 467.5 256 256 411.8 44.5 256z"/>
|
||||
<circle cx="256" cy="256" r="89" fill="#0052b4"/>
|
||||
<path fill="#eee" d="M211.2 250.1c-5.4 0-10.8.4-16 1.3 0-2.7-.1-5.4-.1-8.2 0-53.2 18.9-101.3 50.2-138.6-79.6 4.4-143 71-143 152.6 0 84.4 68.5 152.9 152.9 152.9 39.5 0 75.6-15 102.8-39.7-27.8 22.8-63.3 36.5-101.9 36.5-88.4 0-160.2-71.7-160.2-160.1 0-41.2 15.6-78.8 41.3-107.3z"/>
|
||||
</g>
|
||||
</>
|
||||
),
|
||||
CN: (m) => (
|
||||
<>
|
||||
<mask id={m}><circle cx="256" cy="256" r="256" fill="#fff"/></mask>
|
||||
<g mask={`url(#${m})`}>
|
||||
<path fill="#d80027" d="M0 0h512v512H0z"/>
|
||||
<path fill="#ffda44" d="m226.8 157.2 12 37h38.9l-31.5 22.9 12 37-31.4-22.9-31.5 22.9 12-37-31.4-22.9h38.9zm87.1 38.5 3.9 11.9 12.5.1-10.1 7.4 3.8 11.9-10.1-7.3-10.1 7.3 3.9-11.9-10.1-7.4 12.4-.1zm13.6 53.3 11.8 4.3 7.6-9.7-.5 12.5 11.9 4.2-12.3 2-.5 12.4-4.9-11.4-12.4 2 7.7-9.7zm-44.8 52.7 9.4 8.1 10.2-6.5-5.1 11.3 9.4 8-12.4-.9-5.2 11.2-1.1-12.4-12.4-.9 10.2-6.5zm-46.3-29.3 4.3 11.8 9.7 7.6-12.5-.5-4.2 11.9-2-12.3-12.4-.5 11.4-4.9-2-12.4 9.7 7.7z"/>
|
||||
</g>
|
||||
</>
|
||||
),
|
||||
ES: (m) => (
|
||||
<>
|
||||
<mask id={m}><circle cx="256" cy="256" r="256" fill="#fff"/></mask>
|
||||
<g mask={`url(#${m})`}>
|
||||
<path fill="#d80027" d="M0 0h512v128l-39.8 130.3L512 384v128H0V384l37.1-124L0 128z"/>
|
||||
<path fill="#ffda44" d="M0 128h512v256H0z"/>
|
||||
</g>
|
||||
</>
|
||||
),
|
||||
GB: (m) => (
|
||||
<>
|
||||
<mask id={m}><circle cx="256" cy="256" r="256" fill="#fff"/></mask>
|
||||
<g mask={`url(#${m})`}>
|
||||
<path fill="#0052b4" d="M0 0h512v512H0z"/>
|
||||
<path fill="#eee" d="M512 0v64L322 256l190 192v64h-64L258 320 68 512H0v-64l190-192L0 64V0h64l190 192L444 0z"/>
|
||||
<path fill="#d80027" d="M0 0v32l160 160h32L0 0zm352 192 160 160v-32L384 192h-32zM0 512h32l160-160v-32L0 512zm352-192 160 160h-32L320 320v32z"/>
|
||||
<path fill="#eee" d="M176 0v512h160V0H176zM0 176v160h512V176H0z"/>
|
||||
<path fill="#d80027" d="M0 208v96h512v-96H0zM208 0v512h96V0h-96z"/>
|
||||
</g>
|
||||
</>
|
||||
),
|
||||
DE: (m) => (
|
||||
<>
|
||||
<mask id={m}><circle cx="256" cy="256" r="256" fill="#fff"/></mask>
|
||||
<g mask={`url(#${m})`}>
|
||||
<path fill="#333" d="M0 0h512v170.7H0z"/>
|
||||
<path fill="#d80027" d="M0 170.7h512v170.6H0z"/>
|
||||
<path fill="#ffda44" d="M0 341.3h512V512H0z"/>
|
||||
</g>
|
||||
</>
|
||||
),
|
||||
FR: (m) => (
|
||||
<>
|
||||
<mask id={m}><circle cx="256" cy="256" r="256" fill="#fff"/></mask>
|
||||
<g mask={`url(#${m})`}>
|
||||
<path fill="#eee" d="M167 0h178l25.9 252.3L345 512H167l-29.8-253.4z"/>
|
||||
<path fill="#0052b4" d="M0 0h167v512H0z"/>
|
||||
<path fill="#d80027" d="M345 0h167v512H345z"/>
|
||||
</g>
|
||||
</>
|
||||
),
|
||||
IN: (m) => (
|
||||
<>
|
||||
<mask id={m}><circle cx="256" cy="256" r="256" fill="#fff"/></mask>
|
||||
<g mask={`url(#${m})`}>
|
||||
<path fill="#eee" d="M0 167l253.8-19.3L512 167v178l-254.9 32.3L0 345z"/>
|
||||
<path fill="#ff9811" d="M0 0h512v167H0z"/>
|
||||
<path fill="#6da544" d="M0 345h512v167H0z"/>
|
||||
<circle cx="256" cy="256" r="89" fill="#0052b4"/>
|
||||
<circle cx="256" cy="256" r="55.7" fill="#eee"/>
|
||||
<circle cx="256" cy="256" r="22.3" fill="#0052b4"/>
|
||||
</g>
|
||||
</>
|
||||
),
|
||||
JP: (m) => (
|
||||
<>
|
||||
<mask id={m}><circle cx="256" cy="256" r="256" fill="#fff"/></mask>
|
||||
<g mask={`url(#${m})`}>
|
||||
<path fill="#eee" d="M0 0h512v512H0z"/>
|
||||
<circle cx="256" cy="256" r="111.3" fill="#d80027"/>
|
||||
</g>
|
||||
</>
|
||||
),
|
||||
CA: (m) => (
|
||||
<>
|
||||
<mask id={m}><circle cx="256" cy="256" r="256" fill="#fff"/></mask>
|
||||
<g mask={`url(#${m})`}>
|
||||
<path fill="#eee" d="M144.7 0h222.6v512H144.7z"/>
|
||||
<path fill="#d80027" d="M0 0h144.7v512H0zm367.3 0H512v512H367.3z"/>
|
||||
<path fill="#d80027" d="m300.5 289.4-16.7-4.9-16.6 5 6.2-16.4-10.8-13.6 17.6-.4 6.9-16.1 6.9 16 17.6.5-10.8 13.6zm-89 0 16.7-4.9 16.6 5-6.2-16.4 10.8-13.6-17.6-.4-6.9-16.1-6.9 16-17.6.5 10.8 13.6zm44.5-50.5 4.9 16.7-5 16.6 16.4-6.2 13.6 10.8.4-17.6 16.1-6.9-16-6.9-.5-17.6-13.6 10.8zm0 122.2-4.9-16.7 5-16.6-16.4 6.2-13.6-10.8-.4 17.6-16.1 6.9 16 6.9.5 17.6 13.6-10.8zm-44.5-27.8 16.7 5 16.6-5-6.2 16.4 10.8 13.6-17.6.4-6.9 16.1-6.9-16-17.6-.5 10.8-13.6zm89 0-16.7 5-16.6-5 6.2 16.4-10.8 13.6 17.6.4 6.9 16.1 6.9-16 17.6-.5-10.8-13.6z"/>
|
||||
</g>
|
||||
</>
|
||||
),
|
||||
AU: (m) => (
|
||||
<>
|
||||
<mask id={m}><circle cx="256" cy="256" r="256" fill="#fff"/></mask>
|
||||
<g mask={`url(#${m})`}>
|
||||
<path fill="#0052b4" d="M0 0h512v512H0z"/>
|
||||
<path fill="#eee" d="m154 300.5 8.3 25.5h26.8l-21.7 15.7 8.3 25.5-21.7-15.8-21.7 15.8 8.3-25.5-21.7-15.7h26.8zm206.6-89 4.2 12.7h13.4L367.4 232l4.1 12.8-10.8-7.9-10.9 7.9 4.2-12.8-10.9-7.9h13.4zm0 155.8 4.2 12.8h13.4l-10.8 7.8 4.1 12.8-10.8-7.9-10.9 7.9 4.2-12.8-10.9-7.8h13.4zm-44.5-66.9 4.2 12.8h13.4l-10.8 7.8 4.1 12.8-10.8-7.9-10.9 7.9 4.2-12.8-10.9-7.8h13.4zm88.9 0 4.2 12.8H423l-10.8 7.8 4.1 12.8-10.8-7.9-10.9 7.9 4.2-12.8-10.9-7.8h13.4z"/>
|
||||
<path fill="#0052b4" d="M256 0v32l-45.3 32H256v64h-46.9L256 154.7V192h-22.1L160 134v58H96v-58l-73.9 58H0v-37.3L54.9 128H0V64h54.9L0 37.3V0h22.1L96 58V0h64v58l73.9-58H256z"/>
|
||||
<path fill="#eee" d="M144 0v69.3L32 0H0v32l112 80v80h32v-80l112-80V0h-32l-112 69.3V0h-32z"/>
|
||||
<path fill="#d80027" d="M0 0v15.1L138.7 96H160v-15.1L21.3 0H0zM160 0v15.1L21.3 96H0v-15.1L138.7 0H160z"/>
|
||||
</g>
|
||||
</>
|
||||
),
|
||||
IT: (m) => (
|
||||
<>
|
||||
<mask id={m}><circle cx="256" cy="256" r="256" fill="#fff"/></mask>
|
||||
<g mask={`url(#${m})`}>
|
||||
<path fill="#eee" d="M167 0h178l25.9 252.3L345 512H167l-29.8-253.4z"/>
|
||||
<path fill="#6da544" d="M0 0h167v512H0z"/>
|
||||
<path fill="#d80027" d="M345 0h167v512H345z"/>
|
||||
</g>
|
||||
</>
|
||||
),
|
||||
TR: (m) => (
|
||||
<>
|
||||
<mask id={m}><circle cx="256" cy="256" r="256" fill="#fff"/></mask>
|
||||
<g mask={`url(#${m})`}>
|
||||
<path fill="#d80027" d="M0 0h512v512H0z"/>
|
||||
<path fill="#eee" d="M199.2 155.4a100.6 100.6 0 1 0 21.6 198.3 122.8 122.8 0 1 1 0-197 100.3 100.3 0 0 0-21.6-1.3z"/>
|
||||
<path fill="#eee" d="m294.3 202.5 8 34.7 34.8-3.9-27.9 21.8 17.9 29.8-32.2-13.9-13 33 -4.9-35-34.8 4.9 27.9-22.7-17.9-28.8 32.2 12.9z"/>
|
||||
</g>
|
||||
</>
|
||||
),
|
||||
MX: (m) => (
|
||||
<>
|
||||
<mask id={m}><circle cx="256" cy="256" r="256" fill="#fff"/></mask>
|
||||
<g mask={`url(#${m})`}>
|
||||
<path fill="#eee" d="M167 0h178l25.9 252.3L345 512H167l-29.8-253.4z"/>
|
||||
<path fill="#6da544" d="M0 0h167v512H0z"/>
|
||||
<path fill="#d80027" d="M345 0h167v512H345z"/>
|
||||
<path fill="#6da544" d="M208 256c0-26.5 21.5-48 48-48s48 21.5 48 48-21.5 48-48 48-48-21.5-48-48z"/>
|
||||
</g>
|
||||
</>
|
||||
),
|
||||
};
|
||||
|
||||
export default function CountryFlag({ code, className }: CountryFlagProps) {
|
||||
const id = useId();
|
||||
const maskId = `flag-${code}-${id}`;
|
||||
const renderFlag = FLAG_DATA[code];
|
||||
|
||||
if (!renderFlag) {
|
||||
return (
|
||||
<svg className={className || "w-6 h-6"} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<svg className={className || "w-6 h-6"} viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
|
||||
{renderFlag(maskId)}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
---
|
||||
import type { FeaturedItem } from '../lib/types';
|
||||
|
||||
interface Props {
|
||||
item: FeaturedItem;
|
||||
}
|
||||
|
||||
const { item } = Astro.props;
|
||||
---
|
||||
|
||||
<a
|
||||
href={item.url}
|
||||
class="group flex items-center gap-3 p-4 bg-[var(--color-card-bg)] rounded-md border border-[var(--color-border)] hover:bg-[var(--color-card-hover)] hover:border-[var(--color-accent)] transition-all duration-150"
|
||||
>
|
||||
<div class="w-11 h-11 rounded-sm bg-[var(--color-surface-2)] border border-[var(--color-border)] flex items-center justify-center shrink-0 p-2">
|
||||
<img src={item.logo} alt={item.name} class="w-full h-full object-contain" loading="lazy" onerror="this.style.display='none'" />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-ui-mono text-[13px] font-bold text-[var(--color-text-primary)] group-hover:text-[var(--color-accent)] transition-colors">{item.name}</span>
|
||||
<span class="font-ui-mono text-[10px] font-medium px-2 py-0.5 rounded border border-[var(--color-border)] text-[var(--color-text-tertiary)] bg-[var(--color-surface-2)]">
|
||||
{item.tag}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-[12px] text-[var(--color-text-secondary)] mt-1 leading-[1.5]">{item.description}</p>
|
||||
</div>
|
||||
<span class="terminal-prompt text-[12px] opacity-0 group-hover:opacity-100 transition-opacity shrink-0">›</span>
|
||||
</a>
|
||||
@@ -0,0 +1,32 @@
|
||||
---
|
||||
import FeaturedCard from './FeaturedCard.astro';
|
||||
import { FEATURED_ITEMS } from '../lib/constants';
|
||||
|
||||
const gridColsClass = FEATURED_ITEMS.length % 2 === 0
|
||||
? 'sm:grid-cols-2'
|
||||
: 'sm:grid-cols-2 lg:grid-cols-3';
|
||||
---
|
||||
|
||||
<section class="px-6 py-4 mb-2">
|
||||
<div class="bg-[var(--color-surface-1)] rounded-md p-5 border border-[var(--color-border)]">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2 mb-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="terminal-prompt text-[13px]">★</span>
|
||||
<h2 class="font-ui-mono text-[13px] font-bold text-[var(--color-text-primary)] tracking-wide uppercase">Featured Integrations</h2>
|
||||
</div>
|
||||
<a
|
||||
href="https://docs.google.com/forms/d/e/1FAIpQLSd7uyNot_YG4vMrt8jJuWiCfb2HNHjGcCZEoVS07SU8OCc-yA/viewform"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="w-full sm:w-auto text-center shrink-0 font-ui-mono text-[12px] font-bold text-[var(--color-accent)] border border-[var(--color-accent)] rounded-md px-3 py-1.5 hover:bg-[var(--color-accent)] hover:text-[var(--color-surface-1)] transition-colors"
|
||||
>
|
||||
Promote your component →
|
||||
</a>
|
||||
</div>
|
||||
<div class={`grid grid-cols-1 ${gridColsClass} gap-3`}>
|
||||
{FEATURED_ITEMS.map((item) => (
|
||||
<FeaturedCard item={item} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,200 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
|
||||
interface FileTreeSidebarProps {
|
||||
references: string[];
|
||||
skillName: string;
|
||||
}
|
||||
|
||||
interface TreeNode {
|
||||
name: string;
|
||||
path: string;
|
||||
isFile: boolean;
|
||||
children: TreeNode[];
|
||||
ext?: string;
|
||||
}
|
||||
|
||||
const EXT_COLORS: Record<string, string> = {
|
||||
md: '#60a5fa', js: '#facc15', ts: '#60a5fa', tsx: '#60a5fa', jsx: '#facc15',
|
||||
py: '#4ade80', html: '#fb923c', json: '#fde047', txt: 'var(--color-text-tertiary)',
|
||||
sh: '#86efac', yml: '#f472b6', yaml: '#f472b6', css: '#c084fc', toml: '#f472b6',
|
||||
};
|
||||
|
||||
function buildTree(paths: string[]): TreeNode[] {
|
||||
const root: TreeNode[] = [];
|
||||
|
||||
for (const p of paths) {
|
||||
const parts = p.split('/');
|
||||
let current = root;
|
||||
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const name = parts[i];
|
||||
const isFile = i === parts.length - 1;
|
||||
const fullPath = parts.slice(0, i + 1).join('/');
|
||||
|
||||
let existing = current.find((n) => n.name === name && n.isFile === isFile);
|
||||
if (!existing) {
|
||||
const ext = isFile ? name.split('.').pop()?.toLowerCase() : undefined;
|
||||
existing = { name, path: fullPath, isFile, children: [], ext };
|
||||
current.push(existing);
|
||||
}
|
||||
current = existing.children;
|
||||
}
|
||||
}
|
||||
|
||||
// Sort: folders first, then files, alphabetically
|
||||
function sortNodes(nodes: TreeNode[]) {
|
||||
nodes.sort((a, b) => {
|
||||
if (a.isFile !== b.isFile) return a.isFile ? 1 : -1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
for (const n of nodes) {
|
||||
if (!n.isFile) sortNodes(n.children);
|
||||
}
|
||||
}
|
||||
sortNodes(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
function countFiles(nodes: TreeNode[]): number {
|
||||
let count = 0;
|
||||
for (const n of nodes) {
|
||||
if (n.isFile) count++;
|
||||
else count += countFiles(n.children);
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
export default function FileTreeSidebar({ references, skillName }: FileTreeSidebarProps) {
|
||||
const tree = useMemo(() => buildTree(references), [references]);
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const filteredRefs = useMemo(() => {
|
||||
if (!search.trim()) return null;
|
||||
const q = search.toLowerCase();
|
||||
return references.filter((r) => r.toLowerCase().includes(q));
|
||||
}, [search, references]);
|
||||
|
||||
const filteredTree = useMemo(() => {
|
||||
if (!filteredRefs) return tree;
|
||||
return buildTree(filteredRefs);
|
||||
}, [filteredRefs, tree]);
|
||||
|
||||
return (
|
||||
<div className="w-64 shrink-0 bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded-lg overflow-hidden flex flex-col" style={{ maxHeight: 'calc(100vh - 8rem)' }}>
|
||||
{/* Header */}
|
||||
<div className="px-3 py-2.5 border-b border-[var(--color-border)] flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<svg className="w-4 h-4 text-[var(--color-text-tertiary)] shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 12.75V12A2.25 2.25 0 014.5 9.75h15A2.25 2.25 0 0121.75 12v.75m-8.69-6.44l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-5.379a1.5 1.5 0 01-1.06-.44z" />
|
||||
</svg>
|
||||
<span className="text-xs font-semibold text-[var(--color-text-primary)] truncate">{skillName}</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-[var(--color-text-tertiary)] shrink-0">{references.length} files</span>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="px-2 py-1.5 border-b border-[var(--color-border)]">
|
||||
<div className="relative">
|
||||
<svg className="absolute left-2 top-1/2 -translate-y-1/2 w-3 h-3 text-[var(--color-text-tertiary)] pointer-events-none" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Filter files..."
|
||||
className="w-full bg-[var(--color-surface-3)] border-none rounded pl-7 pr-2 py-1 text-[11px] text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] focus:outline-none focus:ring-1 focus:ring-accent-400/30"
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
onClick={() => setSearch('')}
|
||||
className="absolute right-1.5 top-1/2 -translate-y-1/2 text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)]"
|
||||
>
|
||||
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tree */}
|
||||
<div className="flex-1 overflow-y-auto py-1" style={{ scrollbarWidth: 'thin' }}>
|
||||
{filteredTree.length === 0 && search ? (
|
||||
<div className="px-3 py-4 text-center text-[11px] text-[var(--color-text-tertiary)]">No matching files</div>
|
||||
) : (
|
||||
<TreeNodes nodes={filteredTree} depth={0} forceOpen={!!search} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TreeNodes({ nodes, depth, forceOpen }: { nodes: TreeNode[]; depth: number; forceOpen?: boolean }) {
|
||||
return (
|
||||
<>
|
||||
{nodes.map((node) =>
|
||||
node.isFile ? (
|
||||
<FileNode key={node.path} node={node} depth={depth} />
|
||||
) : (
|
||||
<FolderNode key={node.path} node={node} depth={depth} forceOpen={forceOpen} />
|
||||
)
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function FolderNode({ node, depth, forceOpen }: { node: TreeNode; depth: number; forceOpen?: boolean }) {
|
||||
const [open, setOpen] = useState(forceOpen || depth < 1);
|
||||
const fileCount = useMemo(() => countFiles(node.children), [node.children]);
|
||||
|
||||
// If forceOpen changes to true, open the folder
|
||||
const isOpen = forceOpen || open;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
className="w-full flex items-center gap-1 py-[3px] pr-2 hover:bg-white/[0.04] transition-colors group/folder"
|
||||
style={{ paddingLeft: `${depth * 12 + 8}px` }}
|
||||
>
|
||||
<svg
|
||||
className={`w-3 h-3 text-[var(--color-text-tertiary)] shrink-0 transition-transform duration-100 ${isOpen ? 'rotate-90' : ''}`}
|
||||
fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
|
||||
</svg>
|
||||
<svg className="w-3.5 h-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke={isOpen ? '#facc15' : 'var(--color-text-tertiary)'} strokeWidth={1.5}>
|
||||
{isOpen ? (
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 9.776c.112-.017.227-.026.344-.026h15.812c.117 0 .232.009.344.026m-16.5 0a2.25 2.25 0 00-1.883 2.542l.857 6a2.25 2.25 0 002.227 1.932H19.05a2.25 2.25 0 002.227-1.932l.857-6a2.25 2.25 0 00-1.883-2.542m-16.5 0V6A2.25 2.25 0 016 3.75h3.879a1.5 1.5 0 011.06.44l2.122 2.12a1.5 1.5 0 001.06.44H18A2.25 2.25 0 0120.25 9v.776" />
|
||||
) : (
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 12.75V12A2.25 2.25 0 014.5 9.75h15A2.25 2.25 0 0121.75 12v.75m-8.69-6.44l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-5.379a1.5 1.5 0 01-1.06-.44z" />
|
||||
)}
|
||||
</svg>
|
||||
<span className="text-[11px] text-[var(--color-text-secondary)] truncate">{node.name}</span>
|
||||
<span className="text-[9px] text-[var(--color-text-tertiary)] ml-auto opacity-0 group-hover/folder:opacity-100 transition-opacity">{fileCount}</span>
|
||||
</button>
|
||||
{isOpen && <TreeNodes nodes={node.children} depth={depth + 1} forceOpen={forceOpen} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FileNode({ node, depth }: { node: TreeNode; depth: number }) {
|
||||
const color = EXT_COLORS[node.ext ?? ''] ?? 'var(--color-text-tertiary)';
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-1.5 py-[3px] pr-2 hover:bg-white/[0.04] cursor-default transition-colors"
|
||||
style={{ paddingLeft: `${depth * 12 + 20}px` }}
|
||||
title={node.path}
|
||||
>
|
||||
<svg className="w-3.5 h-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke={color} strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
|
||||
</svg>
|
||||
<span className="text-[11px] text-[var(--color-text-primary)] truncate font-mono">{node.name}</span>
|
||||
{node.ext && (
|
||||
<span className="text-[8px] ml-auto shrink-0 font-mono uppercase" style={{ color }}>{node.ext}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
interface Job {
|
||||
id: string;
|
||||
company: string;
|
||||
position: string;
|
||||
location: string;
|
||||
remote: boolean;
|
||||
salary: string;
|
||||
applyUrl: string;
|
||||
source: string;
|
||||
tags: string[];
|
||||
companyIcon: string;
|
||||
}
|
||||
|
||||
interface JobsData {
|
||||
totalJobs: number;
|
||||
jobs: Job[];
|
||||
}
|
||||
|
||||
const SOURCE_COLORS: Record<string, string> = {
|
||||
HackerNews: 'bg-orange-500/15 text-orange-400 border-orange-500/20',
|
||||
RemoteOK: 'bg-blue-500/15 text-blue-400 border-blue-500/20',
|
||||
WeWorkRemotely: 'bg-emerald-500/15 text-emerald-400 border-emerald-500/20',
|
||||
Anthropic: 'bg-purple-500/15 text-purple-400 border-purple-500/20',
|
||||
};
|
||||
|
||||
function useGlobalAuth() {
|
||||
const [state, setState] = useState<{ isSignedIn: boolean; isLoaded: boolean }>({
|
||||
isSignedIn: false, isLoaded: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
function check() {
|
||||
const clerk = (window as any).Clerk;
|
||||
if (clerk?.loaded) {
|
||||
const signedIn = !!clerk.user;
|
||||
setState((prev) => {
|
||||
if (prev.isLoaded && prev.isSignedIn === signedIn) return prev;
|
||||
return { isSignedIn: signedIn, isLoaded: true };
|
||||
});
|
||||
}
|
||||
}
|
||||
check();
|
||||
const interval = setInterval(check, 500);
|
||||
const handleChange = () => check();
|
||||
window.addEventListener('clerk:session', handleChange);
|
||||
return () => { clearInterval(interval); window.removeEventListener('clerk:session', handleChange); };
|
||||
}, []);
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
function safeUrl(url: string): string {
|
||||
return /^https?:\/\//i.test(url) ? url : '#';
|
||||
}
|
||||
|
||||
function getCompanyLogoUrl(companyName: string, existingIcon?: string): string {
|
||||
// If icon exists in data, use it
|
||||
if (existingIcon) return existingIcon;
|
||||
|
||||
// Otherwise, try to fetch from Clearbit Logo API
|
||||
const domain = companyName.toLowerCase()
|
||||
.replace(/\s+/g, '')
|
||||
.replace(/[^a-z0-9]/g, '');
|
||||
return `https://logo.clearbit.com/${domain}.com`;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
variant?: 'preview' | 'sidebar';
|
||||
}
|
||||
|
||||
export default function JobsPreview({ variant = 'preview' }: Props) {
|
||||
const [data, setData] = useState<JobsData | null>(null);
|
||||
const { isSignedIn, isLoaded } = useGlobalAuth();
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/claude-jobs.json')
|
||||
.then((r) => r.json())
|
||||
.then((d) => setData(d))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
if (!data || data.jobs.length === 0) return null;
|
||||
|
||||
const jobs = variant === 'sidebar' ? data.jobs.slice(0, 12) : data.jobs.slice(0, 5);
|
||||
const showAuthGate = isLoaded && !isSignedIn;
|
||||
|
||||
function handleJobClick(e: React.MouseEvent) {
|
||||
if (!isLoaded) { e.preventDefault(); return; }
|
||||
if (!isSignedIn) {
|
||||
e.preventDefault();
|
||||
(window as any).Clerk?.openSignIn?.();
|
||||
}
|
||||
}
|
||||
|
||||
if (variant === 'sidebar') {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Sticky header */}
|
||||
<div className="px-4 py-3 border-b border-[var(--color-border)] bg-[var(--color-surface-0)] sticky top-0 z-10">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-[11px] font-bold text-[var(--color-text-primary)] uppercase tracking-wider">Jobs</span>
|
||||
<span className="font-mono text-[9px] font-medium bg-orange-500/15 text-orange-400 px-1.5 py-0.5 rounded border border-orange-500/20">
|
||||
{data.totalJobs}
|
||||
</span>
|
||||
</div>
|
||||
<a
|
||||
href="/jobs"
|
||||
className="font-mono text-[10px] text-[var(--color-text-tertiary)] hover:text-[var(--color-accent)] transition-colors"
|
||||
>
|
||||
view all →
|
||||
</a>
|
||||
</div>
|
||||
<p className="font-mono text-[9px] text-[var(--color-text-tertiary)] mt-0.5">Using Claude Code</p>
|
||||
</div>
|
||||
|
||||
{/* Vertical job list */}
|
||||
<div className="flex-1 overflow-y-auto px-3 py-2 space-y-1.5">
|
||||
{jobs.map((job) => (
|
||||
<a
|
||||
key={job.id}
|
||||
href={isSignedIn ? safeUrl(job.applyUrl) : '#'}
|
||||
target={isSignedIn ? '_blank' : undefined}
|
||||
rel={isSignedIn ? 'noopener noreferrer' : undefined}
|
||||
onClick={showAuthGate ? handleJobClick : undefined}
|
||||
className="flex items-start gap-2.5 p-2.5 rounded border border-[var(--color-border)] hover:border-[var(--color-accent)] bg-[var(--color-card-bg)] hover:bg-[var(--color-card-hover)] transition-all group relative overflow-hidden block"
|
||||
>
|
||||
{showAuthGate && (
|
||||
<div className="absolute inset-0 bg-[var(--color-surface-0)]/80 backdrop-blur-[1px] flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<span className="font-mono text-[9px] font-medium text-[var(--color-text-secondary)] bg-[var(--color-surface-2)] px-2 py-1 rounded border border-[var(--color-border)]">
|
||||
Sign in to view
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Logo */}
|
||||
<div className="w-6 h-6 rounded shrink-0 bg-[var(--color-surface-2)] border border-[var(--color-border)] flex items-center justify-center overflow-hidden mt-0.5">
|
||||
<img
|
||||
src={getCompanyLogoUrl(job.company, job.companyIcon)}
|
||||
alt={job.company}
|
||||
className="w-3.5 h-3.5 object-contain"
|
||||
onError={(e) => {
|
||||
const t = e.target as HTMLImageElement;
|
||||
t.style.display = 'none';
|
||||
const fb = t.nextElementSibling as HTMLElement;
|
||||
if (fb) fb.style.display = 'flex';
|
||||
}}
|
||||
/>
|
||||
<span className="text-[9px] font-bold text-[var(--color-text-tertiary)]" style={{ display: 'none' }}>
|
||||
{job.company.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-mono text-[10px] font-bold text-[var(--color-text-primary)] group-hover:text-[var(--color-accent)] transition-colors line-clamp-2 leading-tight mb-1">
|
||||
{job.position}
|
||||
</p>
|
||||
<p className="font-mono text-[9px] text-[var(--color-text-tertiary)] truncate mb-1.5">{job.company}</p>
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
{job.remote && (
|
||||
<span className="text-[8px] font-medium bg-blue-500/15 text-blue-400 px-1 py-0.5 rounded border border-blue-500/20">Remote</span>
|
||||
)}
|
||||
{job.salary && (
|
||||
<span className="text-[8px] font-medium bg-emerald-500/15 text-emerald-400 px-1 py-0.5 rounded border border-emerald-500/20">{job.salary}</span>
|
||||
)}
|
||||
<span className={`text-[8px] font-medium px-1 py-0.5 rounded border ${SOURCE_COLORS[job.source] || 'bg-[var(--color-surface-3)] text-[var(--color-text-secondary)] border-[var(--color-border)]'}`}>
|
||||
{job.source}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="px-6 py-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-sm font-semibold text-[var(--color-text-primary)]">Jobs Using Claude Code</h2>
|
||||
<span className="text-[10px] font-medium bg-orange-500/15 text-orange-400 px-1.5 py-0.5 rounded">
|
||||
{data.totalJobs} jobs
|
||||
</span>
|
||||
</div>
|
||||
<a
|
||||
href="/jobs"
|
||||
className="text-[12px] text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)] transition-colors"
|
||||
>
|
||||
View all →
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-5 gap-3">
|
||||
{jobs.map((job) => (
|
||||
<a
|
||||
key={job.id}
|
||||
href={isSignedIn ? safeUrl(job.applyUrl) : '#'}
|
||||
target={isSignedIn ? '_blank' : undefined}
|
||||
rel={isSignedIn ? 'noopener noreferrer' : undefined}
|
||||
onClick={showAuthGate ? handleJobClick : undefined}
|
||||
className="bg-[var(--color-card-bg)] border border-[var(--color-border)] rounded-lg p-3.5 hover:border-[var(--color-border-hover)] hover:shadow-md transition-all group relative overflow-hidden"
|
||||
>
|
||||
{showAuthGate && (
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-[var(--color-surface-0)]/80 to-transparent backdrop-blur-[1px] flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<div className="flex items-center gap-1.5 text-[11px] font-medium text-[var(--color-text-primary)] bg-[var(--color-surface-2)] px-3 py-1.5 rounded-full border border-[var(--color-border)]">
|
||||
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
|
||||
<path d="M7 11V7a5 5 0 0110 0v4" />
|
||||
</svg>
|
||||
Sign in to view
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 mb-2.5">
|
||||
<div className="w-7 h-7 rounded bg-[var(--color-surface-2)] border border-[var(--color-border)] flex items-center justify-center shrink-0 overflow-hidden">
|
||||
<img
|
||||
src={getCompanyLogoUrl(job.company, job.companyIcon)}
|
||||
alt={job.company}
|
||||
className="w-4 h-4 object-contain"
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.style.display = 'none';
|
||||
const fallback = target.nextElementSibling as HTMLElement;
|
||||
if (fallback) fallback.style.display = 'flex';
|
||||
}}
|
||||
/>
|
||||
<span className="text-[10px] font-bold text-[var(--color-text-tertiary)]" style={{ display: 'none' }}>
|
||||
{job.company.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[11px] font-medium text-[var(--color-text-tertiary)] truncate">{job.company}</span>
|
||||
</div>
|
||||
|
||||
<h3 className="text-[12px] font-medium text-[var(--color-text-primary)] group-hover:text-[var(--color-text-primary)] transition-colors line-clamp-2 leading-tight mb-2.5 min-h-[2.5rem]">
|
||||
{job.position}
|
||||
</h3>
|
||||
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
{job.remote && (
|
||||
<span className="text-[9px] font-medium bg-blue-500/15 text-blue-400 px-1.5 py-0.5 rounded border border-blue-500/20">
|
||||
Remote
|
||||
</span>
|
||||
)}
|
||||
{job.salary && (
|
||||
<span className="text-[9px] font-medium bg-emerald-500/15 text-emerald-400 px-1.5 py-0.5 rounded border border-emerald-500/20">
|
||||
{job.salary}
|
||||
</span>
|
||||
)}
|
||||
<span className={`text-[9px] font-medium px-1.5 py-0.5 rounded border ${SOURCE_COLORS[job.source] || 'bg-[var(--color-surface-3)] text-[var(--color-text-secondary)] border-[var(--color-border)]'}`}>
|
||||
{job.source}
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,750 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
|
||||
interface Job {
|
||||
id: string;
|
||||
company: string;
|
||||
position: string;
|
||||
location: string;
|
||||
remote: boolean;
|
||||
salary: string;
|
||||
description: string;
|
||||
applyUrl: string;
|
||||
source: string;
|
||||
sourceUrl: string;
|
||||
postedAt: string;
|
||||
tags: string[];
|
||||
companyIcon: string;
|
||||
}
|
||||
|
||||
interface JobsData {
|
||||
lastUpdated: string;
|
||||
totalJobs: number;
|
||||
sources: string[];
|
||||
jobs: Job[];
|
||||
}
|
||||
|
||||
type LocationFilter = 'all' | 'remote' | 'onsite';
|
||||
type SortOption = 'recent' | 'company' | 'salary';
|
||||
|
||||
const SOURCE_COLORS: Record<string, string> = {
|
||||
HackerNews: 'bg-orange-500/15 text-orange-400 border-orange-500/20',
|
||||
RemoteOK: 'bg-blue-500/15 text-blue-400 border-blue-500/20',
|
||||
WeWorkRemotely: 'bg-emerald-500/15 text-emerald-400 border-emerald-500/20',
|
||||
Anthropic: 'bg-purple-500/15 text-purple-400 border-purple-500/20',
|
||||
};
|
||||
|
||||
function useGlobalAuth() {
|
||||
const [state, setState] = useState<{ isSignedIn: boolean; isLoaded: boolean }>({
|
||||
isSignedIn: false, isLoaded: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
function check() {
|
||||
const clerk = (window as any).Clerk;
|
||||
if (clerk?.loaded) {
|
||||
const signedIn = !!clerk.user;
|
||||
setState((prev) => {
|
||||
if (prev.isLoaded && prev.isSignedIn === signedIn) return prev;
|
||||
return { isSignedIn: signedIn, isLoaded: true };
|
||||
});
|
||||
}
|
||||
}
|
||||
check();
|
||||
const interval = setInterval(check, 500);
|
||||
const handleChange = () => check();
|
||||
window.addEventListener('clerk:session', handleChange);
|
||||
return () => { clearInterval(interval); window.removeEventListener('clerk:session', handleChange); };
|
||||
}, []);
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
function timeAgo(dateStr: string): string {
|
||||
if (!dateStr) return '';
|
||||
try {
|
||||
const date = new Date(dateStr);
|
||||
if (isNaN(date.getTime())) return '';
|
||||
const now = new Date();
|
||||
const days = Math.floor((now.getTime() - date.getTime()) / 86400000);
|
||||
if (days < 0 || isNaN(days)) return '';
|
||||
if (days === 0) return 'Today';
|
||||
if (days === 1) return '1d ago';
|
||||
if (days < 30) return `${days}d ago`;
|
||||
if (days < 60) return '1mo ago';
|
||||
return `${Math.floor(days / 30)}mo ago`;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function safeUrl(url: string): string {
|
||||
return /^https?:\/\//i.test(url) ? url : '#';
|
||||
}
|
||||
|
||||
export default function JobsView() {
|
||||
const [data, setData] = useState<JobsData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [locationFilter, setLocationFilter] = useState<LocationFilter>('all');
|
||||
const [sourceFilter, setSourceFilter] = useState<string>('all');
|
||||
const [companyFilter, setCompanyFilter] = useState<string>('all');
|
||||
const [selectedTag, setSelectedTag] = useState<string>('all');
|
||||
const [sortBy, setSortBy] = useState<SortOption>('recent');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const { isSignedIn, isLoaded } = useGlobalAuth();
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/claude-jobs.json')
|
||||
.then((r) => r.json())
|
||||
.then((d) => { setData(d); setLoading(false); })
|
||||
.catch(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (!target.closest('.company-dropdown')) {
|
||||
setIsCompanyDropdownOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const filteredJobs = useMemo(() => {
|
||||
if (!data) return [];
|
||||
let filtered = data.jobs.filter((job) => {
|
||||
if (locationFilter === 'remote' && !job.remote) return false;
|
||||
if (locationFilter === 'onsite' && job.remote) return false;
|
||||
if (sourceFilter !== 'all' && job.source !== sourceFilter) return false;
|
||||
if (companyFilter !== 'all' && job.company !== companyFilter) return false;
|
||||
if (selectedTag !== 'all' && !job.tags.includes(selectedTag)) return false;
|
||||
if (searchQuery) {
|
||||
const q = searchQuery.toLowerCase();
|
||||
const text = `${job.company} ${job.position} ${job.location} ${job.tags.join(' ')}`.toLowerCase();
|
||||
if (!text.includes(q)) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// Sort jobs
|
||||
filtered.sort((a, b) => {
|
||||
if (sortBy === 'recent') {
|
||||
return new Date(b.postedAt || 0).getTime() - new Date(a.postedAt || 0).getTime();
|
||||
} else if (sortBy === 'company') {
|
||||
return a.company.localeCompare(b.company);
|
||||
} else if (sortBy === 'salary') {
|
||||
// Sort by salary (jobs with salary first)
|
||||
if (a.salary && !b.salary) return -1;
|
||||
if (!a.salary && b.salary) return 1;
|
||||
return 0;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
return filtered;
|
||||
}, [data, locationFilter, sourceFilter, companyFilter, selectedTag, sortBy, searchQuery]);
|
||||
|
||||
// Get unique companies and tags for filters
|
||||
const companies = useMemo(() => {
|
||||
if (!data) return [];
|
||||
const uniqueCompanies = Array.from(new Set(data.jobs.map(j => j.company))).sort();
|
||||
return uniqueCompanies;
|
||||
}, [data]);
|
||||
|
||||
// Get company logo for a given company name
|
||||
const getCompanyLogo = (companyName: string): string => {
|
||||
if (!data) return '';
|
||||
const job = data.jobs.find(j => j.company === companyName);
|
||||
// If companyIcon exists in data, use it
|
||||
if (job?.companyIcon) return job.companyIcon;
|
||||
|
||||
// Otherwise, try to fetch from Clearbit Logo API
|
||||
// Format: https://logo.clearbit.com/{domain}
|
||||
const domain = companyName.toLowerCase()
|
||||
.replace(/\s+/g, '')
|
||||
.replace(/[^a-z0-9]/g, '');
|
||||
return `https://logo.clearbit.com/${domain}.com`;
|
||||
};
|
||||
|
||||
const allTags = useMemo(() => {
|
||||
if (!data) return [];
|
||||
const tagSet = new Set<string>();
|
||||
data.jobs.forEach(job => job.tags.forEach(tag => tagSet.add(tag)));
|
||||
return Array.from(tagSet).sort();
|
||||
}, [data]);
|
||||
|
||||
// Custom dropdown state
|
||||
const [isCompanyDropdownOpen, setIsCompanyDropdownOpen] = useState(false);
|
||||
|
||||
function handleJobClick(e: React.MouseEvent, job: Job) {
|
||||
if (!isLoaded) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (!isSignedIn) {
|
||||
e.preventDefault();
|
||||
(window as any).Clerk?.openSignIn?.();
|
||||
}
|
||||
// If signed in, let the <a> navigate normally
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="px-6 py-20 flex flex-col items-center gap-3">
|
||||
<div className="w-5 h-5 border-2 border-[var(--color-text-tertiary)] border-t-transparent rounded-full animate-spin" />
|
||||
<span className="text-[13px] text-[var(--color-text-tertiary)]">Loading jobs...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data || data.jobs.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center px-6 py-24">
|
||||
{/* Icon */}
|
||||
<div className="w-20 h-20 rounded-2xl bg-[var(--color-surface-2)] border border-[var(--color-border)] flex items-center justify-center mb-6">
|
||||
<svg className="w-10 h-10 text-[var(--color-text-tertiary)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="1.5">
|
||||
<rect x="2" y="7" width="20" height="14" rx="2" ry="2" />
|
||||
<path d="M16 7V5a2 2 0 00-2-2h-4a2 2 0 00-2 2v2" />
|
||||
<line x1="12" y1="12" x2="12" y2="12.01" />
|
||||
</svg>
|
||||
</div>
|
||||
{/* Message */}
|
||||
<h3 className="text-[15px] font-semibold text-[var(--color-text-primary)] mb-2">No jobs available yet</h3>
|
||||
<p className="text-[13px] text-[var(--color-text-secondary)] text-center max-w-md leading-relaxed">
|
||||
We're currently curating exciting opportunities for AI developers. Check back soon for new job postings!
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const remoteCount = data.jobs.filter((j) => j.remote).length;
|
||||
const onsiteCount = data.jobs.length - remoteCount;
|
||||
const showAuthGate = isLoaded && !isSignedIn;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Header */}
|
||||
<div className="px-6 pt-5 pb-2">
|
||||
<h1 className="text-lg font-semibold text-[var(--color-text-primary)] flex items-center gap-2">
|
||||
Jobs Requiring Claude Code
|
||||
<picture>
|
||||
<source srcSet="/claude-code-logo.webp" type="image/webp" />
|
||||
<img src="/claude-code-logo.png" alt="Claude Code" className="w-6 h-6 inline-block" loading="lazy" />
|
||||
</picture>
|
||||
</h1>
|
||||
<p className="text-[13px] text-[var(--color-text-tertiary)] mt-1">
|
||||
Companies actively hiring developers who use Claude Code in their workflow.
|
||||
Updated daily from HackerNews "Who is Hiring", RemoteOK, and more.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Auth banner for non-signed-in users */}
|
||||
{showAuthGate && (
|
||||
<div className="mx-6 mt-2 mb-1 bg-gradient-to-r from-purple-500/10 via-blue-500/10 to-emerald-500/10 border border-[var(--color-border)] rounded-lg px-4 py-3 flex items-center gap-3">
|
||||
<svg className="w-5 h-5 text-purple-400 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="1.5">
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
|
||||
<path d="M7 11V7a5 5 0 0110 0v4" />
|
||||
</svg>
|
||||
<div className="flex-1">
|
||||
<p className="text-[13px] text-[var(--color-text-primary)]">
|
||||
<span className="font-medium">Sign in</span>
|
||||
<span className="text-[var(--color-text-secondary)]"> to view job details and apply to positions</span>
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => (window as any).Clerk?.openSignIn?.()}
|
||||
className="px-3 py-1.5 bg-[var(--color-surface-3)] hover:bg-[var(--color-surface-4)] text-[12px] font-medium text-[var(--color-text-primary)] rounded-md transition-colors"
|
||||
>
|
||||
Sign In
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats cards */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 px-6 py-4">
|
||||
{[
|
||||
{ label: 'Total Jobs', value: String(data.totalJobs) },
|
||||
{ label: 'Remote', value: String(remoteCount) },
|
||||
{ label: 'On-site', value: String(onsiteCount) },
|
||||
{ label: 'Sources', value: String(data.sources.length) },
|
||||
].map((s) => (
|
||||
<div key={s.label} className="bg-[var(--color-card-bg)] border border-[var(--color-border)] rounded-lg px-4 py-3">
|
||||
<div className="text-[18px] font-semibold text-[var(--color-text-primary)] tabular-nums">{s.value}</div>
|
||||
<div className="text-[11px] text-[var(--color-text-tertiary)] mt-0.5">{s.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="border-t border-[var(--color-border)]" />
|
||||
|
||||
{/* Filter bar - Enhanced */}
|
||||
<div className="px-6 py-4 space-y-3">
|
||||
{/* Top row: Location, Source, Company, Sort */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{/* Location filter */}
|
||||
<div className="flex items-center gap-1 bg-[var(--color-surface-2)] rounded-lg p-0.5">
|
||||
{(['all', 'remote', 'onsite'] as LocationFilter[]).map((loc) => (
|
||||
<button
|
||||
key={loc}
|
||||
onClick={() => setLocationFilter(loc)}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-[12px] font-medium transition-all ${
|
||||
locationFilter === loc
|
||||
? 'bg-[var(--color-surface-3)] text-[var(--color-text-primary)] shadow-sm'
|
||||
: 'text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)]'
|
||||
}`}
|
||||
>
|
||||
{loc === 'all' ? (
|
||||
<>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
||||
</svg>
|
||||
All Locations
|
||||
</>
|
||||
) : loc === 'remote' ? (
|
||||
<>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2 12h20M12 2a15.3 15.3 0 014 10 15.3 15.3 0 01-4 10 15.3 15.3 0 01-4-10 15.3 15.3 0 014-10z" />
|
||||
</svg>
|
||||
Remote
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
|
||||
</svg>
|
||||
On-site
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Source filter dropdown */}
|
||||
<div className="relative">
|
||||
<select
|
||||
value={sourceFilter}
|
||||
onChange={(e) => setSourceFilter(e.target.value)}
|
||||
className="appearance-none bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded-lg text-[12px] font-medium text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] px-3 py-1.5 pr-8 outline-none cursor-pointer transition-colors"
|
||||
>
|
||||
<option value="all">All Sources</option>
|
||||
{data.sources.map((src) => (
|
||||
<option key={src} value={src}>{src}</option>
|
||||
))}
|
||||
</select>
|
||||
<svg className="absolute right-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-[var(--color-text-tertiary)] pointer-events-none" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Company filter dropdown - Custom with logos */}
|
||||
<div className="relative company-dropdown">
|
||||
<button
|
||||
onClick={() => setIsCompanyDropdownOpen(!isCompanyDropdownOpen)}
|
||||
className="flex items-center gap-2 bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded-lg text-[12px] font-medium text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] pl-3 pr-8 py-1.5 outline-none cursor-pointer transition-colors min-w-[180px]"
|
||||
>
|
||||
{companyFilter === 'all' ? (
|
||||
<>
|
||||
<svg className="w-3.5 h-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
|
||||
</svg>
|
||||
<span>All Companies ({companies.length})</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="w-4 h-4 rounded bg-[var(--color-surface-3)] border border-[var(--color-border)] flex items-center justify-center shrink-0 overflow-hidden">
|
||||
<img
|
||||
src={getCompanyLogo(companyFilter)}
|
||||
alt=""
|
||||
className="w-3 h-3 object-contain"
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.style.display = 'none';
|
||||
const fallback = target.nextElementSibling as HTMLElement;
|
||||
if (fallback) fallback.style.display = 'flex';
|
||||
}}
|
||||
/>
|
||||
<span className="text-[8px] font-bold text-[var(--color-text-tertiary)]" style={{ display: 'none' }}>
|
||||
{companyFilter.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
<span className="truncate">{companyFilter}</span>
|
||||
</>
|
||||
)}
|
||||
<svg className="absolute right-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-[var(--color-text-tertiary)] pointer-events-none" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Dropdown menu */}
|
||||
{isCompanyDropdownOpen && (
|
||||
<div className="absolute top-full left-0 mt-1 w-72 max-h-80 overflow-y-auto bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded-lg shadow-lg z-50">
|
||||
<button
|
||||
onClick={() => {
|
||||
setCompanyFilter('all');
|
||||
setIsCompanyDropdownOpen(false);
|
||||
}}
|
||||
className={`w-full flex items-center gap-2.5 px-3 py-2 text-[12px] text-left transition-colors ${
|
||||
companyFilter === 'all'
|
||||
? 'bg-[var(--color-surface-3)] text-[var(--color-text-primary)] font-medium'
|
||||
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-primary)]'
|
||||
}`}
|
||||
>
|
||||
<svg className="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
|
||||
</svg>
|
||||
<span>All Companies ({companies.length})</span>
|
||||
</button>
|
||||
<div className="border-t border-[var(--color-border)] my-1" />
|
||||
{companies.map((company) => (
|
||||
<button
|
||||
key={company}
|
||||
onClick={() => {
|
||||
setCompanyFilter(company);
|
||||
setIsCompanyDropdownOpen(false);
|
||||
}}
|
||||
className={`w-full flex items-center gap-2.5 px-3 py-2 text-[12px] text-left transition-colors ${
|
||||
companyFilter === company
|
||||
? 'bg-[var(--color-surface-3)] text-[var(--color-text-primary)] font-medium'
|
||||
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-primary)]'
|
||||
}`}
|
||||
>
|
||||
<div className="w-5 h-5 rounded bg-[var(--color-surface-3)] border border-[var(--color-border)] flex items-center justify-center shrink-0 overflow-hidden">
|
||||
<img
|
||||
src={getCompanyLogo(company)}
|
||||
alt=""
|
||||
className="w-4 h-4 object-contain"
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.style.display = 'none';
|
||||
const fallback = target.nextElementSibling as HTMLElement;
|
||||
if (fallback) fallback.style.display = 'flex';
|
||||
}}
|
||||
/>
|
||||
<span className="text-[9px] font-bold text-[var(--color-text-tertiary)]" style={{ display: 'none' }}>
|
||||
{company.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
<span className="truncate">{company}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sort dropdown */}
|
||||
<div className="relative">
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={(e) => setSortBy(e.target.value as SortOption)}
|
||||
className="appearance-none bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded-lg text-[12px] font-medium text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] pl-9 pr-8 py-1.5 outline-none cursor-pointer transition-colors"
|
||||
>
|
||||
<option value="recent">Most Recent</option>
|
||||
<option value="company">Company A-Z</option>
|
||||
<option value="salary">With Salary</option>
|
||||
</select>
|
||||
<svg className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-[var(--color-text-tertiary)] pointer-events-none" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
|
||||
{sortBy === 'recent' ? (
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
) : sortBy === 'company' ? (
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 4h13M3 8h9m-9 4h6m4 0l4-4m0 0l4 4m-4-4v12" />
|
||||
) : (
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
)}
|
||||
</svg>
|
||||
<svg className="absolute right-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-[var(--color-text-tertiary)] pointer-events-none" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="ml-auto relative">
|
||||
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-[var(--color-text-tertiary)] pointer-events-none" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-4.35-4.35" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search jobs..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded-lg text-[12px] text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] pl-9 pr-3 py-1.5 w-56 outline-none focus:border-[var(--color-border-hover)] transition-colors"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
onClick={() => setSearchQuery('')}
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)] transition-colors"
|
||||
>
|
||||
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom row: Tags filter */}
|
||||
{allTags.length > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[11px] font-medium text-[var(--color-text-tertiary)] uppercase tracking-wide">Tags:</span>
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<button
|
||||
onClick={() => setSelectedTag('all')}
|
||||
className={`px-2.5 py-1 rounded-md text-[11px] font-medium transition-all ${
|
||||
selectedTag === 'all'
|
||||
? 'bg-[var(--color-surface-3)] text-[var(--color-text-primary)] border border-[var(--color-border)]'
|
||||
: 'bg-[var(--color-surface-2)] text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-3)]'
|
||||
}`}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
{allTags.slice(0, 12).map((tag) => (
|
||||
<button
|
||||
key={tag}
|
||||
onClick={() => setSelectedTag(tag)}
|
||||
className={`px-2.5 py-1 rounded-md text-[11px] font-medium transition-all ${
|
||||
selectedTag === tag
|
||||
? 'bg-blue-500/15 text-blue-400 border border-blue-500/30'
|
||||
: 'bg-[var(--color-surface-2)] text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-3)]'
|
||||
}`}
|
||||
>
|
||||
{tag}
|
||||
</button>
|
||||
))}
|
||||
{allTags.length > 12 && (
|
||||
<span className="text-[11px] text-[var(--color-text-tertiary)] px-2">
|
||||
+{allTags.length - 12} more
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Active filters indicator */}
|
||||
{(locationFilter !== 'all' || sourceFilter !== 'all' || companyFilter !== 'all' || selectedTag !== 'all' || searchQuery) && (
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<span className="text-[11px] text-[var(--color-text-tertiary)]">Active filters:</span>
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
{locationFilter !== 'all' && (
|
||||
<span className="inline-flex items-center gap-1 text-[11px] bg-blue-500/15 text-blue-400 px-2 py-0.5 rounded-full">
|
||||
{locationFilter}
|
||||
<button onClick={() => setLocationFilter('all')} className="hover:text-blue-300">×</button>
|
||||
</span>
|
||||
)}
|
||||
{sourceFilter !== 'all' && (
|
||||
<span className="inline-flex items-center gap-1 text-[11px] bg-orange-500/15 text-orange-400 px-2 py-0.5 rounded-full">
|
||||
{sourceFilter}
|
||||
<button onClick={() => setSourceFilter('all')} className="hover:text-orange-300">×</button>
|
||||
</span>
|
||||
)}
|
||||
{companyFilter !== 'all' && (
|
||||
<span className="inline-flex items-center gap-1 text-[11px] bg-purple-500/15 text-purple-400 px-2 py-0.5 rounded-full">
|
||||
{companyFilter}
|
||||
<button onClick={() => setCompanyFilter('all')} className="hover:text-purple-300">×</button>
|
||||
</span>
|
||||
)}
|
||||
{selectedTag !== 'all' && (
|
||||
<span className="inline-flex items-center gap-1 text-[11px] bg-emerald-500/15 text-emerald-400 px-2 py-0.5 rounded-full">
|
||||
{selectedTag}
|
||||
<button onClick={() => setSelectedTag('all')} className="hover:text-emerald-300">×</button>
|
||||
</span>
|
||||
)}
|
||||
{searchQuery && (
|
||||
<span className="inline-flex items-center gap-1 text-[11px] bg-pink-500/15 text-pink-400 px-2 py-0.5 rounded-full">
|
||||
"{searchQuery}"
|
||||
<button onClick={() => setSearchQuery('')} className="hover:text-pink-300">×</button>
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => {
|
||||
setLocationFilter('all');
|
||||
setSourceFilter('all');
|
||||
setCompanyFilter('all');
|
||||
setSelectedTag('all');
|
||||
setSearchQuery('');
|
||||
}}
|
||||
className="text-[11px] text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)] underline"
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Results count */}
|
||||
<div className="px-6 pb-3 flex items-center justify-between">
|
||||
<span className="text-[12px] font-medium text-[var(--color-text-secondary)]">
|
||||
Showing {filteredJobs.length} of {data.totalJobs} job{filteredJobs.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
{filteredJobs.length === 0 && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setLocationFilter('all');
|
||||
setSourceFilter('all');
|
||||
setCompanyFilter('all');
|
||||
setSelectedTag('all');
|
||||
setSearchQuery('');
|
||||
}}
|
||||
className="text-[12px] text-blue-400 hover:text-blue-300 underline"
|
||||
>
|
||||
Reset filters
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Job cards */}
|
||||
{filteredJobs.length === 0 ? (
|
||||
<div className="px-6 pb-8 flex flex-col items-center justify-center py-12">
|
||||
<div className="w-16 h-16 rounded-full bg-[var(--color-surface-2)] border border-[var(--color-border)] flex items-center justify-center mb-4">
|
||||
<svg className="w-8 h-8 text-[var(--color-text-tertiary)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="1.5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-[14px] font-semibold text-[var(--color-text-primary)] mb-1">No jobs found</h3>
|
||||
<p className="text-[12px] text-[var(--color-text-tertiary)] text-center max-w-sm">
|
||||
Try adjusting your filters or search query to find more opportunities.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-6 pb-8 grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{filteredJobs.map((job) => (
|
||||
<a
|
||||
key={job.id}
|
||||
href={isSignedIn ? safeUrl(job.applyUrl) : '#'}
|
||||
target={isSignedIn ? '_blank' : undefined}
|
||||
rel={isSignedIn ? 'noopener noreferrer' : undefined}
|
||||
onClick={(e) => handleJobClick(e, job)}
|
||||
className="block bg-[var(--color-card-bg)] border border-[var(--color-border)] rounded-lg p-4 hover:border-[var(--color-border-hover)] hover:bg-[var(--color-card-hover)] hover:shadow-lg transition-all group relative"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Company icon - Enhanced with internet fallback */}
|
||||
<div className="w-12 h-12 rounded-lg bg-[var(--color-surface-2)] border border-[var(--color-border)] flex items-center justify-center shrink-0 overflow-hidden group-hover:border-[var(--color-border-hover)] transition-colors">
|
||||
<img
|
||||
src={job.companyIcon || getCompanyLogo(job.company)}
|
||||
alt={job.company}
|
||||
className="w-7 h-7 object-contain"
|
||||
style={{ display: 'block' }}
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.style.display = 'none';
|
||||
const fallback = target.nextElementSibling as HTMLElement;
|
||||
if (fallback) fallback.style.display = 'flex';
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
className="text-[16px] font-bold text-[var(--color-text-tertiary)]"
|
||||
style={{ display: 'none' }}
|
||||
>
|
||||
{job.company.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start gap-2 flex-wrap">
|
||||
<h3 className="text-[14px] font-semibold text-[var(--color-text-primary)] group-hover:text-[var(--color-text-primary)] transition-colors flex-1">
|
||||
{job.position}
|
||||
</h3>
|
||||
{job.salary && (
|
||||
<span className="flex items-center gap-1 text-[11px] font-semibold bg-emerald-500/15 text-emerald-400 px-2 py-1 rounded border border-emerald-500/20">
|
||||
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
{job.salary}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 mt-1.5 flex-wrap">
|
||||
<span className="text-[13px] font-medium text-[var(--color-text-secondary)]">{job.company}</span>
|
||||
<span className="text-[10px] text-[var(--color-text-tertiary)]">•</span>
|
||||
<span className="text-[12px] text-[var(--color-text-tertiary)]">{job.location}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 mt-2 flex-wrap">
|
||||
{job.remote && (
|
||||
<span className="flex items-center gap-1 text-[10px] font-semibold bg-blue-500/15 text-blue-400 px-2 py-1 rounded border border-blue-500/20">
|
||||
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2 12h20M12 2a15.3 15.3 0 014 10 15.3 15.3 0 01-4 10 15.3 15.3 0 01-4-10 15.3 15.3 0 014-10z" />
|
||||
</svg>
|
||||
Remote
|
||||
</span>
|
||||
)}
|
||||
<span className={`text-[10px] font-semibold px-2 py-1 rounded border ${SOURCE_COLORS[job.source] || 'bg-[var(--color-surface-3)] text-[var(--color-text-secondary)]'}`}>
|
||||
{job.source}
|
||||
</span>
|
||||
{job.postedAt && (
|
||||
<span className="flex items-center gap-1 text-[11px] text-[var(--color-text-tertiary)] ml-auto">
|
||||
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
{timeAgo(job.postedAt)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{job.description && (
|
||||
<p className="text-[12px] text-[var(--color-text-secondary)] mt-2.5 line-clamp-2 leading-relaxed">{job.description}</p>
|
||||
)}
|
||||
|
||||
{job.tags.length > 0 && (
|
||||
<div className="flex items-center gap-1.5 mt-2.5 flex-wrap">
|
||||
{job.tags.slice(0, 6).map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="text-[10px] text-[var(--color-text-secondary)] bg-[var(--color-surface-2)] px-2 py-0.5 rounded border border-[var(--color-border)] hover:border-[var(--color-border-hover)] transition-colors cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setSelectedTag(tag);
|
||||
}}
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
{job.tags.length > 6 && (
|
||||
<span className="text-[10px] text-[var(--color-text-tertiary)]">
|
||||
+{job.tags.length - 6}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right side: apply arrow or lock icon */}
|
||||
{showAuthGate ? (
|
||||
<svg
|
||||
className="w-4 h-4 text-[var(--color-text-tertiary)] shrink-0 mt-1"
|
||||
fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}
|
||||
>
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
|
||||
<path d="M7 11V7a5 5 0 0110 0v4" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
className="w-4 h-4 text-[var(--color-text-tertiary)] group-hover:text-[var(--color-text-secondary)] group-hover:translate-x-0.5 group-hover:-translate-y-0.5 shrink-0 mt-1 transition-all"
|
||||
fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-6 pb-6 text-center">
|
||||
<p className="text-[11px] text-[var(--color-text-tertiary)]">
|
||||
Last updated: {new Date(data.lastUpdated).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
|
||||
{' | '}Data sourced from HackerNews "Who is Hiring", RemoteOK, WeWorkRemotely, and Anthropic Careers
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
interface JsonViewerProps {
|
||||
content: string;
|
||||
}
|
||||
|
||||
type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };
|
||||
|
||||
function parseJson(content: string): JsonValue | null {
|
||||
try {
|
||||
return JSON.parse(content);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default function JsonViewer({ content }: JsonViewerProps) {
|
||||
const parsed = parseJson(content);
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(content);
|
||||
};
|
||||
|
||||
if (parsed === null) {
|
||||
return (
|
||||
<div className="bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded-lg p-6 text-sm text-red-400 font-mono whitespace-pre-wrap">
|
||||
Invalid JSON
|
||||
<pre className="mt-4 text-[var(--color-text-tertiary)]">{content}</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-[10px] font-semibold text-[var(--color-text-tertiary)] uppercase tracking-wider">JSON</span>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="text-[11px] px-2.5 py-1 rounded-md bg-white/[0.06] hover:bg-white/[0.12] text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)] transition-colors"
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
<div className="bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded-lg py-3 px-1 font-mono text-sm overflow-x-auto">
|
||||
<JsonNode value={parsed} path="" depth={0} defaultOpen />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function JsonNode({
|
||||
value,
|
||||
path,
|
||||
depth,
|
||||
defaultOpen = false,
|
||||
keyName,
|
||||
}: {
|
||||
value: JsonValue;
|
||||
path: string;
|
||||
depth: number;
|
||||
defaultOpen?: boolean;
|
||||
keyName?: string;
|
||||
}) {
|
||||
if (value === null) return <JsonPrimitive keyName={keyName} depth={depth} value="null" className="text-[var(--color-text-tertiary)] italic" />;
|
||||
if (typeof value === 'boolean') return <JsonPrimitive keyName={keyName} depth={depth} value={String(value)} className="text-purple-400" />;
|
||||
if (typeof value === 'number') return <JsonPrimitive keyName={keyName} depth={depth} value={String(value)} className="text-cyan-400" />;
|
||||
if (typeof value === 'string') return <JsonString keyName={keyName} depth={depth} value={value} />;
|
||||
if (Array.isArray(value)) return <JsonArray keyName={keyName} depth={depth} value={value} path={path} defaultOpen={defaultOpen} />;
|
||||
if (typeof value === 'object') return <JsonObject keyName={keyName} depth={depth} value={value as Record<string, JsonValue>} path={path} defaultOpen={defaultOpen} />;
|
||||
return null;
|
||||
}
|
||||
|
||||
function JsonPrimitive({ keyName, depth, value, className }: { keyName?: string; depth: number; value: string; className: string }) {
|
||||
return (
|
||||
<div className="flex items-start" style={{ paddingLeft: `${depth * 1.25 + 0.75}rem` }}>
|
||||
{keyName !== undefined && (
|
||||
<span className="text-accent-400 shrink-0">"{keyName}"<span className="text-[var(--color-text-tertiary)]">: </span></span>
|
||||
)}
|
||||
<span className={className}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function JsonString({ keyName, depth, value }: { keyName?: string; depth: number; value: string }) {
|
||||
const isLong = value.length > 120;
|
||||
const [expanded, setExpanded] = useState(!isLong);
|
||||
|
||||
const display = expanded ? value : value.slice(0, 80) + '...';
|
||||
|
||||
return (
|
||||
<div className="flex items-start group/str" style={{ paddingLeft: `${depth * 1.25 + 0.75}rem` }}>
|
||||
{keyName !== undefined && (
|
||||
<span className="text-accent-400 shrink-0">"{keyName}"<span className="text-[var(--color-text-tertiary)]">: </span></span>
|
||||
)}
|
||||
<span className="text-green-400 break-all">
|
||||
"{display}"
|
||||
{isLong && (
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="ml-1.5 text-[10px] px-1.5 py-0.5 rounded bg-white/[0.06] hover:bg-white/[0.1] text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)] transition-colors align-middle"
|
||||
>
|
||||
{expanded ? 'less' : `+${value.length - 80} chars`}
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function JsonObject({
|
||||
keyName,
|
||||
depth,
|
||||
value,
|
||||
path,
|
||||
defaultOpen,
|
||||
}: {
|
||||
keyName?: string;
|
||||
depth: number;
|
||||
value: Record<string, JsonValue>;
|
||||
path: string;
|
||||
defaultOpen?: boolean;
|
||||
}) {
|
||||
const keys = Object.keys(value);
|
||||
const [open, setOpen] = useState(defaultOpen || depth < 2);
|
||||
|
||||
const handleCopySection = useCallback(() => {
|
||||
navigator.clipboard.writeText(JSON.stringify(value, null, 2));
|
||||
}, [value]);
|
||||
|
||||
if (keys.length === 0) {
|
||||
return (
|
||||
<div style={{ paddingLeft: `${depth * 1.25 + 0.75}rem` }}>
|
||||
{keyName !== undefined && (
|
||||
<span className="text-accent-400">"{keyName}"<span className="text-[var(--color-text-tertiary)]">: </span></span>
|
||||
)}
|
||||
<span className="text-[var(--color-text-tertiary)]">{'{}'}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className="flex items-center gap-1 group/obj cursor-pointer hover:bg-white/[0.02] rounded py-0.5 transition-colors"
|
||||
style={{ paddingLeft: `${depth * 1.25 + 0.25}rem` }}
|
||||
onClick={() => setOpen(!open)}
|
||||
>
|
||||
<svg
|
||||
className={`w-3 h-3 text-[var(--color-text-tertiary)] shrink-0 transition-transform duration-150 ${open ? 'rotate-90' : ''}`}
|
||||
fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
|
||||
</svg>
|
||||
{keyName !== undefined && (
|
||||
<span className="text-accent-400">"{keyName}"<span className="text-[var(--color-text-tertiary)]">: </span></span>
|
||||
)}
|
||||
<span className="text-[var(--color-text-tertiary)]">{'{'}</span>
|
||||
{!open && (
|
||||
<span className="text-[var(--color-text-tertiary)] text-xs ml-1">
|
||||
{keys.length} key{keys.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
{!open && <span className="text-[var(--color-text-tertiary)]">{'}'}</span>}
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleCopySection(); }}
|
||||
className="ml-auto mr-2 opacity-0 group-hover/obj:opacity-100 text-[10px] px-1.5 py-0.5 rounded bg-white/[0.06] hover:bg-white/[0.1] text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)] transition-all"
|
||||
title="Copy this section"
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
{open && (
|
||||
<>
|
||||
{keys.map((k) => (
|
||||
<JsonNode key={k} keyName={k} value={value[k]} path={`${path}.${k}`} depth={depth + 1} />
|
||||
))}
|
||||
<div className="text-[var(--color-text-tertiary)]" style={{ paddingLeft: `${depth * 1.25 + 0.75}rem` }}>{'}'}</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function JsonArray({
|
||||
keyName,
|
||||
depth,
|
||||
value,
|
||||
path,
|
||||
defaultOpen,
|
||||
}: {
|
||||
keyName?: string;
|
||||
depth: number;
|
||||
value: JsonValue[];
|
||||
path: string;
|
||||
defaultOpen?: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(defaultOpen || depth < 2);
|
||||
|
||||
const handleCopySection = useCallback(() => {
|
||||
navigator.clipboard.writeText(JSON.stringify(value, null, 2));
|
||||
}, [value]);
|
||||
|
||||
if (value.length === 0) {
|
||||
return (
|
||||
<div style={{ paddingLeft: `${depth * 1.25 + 0.75}rem` }}>
|
||||
{keyName !== undefined && (
|
||||
<span className="text-accent-400">"{keyName}"<span className="text-[var(--color-text-tertiary)]">: </span></span>
|
||||
)}
|
||||
<span className="text-[var(--color-text-tertiary)]">{'[]'}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className="flex items-center gap-1 group/arr cursor-pointer hover:bg-white/[0.02] rounded py-0.5 transition-colors"
|
||||
style={{ paddingLeft: `${depth * 1.25 + 0.25}rem` }}
|
||||
onClick={() => setOpen(!open)}
|
||||
>
|
||||
<svg
|
||||
className={`w-3 h-3 text-[var(--color-text-tertiary)] shrink-0 transition-transform duration-150 ${open ? 'rotate-90' : ''}`}
|
||||
fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
|
||||
</svg>
|
||||
{keyName !== undefined && (
|
||||
<span className="text-accent-400">"{keyName}"<span className="text-[var(--color-text-tertiary)]">: </span></span>
|
||||
)}
|
||||
<span className="text-[var(--color-text-tertiary)]">{'['}</span>
|
||||
{!open && (
|
||||
<span className="text-[var(--color-text-tertiary)] text-xs ml-1">
|
||||
{value.length} item{value.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
{!open && <span className="text-[var(--color-text-tertiary)]">{']'}</span>}
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleCopySection(); }}
|
||||
className="ml-auto mr-2 opacity-0 group-hover/arr:opacity-100 text-[10px] px-1.5 py-0.5 rounded bg-white/5 hover:bg-white/10 text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)] transition-all"
|
||||
title="Copy this section"
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
{open && (
|
||||
<>
|
||||
{value.map((item, i) => (
|
||||
<JsonNode key={i} value={item} path={`${path}[${i}]`} depth={depth + 1} />
|
||||
))}
|
||||
<div className="text-[var(--color-text-tertiary)]" style={{ paddingLeft: `${depth * 1.25 + 0.75}rem` }}>{']'}</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,521 @@
|
||||
import { useState, useEffect, useRef, useMemo, useCallback } from 'react';
|
||||
import { marked } from 'marked';
|
||||
|
||||
interface Heading {
|
||||
level: number;
|
||||
text: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
interface SearchMatch {
|
||||
type: 'heading' | 'text';
|
||||
text: string;
|
||||
id: string; // heading id to scroll to
|
||||
context?: string; // surrounding text for text matches
|
||||
}
|
||||
|
||||
interface MarkdownViewerProps {
|
||||
content: string;
|
||||
headings: Heading[];
|
||||
}
|
||||
|
||||
function renderMarkdown(content: string): string {
|
||||
const stripped = content.replace(/^---\s*\n[\s\S]*?\n---\s*\n?/, '');
|
||||
|
||||
marked.setOptions({ gfm: true, breaks: false });
|
||||
|
||||
const renderer = new marked.Renderer();
|
||||
renderer.heading = ({ text, depth }: { text: string; depth: number }) => {
|
||||
const id = text.toLowerCase().replace(/[^\w]+/g, '-').replace(/^-|-$/g, '');
|
||||
return `<h${depth} id="${id}" class="md-heading">${text}</h${depth}>`;
|
||||
};
|
||||
|
||||
renderer.code = ({ text, lang }: { text: string; lang?: string }) => {
|
||||
return `<div class="md-code-block"><div class="md-code-lang">${lang || ''}</div><pre><code class="language-${lang || ''}">${text.replace(/</g, '<').replace(/>/g, '>')}</code></pre></div>`;
|
||||
};
|
||||
|
||||
return marked.parse(stripped, { renderer }) as string;
|
||||
}
|
||||
|
||||
/** Build a searchable index: map each paragraph/line to its nearest preceding heading */
|
||||
function buildSearchIndex(content: string, headings: Heading[]): { text: string; headingId: string }[] {
|
||||
const stripped = content.replace(/^---\s*\n[\s\S]*?\n---\s*\n?/, '');
|
||||
const lines = stripped.split('\n');
|
||||
const entries: { text: string; headingId: string }[] = [];
|
||||
let currentHeadingId = headings[0]?.id ?? '';
|
||||
|
||||
let paragraph = '';
|
||||
for (const line of lines) {
|
||||
const headingMatch = line.match(/^(#{1,4})\s+(.+)$/);
|
||||
if (headingMatch) {
|
||||
if (paragraph.trim()) {
|
||||
entries.push({ text: paragraph.trim(), headingId: currentHeadingId });
|
||||
paragraph = '';
|
||||
}
|
||||
const text = headingMatch[2].replace(/[*_`\[\]]/g, '').trim();
|
||||
currentHeadingId = text.toLowerCase().replace(/[^\w]+/g, '-').replace(/^-|-$/g, '');
|
||||
continue;
|
||||
}
|
||||
if (line.trim() === '') {
|
||||
if (paragraph.trim()) {
|
||||
entries.push({ text: paragraph.trim(), headingId: currentHeadingId });
|
||||
paragraph = '';
|
||||
}
|
||||
} else {
|
||||
paragraph += (paragraph ? ' ' : '') + line.trim();
|
||||
}
|
||||
}
|
||||
if (paragraph.trim()) {
|
||||
entries.push({ text: paragraph.trim(), headingId: currentHeadingId });
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
export default function MarkdownViewer({ content, headings }: MarkdownViewerProps) {
|
||||
const [mode, setMode] = useState<'code' | 'preview'>('preview');
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [activeHeading, setActiveHeading] = useState('');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const [selectedIdx, setSelectedIdx] = useState(0);
|
||||
const [highlightCount, setHighlightCount] = useState(0);
|
||||
const previewRef = useRef<HTMLDivElement>(null);
|
||||
const codeRef = useRef<HTMLDivElement>(null);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const html = useMemo(() => renderMarkdown(content), [content]);
|
||||
const searchIndex = useMemo(() => buildSearchIndex(content, headings), [content, headings]);
|
||||
|
||||
// Search results
|
||||
const searchResults = useMemo((): SearchMatch[] => {
|
||||
if (!searchQuery.trim() || searchQuery.length < 2) return [];
|
||||
const q = searchQuery.toLowerCase();
|
||||
const results: SearchMatch[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
// Search headings first
|
||||
for (const h of headings) {
|
||||
if (h.text.toLowerCase().includes(q)) {
|
||||
results.push({ type: 'heading', text: h.text, id: h.id });
|
||||
seen.add(h.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Search content paragraphs
|
||||
for (const entry of searchIndex) {
|
||||
if (entry.text.toLowerCase().includes(q) && !seen.has(entry.headingId)) {
|
||||
const idx = entry.text.toLowerCase().indexOf(q);
|
||||
const start = Math.max(0, idx - 40);
|
||||
const end = Math.min(entry.text.length, idx + q.length + 40);
|
||||
const context = (start > 0 ? '...' : '') +
|
||||
entry.text.slice(start, end) +
|
||||
(end < entry.text.length ? '...' : '');
|
||||
results.push({ type: 'text', text: entry.text.slice(idx, idx + q.length), id: entry.headingId, context });
|
||||
seen.add(entry.headingId);
|
||||
}
|
||||
}
|
||||
|
||||
return results.slice(0, 15);
|
||||
}, [searchQuery, headings, searchIndex]);
|
||||
|
||||
// Highlight matches inside preview
|
||||
useEffect(() => {
|
||||
if (!previewRef.current || mode !== 'preview') return;
|
||||
|
||||
// Remove old highlights
|
||||
previewRef.current.querySelectorAll('mark.md-search-hl').forEach((m) => {
|
||||
const parent = m.parentNode;
|
||||
if (parent) {
|
||||
parent.replaceChild(document.createTextNode(m.textContent || ''), m);
|
||||
parent.normalize();
|
||||
}
|
||||
});
|
||||
|
||||
if (!searchQuery.trim() || searchQuery.length < 2) {
|
||||
setHighlightCount(0);
|
||||
return;
|
||||
}
|
||||
|
||||
const walker = document.createTreeWalker(previewRef.current, NodeFilter.SHOW_TEXT);
|
||||
const q = searchQuery.toLowerCase();
|
||||
const nodesToProcess: { node: Text; indices: number[] }[] = [];
|
||||
|
||||
let node: Text | null;
|
||||
while ((node = walker.nextNode() as Text | null)) {
|
||||
const text = node.textContent || '';
|
||||
const lower = text.toLowerCase();
|
||||
const indices: number[] = [];
|
||||
let pos = 0;
|
||||
while ((pos = lower.indexOf(q, pos)) !== -1) {
|
||||
indices.push(pos);
|
||||
pos += q.length;
|
||||
}
|
||||
if (indices.length > 0) {
|
||||
nodesToProcess.push({ node, indices });
|
||||
}
|
||||
}
|
||||
|
||||
let count = 0;
|
||||
for (const { node: textNode, indices } of nodesToProcess) {
|
||||
const text = textNode.textContent || '';
|
||||
const parent = textNode.parentNode;
|
||||
if (!parent || parent.nodeName === 'CODE' || parent.nodeName === 'PRE') continue;
|
||||
|
||||
const frag = document.createDocumentFragment();
|
||||
let lastIdx = 0;
|
||||
for (const idx of indices) {
|
||||
if (idx > lastIdx) frag.appendChild(document.createTextNode(text.slice(lastIdx, idx)));
|
||||
const mark = document.createElement('mark');
|
||||
mark.className = 'md-search-hl';
|
||||
mark.textContent = text.slice(idx, idx + q.length);
|
||||
frag.appendChild(mark);
|
||||
lastIdx = idx + q.length;
|
||||
count++;
|
||||
}
|
||||
if (lastIdx < text.length) frag.appendChild(document.createTextNode(text.slice(lastIdx)));
|
||||
parent.replaceChild(frag, textNode);
|
||||
}
|
||||
setHighlightCount(count);
|
||||
}, [searchQuery, mode, html]);
|
||||
|
||||
// Track active heading on scroll
|
||||
useEffect(() => {
|
||||
if (mode !== 'preview' || !previewRef.current) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const entry of entries) {
|
||||
if (entry.isIntersecting) setActiveHeading(entry.target.id);
|
||||
}
|
||||
},
|
||||
{ rootMargin: '-80px 0px -70% 0px', threshold: 0 }
|
||||
);
|
||||
|
||||
const headingEls = previewRef.current.querySelectorAll('.md-heading');
|
||||
headingEls.forEach((el) => observer.observe(el));
|
||||
return () => observer.disconnect();
|
||||
}, [mode, html]);
|
||||
|
||||
// Keyboard shortcut: Cmd/Ctrl+F to open search
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'f') {
|
||||
// Only intercept if the viewer is visible
|
||||
if (previewRef.current || codeRef.current) {
|
||||
e.preventDefault();
|
||||
setSearchOpen(true);
|
||||
setTimeout(() => searchInputRef.current?.focus(), 50);
|
||||
}
|
||||
}
|
||||
if (e.key === 'Escape' && searchOpen) {
|
||||
setSearchOpen(false);
|
||||
setSearchQuery('');
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', handler);
|
||||
return () => document.removeEventListener('keydown', handler);
|
||||
}, [searchOpen]);
|
||||
|
||||
// Reset selection when results change
|
||||
useEffect(() => { setSelectedIdx(0); }, [searchResults]);
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(content);
|
||||
};
|
||||
|
||||
const scrollToHeading = useCallback((id: string) => {
|
||||
if (!expanded) setExpanded(true);
|
||||
// Small delay to allow expand animation
|
||||
setTimeout(() => {
|
||||
const container = previewRef.current || codeRef.current;
|
||||
const el = container?.querySelector(`#${CSS.escape(id)}`);
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
setActiveHeading(id);
|
||||
}
|
||||
}, expanded ? 0 : 100);
|
||||
}, [expanded]);
|
||||
|
||||
const navigateToResult = useCallback((result: SearchMatch) => {
|
||||
if (mode !== 'preview') setMode('preview');
|
||||
scrollToHeading(result.id);
|
||||
setSearchOpen(false);
|
||||
}, [mode, scrollToHeading]);
|
||||
|
||||
const handleSearchKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setSelectedIdx((i) => Math.min(i + 1, searchResults.length - 1));
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setSelectedIdx((i) => Math.max(i - 1, 0));
|
||||
} else if (e.key === 'Enter' && searchResults[selectedIdx]) {
|
||||
e.preventDefault();
|
||||
navigateToResult(searchResults[selectedIdx]);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex gap-4">
|
||||
{/* Main content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1 bg-[var(--color-surface-3)] rounded-lg p-0.5">
|
||||
<button
|
||||
onClick={() => setMode('code')}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${
|
||||
mode === 'code'
|
||||
? 'bg-[var(--color-surface-2)] text-[var(--color-text-primary)] shadow-sm'
|
||||
: 'text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)]'
|
||||
}`}
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M17.25 6.75L22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3l-4.5 16.5" />
|
||||
</svg>
|
||||
Code
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setMode('preview')}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${
|
||||
mode === 'preview'
|
||||
? 'bg-[var(--color-surface-2)] text-[var(--color-text-primary)] shadow-sm'
|
||||
: 'text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)]'
|
||||
}`}
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
Preview
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search toggle */}
|
||||
<button
|
||||
onClick={() => {
|
||||
setSearchOpen(!searchOpen);
|
||||
if (!searchOpen) setTimeout(() => searchInputRef.current?.focus(), 50);
|
||||
}}
|
||||
className={`flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs transition-colors ${
|
||||
searchOpen
|
||||
? 'bg-accent-500/15 text-accent-400'
|
||||
: 'text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)] hover:bg-white/[0.04]'
|
||||
}`}
|
||||
title="Search in document (Cmd+F)"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
|
||||
</svg>
|
||||
<span className="hidden sm:inline">Search</span>
|
||||
<kbd className="hidden sm:inline text-[9px] px-1 py-0.5 rounded bg-white/[0.06] text-[var(--color-text-tertiary)] font-mono ml-1">
|
||||
{navigator.platform?.includes('Mac') ? '\u2318' : 'Ctrl'}F
|
||||
</kbd>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="text-[11px] px-2.5 py-1 rounded-md bg-white/[0.06] hover:bg-white/[0.12] text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)] transition-colors"
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search bar */}
|
||||
{searchOpen && (
|
||||
<div className="mb-3 relative">
|
||||
<div className="relative">
|
||||
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-[var(--color-text-tertiary)] pointer-events-none" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
|
||||
</svg>
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={handleSearchKeyDown}
|
||||
placeholder="Search sections and content..."
|
||||
className="w-full bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded-lg pl-9 pr-20 py-2 text-sm text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] focus:outline-none focus:border-accent-400/50 focus:ring-1 focus:ring-accent-400/20 font-sans"
|
||||
autoFocus
|
||||
/>
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2 flex items-center gap-2">
|
||||
{searchQuery.length >= 2 && (
|
||||
<span className="text-[10px] text-[var(--color-text-tertiary)]">
|
||||
{highlightCount > 0 ? `${highlightCount} match${highlightCount !== 1 ? 'es' : ''}` : 'No matches'}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => { setSearchOpen(false); setSearchQuery(''); }}
|
||||
className="text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)]"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search results dropdown */}
|
||||
{searchResults.length > 0 && (
|
||||
<div className="absolute z-20 top-full left-0 right-0 mt-1 bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded-lg shadow-xl overflow-hidden max-h-72 overflow-y-auto">
|
||||
{searchResults.map((result, i) => (
|
||||
<button
|
||||
key={`${result.id}-${i}`}
|
||||
onClick={() => navigateToResult(result)}
|
||||
onMouseEnter={() => setSelectedIdx(i)}
|
||||
className={`w-full text-left px-3 py-2.5 flex items-start gap-2.5 transition-colors ${
|
||||
i === selectedIdx ? 'bg-white/[0.06]' : 'hover:bg-white/[0.03]'
|
||||
}`}
|
||||
>
|
||||
{result.type === 'heading' ? (
|
||||
<svg className="w-3.5 h-3.5 mt-0.5 shrink-0 text-accent-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5.25 7.5h13.5m-13.5 4.5h7.5m-7.5 4.5h13.5" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-3.5 h-3.5 mt-0.5 shrink-0 text-[var(--color-text-tertiary)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
|
||||
</svg>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
{result.type === 'heading' ? (
|
||||
<HighlightText text={result.text} query={searchQuery} className="text-sm text-[var(--color-text-primary)] font-medium" />
|
||||
) : (
|
||||
<>
|
||||
<div className="text-[11px] text-[var(--color-text-tertiary)] mb-0.5">
|
||||
{headings.find((h) => h.id === result.id)?.text ?? ''}
|
||||
</div>
|
||||
<HighlightText
|
||||
text={result.context || ''}
|
||||
query={searchQuery}
|
||||
className="text-xs text-[var(--color-text-secondary)] leading-relaxed"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{i === selectedIdx && (
|
||||
<kbd className="text-[9px] px-1 py-0.5 rounded bg-white/[0.06] text-[var(--color-text-tertiary)] font-mono self-center shrink-0">
|
||||
Enter
|
||||
</kbd>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content area */}
|
||||
<div className="relative">
|
||||
{mode === 'code' ? (
|
||||
<div
|
||||
ref={codeRef}
|
||||
className={`bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded-lg p-6 text-sm text-[var(--color-text-primary)] leading-relaxed whitespace-pre-wrap font-mono overflow-hidden transition-[max-height] duration-300 ${
|
||||
expanded ? '' : 'max-h-[32rem]'
|
||||
}`}
|
||||
>
|
||||
{content || 'No content available'}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
ref={previewRef}
|
||||
className={`md-preview bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded-lg p-6 overflow-hidden transition-[max-height] duration-300 ${
|
||||
expanded ? '' : 'max-h-[32rem]'
|
||||
}`}
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!expanded && (
|
||||
<div className="mt-4 flex justify-center">
|
||||
<button
|
||||
onClick={() => setExpanded(true)}
|
||||
className="text-[13px] font-medium text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] flex items-center gap-2 px-4 py-2 rounded-lg hover:bg-[var(--color-surface-3)] border border-[var(--color-border)] hover:border-[var(--color-primary-500)]/40 transition-all duration-150 active:scale-95"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
Show full {mode === 'preview' ? 'document' : 'source'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{expanded && (
|
||||
<div className="flex justify-center mt-4">
|
||||
<button
|
||||
onClick={() => setExpanded(false)}
|
||||
className="text-[13px] font-medium text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] flex items-center gap-2 px-4 py-2 rounded-lg hover:bg-[var(--color-surface-3)] border border-[var(--color-border)] hover:border-[var(--color-primary-500)]/40 transition-all duration-150 active:scale-95"
|
||||
>
|
||||
<svg className="w-4 h-4 rotate-180" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
Collapse
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table of Contents sidebar */}
|
||||
{headings.length > 1 && mode === 'preview' && (
|
||||
<nav className="hidden xl:block w-56 shrink-0 ml-6">
|
||||
<div className="sticky top-28">
|
||||
<h4 className="text-[10px] font-bold text-[var(--color-text-tertiary)] uppercase tracking-[0.08em] mb-3 px-3">
|
||||
On this page
|
||||
</h4>
|
||||
<ul className="space-y-1 text-[12px] border-l-2 border-[var(--color-border)] max-h-[calc(100vh-12rem)] overflow-y-auto">
|
||||
{headings.map((h) => (
|
||||
<li key={h.id}>
|
||||
<button
|
||||
onClick={() => scrollToHeading(h.id)}
|
||||
className={`block w-full text-left py-1.5 transition-all duration-150 border-l-2 -ml-0.5 ${
|
||||
h.level === 1 ? 'pl-3' : h.level === 2 ? 'pl-5' : 'pl-7'
|
||||
} ${
|
||||
activeHeading === h.id
|
||||
? 'border-[var(--color-primary-400)] text-[var(--color-primary-400)] font-medium'
|
||||
: 'border-transparent text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)] hover:border-[var(--color-border)]'
|
||||
}`}
|
||||
title={h.text}
|
||||
>
|
||||
<span className="line-clamp-2 leading-snug">{h.text}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Highlights query matches within text */
|
||||
function HighlightText({ text, query, className }: { text: string; query: string; className?: string }) {
|
||||
if (!query.trim()) return <span className={className}>{text}</span>;
|
||||
|
||||
const parts: { text: string; match: boolean }[] = [];
|
||||
const lower = text.toLowerCase();
|
||||
const q = query.toLowerCase();
|
||||
let lastIdx = 0;
|
||||
|
||||
let pos = 0;
|
||||
while ((pos = lower.indexOf(q, lastIdx)) !== -1) {
|
||||
if (pos > lastIdx) parts.push({ text: text.slice(lastIdx, pos), match: false });
|
||||
parts.push({ text: text.slice(pos, pos + q.length), match: true });
|
||||
lastIdx = pos + q.length;
|
||||
}
|
||||
if (lastIdx < text.length) parts.push({ text: text.slice(lastIdx), match: false });
|
||||
|
||||
return (
|
||||
<span className={className}>
|
||||
{parts.map((p, i) =>
|
||||
p.match ? (
|
||||
<mark key={i} className="bg-accent-400/25 text-accent-300 rounded-sm px-px">{p.text}</mark>
|
||||
) : (
|
||||
<span key={i}>{p.text}</span>
|
||||
)
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
import { useState, useMemo, useRef, useEffect } from 'react';
|
||||
|
||||
interface ComponentItem {
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface MarketplacePlugin {
|
||||
name: string;
|
||||
description?: string;
|
||||
source?: string;
|
||||
source_path?: string;
|
||||
author?: string;
|
||||
tags?: string[];
|
||||
components?: Record<string, number>;
|
||||
components_items?: Record<string, ComponentItem[]>;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
pluginsList: MarketplacePlugin[];
|
||||
marketplaceAuthor: string;
|
||||
marketplaceName: string;
|
||||
tagColors: Record<string, string>;
|
||||
}
|
||||
|
||||
function getSourceLabel(p: MarketplacePlugin): 'local' | 'github' {
|
||||
const src = p.source ?? '';
|
||||
if (typeof src === 'string') {
|
||||
if (src.startsWith('./') || src.startsWith('../')) return 'local';
|
||||
if (src.includes('github.com') || src.includes('.git')) return 'github';
|
||||
if (src.includes('/')) return 'github';
|
||||
}
|
||||
return 'local';
|
||||
}
|
||||
|
||||
export default function MarketplacePluginsList({
|
||||
pluginsList,
|
||||
marketplaceAuthor,
|
||||
marketplaceName,
|
||||
tagColors,
|
||||
}: Props) {
|
||||
const [search, setSearch] = useState('');
|
||||
const [selected, setSelected] = useState<MarketplacePlugin | null>(null);
|
||||
const backdropRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!search.trim()) return pluginsList;
|
||||
const q = search.toLowerCase().trim();
|
||||
return pluginsList.filter(
|
||||
(p) =>
|
||||
p.name?.toLowerCase().includes(q) ||
|
||||
p.description?.toLowerCase().includes(q) ||
|
||||
p.author?.toLowerCase().includes(q) ||
|
||||
p.tags?.some((t) => t.toLowerCase().includes(q)) ||
|
||||
Object.keys(p.components ?? {}).some((t) => t.toLowerCase().includes(q))
|
||||
);
|
||||
}, [pluginsList, search]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selected) return;
|
||||
function handleKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') setSelected(null);
|
||||
}
|
||||
window.addEventListener('keydown', handleKey);
|
||||
return () => window.removeEventListener('keydown', handleKey);
|
||||
}, [selected]);
|
||||
|
||||
function handleBackdropClick(e: React.MouseEvent) {
|
||||
if (e.target === backdropRef.current) setSelected(null);
|
||||
}
|
||||
|
||||
const namedTypes = new Set(Object.keys(selected?.components_items ?? {}));
|
||||
const countOnlyTypes = Object.entries(selected?.components ?? {}).filter(
|
||||
([type]) => !namedTypes.has(type)
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between gap-3 flex-wrap mb-4">
|
||||
<h2 className="text-lg font-bold text-[var(--color-text-primary)]">
|
||||
Plugins ({filtered.length}
|
||||
{filtered.length !== pluginsList.length ? ` of ${pluginsList.length}` : ''})
|
||||
</h2>
|
||||
|
||||
<div className="relative w-full sm:w-72">
|
||||
<svg
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 pointer-events-none"
|
||||
style={{ color: 'var(--color-text-tertiary)' }}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<path d="m21 21-4.35-4.35" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search plugins in this marketplace..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full pl-9 pr-8 py-2 rounded-lg text-[12.5px] outline-none transition-all duration-200"
|
||||
style={{
|
||||
background: 'var(--color-surface-2)',
|
||||
border: '1px solid var(--color-border)',
|
||||
color: 'var(--color-text-primary)',
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
e.currentTarget.style.borderColor = 'var(--color-border-hover)';
|
||||
e.currentTarget.style.boxShadow = '0 0 0 3px rgba(255,217,61,0.1)';
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
e.currentTarget.style.borderColor = 'var(--color-border)';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
}}
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
onClick={() => setSearch('')}
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 w-4 h-4 rounded-full flex items-center justify-center transition-colors hover:opacity-80"
|
||||
style={{ background: 'var(--color-surface-3)', color: 'var(--color-text-tertiary)' }}
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<svg className="w-2.5 h-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path d="M18 6 6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<div
|
||||
className="flex flex-col items-center justify-center py-14 rounded-2xl"
|
||||
style={{ background: 'var(--color-surface-1)', border: '1px dashed var(--color-border)' }}
|
||||
>
|
||||
<p className="text-[13px] font-medium" style={{ color: 'var(--color-text-secondary)' }}>
|
||||
No plugins match "{search}"
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{filtered.map((p, idx) => {
|
||||
const sourceType = getSourceLabel(p);
|
||||
const hasAuthor = p.author && p.author !== marketplaceAuthor;
|
||||
const hasComponents = p.components && Object.keys(p.components).length > 0;
|
||||
return (
|
||||
<button
|
||||
key={`${p.name}-${idx}`}
|
||||
type="button"
|
||||
onClick={() => setSelected(p)}
|
||||
className="text-left flex flex-col p-4 rounded-xl bg-[var(--color-card-bg)] border border-[var(--color-border)] hover:border-[var(--color-border-hover)] transition-all duration-200 cursor-pointer"
|
||||
style={{ boxShadow: 'var(--shadow-card)' }}
|
||||
>
|
||||
<div className="flex items-start gap-3 mb-2">
|
||||
<div
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center shrink-0 text-[12px] font-bold select-none"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #ffd93d12 0%, #ffd93d08 100%)',
|
||||
border: '1px solid #ffd93d20',
|
||||
color: '#ffd93d99',
|
||||
}}
|
||||
>
|
||||
{(p.name ?? '?').charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h3 className="text-[13px] font-semibold text-[var(--color-text-primary)] truncate">
|
||||
{p.name}
|
||||
</h3>
|
||||
{sourceType === 'github' && (
|
||||
<span className="text-[9px] font-medium px-1.5 py-0.5 rounded bg-[var(--color-surface-3)] text-[var(--color-text-tertiary)] border border-[var(--color-border)]">
|
||||
external
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{hasAuthor && (
|
||||
<p className="text-[11px] text-[var(--color-text-tertiary)]">by {p.author}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{p.description && (
|
||||
<p className="text-[12px] text-[var(--color-text-secondary)] leading-[1.5] line-clamp-2 mb-2 flex-1">
|
||||
{p.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{hasComponents && (
|
||||
<div className="flex items-center gap-1.5 flex-wrap mt-auto">
|
||||
{Object.entries(p.components!).map(([type, count]) => (
|
||||
<span
|
||||
key={type}
|
||||
className="text-[9px] font-semibold px-2 py-0.5 rounded-full border"
|
||||
style={{
|
||||
background: `${tagColors[type] ?? '#666'}10`,
|
||||
color: tagColors[type] ?? '#666',
|
||||
borderColor: `${tagColors[type] ?? '#666'}20`,
|
||||
}}
|
||||
>
|
||||
{count} {type}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{p.tags && p.tags.length > 0 && !hasComponents && (
|
||||
<div className="flex items-center gap-1.5 flex-wrap mt-auto">
|
||||
{p.tags.slice(0, 4).map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="text-[9px] font-medium px-2 py-0.5 rounded-full bg-[var(--color-surface-3)] text-[var(--color-text-tertiary)] border border-[var(--color-border)]"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selected && (
|
||||
<div
|
||||
ref={backdropRef}
|
||||
onClick={handleBackdropClick}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4"
|
||||
>
|
||||
<div className="w-full max-w-xl bg-[var(--color-surface-1)] border border-[var(--color-border)] rounded-xl shadow-2xl overflow-hidden max-h-[85vh] flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-[var(--color-border)] shrink-0">
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<div
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center shrink-0 text-[13px] font-bold select-none"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #ffd93d18 0%, #ffd93d08 100%)',
|
||||
border: '1.5px solid #ffd93d30',
|
||||
color: '#ffd93d',
|
||||
}}
|
||||
>
|
||||
{(selected.name ?? '?').charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold text-[var(--color-text-primary)] truncate">
|
||||
{selected.name}
|
||||
</h3>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSelected(null)}
|
||||
className="p-1 rounded hover:bg-white/10 text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)] transition-colors shrink-0"
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="px-5 py-4 overflow-y-auto space-y-4">
|
||||
{selected.description && (
|
||||
<p className="text-[13px] text-[var(--color-text-secondary)] leading-relaxed">
|
||||
{selected.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{selected.author && selected.author !== marketplaceAuthor && (
|
||||
<p className="text-[12px] text-[var(--color-text-tertiary)]">by {selected.author}</p>
|
||||
)}
|
||||
|
||||
{/* Components with resolved item names */}
|
||||
{Object.entries(selected.components_items ?? {}).map(([type, items]) => (
|
||||
<div key={type}>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span
|
||||
className="text-[10px] font-semibold px-2.5 py-1 rounded-full border uppercase tracking-wider"
|
||||
style={{
|
||||
background: `${tagColors[type] ?? '#666'}15`,
|
||||
color: tagColors[type] ?? '#666',
|
||||
borderColor: `${tagColors[type] ?? '#666'}30`,
|
||||
}}
|
||||
>
|
||||
{type}
|
||||
</span>
|
||||
<span className="text-[11px] text-[var(--color-text-tertiary)]">{items.length} items</span>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
{items.map((item) => (
|
||||
<div
|
||||
key={item.name}
|
||||
className="flex items-start gap-2 px-2.5 py-2 rounded-lg bg-[var(--color-surface-2)] border border-[var(--color-border)] text-[12px]"
|
||||
>
|
||||
<span
|
||||
className="w-1.5 h-1.5 rounded-full shrink-0 mt-1.5"
|
||||
style={{ background: tagColors[type] ?? '#666' }}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<div className="text-[var(--color-text-primary)] font-mono text-[11.5px] font-medium">
|
||||
{item.name}
|
||||
</div>
|
||||
{item.description && (
|
||||
<p className="text-[var(--color-text-tertiary)] text-[11.5px] leading-snug mt-0.5">
|
||||
{item.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Component types where only a count is known (names not resolved) */}
|
||||
{countOnlyTypes.length > 0 && (
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
{countOnlyTypes.map(([type, count]) => (
|
||||
<span
|
||||
key={type}
|
||||
className="text-[10px] font-semibold px-2.5 py-1 rounded-full border"
|
||||
style={{
|
||||
background: `${tagColors[type] ?? '#666'}12`,
|
||||
color: tagColors[type] ?? '#666',
|
||||
borderColor: `${tagColors[type] ?? '#666'}25`,
|
||||
}}
|
||||
>
|
||||
{count} {type}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!selected.components || Object.keys(selected.components).length === 0 ? (
|
||||
<p className="text-[12px] text-[var(--color-text-tertiary)] italic">
|
||||
No component breakdown available for this plugin yet.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{/* Install command */}
|
||||
<div>
|
||||
<div className="text-[11px] uppercase tracking-wider text-[var(--color-text-tertiary)] mb-1.5 font-medium">
|
||||
Install this plugin
|
||||
</div>
|
||||
<pre className="p-3 rounded-lg bg-[var(--color-surface-2)] border border-[var(--color-border)] text-[12px] text-[var(--color-text-secondary)] overflow-x-auto font-mono">
|
||||
<code>{`/plugin install ${selected.name}@${marketplaceName}`}</code>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
function useGlobalAuth() {
|
||||
const [state, setState] = useState({ isSignedIn: false, isLoaded: false });
|
||||
|
||||
useEffect(() => {
|
||||
function check() {
|
||||
const clerk = (window as any).Clerk;
|
||||
if (clerk?.loaded) {
|
||||
setState({ isSignedIn: !!clerk.user, isLoaded: true });
|
||||
}
|
||||
}
|
||||
check();
|
||||
const interval = setInterval(check, 500);
|
||||
const handleChange = () => check();
|
||||
window.addEventListener('clerk:session', handleChange);
|
||||
return () => { clearInterval(interval); window.removeEventListener('clerk:session', handleChange); };
|
||||
}, []);
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
export function NavContent({ isActive }: { isActive: boolean }) {
|
||||
const { isSignedIn, isLoaded } = useGlobalAuth();
|
||||
|
||||
if (!isLoaded) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 px-3 py-2 rounded-lg">
|
||||
<div className="w-[18px] h-[18px] flex items-center justify-center shrink-0">
|
||||
<div className="w-[18px] h-[18px] rounded bg-[var(--color-surface-3)] animate-pulse" />
|
||||
</div>
|
||||
<div className="sidebar-text w-24 h-3.5 rounded bg-[var(--color-surface-3)] animate-pulse" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const icon = (
|
||||
<svg className="w-[18px] h-[18px] flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="1.5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2 17V7a2 2 0 012-2h5l2 2h9a2 2 0 012 2v8a2 2 0 01-2 2H4a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
if (isSignedIn) {
|
||||
return (
|
||||
<a
|
||||
href="/my-components"
|
||||
data-tooltip="My Components"
|
||||
className={`flex items-center gap-3 px-3 py-2 rounded-lg text-[13px] transition-all duration-150 group ${
|
||||
isActive
|
||||
? 'bg-[var(--color-surface-3)] text-[var(--color-text-primary)] font-medium shadow-sm'
|
||||
: 'text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-surface-2)] hover:shadow-sm'
|
||||
}`}
|
||||
>
|
||||
<span className={`w-[18px] h-[18px] shrink-0 flex items-center justify-center transition-all duration-150 ${isActive ? 'text-[var(--color-text-primary)] scale-105' : 'text-[var(--color-text-tertiary)] group-hover:text-[var(--color-text-secondary)] group-hover:scale-105'}`}>
|
||||
{icon}
|
||||
</span>
|
||||
<span className="sidebar-text truncate transition-all duration-300">My Components</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => (window as any).Clerk?.openSignIn?.()}
|
||||
data-tooltip="My Components"
|
||||
className="flex items-center gap-3 px-3 py-2 rounded-lg text-[13px] text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-surface-2)] hover:shadow-sm transition-all duration-150 group w-full text-left"
|
||||
>
|
||||
<span className="w-[18px] h-[18px] shrink-0 flex items-center justify-center text-[var(--color-text-tertiary)] group-hover:text-[var(--color-text-secondary)] transition-all duration-150 group-hover:scale-105">
|
||||
{icon}
|
||||
</span>
|
||||
<span className="sidebar-text truncate transition-all duration-300">My Components</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MyComponentsSidebarItem({ isActive = false }: { isActive?: boolean }) {
|
||||
return <NavContent isActive={isActive} />;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,37 @@
|
||||
---
|
||||
interface Props {
|
||||
href: string;
|
||||
label: string;
|
||||
icon?: string;
|
||||
active?: boolean;
|
||||
external?: boolean;
|
||||
}
|
||||
|
||||
const { href, label, icon, active = false, external = false } = Astro.props;
|
||||
const Tag = external ? 'a' : 'a';
|
||||
---
|
||||
|
||||
<a
|
||||
href={href}
|
||||
class:list={[
|
||||
'flex items-center gap-2.5 px-3 py-1.5 rounded-md text-sm transition-colors',
|
||||
active
|
||||
? 'bg-primary-500/15 text-primary-400 font-medium'
|
||||
: 'text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-surface-3)]',
|
||||
]}
|
||||
{...external ? { target: '_blank', rel: 'noopener noreferrer' } : {}}
|
||||
>
|
||||
{icon && (
|
||||
<span
|
||||
class="w-5 h-5 shrink-0 inline-flex items-center justify-center leading-none text-center [&>svg]:w-full [&>svg]:h-full [&>svg]:block [&>svg]:m-auto"
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
)}
|
||||
<span class="truncate">{label}</span>
|
||||
{external && (
|
||||
<svg class="w-3 h-3 ml-auto opacity-40 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
)}
|
||||
</a>
|
||||
@@ -0,0 +1,278 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
|
||||
interface Plugin {
|
||||
slug: string;
|
||||
name: string;
|
||||
author: string;
|
||||
description: string;
|
||||
github: string;
|
||||
website?: string;
|
||||
stars: number;
|
||||
type: string;
|
||||
tags: string[];
|
||||
contains: Record<string, number>;
|
||||
highlights: string[];
|
||||
}
|
||||
|
||||
const TAG_COLORS: Record<string, string> = {
|
||||
skills: '#f59e0b',
|
||||
agents: '#3b82f6',
|
||||
commands: '#10b981',
|
||||
hooks: '#f97316',
|
||||
mcps: '#06b6d4',
|
||||
settings: '#8b5cf6',
|
||||
rules: '#ec4899',
|
||||
};
|
||||
|
||||
function formatStars(stars: number): string {
|
||||
if (stars >= 1000) return `${(stars / 1000).toFixed(stars >= 10000 ? 0 : 1)}k`;
|
||||
return String(stars);
|
||||
}
|
||||
|
||||
type SortMode = 'stars' | 'alpha';
|
||||
|
||||
export default function PluginsGrid({ plugins }: { plugins: Plugin[] }) {
|
||||
const [search, setSearch] = useState('');
|
||||
const [sort, setSort] = useState<SortMode>('stars');
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
let result = plugins;
|
||||
|
||||
if (search.trim()) {
|
||||
const q = search.toLowerCase().trim();
|
||||
result = result.filter(
|
||||
(p) =>
|
||||
p.name.toLowerCase().includes(q) ||
|
||||
p.author.toLowerCase().includes(q) ||
|
||||
p.description.toLowerCase().includes(q) ||
|
||||
p.tags.some((t) => t.toLowerCase().includes(q))
|
||||
);
|
||||
}
|
||||
|
||||
if (sort === 'stars') {
|
||||
result = [...result].sort((a, b) => b.stars - a.stars);
|
||||
} else {
|
||||
result = [...result].sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [plugins, search, sort]);
|
||||
|
||||
return (
|
||||
<div className="px-6 py-8 max-w-7xl mx-auto">
|
||||
{/* Controls */}
|
||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-3 mb-6">
|
||||
{/* Search */}
|
||||
<div className="relative flex-1">
|
||||
<svg
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 pointer-events-none"
|
||||
style={{ color: 'var(--color-text-tertiary)' }}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<path d="m21 21-4.35-4.35" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search plugins by name, author, or tag..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2.5 rounded-xl text-[13px] outline-none transition-all duration-200"
|
||||
style={{
|
||||
background: 'var(--color-surface-2)',
|
||||
border: '1px solid var(--color-border)',
|
||||
color: 'var(--color-text-primary)',
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
e.currentTarget.style.borderColor = 'var(--color-border-hover)';
|
||||
e.currentTarget.style.boxShadow = '0 0 0 3px rgba(255,217,61,0.1)';
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
e.currentTarget.style.borderColor = 'var(--color-border)';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
}}
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
onClick={() => setSearch('')}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 w-5 h-5 rounded-full flex items-center justify-center transition-colors hover:opacity-80"
|
||||
style={{
|
||||
background: 'var(--color-surface-3)',
|
||||
color: 'var(--color-text-tertiary)',
|
||||
}}
|
||||
>
|
||||
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path d="M18 6 6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sort selector */}
|
||||
<div
|
||||
className="flex items-center rounded-xl overflow-hidden shrink-0"
|
||||
style={{
|
||||
background: 'var(--color-surface-2)',
|
||||
border: '1px solid var(--color-border)',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={() => setSort('stars')}
|
||||
className="flex items-center gap-1.5 px-4 py-2.5 text-[12px] font-semibold transition-all duration-200"
|
||||
style={{
|
||||
background: sort === 'stars' ? 'var(--color-surface-3)' : 'transparent',
|
||||
color: sort === 'stars' ? '#ffd93d' : 'var(--color-text-tertiary)',
|
||||
}}
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
|
||||
</svg>
|
||||
Stars
|
||||
</button>
|
||||
<div style={{ width: '1px', height: '20px', background: 'var(--color-border)' }} />
|
||||
<button
|
||||
onClick={() => setSort('alpha')}
|
||||
className="flex items-center gap-1.5 px-4 py-2.5 text-[12px] font-semibold transition-all duration-200"
|
||||
style={{
|
||||
background: sort === 'alpha' ? 'var(--color-surface-3)' : 'transparent',
|
||||
color: sort === 'alpha' ? '#ffd93d' : 'var(--color-text-tertiary)',
|
||||
}}
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path d="M3 6h7M3 12h10M3 18h5M16 6l3 3m0 0l3-3m-3 3V3m0 18l-3-3m3 3l3-3m-3 3V12" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
A-Z
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results count */}
|
||||
{search && (
|
||||
<p className="text-[12px] mb-4" style={{ color: 'var(--color-text-tertiary)' }}>
|
||||
{filtered.length} {filtered.length === 1 ? 'result' : 'results'} for "{search}"
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Grid */}
|
||||
{filtered.length === 0 ? (
|
||||
<div
|
||||
className="flex flex-col items-center justify-center py-20 rounded-2xl"
|
||||
style={{
|
||||
background: 'var(--color-surface-1)',
|
||||
border: '1px dashed var(--color-border)',
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
className="w-12 h-12 mb-3"
|
||||
style={{ color: 'var(--color-text-tertiary)', opacity: 0.4 }}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<path d="m21 21-4.35-4.35" strokeLinecap="round" />
|
||||
</svg>
|
||||
<p className="text-[14px] font-medium" style={{ color: 'var(--color-text-secondary)' }}>
|
||||
No plugins found
|
||||
</p>
|
||||
<p className="text-[12px] mt-1" style={{ color: 'var(--color-text-tertiary)' }}>
|
||||
Try a different search term
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-5">
|
||||
{filtered.map((plugin, idx) => (
|
||||
<a
|
||||
key={plugin.slug}
|
||||
href={`/plugins/${plugin.slug}`}
|
||||
className="group relative flex flex-col p-6 rounded-2xl transition-all duration-300 cursor-pointer no-underline"
|
||||
style={{
|
||||
background: 'var(--color-card-bg)',
|
||||
border: '1px solid var(--color-border)',
|
||||
boxShadow: 'var(--shadow-card)',
|
||||
animationDelay: `${idx * 50}ms`,
|
||||
animationFillMode: 'both',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'var(--color-card-hover)';
|
||||
e.currentTarget.style.borderColor = 'var(--color-border-hover)';
|
||||
e.currentTarget.style.transform = 'scale(1.02) translateY(-4px)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'var(--color-card-bg)';
|
||||
e.currentTarget.style.borderColor = 'var(--color-border)';
|
||||
e.currentTarget.style.transform = 'scale(1) translateY(0)';
|
||||
}}
|
||||
>
|
||||
{/* Gradient overlay on hover */}
|
||||
<div className="absolute inset-0 rounded-2xl bg-gradient-to-br from-yellow-500/0 to-purple-500/0 group-hover:from-yellow-500/5 group-hover:to-purple-500/5 transition-all duration-300 pointer-events-none" />
|
||||
|
||||
{/* Header */}
|
||||
<div className="relative z-10 flex items-start gap-4 mb-4">
|
||||
<div
|
||||
className="w-12 h-12 rounded-xl flex items-center justify-center shrink-0 transition-all duration-300 group-hover:scale-110 group-hover:rotate-3 text-[22px] font-bold select-none"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #ffd93d18 0%, #ffd93d08 100%)',
|
||||
border: '1.5px solid #ffd93d30',
|
||||
color: '#ffd93d',
|
||||
}}
|
||||
>
|
||||
{plugin.author.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3
|
||||
className="text-[15px] font-bold leading-tight mb-1 transition-colors"
|
||||
style={{ color: 'var(--color-text-primary)' }}
|
||||
>
|
||||
{plugin.name}
|
||||
</h3>
|
||||
<p className="text-[12px]" style={{ color: 'var(--color-text-tertiary)' }}>
|
||||
by {plugin.author}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stars badge */}
|
||||
<span className="shrink-0 text-[11px] font-semibold px-2.5 py-1 rounded-full flex items-center gap-1 transition-all bg-gradient-to-r from-yellow-500/15 to-amber-400/15 text-yellow-400 border border-yellow-500/25 group-hover:from-yellow-500/25 group-hover:to-amber-400/25 group-hover:border-yellow-500/40">
|
||||
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
|
||||
</svg>
|
||||
{formatStars(plugin.stars)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<p
|
||||
className="relative z-10 text-[13px] line-clamp-2 leading-[1.6] mb-4 flex-1"
|
||||
style={{ color: 'var(--color-text-secondary)' }}
|
||||
>
|
||||
{plugin.description}
|
||||
</p>
|
||||
|
||||
{/* Tags */}
|
||||
<div className="relative z-10 flex items-center gap-1.5 flex-wrap">
|
||||
{plugin.tags.slice(0, 5).map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="text-[10px] font-semibold px-2.5 py-1 rounded-full border transition-all"
|
||||
style={{
|
||||
background: `${TAG_COLORS[tag] ?? '#666'}12`,
|
||||
color: TAG_COLORS[tag] ?? '#666',
|
||||
borderColor: `${TAG_COLORS[tag] ?? '#666'}25`,
|
||||
}}
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { collectionsApi } from '../lib/collections-api';
|
||||
import type { Collection } from '../lib/types';
|
||||
|
||||
// Hook that reads auth state from Clerk's global instance (window.Clerk)
|
||||
// Works without needing a ClerkProvider in the same React tree
|
||||
function useGlobalAuth() {
|
||||
const [state, setState] = useState({ isSignedIn: false, isLoaded: false });
|
||||
|
||||
useEffect(() => {
|
||||
function check() {
|
||||
const clerk = (window as any).Clerk;
|
||||
if (clerk?.loaded) {
|
||||
setState({ isSignedIn: !!clerk.user, isLoaded: true });
|
||||
}
|
||||
}
|
||||
check();
|
||||
// Re-check periodically until Clerk loads
|
||||
const interval = setInterval(check, 500);
|
||||
// Also listen for clerk state changes
|
||||
const handleChange = () => check();
|
||||
window.addEventListener('clerk:session', handleChange);
|
||||
return () => { clearInterval(interval); window.removeEventListener('clerk:session', handleChange); };
|
||||
}, []);
|
||||
|
||||
const getToken = async () => {
|
||||
const clerk = (window as any).Clerk;
|
||||
return clerk?.session?.getToken() ?? null;
|
||||
};
|
||||
|
||||
return { ...state, getToken };
|
||||
}
|
||||
|
||||
interface Props {
|
||||
componentType: string;
|
||||
componentPath: string;
|
||||
componentName: string;
|
||||
componentCategory?: string;
|
||||
}
|
||||
|
||||
function SaveButton({ componentType, componentPath, componentName, componentCategory }: Props) {
|
||||
const { isSignedIn, isLoaded, getToken } = useGlobalAuth();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [collections, setCollections] = useState<Collection[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [newName, setNewName] = useState('');
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const [dropdownPos, setDropdownPos] = useState({ top: 0, left: 0 });
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [savedIn, setSavedIn] = useState<Set<string>>(new Set());
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Close dropdown on outside click
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClick);
|
||||
return () => document.removeEventListener('mousedown', handleClick);
|
||||
}, [open]);
|
||||
|
||||
async function loadCollections() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const token = await getToken();
|
||||
if (!token) return;
|
||||
const cols = await collectionsApi.list(token);
|
||||
setCollections(cols);
|
||||
|
||||
// Track which collections already contain this component
|
||||
const saved = new Set<string>();
|
||||
for (const col of cols) {
|
||||
if (col.collection_items?.some((i) => i.component_path === componentPath && i.component_type === componentType)) {
|
||||
saved.add(col.id);
|
||||
}
|
||||
}
|
||||
setSavedIn(saved);
|
||||
} catch (err) {
|
||||
console.error('Failed to load collections:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Elevate the parent card's z-index when dropdown is open
|
||||
useEffect(() => {
|
||||
const card = dropdownRef.current?.closest('.group') as HTMLElement | null;
|
||||
if (!card) return;
|
||||
if (open) {
|
||||
card.style.zIndex = '50';
|
||||
card.style.position = 'relative';
|
||||
} else {
|
||||
card.style.zIndex = '';
|
||||
card.style.position = '';
|
||||
}
|
||||
return () => { card.style.zIndex = ''; card.style.position = ''; };
|
||||
}, [open]);
|
||||
|
||||
async function handleToggle(e: React.MouseEvent) {
|
||||
e.stopPropagation();
|
||||
if (!open) {
|
||||
setOpen(true);
|
||||
await loadCollections();
|
||||
} else {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleCollection(collectionId: string) {
|
||||
const token = await getToken();
|
||||
if (!token) return;
|
||||
|
||||
try {
|
||||
if (savedIn.has(collectionId)) {
|
||||
// Remove from collection
|
||||
const col = collections.find((c) => c.id === collectionId);
|
||||
const item = col?.collection_items?.find(
|
||||
(i) => i.component_path === componentPath && i.component_type === componentType
|
||||
);
|
||||
if (item) {
|
||||
await collectionsApi.removeItem(token, item.id, collectionId);
|
||||
setSavedIn((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(collectionId);
|
||||
return next;
|
||||
});
|
||||
// Update local state
|
||||
setCollections((prev) =>
|
||||
prev.map((c) =>
|
||||
c.id === collectionId
|
||||
? { ...c, collection_items: c.collection_items.filter((i) => i.id !== item.id) }
|
||||
: c
|
||||
)
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Add to collection
|
||||
const newItem = await collectionsApi.addItem(token, collectionId, {
|
||||
type: componentType,
|
||||
path: componentPath,
|
||||
name: componentName,
|
||||
category: componentCategory,
|
||||
});
|
||||
setSavedIn((prev) => new Set(prev).add(collectionId));
|
||||
setCollections((prev) =>
|
||||
prev.map((c) =>
|
||||
c.id === collectionId
|
||||
? { ...c, collection_items: [...(c.collection_items ?? []), newItem] }
|
||||
: c
|
||||
)
|
||||
);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Failed to toggle collection item:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
if (!newName.trim()) return;
|
||||
const token = await getToken();
|
||||
if (!token) return;
|
||||
|
||||
setCreating(true);
|
||||
try {
|
||||
const col = await collectionsApi.create(token, newName.trim());
|
||||
// Add item to the new collection immediately
|
||||
const newItem = await collectionsApi.addItem(token, col.id, {
|
||||
type: componentType,
|
||||
path: componentPath,
|
||||
name: componentName,
|
||||
category: componentCategory,
|
||||
});
|
||||
col.collection_items = [newItem];
|
||||
setCollections((prev) => [...prev, col]);
|
||||
setSavedIn((prev) => new Set(prev).add(col.id));
|
||||
setNewName('');
|
||||
} catch (err: any) {
|
||||
console.error('Failed to create collection:', err);
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!isLoaded) return null;
|
||||
|
||||
const isSaved = savedIn.size > 0;
|
||||
|
||||
// Not signed in - open Clerk modal via global instance
|
||||
if (!isSignedIn) {
|
||||
return (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); (window as any).Clerk?.openSignIn?.(); }}
|
||||
className="w-7 h-7 rounded-lg flex items-center justify-center shrink-0 mt-0.5 text-[var(--color-text-tertiary)] opacity-0 group-hover:opacity-100 hover:text-white hover:bg-white/10 transition-all"
|
||||
title="Sign in to save"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="1.5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z" />
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<button
|
||||
ref={buttonRef}
|
||||
onClick={handleToggle}
|
||||
className={`w-7 h-7 rounded-lg flex items-center justify-center shrink-0 mt-0.5 transition-all ${
|
||||
isSaved
|
||||
? 'text-blue-400'
|
||||
: 'text-[var(--color-text-tertiary)] opacity-0 group-hover:opacity-100 hover:text-white hover:bg-white/10'
|
||||
}`}
|
||||
title={isSaved ? 'Saved to collection' : 'Save to collection'}
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill={isSaved ? 'currentColor' : 'none'} viewBox="0 0 24 24" stroke="currentColor" strokeWidth="1.5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute right-0 top-full mt-1 w-56 bg-[var(--color-surface-1)] border border-[var(--color-border)] rounded-lg shadow-xl z-[9999] py-1">
|
||||
{loading ? (
|
||||
<div className="px-3 py-4 text-center">
|
||||
<div className="w-4 h-4 border-2 border-[var(--color-text-tertiary)] border-t-transparent rounded-full animate-spin mx-auto" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="px-3 py-1.5 text-[11px] font-medium text-[var(--color-text-tertiary)] uppercase tracking-wide">
|
||||
Save to collection
|
||||
</div>
|
||||
|
||||
{collections.length === 0 && (
|
||||
<div className="px-3 py-2 text-[12px] text-[var(--color-text-tertiary)]">
|
||||
No collections yet
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="max-h-40 overflow-y-auto">
|
||||
{collections.map((col) => (
|
||||
<button
|
||||
key={col.id}
|
||||
onClick={(e) => { e.stopPropagation(); handleToggleCollection(col.id); }}
|
||||
className="flex items-center gap-2 w-full px-3 py-1.5 text-[12px] text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-white/[0.06] transition-colors"
|
||||
>
|
||||
<span className={`w-3.5 h-3.5 rounded border flex items-center justify-center shrink-0 ${
|
||||
savedIn.has(col.id) ? 'bg-blue-500 border-blue-500' : 'border-[var(--color-border-hover)]'
|
||||
}`}>
|
||||
{savedIn.has(col.id) && (
|
||||
<svg className="w-2.5 h-2.5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="3">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
)}
|
||||
</span>
|
||||
<span className="truncate">{col.name}</span>
|
||||
<span className="ml-auto text-[10px] text-[var(--color-text-tertiary)]">
|
||||
{col.collection_items?.length ?? 0}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-[var(--color-border)] mt-1 pt-1 px-2 pb-1">
|
||||
<div className="flex items-center gap-1">
|
||||
<input
|
||||
type="text"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleCreate(); }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
placeholder="New collection..."
|
||||
className="flex-1 bg-transparent border-none text-[12px] text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] px-1 py-1 outline-none"
|
||||
maxLength={100}
|
||||
/>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleCreate(); }}
|
||||
disabled={!newName.trim() || creating}
|
||||
className="text-[11px] px-2 py-0.5 rounded bg-blue-600 hover:bg-blue-500 text-white disabled:opacity-40 transition-colors"
|
||||
>
|
||||
{creating ? '...' : 'Add'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SaveToCollectionButton(props: Props) {
|
||||
return <SaveButton {...props} />;
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
import { useState, useEffect, useRef, useMemo } from 'react';
|
||||
import { TYPE_CONFIG } from '../lib/icons';
|
||||
import TypeIcon from './TypeIcon';
|
||||
import { fetchSearchIndex, type SearchIndexEntry } from '../lib/data';
|
||||
|
||||
function cleanPath(path: string): string {
|
||||
return path?.replace(/\.(md|json)$/, '') ?? '';
|
||||
}
|
||||
|
||||
function formatName(name: string): string {
|
||||
if (!name) return '';
|
||||
return name
|
||||
.replace(/\.(md|json)$/, '')
|
||||
.replace(/[-_]/g, ' ')
|
||||
.split(' ')
|
||||
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
export default function SearchModal() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState('');
|
||||
const [data, setData] = useState<SearchIndexEntry[] | null>(null);
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const [isAnimating, setIsAnimating] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const resultsRef = useRef<HTMLDivElement>(null);
|
||||
const closeTimeoutRef = useRef<number | null>(null);
|
||||
|
||||
// Load the lightweight search index lazily on first open
|
||||
useEffect(() => {
|
||||
if (!open || data) return;
|
||||
|
||||
fetchSearchIndex()
|
||||
.then((entries) => setData(entries))
|
||||
.catch(() => {});
|
||||
}, [open, data]);
|
||||
|
||||
// Cmd+K listener
|
||||
useEffect(() => {
|
||||
function handleKey(e: KeyboardEvent) {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
||||
e.preventDefault();
|
||||
if (open) {
|
||||
closeModal();
|
||||
} else {
|
||||
openModal();
|
||||
}
|
||||
}
|
||||
if (e.key === 'Escape' && open) closeModal();
|
||||
}
|
||||
|
||||
const handleTriggerClick = (e: Event) => {
|
||||
e.preventDefault();
|
||||
openModal();
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKey);
|
||||
|
||||
// Use MutationObserver to watch for the trigger button
|
||||
let observer: MutationObserver | null = null;
|
||||
let timeoutId: number | null = null;
|
||||
|
||||
const trigger = document.getElementById('searchTrigger');
|
||||
if (trigger) {
|
||||
trigger.addEventListener('click', handleTriggerClick);
|
||||
} else {
|
||||
// Watch for the button to be added to the DOM
|
||||
observer = new MutationObserver(() => {
|
||||
const btn = document.getElementById('searchTrigger');
|
||||
if (btn) {
|
||||
btn.addEventListener('click', handleTriggerClick);
|
||||
observer?.disconnect();
|
||||
observer = null;
|
||||
}
|
||||
});
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
|
||||
// Cleanup observer after 5 seconds if button never appears
|
||||
timeoutId = window.setTimeout(() => {
|
||||
observer?.disconnect();
|
||||
observer = null;
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKey);
|
||||
const trigger = document.getElementById('searchTrigger');
|
||||
trigger?.removeEventListener('click', handleTriggerClick);
|
||||
if (observer) {
|
||||
observer.disconnect();
|
||||
}
|
||||
if (timeoutId !== null) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
function openModal() {
|
||||
// Clear any pending close timeout to prevent race conditions
|
||||
if (closeTimeoutRef.current !== null) {
|
||||
clearTimeout(closeTimeoutRef.current);
|
||||
closeTimeoutRef.current = null;
|
||||
}
|
||||
setOpen(true);
|
||||
setIsAnimating(true);
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
setIsAnimating(false);
|
||||
// Clear any existing timeout before setting a new one
|
||||
if (closeTimeoutRef.current !== null) {
|
||||
clearTimeout(closeTimeoutRef.current);
|
||||
}
|
||||
closeTimeoutRef.current = window.setTimeout(() => {
|
||||
setOpen(false);
|
||||
closeTimeoutRef.current = null;
|
||||
}, 200); // Match animation duration
|
||||
}
|
||||
|
||||
// Focus input when opened
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setQuery('');
|
||||
setSelectedIndex(0);
|
||||
setTimeout(() => inputRef.current?.focus(), 50);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// Search results
|
||||
const results = useMemo(() => {
|
||||
if (!data || !query.trim()) return [];
|
||||
|
||||
const q = query.toLowerCase();
|
||||
|
||||
return data
|
||||
.filter(
|
||||
(c) =>
|
||||
c.name.toLowerCase().includes(q) ||
|
||||
c.description?.toLowerCase().includes(q) ||
|
||||
c.category?.toLowerCase().includes(q)
|
||||
)
|
||||
.slice(0, 20);
|
||||
}, [data, query]);
|
||||
|
||||
// Keyboard navigation
|
||||
useEffect(() => {
|
||||
setSelectedIndex(0);
|
||||
}, [query]);
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent) {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setSelectedIndex((i) => Math.min(results.length - 1, i + 1));
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setSelectedIndex((i) => Math.max(0, i - 1));
|
||||
} else if (e.key === 'Enter' && results[selectedIndex]) {
|
||||
const c = results[selectedIndex];
|
||||
navigate(c);
|
||||
}
|
||||
}
|
||||
|
||||
function navigate(component: SearchIndexEntry) {
|
||||
closeModal();
|
||||
window.location.href = `/component/${component.type}/${cleanPath(component.path ?? component.name)}`;
|
||||
}
|
||||
|
||||
// Scroll selected into view
|
||||
useEffect(() => {
|
||||
const container = resultsRef.current;
|
||||
const selected = container?.children[selectedIndex] as HTMLElement;
|
||||
selected?.scrollIntoView({ block: 'nearest' });
|
||||
}, [selectedIndex]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-start justify-center pt-[20vh]"
|
||||
onClick={closeModal}
|
||||
>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className={`absolute inset-0 bg-black/60 backdrop-blur-sm transition-opacity duration-200 ${
|
||||
isAnimating ? 'opacity-100' : 'opacity-0'
|
||||
}`}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<div
|
||||
className={`relative w-full max-w-xl bg-[var(--color-surface-1)] border border-[var(--color-border)] rounded-xl shadow-2xl overflow-hidden transition-all duration-200 ${
|
||||
isAnimating
|
||||
? 'opacity-100 scale-100 translate-y-0'
|
||||
: 'opacity-0 scale-95 translate-y-4'
|
||||
}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Input */}
|
||||
<div className={`flex items-center gap-3 px-4 py-3 border-b border-[var(--color-border)] transition-all duration-200 delay-[75ms] ${
|
||||
isAnimating ? 'opacity-100 translate-x-0' : 'opacity-0 translate-x-4'
|
||||
}`}>
|
||||
<svg className="w-5 h-5 text-[var(--color-text-tertiary)] shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
placeholder="Search all components..."
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="flex-1 bg-transparent text-sm text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] outline-none"
|
||||
/>
|
||||
<kbd className="px-1.5 py-0.5 text-[10px] bg-[var(--color-surface-3)] border border-[var(--color-border)] rounded text-[var(--color-text-tertiary)]">
|
||||
ESC
|
||||
</kbd>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
<div ref={resultsRef} className={`max-h-80 overflow-y-auto transition-all duration-200 delay-100 ${
|
||||
isAnimating ? 'opacity-100' : 'opacity-0'
|
||||
}`}>
|
||||
{query.trim() && results.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center px-4 py-12">
|
||||
{/* Icon */}
|
||||
<div className="w-16 h-16 rounded-xl bg-[var(--color-surface-2)] border border-[var(--color-border)] flex items-center justify-center mb-4">
|
||||
<svg className="w-8 h-8 text-[var(--color-text-tertiary)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="1.5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M12 12h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
{/* Message */}
|
||||
<p className="text-[14px] font-medium text-[var(--color-text-primary)] mb-1">No results found</p>
|
||||
<p className="text-[13px] text-[var(--color-text-secondary)] text-center max-w-xs">
|
||||
We couldn't find any components for "<span className="font-medium text-[var(--color-text-primary)]">{query}</span>". Try different keywords.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{results.map((component, i) => {
|
||||
const typePlural = component.type.endsWith('s') ? component.type : component.type + 's';
|
||||
const config = TYPE_CONFIG[typePlural];
|
||||
|
||||
return (
|
||||
<button
|
||||
key={component.path ?? `${component.name}-${i}`}
|
||||
onClick={() => navigate(component)}
|
||||
className={`w-full flex items-center gap-3 px-4 py-2.5 text-left transition-colors ${
|
||||
i === selectedIndex
|
||||
? 'bg-[var(--color-primary-500)]/10'
|
||||
: 'hover:bg-[var(--color-surface-2)]'
|
||||
}`}
|
||||
>
|
||||
<TypeIcon type={typePlural} size={16} className="w-4 h-4 shrink-0 [&>svg]:w-4 [&>svg]:h-4 text-[var(--color-text-tertiary)]" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="text-sm text-[var(--color-text-primary)]">{formatName(component.name)}</span>
|
||||
<span className="text-xs text-[var(--color-text-tertiary)] ml-2">{config?.label ?? component.type}</span>
|
||||
</div>
|
||||
{component.category && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-[var(--color-surface-3)] text-[var(--color-text-tertiary)] shrink-0">
|
||||
{component.category}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
{query.trim() && results.length > 0 && (
|
||||
<div className={`flex items-center gap-4 px-4 py-2 border-t border-[var(--color-border)] text-[10px] text-[var(--color-text-tertiary)] transition-all duration-200 delay-[150ms] ${
|
||||
isAnimating ? 'opacity-100 translate-y-0' : 'opacity-0 -translate-y-2'
|
||||
}`}>
|
||||
<span>↑↓ navigate</span>
|
||||
<span>↵ open</span>
|
||||
<span>esc close</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { listRepos, createPR, type GitHubRepo, type PRResult } from '../lib/github-api';
|
||||
import type { CollectionItem } from '../lib/types';
|
||||
import { fetchComponentContent } from '../lib/data';
|
||||
|
||||
type Step = 'connect' | 'select-repo' | 'creating' | 'done' | 'error';
|
||||
|
||||
interface Props {
|
||||
items: CollectionItem[];
|
||||
collectionName: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const GITHUB_CLIENT_ID = import.meta.env.PUBLIC_GITHUB_CLIENT_ID ?? '';
|
||||
|
||||
function pluralType(type: string): string {
|
||||
return type.endsWith('s') ? type : type + 's';
|
||||
}
|
||||
|
||||
function cleanPath(path: string): string {
|
||||
return path?.replace(/\.(md|json)$/, '') ?? '';
|
||||
}
|
||||
|
||||
// Map collection items to file paths + content.
|
||||
// Content lives in per-component files (/component-content/{type}/{slug}.json),
|
||||
// not in the index, so we fetch each item's content on demand.
|
||||
async function buildFileMap(
|
||||
items: CollectionItem[]
|
||||
): Promise<Record<string, string>> {
|
||||
const files: Record<string, string> = {};
|
||||
|
||||
await Promise.all(
|
||||
items.map(async (item) => {
|
||||
const type = pluralType(item.component_type);
|
||||
const cleanItemPath = cleanPath(item.component_path);
|
||||
|
||||
const content = await fetchComponentContent(item.component_type, cleanItemPath);
|
||||
if (!content) return;
|
||||
|
||||
const name = item.component_name?.replace(/\.(md|json)$/, '') ?? '';
|
||||
|
||||
switch (type) {
|
||||
case 'agents':
|
||||
files[`.claude/agents/${name}.md`] = content;
|
||||
break;
|
||||
case 'commands':
|
||||
files[`.claude/commands/${name}.md`] = content;
|
||||
break;
|
||||
case 'skills': {
|
||||
// Skills may have multiple files; content is the main SKILL.md
|
||||
const skillPath = cleanItemPath.replace(/^[^/]+\//, '');
|
||||
files[`.claude/skills/${skillPath}/SKILL.md`] = content;
|
||||
break;
|
||||
}
|
||||
case 'hooks':
|
||||
files[`.claude/hooks/${name}.json`] = content;
|
||||
break;
|
||||
case 'settings':
|
||||
// Settings content is JSON that should be merged into settings.json
|
||||
files[`.claude/settings/${name}.json`] = content;
|
||||
break;
|
||||
case 'mcps':
|
||||
files[`.mcp-components/${name}.json`] = content;
|
||||
break;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
export default function SendToRepoModal({ items, collectionName, onClose }: Props) {
|
||||
const [step, setStep] = useState<Step>(() => {
|
||||
const token = sessionStorage.getItem('github_token');
|
||||
return token ? 'select-repo' : 'connect';
|
||||
});
|
||||
const [token, setToken] = useState(() => sessionStorage.getItem('github_token') ?? '');
|
||||
const [repos, setRepos] = useState<GitHubRepo[]>([]);
|
||||
const [filteredRepos, setFilteredRepos] = useState<GitHubRepo[]>([]);
|
||||
const [search, setSearch] = useState('');
|
||||
const [loadingRepos, setLoadingRepos] = useState(false);
|
||||
const [selectedRepo, setSelectedRepo] = useState<GitHubRepo | null>(null);
|
||||
const [prResult, setPrResult] = useState<PRResult | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
const backdropRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Listen for OAuth callback
|
||||
useEffect(() => {
|
||||
function handleMessage(event: MessageEvent) {
|
||||
if (event.origin !== window.location.origin) return;
|
||||
if (event.data?.type === 'github-oauth' && event.data.token) {
|
||||
sessionStorage.setItem('github_token', event.data.token);
|
||||
setToken(event.data.token);
|
||||
setStep('select-repo');
|
||||
}
|
||||
}
|
||||
window.addEventListener('message', handleMessage);
|
||||
return () => window.removeEventListener('message', handleMessage);
|
||||
}, []);
|
||||
|
||||
// Load repos when we have a token
|
||||
useEffect(() => {
|
||||
if (step === 'select-repo' && token && repos.length === 0) {
|
||||
loadRepos();
|
||||
}
|
||||
}, [step, token]);
|
||||
|
||||
// Filter repos on search
|
||||
useEffect(() => {
|
||||
if (!search.trim()) {
|
||||
setFilteredRepos(repos);
|
||||
} else {
|
||||
const q = search.toLowerCase();
|
||||
setFilteredRepos(repos.filter((r) => r.full_name.toLowerCase().includes(q)));
|
||||
}
|
||||
}, [search, repos]);
|
||||
|
||||
async function loadRepos() {
|
||||
setLoadingRepos(true);
|
||||
try {
|
||||
const data = await listRepos(token);
|
||||
setRepos(data);
|
||||
setFilteredRepos(data);
|
||||
} catch (err: any) {
|
||||
if (err.message?.includes('401')) {
|
||||
sessionStorage.removeItem('github_token');
|
||||
setToken('');
|
||||
setStep('connect');
|
||||
} else {
|
||||
setError(err.message);
|
||||
setStep('error');
|
||||
}
|
||||
} finally {
|
||||
setLoadingRepos(false);
|
||||
}
|
||||
}
|
||||
|
||||
function startOAuth() {
|
||||
if (!GITHUB_CLIENT_ID) {
|
||||
setError('GitHub OAuth is not configured (missing PUBLIC_GITHUB_CLIENT_ID)');
|
||||
setStep('error');
|
||||
return;
|
||||
}
|
||||
const redirectUri = `${window.location.origin}/github-callback`;
|
||||
const url = `https://github.com/login/oauth/authorize?client_id=${GITHUB_CLIENT_ID}&redirect_uri=${encodeURIComponent(redirectUri)}&scope=repo`;
|
||||
const w = 600, h = 700;
|
||||
const left = window.screenX + (window.outerWidth - w) / 2;
|
||||
const top = window.screenY + (window.outerHeight - h) / 2;
|
||||
window.open(url, 'github-oauth', `width=${w},height=${h},left=${left},top=${top}`);
|
||||
}
|
||||
|
||||
const handleCreatePR = useCallback(async () => {
|
||||
if (!selectedRepo || !token) return;
|
||||
setStep('creating');
|
||||
|
||||
try {
|
||||
// Fetch per-component content on demand and assemble the PR file map.
|
||||
const files = await buildFileMap(items);
|
||||
|
||||
if (Object.keys(files).length === 0) {
|
||||
throw new Error('No component content found to include in PR');
|
||||
}
|
||||
|
||||
const branchName = `claude-components/${collectionName.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`;
|
||||
const timestamp = new Date().toISOString().slice(0, 10);
|
||||
|
||||
const pr = await createPR(
|
||||
token,
|
||||
selectedRepo.full_name,
|
||||
files,
|
||||
`${branchName}-${timestamp}`,
|
||||
`Add Claude Code components: ${collectionName}`,
|
||||
`Add Claude Code components: ${collectionName}`,
|
||||
[
|
||||
`## Claude Code Components`,
|
||||
'',
|
||||
`This PR adds **${items.length}** component${items.length !== 1 ? 's' : ''} from the "${collectionName}" collection.`,
|
||||
'',
|
||||
'### Files',
|
||||
'',
|
||||
...Object.keys(files).map((f) => `- \`${f}\``),
|
||||
'',
|
||||
'---',
|
||||
`Sent from [AI Templates](https://app.aitmpl.com/my-components)`,
|
||||
].join('\n')
|
||||
);
|
||||
|
||||
setPrResult(pr);
|
||||
setStep('done');
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
setStep('error');
|
||||
}
|
||||
}, [selectedRepo, token, items, collectionName]);
|
||||
|
||||
function handleBackdropClick(e: React.MouseEvent) {
|
||||
if (e.target === backdropRef.current) onClose();
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={backdropRef}
|
||||
onClick={handleBackdropClick}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
||||
>
|
||||
<div className="w-full max-w-lg mx-4 bg-[var(--color-surface-1)] border border-[var(--color-border)] rounded-xl shadow-2xl overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-[var(--color-border)]">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<svg className="w-5 h-5 text-[var(--color-text-primary)]" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z" />
|
||||
</svg>
|
||||
<h3 className="text-sm font-semibold text-white">Send to GitHub</h3>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 rounded hover:bg-white/10 text-[var(--color-text-tertiary)] hover:text-white transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="px-5 py-4">
|
||||
{/* Step: Connect GitHub */}
|
||||
{step === 'connect' && (
|
||||
<div className="text-center py-6">
|
||||
<p className="text-sm text-[var(--color-text-secondary)] mb-4">
|
||||
Connect your GitHub account to create a PR with your components.
|
||||
</p>
|
||||
<button
|
||||
onClick={startOAuth}
|
||||
className="inline-flex items-center gap-2 px-5 py-2.5 bg-white text-black rounded-lg text-sm font-medium hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z" />
|
||||
</svg>
|
||||
Connect GitHub
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step: Select repo */}
|
||||
{step === 'select-repo' && (
|
||||
<div>
|
||||
<div className="mb-3">
|
||||
<input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search repositories..."
|
||||
className="w-full bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded-lg px-3 py-2 text-sm text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] outline-none focus:border-[var(--color-border-hover)] transition-colors"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{loadingRepos ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="w-5 h-5 border-2 border-[var(--color-text-tertiary)] border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-h-72 overflow-y-auto space-y-0.5 -mx-1 px-1">
|
||||
{filteredRepos.map((repo) => (
|
||||
<button
|
||||
key={repo.id}
|
||||
onClick={() => setSelectedRepo(repo)}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-left transition-colors ${
|
||||
selectedRepo?.id === repo.id
|
||||
? 'bg-blue-500/15 border border-blue-500/30'
|
||||
: 'hover:bg-white/[0.04] border border-transparent'
|
||||
}`}
|
||||
>
|
||||
<svg className="w-4 h-4 shrink-0 text-[var(--color-text-tertiary)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="1.5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
||||
</svg>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[13px] text-white truncate">{repo.full_name}</div>
|
||||
{repo.description && (
|
||||
<div className="text-[11px] text-[var(--color-text-tertiary)] truncate mt-0.5">
|
||||
{repo.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{repo.private && (
|
||||
<svg className="w-3.5 h-3.5 shrink-0 text-[var(--color-text-tertiary)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="1.5">
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
|
||||
<path d="M7 11V7a5 5 0 0110 0v4" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
{filteredRepos.length === 0 && !loadingRepos && (
|
||||
<div className="flex flex-col items-center justify-center py-12 px-4">
|
||||
<div className="w-16 h-16 rounded-xl bg-[var(--color-surface-2)] border border-[var(--color-border)] flex items-center justify-center mb-4">
|
||||
<svg className="w-8 h-8 text-[var(--color-text-tertiary)]" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-[14px] font-medium text-[var(--color-text-primary)] mb-1">
|
||||
{search ? 'No repositories found' : 'No repositories available'}
|
||||
</p>
|
||||
<p className="text-[13px] text-[var(--color-text-secondary)] text-center max-w-xs">
|
||||
{search ? 'Try a different search term' : 'Connect your GitHub account to see your repositories'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer with create PR button */}
|
||||
{selectedRepo && (
|
||||
<div className="mt-4 pt-3 border-t border-[var(--color-border)] flex items-center justify-between">
|
||||
<div className="text-[12px] text-[var(--color-text-tertiary)]">
|
||||
{items.length} component{items.length !== 1 ? 's' : ''} → <span className="text-[var(--color-text-primary)]">{selectedRepo.full_name}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCreatePR}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-green-600 hover:bg-green-500 text-white rounded-lg text-[13px] font-medium transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="18" cy="18" r="3" />
|
||||
<circle cx="6" cy="6" r="3" />
|
||||
<path d="M13 6h3a2 2 0 012 2v7" />
|
||||
<line x1="6" y1="9" x2="6" y2="21" />
|
||||
</svg>
|
||||
Create PR
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step: Creating */}
|
||||
{step === 'creating' && (
|
||||
<div className="text-center py-8">
|
||||
<div className="w-6 h-6 border-2 border-[var(--color-text-tertiary)] border-t-[var(--color-text-primary)] rounded-full animate-spin mx-auto mb-3" />
|
||||
<p className="text-sm text-[var(--color-text-secondary)]">Creating pull request...</p>
|
||||
<p className="text-[11px] text-[var(--color-text-tertiary)] mt-1">
|
||||
Adding {items.length} component{items.length !== 1 ? 's' : ''} to {selectedRepo?.full_name}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step: Done */}
|
||||
{step === 'done' && prResult && (
|
||||
<div className="text-center py-6">
|
||||
<div className="w-10 h-10 rounded-full bg-green-500/15 flex items-center justify-center mx-auto mb-3">
|
||||
<svg className="w-5 h-5 text-green-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2.5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-sm font-medium text-white mb-1">Pull Request Created!</p>
|
||||
<p className="text-[12px] text-[var(--color-text-tertiary)] mb-4">
|
||||
PR #{prResult.number} in {selectedRepo?.full_name}
|
||||
</p>
|
||||
<a
|
||||
href={prResult.html_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 px-5 py-2.5 bg-white text-black rounded-lg text-sm font-medium hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
View on GitHub
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step: Error */}
|
||||
{step === 'error' && (
|
||||
<div className="text-center py-6">
|
||||
<div className="w-10 h-10 rounded-full bg-red-500/15 flex items-center justify-center mx-auto mb-3">
|
||||
<svg className="w-5 h-5 text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-sm font-medium text-white mb-1">Something went wrong</p>
|
||||
<p className="text-[12px] text-[var(--color-text-tertiary)] mb-4 max-w-sm mx-auto">{error}</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
setError('');
|
||||
setStep(token ? 'select-repo' : 'connect');
|
||||
}}
|
||||
className="px-4 py-2 bg-[var(--color-surface-3)] hover:bg-[var(--color-surface-4)] rounded-lg text-sm text-[var(--color-text-secondary)] hover:text-white transition-colors"
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
---
|
||||
import { NAV_LINKS } from '../lib/constants';
|
||||
import { TYPE_CONFIG, ICONS } from '../lib/icons';
|
||||
import AuthButton from './AuthButton';
|
||||
import MyComponentsSidebarItem from './MyComponentsSidebarItem';
|
||||
|
||||
const currentPath = Astro.url.pathname;
|
||||
|
||||
function isActive(path: string): boolean {
|
||||
if (path === '/') return currentPath === '/';
|
||||
return currentPath.startsWith(path);
|
||||
}
|
||||
---
|
||||
|
||||
<div class="flex flex-col h-full bg-[var(--color-surface-0)] relative">
|
||||
<!-- Logo -->
|
||||
<div class="px-4 py-4 border-b border-[var(--color-border)] flex items-center justify-between min-h-[65px] transition-all duration-300 bg-[var(--color-surface-0)]">
|
||||
<a href="/" class="flex items-center gap-2.5 group min-w-0 overflow-hidden transition-all duration-300">
|
||||
<span
|
||||
class="w-8 h-8 shrink-0 rounded-md border border-[var(--color-accent)]/40 bg-[var(--color-accent)]/10 flex items-center justify-center transition-all duration-200 group-hover:scale-110 group-hover:border-[var(--color-accent)]"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<svg class="w-4 h-4 text-[var(--color-accent)]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="4 17 10 11 4 5" />
|
||||
<line x1="12" y1="19" x2="20" y2="19" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="sidebar-text text-[14px] font-bold text-[var(--color-text-primary)] tracking-tight whitespace-nowrap transition-all duration-300">AI Templates</span>
|
||||
</a>
|
||||
<button
|
||||
id="sidebar-toggle"
|
||||
class="hidden md:flex shrink-0 w-8 h-8 items-center justify-center rounded-lg hover:bg-[var(--color-surface-2)] transition-all duration-200 text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)] active:scale-95 hover:rotate-180"
|
||||
aria-label="Toggle sidebar"
|
||||
title="Collapse sidebar"
|
||||
>
|
||||
<svg class="w-4 h-4 transition-transform duration-300 ease-in-out" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M11 19l-7-7 7-7m8 14l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
id="mobile-sidebar-close"
|
||||
class="md:hidden shrink-0 w-8 h-8 flex items-center justify-center rounded-lg hover:bg-[var(--color-surface-2)] transition-all duration-200 text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)] active:scale-95"
|
||||
onclick="window.toggleSidebar && window.toggleSidebar()"
|
||||
aria-label="Close menu"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Navigation -->
|
||||
<nav class="flex-1 overflow-y-auto overflow-x-hidden px-3 py-3 space-y-1 scrollbar-thin scrollbar-thumb-[var(--color-surface-3)] scrollbar-track-transparent transition-all duration-300">
|
||||
<!-- Workplace section -->
|
||||
<div class="px-2 pt-1 pb-2 overflow-hidden">
|
||||
<span class="sidebar-text font-ui-mono text-[10px] font-semibold text-[var(--color-text-tertiary)] uppercase tracking-[0.1em] select-none transition-all duration-300 block">Workplace</span>
|
||||
</div>
|
||||
|
||||
<!-- My Components (React island) -->
|
||||
<MyComponentsSidebarItem client:load isActive={isActive('/my-components')} />
|
||||
|
||||
<!-- Divider -->
|
||||
<div class="my-4 mx-2 border-t border-[var(--color-border)] opacity-60"></div>
|
||||
|
||||
<!-- Browse section -->
|
||||
<div class="px-2 pt-1 pb-2 overflow-hidden">
|
||||
<span class="sidebar-text font-ui-mono text-[10px] font-semibold text-[var(--color-text-tertiary)] uppercase tracking-[0.1em] select-none transition-all duration-300 block">Browse</span>
|
||||
</div>
|
||||
|
||||
{Object.entries(TYPE_CONFIG).map(([type, config]) => (
|
||||
<a
|
||||
href={`/${type}`}
|
||||
data-nav-type={type}
|
||||
data-tooltip={config.label}
|
||||
class:list={[
|
||||
'sidebar-nav-link flex items-center gap-3 px-3 py-2 rounded text-[13px] transition-all duration-150 group relative overflow-hidden font-ui-mono',
|
||||
isActive(`/${type}`)
|
||||
? 'bg-[var(--color-surface-2)] text-[var(--color-text-primary)] font-semibold border border-[var(--color-accent)]/40'
|
||||
: 'text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-surface-2)] border border-transparent hover:border-[var(--color-border)]',
|
||||
]}
|
||||
>
|
||||
{isActive(`/${type}`) && (
|
||||
<span class="absolute left-0 top-1/2 -translate-y-1/2 w-1 h-6 bg-[var(--color-accent)] rounded-r-full"></span>
|
||||
)}
|
||||
<span
|
||||
class="sidebar-nav-icon w-[18px] h-[18px] shrink-0 inline-flex items-center justify-center"
|
||||
>
|
||||
<span
|
||||
class:list={[
|
||||
'w-full h-full inline-flex items-center justify-center [&>svg]:w-full [&>svg]:h-full [&>svg]:flex-shrink-0 [&>svg]:block [&>svg]:m-auto transition-all duration-200',
|
||||
isActive(`/${type}`)
|
||||
? 'text-[var(--color-accent)] scale-110'
|
||||
: 'text-[var(--color-text-tertiary)] group-hover:text-[var(--color-text-secondary)] group-hover:scale-105',
|
||||
]}
|
||||
set:html={ICONS[type]}
|
||||
/>
|
||||
</span>
|
||||
<span class="sidebar-text truncate transition-all duration-300">{config.label}</span>
|
||||
<span class="sidebar-nav-count font-ui-mono ml-auto text-[11px] font-semibold tabular-nums text-[var(--color-text-tertiary)] bg-[var(--color-surface-2)] border border-[var(--color-border)] px-2 py-0.5 rounded min-w-[24px] text-center transition-all duration-300 group-hover:bg-[var(--color-surface-3)]" data-count-type={type}></span>
|
||||
</a>
|
||||
))}
|
||||
|
||||
<!-- Plugins (external marketplaces) -->
|
||||
<a
|
||||
href="/plugins"
|
||||
data-nav-type="plugins"
|
||||
data-tooltip="Plugins"
|
||||
class:list={[
|
||||
'sidebar-nav-link flex items-center gap-3 px-3 py-2 rounded text-[13px] transition-all duration-150 group relative overflow-hidden font-ui-mono',
|
||||
isActive('/plugins')
|
||||
? 'bg-[var(--color-surface-2)] text-[var(--color-text-primary)] font-semibold border border-[var(--color-accent)]/40'
|
||||
: 'text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-surface-2)] border border-transparent hover:border-[var(--color-border)]',
|
||||
]}
|
||||
>
|
||||
{isActive('/plugins') && (
|
||||
<span class="absolute left-0 top-1/2 -translate-y-1/2 w-1 h-6 bg-[var(--color-accent)] rounded-r-full"></span>
|
||||
)}
|
||||
<span class="sidebar-nav-icon w-[18px] h-[18px] shrink-0 inline-flex items-center justify-center">
|
||||
<span
|
||||
class:list={[
|
||||
'w-full h-full inline-flex items-center justify-center [&>svg]:w-full [&>svg]:h-full [&>svg]:flex-shrink-0 [&>svg]:block [&>svg]:m-auto transition-all duration-200',
|
||||
isActive('/plugins')
|
||||
? 'text-[var(--color-accent)] scale-110'
|
||||
: 'text-[var(--color-text-tertiary)] group-hover:text-[var(--color-text-secondary)] group-hover:scale-105',
|
||||
]}
|
||||
>
|
||||
<svg class="w-full h-full" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M14.7 6.3a1 1 0 000 1.4l1.6 1.6a1 1 0 001.4 0l3.77-3.77a6 6 0 01-7.94 7.94l-6.91 6.91a2.12 2.12 0 01-3-3l6.91-6.91a6 6 0 017.94-7.94l-3.76 3.76z"/>
|
||||
</svg>
|
||||
</span>
|
||||
</span>
|
||||
<span class="sidebar-text truncate transition-all duration-300">Plugins</span>
|
||||
<span class="sidebar-nav-count font-ui-mono ml-auto text-[11px] font-semibold tabular-nums text-[var(--color-text-tertiary)] bg-[var(--color-surface-2)] border border-[var(--color-border)] px-2 py-0.5 rounded min-w-[24px] text-center transition-all duration-300 group-hover:bg-[var(--color-surface-3)]" data-count-type="plugins"></span>
|
||||
</a>
|
||||
|
||||
<!-- Divider -->
|
||||
<div class="my-4 mx-2 border-t border-[var(--color-border)] opacity-60"></div>
|
||||
|
||||
<!-- Resources -->
|
||||
<div class="px-2 pt-1 pb-2 overflow-hidden">
|
||||
<span class="sidebar-text font-ui-mono text-[10px] font-semibold text-[var(--color-text-tertiary)] uppercase tracking-[0.1em] select-none transition-all duration-300 block">Resources</span>
|
||||
</div>
|
||||
|
||||
<a href="/trending"
|
||||
data-tooltip="Trending"
|
||||
class:list={[
|
||||
'flex items-center gap-3 px-3 py-2 rounded-lg text-[13px] transition-all duration-200 group overflow-hidden',
|
||||
isActive('/trending')
|
||||
? 'bg-[var(--color-surface-3)] text-[var(--color-text-primary)] font-medium shadow-sm'
|
||||
: 'text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-surface-2)] hover:shadow-sm',
|
||||
]}>
|
||||
<span class:list={[
|
||||
'w-[18px] h-[18px] shrink-0 inline-flex items-center justify-center transition-all duration-200',
|
||||
isActive('/trending') ? 'text-[var(--color-text-primary)] scale-105' : 'text-[var(--color-text-tertiary)] group-hover:text-[var(--color-text-secondary)] group-hover:scale-105',
|
||||
]}>
|
||||
<svg class="w-full h-full flex-shrink-0 block m-auto" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="sidebar-text transition-all duration-300">Trending</span>
|
||||
</a>
|
||||
|
||||
<a href="/jobs"
|
||||
data-tooltip="Jobs"
|
||||
class:list={[
|
||||
'flex items-center gap-3 px-3 py-2 rounded-lg text-[13px] transition-all duration-200 group overflow-hidden',
|
||||
isActive('/jobs')
|
||||
? 'bg-[var(--color-surface-3)] text-[var(--color-text-primary)] font-medium shadow-sm'
|
||||
: 'text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-surface-2)] hover:shadow-sm',
|
||||
]}>
|
||||
<span class:list={[
|
||||
'w-[18px] h-[18px] shrink-0 inline-flex items-center justify-center transition-all duration-200',
|
||||
isActive('/jobs') ? 'text-[var(--color-text-primary)] scale-105' : 'text-[var(--color-text-tertiary)] group-hover:text-[var(--color-text-secondary)] group-hover:scale-105',
|
||||
]}>
|
||||
<svg class="w-full h-full flex-shrink-0 block m-auto" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="2" y="7" width="20" height="14" rx="2" ry="2" /><path d="M16 7V5a2 2 0 00-2-2h-4a2 2 0 00-2 2v2" /><line x1="12" y1="12" x2="12" y2="12.01" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="sidebar-text transition-all duration-300">Jobs</span>
|
||||
<span class="sidebar-text ml-auto text-[9px] font-bold uppercase tracking-wider bg-orange-500/15 text-orange-400 px-1.5 py-0.5 rounded-md shadow-sm transition-all duration-300">New</span>
|
||||
</a>
|
||||
|
||||
<a href={NAV_LINKS.blog} target="_blank" rel="noopener noreferrer"
|
||||
data-tooltip="Blog"
|
||||
class="flex items-center gap-3 px-3 py-2 rounded-lg text-[13px] text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-surface-2)] hover:shadow-sm transition-all duration-200 group overflow-hidden">
|
||||
<span class="w-[18px] h-[18px] shrink-0 inline-flex items-center justify-center text-[var(--color-text-tertiary)] group-hover:text-[var(--color-text-secondary)] transition-all duration-200 group-hover:scale-105">
|
||||
<svg class="w-full h-full flex-shrink-0 block m-auto" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M4 19.5v-15A2.5 2.5 0 016.5 2H20v20H6.5a2.5 2.5 0 010-5H20" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="sidebar-text transition-all duration-300">Blog</span>
|
||||
<svg class="sidebar-text w-3 h-3 ml-auto opacity-0 group-hover:opacity-50 transition-all duration-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" /></svg>
|
||||
</a>
|
||||
|
||||
<a href={NAV_LINKS.docs} target="_blank" rel="noopener noreferrer"
|
||||
data-tooltip="Docs"
|
||||
class="flex items-center gap-3 px-3 py-2 rounded-lg text-[13px] text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-surface-2)] hover:shadow-sm transition-all duration-150 group">
|
||||
<span class="w-[18px] h-[18px] shrink-0 inline-flex items-center justify-center text-[var(--color-text-tertiary)] group-hover:text-[var(--color-text-secondary)] transition-transform duration-150 group-hover:scale-105">
|
||||
<svg class="w-full h-full flex-shrink-0 block m-auto" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" /><polyline points="14 2 14 8 20 8" /><line x1="16" y1="13" x2="8" y2="13" /><line x1="16" y1="17" x2="8" y2="17" /><polyline points="10 9 9 9 8 9" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="sidebar-text transition-all duration-300">Docs</span>
|
||||
<svg class="sidebar-text w-3 h-3 ml-auto opacity-0 group-hover:opacity-50 transition-opacity" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" /></svg>
|
||||
</a>
|
||||
|
||||
<a href={NAV_LINKS.github} target="_blank" rel="noopener noreferrer"
|
||||
data-tooltip="GitHub"
|
||||
class="flex items-center gap-3 px-3 py-2 rounded-lg text-[13px] text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-surface-2)] hover:shadow-sm transition-all duration-200 group overflow-hidden">
|
||||
<span class="w-[18px] h-[18px] shrink-0 inline-flex items-center justify-center text-[var(--color-text-tertiary)] group-hover:text-[var(--color-text-secondary)] transition-all duration-200 group-hover:scale-105">
|
||||
<svg class="w-full h-full flex-shrink-0 block m-auto" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="sidebar-text transition-all duration-300">GitHub</span>
|
||||
<svg class="sidebar-text w-3 h-3 ml-auto opacity-0 group-hover:opacity-50 transition-all duration-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" /></svg>
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<!-- Bottom -->
|
||||
<div class="px-3 py-3 border-t border-[var(--color-border)] space-y-2.5 bg-[var(--color-surface-0)] overflow-hidden">
|
||||
<AuthButton client:idle />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function updateSidebarActive() {
|
||||
const path = window.location.pathname.replace(/^\//, '').split('/')[0] || 'skills';
|
||||
document.querySelectorAll('.sidebar-nav-link').forEach((link) => {
|
||||
const type = (link as HTMLElement).dataset.navType;
|
||||
const iconWrapper = link.querySelector('.sidebar-nav-icon');
|
||||
const iconSpan = iconWrapper?.querySelector('span');
|
||||
if (type === path) {
|
||||
link.classList.remove('text-[var(--color-text-secondary)]', 'hover:text-[var(--color-text-primary)]', 'hover:bg-[var(--color-surface-2)]', 'hover:shadow-sm');
|
||||
link.classList.add('bg-[var(--color-surface-3)]', 'text-[var(--color-text-primary)]', 'font-medium', 'shadow-sm');
|
||||
iconSpan?.classList.remove('text-[var(--color-text-tertiary)]', 'group-hover:text-[var(--color-text-secondary)]', 'group-hover:scale-105');
|
||||
iconSpan?.classList.add('text-[var(--color-accent)]', 'scale-110');
|
||||
} else {
|
||||
link.classList.remove('bg-[var(--color-surface-3)]', 'text-[var(--color-text-primary)]', 'font-medium', 'shadow-sm');
|
||||
link.classList.add('text-[var(--color-text-secondary)]', 'hover:text-[var(--color-text-primary)]', 'hover:bg-[var(--color-surface-2)]', 'hover:shadow-sm');
|
||||
iconSpan?.classList.remove('text-[var(--color-accent)]', 'scale-110');
|
||||
iconSpan?.classList.add('text-[var(--color-text-tertiary)]', 'group-hover:text-[var(--color-text-secondary)]', 'group-hover:scale-105');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Patch pushState to emit events for sidebar sync
|
||||
const origPushState = history.pushState.bind(history);
|
||||
history.pushState = function(...args: Parameters<typeof origPushState>) {
|
||||
origPushState(...args);
|
||||
updateSidebarActive();
|
||||
};
|
||||
|
||||
window.addEventListener('popstate', updateSidebarActive);
|
||||
|
||||
// Load component counts directly — no dependency on page components
|
||||
function applyCounts(counts: Record<string, number>) {
|
||||
document.querySelectorAll('.sidebar-nav-count').forEach((el) => {
|
||||
const type = (el as HTMLElement).dataset.countType;
|
||||
if (type && counts[type]) {
|
||||
el.textContent = String(counts[type]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch counts on load (tiny counts.json, not the full index)
|
||||
fetch('/counts.json')
|
||||
.then((r) => r.json())
|
||||
.then((counts: Record<string, number>) => {
|
||||
applyCounts(counts);
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
// Plugins isn't part of the component catalog (counts.json), it's a
|
||||
// curated list of external marketplaces — count comes from plugins.json.
|
||||
fetch('/plugins.json')
|
||||
.then((r) => r.json())
|
||||
.then((plugins: unknown[]) => {
|
||||
applyCounts({ plugins: plugins.length });
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
// Also listen for counts from React grid (may arrive with more accurate data)
|
||||
window.addEventListener('component-counts', ((e: CustomEvent) => {
|
||||
applyCounts(e.detail as Record<string, number>);
|
||||
}) as EventListener);
|
||||
|
||||
// Custom tooltip functionality
|
||||
let tooltipEl: HTMLElement | null = null;
|
||||
let hideTimeout: number | null = null;
|
||||
|
||||
function createTooltip() {
|
||||
if (tooltipEl) return tooltipEl;
|
||||
|
||||
tooltipEl = document.createElement('div');
|
||||
tooltipEl.className = 'sidebar-tooltip';
|
||||
tooltipEl.style.cssText = `
|
||||
position: fixed;
|
||||
background: var(--color-surface-3);
|
||||
color: var(--color-text-primary);
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
pointer-events: none;
|
||||
z-index: 9999;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
white-space: nowrap;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
border: 1px solid var(--color-border);
|
||||
`;
|
||||
document.body.appendChild(tooltipEl);
|
||||
return tooltipEl;
|
||||
}
|
||||
|
||||
function showTooltip(element: HTMLElement, text: string) {
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
const isCollapsed = sidebar?.dataset.collapsed === 'true';
|
||||
|
||||
// Only show tooltip when sidebar is collapsed
|
||||
if (!isCollapsed) return;
|
||||
|
||||
if (hideTimeout) {
|
||||
clearTimeout(hideTimeout);
|
||||
hideTimeout = null;
|
||||
}
|
||||
|
||||
const tooltip = createTooltip();
|
||||
tooltip.textContent = text;
|
||||
|
||||
const rect = element.getBoundingClientRect();
|
||||
tooltip.style.left = `${rect.right + 12}px`;
|
||||
tooltip.style.top = `${rect.top + rect.height / 2}px`;
|
||||
tooltip.style.transform = 'translateY(-50%)';
|
||||
|
||||
// Force reflow
|
||||
tooltip.offsetHeight;
|
||||
tooltip.style.opacity = '1';
|
||||
}
|
||||
|
||||
function hideTooltip() {
|
||||
if (!tooltipEl) return;
|
||||
|
||||
hideTimeout = window.setTimeout(() => {
|
||||
if (tooltipEl) {
|
||||
tooltipEl.style.opacity = '0';
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
function initTooltips() {
|
||||
document.querySelectorAll('[data-tooltip]').forEach((el) => {
|
||||
const element = el as HTMLElement;
|
||||
const text = element.dataset.tooltip || '';
|
||||
|
||||
// Check if already bound to prevent duplicate listeners
|
||||
if (element.dataset.tooltipBound === 'true') return;
|
||||
|
||||
element.addEventListener('mouseenter', () => showTooltip(element, text));
|
||||
element.addEventListener('mouseleave', hideTooltip);
|
||||
element.dataset.tooltipBound = 'true';
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize tooltips
|
||||
initTooltips();
|
||||
document.addEventListener('astro:page-load', initTooltips);
|
||||
</script>
|
||||
@@ -0,0 +1,628 @@
|
||||
import { useState, useEffect, useRef, useMemo, useCallback } from 'react';
|
||||
import { marked } from 'marked';
|
||||
import SkillSlideView from './SkillSlideView';
|
||||
|
||||
interface Heading {
|
||||
level: number;
|
||||
text: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
interface SearchMatch {
|
||||
type: 'heading' | 'text';
|
||||
text: string;
|
||||
id: string;
|
||||
context?: string;
|
||||
}
|
||||
|
||||
interface TreeNode {
|
||||
name: string;
|
||||
path: string;
|
||||
isFile: boolean;
|
||||
children: TreeNode[];
|
||||
ext?: string;
|
||||
}
|
||||
|
||||
interface SkillExplorerProps {
|
||||
skillContent: string;
|
||||
skillName: string;
|
||||
skillPath: string;
|
||||
references: string[];
|
||||
headings: Heading[];
|
||||
}
|
||||
|
||||
const GITHUB_RAW_BASE = 'https://raw.githubusercontent.com/davila7/claude-code-templates/main/cli-tool/components/skills';
|
||||
|
||||
const EXT_COLORS: Record<string, string> = {
|
||||
md: '#60a5fa', js: '#facc15', ts: '#60a5fa', tsx: '#60a5fa', jsx: '#facc15',
|
||||
py: '#4ade80', html: '#fb923c', json: '#fde047', txt: 'var(--color-text-tertiary)',
|
||||
sh: '#86efac', yml: '#f472b6', yaml: '#f472b6', css: '#c084fc', toml: '#f472b6',
|
||||
};
|
||||
|
||||
// ─── Markdown rendering ────────────────────────────────────────────
|
||||
function renderMarkdown(content: string): string {
|
||||
const stripped = content.replace(/^---\s*\n[\s\S]*?\n---\s*\n?/, '');
|
||||
marked.setOptions({ gfm: true, breaks: false });
|
||||
const renderer = new marked.Renderer();
|
||||
renderer.heading = ({ text, depth }: { text: string; depth: number }) => {
|
||||
const id = text.toLowerCase().replace(/[^\w]+/g, '-').replace(/^-|-$/g, '');
|
||||
return `<h${depth} id="${id}" class="md-heading">${text}</h${depth}>`;
|
||||
};
|
||||
renderer.code = ({ text, lang }: { text: string; lang?: string }) => {
|
||||
return `<div class="md-code-block"><div class="md-code-lang">${lang || ''}</div><pre><code class="language-${lang || ''}">${text.replace(/</g, '<').replace(/>/g, '>')}</code></pre></div>`;
|
||||
};
|
||||
return marked.parse(stripped, { renderer }) as string;
|
||||
}
|
||||
|
||||
function extractHeadings(content: string): Heading[] {
|
||||
const stripped = content.replace(/^---\s*\n[\s\S]*?\n---\s*\n?/, '');
|
||||
const headings: Heading[] = [];
|
||||
const regex = /^(#{1,4})\s+(.+)$/gm;
|
||||
let m;
|
||||
while ((m = regex.exec(stripped)) !== null) {
|
||||
const text = m[2].replace(/[*_`\[\]]/g, '').trim();
|
||||
const id = text.toLowerCase().replace(/[^\w]+/g, '-').replace(/^-|-$/g, '');
|
||||
headings.push({ level: m[1].length, text, id });
|
||||
}
|
||||
return headings;
|
||||
}
|
||||
|
||||
function buildSearchIndex(content: string, headings: Heading[]): { text: string; headingId: string }[] {
|
||||
const stripped = content.replace(/^---\s*\n[\s\S]*?\n---\s*\n?/, '');
|
||||
const lines = stripped.split('\n');
|
||||
const entries: { text: string; headingId: string }[] = [];
|
||||
let currentHeadingId = headings[0]?.id ?? '';
|
||||
let paragraph = '';
|
||||
for (const line of lines) {
|
||||
const headingMatch = line.match(/^(#{1,4})\s+(.+)$/);
|
||||
if (headingMatch) {
|
||||
if (paragraph.trim()) { entries.push({ text: paragraph.trim(), headingId: currentHeadingId }); paragraph = ''; }
|
||||
const text = headingMatch[2].replace(/[*_`\[\]]/g, '').trim();
|
||||
currentHeadingId = text.toLowerCase().replace(/[^\w]+/g, '-').replace(/^-|-$/g, '');
|
||||
continue;
|
||||
}
|
||||
if (line.trim() === '') {
|
||||
if (paragraph.trim()) { entries.push({ text: paragraph.trim(), headingId: currentHeadingId }); paragraph = ''; }
|
||||
} else {
|
||||
paragraph += (paragraph ? ' ' : '') + line.trim();
|
||||
}
|
||||
}
|
||||
if (paragraph.trim()) entries.push({ text: paragraph.trim(), headingId: currentHeadingId });
|
||||
return entries;
|
||||
}
|
||||
|
||||
// ─── Tree building ─────────────────────────────────────────────────
|
||||
function buildTree(paths: string[], skillName: string): TreeNode[] {
|
||||
const root: TreeNode[] = [];
|
||||
root.push({ name: 'SKILL.md', path: 'SKILL.md', isFile: true, children: [], ext: 'md' });
|
||||
for (const p of paths) {
|
||||
const parts = p.split('/');
|
||||
let current = root;
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const name = parts[i];
|
||||
const isFile = i === parts.length - 1;
|
||||
const fullPath = parts.slice(0, i + 1).join('/');
|
||||
let existing = current.find((n) => n.name === name && n.isFile === isFile);
|
||||
if (!existing) {
|
||||
const ext = isFile ? name.split('.').pop()?.toLowerCase() : undefined;
|
||||
existing = { name, path: fullPath, isFile, children: [], ext };
|
||||
current.push(existing);
|
||||
}
|
||||
current = existing.children;
|
||||
}
|
||||
}
|
||||
function sortNodes(nodes: TreeNode[]) {
|
||||
nodes.sort((a, b) => {
|
||||
if (a.name === 'SKILL.md') return -1;
|
||||
if (b.name === 'SKILL.md') return 1;
|
||||
if (a.isFile !== b.isFile) return a.isFile ? 1 : -1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
for (const n of nodes) if (!n.isFile) sortNodes(n.children);
|
||||
}
|
||||
sortNodes(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
function countFiles(nodes: TreeNode[]): number {
|
||||
let c = 0;
|
||||
for (const n of nodes) { if (n.isFile) c++; else c += countFiles(n.children); }
|
||||
return c;
|
||||
}
|
||||
|
||||
// ─── Main Component ────────────────────────────────────────────────
|
||||
export default function SkillExplorer({ skillContent, skillName, skillPath, references, headings: initialHeadings }: SkillExplorerProps) {
|
||||
const [selectedFile, setSelectedFile] = useState('SKILL.md');
|
||||
const [fileContent, setFileContent] = useState(skillContent);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [mode, setMode] = useState<'code' | 'preview' | 'slides'>('preview');
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [activeHeading, setActiveHeading] = useState('');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const [selectedIdx, setSelectedIdx] = useState(0);
|
||||
const [highlightCount, setHighlightCount] = useState(0);
|
||||
const [treeOpen, setTreeOpen] = useState(false);
|
||||
const [treeFilter, setTreeFilter] = useState('');
|
||||
const previewRef = useRef<HTMLDivElement>(null);
|
||||
const codeRef = useRef<HTMLDivElement>(null);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const isMarkdown = selectedFile.endsWith('.md');
|
||||
const html = useMemo(() => isMarkdown ? renderMarkdown(fileContent) : '', [fileContent, isMarkdown]);
|
||||
const currentHeadings = useMemo(() => {
|
||||
if (selectedFile === 'SKILL.md') return initialHeadings;
|
||||
if (isMarkdown) return extractHeadings(fileContent);
|
||||
return [];
|
||||
}, [selectedFile, fileContent, isMarkdown, initialHeadings]);
|
||||
const searchIndex = useMemo(() => buildSearchIndex(fileContent, currentHeadings), [fileContent, currentHeadings]);
|
||||
|
||||
const tree = useMemo(() => buildTree(references, skillName), [references, skillName]);
|
||||
const filteredTree = useMemo(() => {
|
||||
if (!treeFilter.trim()) return tree;
|
||||
const q = treeFilter.toLowerCase();
|
||||
const filtered = references.filter((r) => r.toLowerCase().includes(q));
|
||||
const t = buildTree(filtered, skillName);
|
||||
if (!('SKILL.md').toLowerCase().includes(q)) {
|
||||
const idx = t.findIndex((n) => n.name === 'SKILL.md');
|
||||
if (idx >= 0) t.splice(idx, 1);
|
||||
}
|
||||
return t;
|
||||
}, [treeFilter, references, tree, skillName]);
|
||||
|
||||
// Fetch file content
|
||||
useEffect(() => {
|
||||
if (selectedFile === 'SKILL.md') { setFileContent(skillContent); setError(''); return; }
|
||||
setLoading(true); setError('');
|
||||
fetch(`${GITHUB_RAW_BASE}/${skillPath}/${selectedFile}`)
|
||||
.then((res) => { if (!res.ok) throw new Error(`HTTP ${res.status}`); return res.text(); })
|
||||
.then((text) => setFileContent(text))
|
||||
.catch((e) => { setError(`Failed to load file: ${e.message}`); setFileContent(''); })
|
||||
.finally(() => setLoading(false));
|
||||
}, [selectedFile, skillPath, skillContent]);
|
||||
|
||||
// Search results
|
||||
const searchResults = useMemo((): SearchMatch[] => {
|
||||
if (!searchQuery.trim() || searchQuery.length < 2) return [];
|
||||
const q = searchQuery.toLowerCase();
|
||||
const results: SearchMatch[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const h of currentHeadings) {
|
||||
if (h.text.toLowerCase().includes(q)) { results.push({ type: 'heading', text: h.text, id: h.id }); seen.add(h.id); }
|
||||
}
|
||||
for (const entry of searchIndex) {
|
||||
if (entry.text.toLowerCase().includes(q) && !seen.has(entry.headingId)) {
|
||||
const idx = entry.text.toLowerCase().indexOf(q);
|
||||
const start = Math.max(0, idx - 40);
|
||||
const end = Math.min(entry.text.length, idx + q.length + 40);
|
||||
const context = (start > 0 ? '...' : '') + entry.text.slice(start, end) + (end < entry.text.length ? '...' : '');
|
||||
results.push({ type: 'text', text: entry.text.slice(idx, idx + q.length), id: entry.headingId, context });
|
||||
seen.add(entry.headingId);
|
||||
}
|
||||
}
|
||||
return results.slice(0, 15);
|
||||
}, [searchQuery, currentHeadings, searchIndex]);
|
||||
|
||||
// Highlight matches
|
||||
useEffect(() => {
|
||||
if (!previewRef.current || mode !== 'preview') return;
|
||||
previewRef.current.querySelectorAll('mark.md-search-hl').forEach((m) => {
|
||||
const p = m.parentNode; if (p) { p.replaceChild(document.createTextNode(m.textContent || ''), m); p.normalize(); }
|
||||
});
|
||||
if (!searchQuery.trim() || searchQuery.length < 2) { setHighlightCount(0); return; }
|
||||
const walker = document.createTreeWalker(previewRef.current, NodeFilter.SHOW_TEXT);
|
||||
const q = searchQuery.toLowerCase();
|
||||
const nodesToProcess: { node: Text; indices: number[] }[] = [];
|
||||
let node: Text | null;
|
||||
while ((node = walker.nextNode() as Text | null)) {
|
||||
const text = (node.textContent || '').toLowerCase();
|
||||
const indices: number[] = []; let pos = 0;
|
||||
while ((pos = text.indexOf(q, pos)) !== -1) { indices.push(pos); pos += q.length; }
|
||||
if (indices.length) nodesToProcess.push({ node, indices });
|
||||
}
|
||||
let count = 0;
|
||||
for (const { node: textNode, indices } of nodesToProcess) {
|
||||
const text = textNode.textContent || '';
|
||||
const parent = textNode.parentNode;
|
||||
if (!parent || parent.nodeName === 'CODE' || parent.nodeName === 'PRE') continue;
|
||||
const frag = document.createDocumentFragment();
|
||||
let lastIdx = 0;
|
||||
for (const idx of indices) {
|
||||
if (idx > lastIdx) frag.appendChild(document.createTextNode(text.slice(lastIdx, idx)));
|
||||
const mark = document.createElement('mark'); mark.className = 'md-search-hl';
|
||||
mark.textContent = text.slice(idx, idx + q.length); frag.appendChild(mark);
|
||||
lastIdx = idx + q.length; count++;
|
||||
}
|
||||
if (lastIdx < text.length) frag.appendChild(document.createTextNode(text.slice(lastIdx)));
|
||||
parent.replaceChild(frag, textNode);
|
||||
}
|
||||
setHighlightCount(count);
|
||||
}, [searchQuery, mode, html]);
|
||||
|
||||
// Heading observer
|
||||
useEffect(() => {
|
||||
if (mode !== 'preview' || !previewRef.current) return;
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => { for (const e of entries) if (e.isIntersecting) setActiveHeading(e.target.id); },
|
||||
{ rootMargin: '-80px 0px -70% 0px', threshold: 0 }
|
||||
);
|
||||
previewRef.current.querySelectorAll('.md-heading').forEach((el) => observer.observe(el));
|
||||
return () => observer.disconnect();
|
||||
}, [mode, html]);
|
||||
|
||||
// Keyboard shortcuts
|
||||
useEffect(() => {
|
||||
const h = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'f' && (previewRef.current || codeRef.current)) {
|
||||
e.preventDefault(); setSearchOpen(true);
|
||||
setTimeout(() => searchInputRef.current?.focus(), 50);
|
||||
}
|
||||
if (e.key === 'Escape' && searchOpen) { setSearchOpen(false); setSearchQuery(''); }
|
||||
};
|
||||
document.addEventListener('keydown', h);
|
||||
return () => document.removeEventListener('keydown', h);
|
||||
}, [searchOpen]);
|
||||
|
||||
useEffect(() => { setSelectedIdx(0); }, [searchResults]);
|
||||
|
||||
const handleCopy = () => navigator.clipboard.writeText(fileContent);
|
||||
|
||||
const scrollToHeading = useCallback((id: string) => {
|
||||
if (!expanded) setExpanded(true);
|
||||
setTimeout(() => {
|
||||
const container = previewRef.current || codeRef.current;
|
||||
container?.querySelector(`#${CSS.escape(id)}`)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
setActiveHeading(id);
|
||||
}, expanded ? 0 : 100);
|
||||
}, [expanded]);
|
||||
|
||||
const navigateToResult = useCallback((result: SearchMatch) => {
|
||||
if (mode !== 'preview') setMode('preview');
|
||||
scrollToHeading(result.id);
|
||||
setSearchOpen(false);
|
||||
}, [mode, scrollToHeading]);
|
||||
|
||||
const handleSearchKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); setSelectedIdx((i) => Math.min(i + 1, searchResults.length - 1)); }
|
||||
else if (e.key === 'ArrowUp') { e.preventDefault(); setSelectedIdx((i) => Math.max(i - 1, 0)); }
|
||||
else if (e.key === 'Enter' && searchResults[selectedIdx]) { e.preventDefault(); navigateToResult(searchResults[selectedIdx]); }
|
||||
};
|
||||
|
||||
const handleFileClick = (path: string) => {
|
||||
setSelectedFile(path);
|
||||
if (mode === 'slides') setMode('preview');
|
||||
setSearchOpen(false);
|
||||
setSearchQuery('');
|
||||
setExpanded(false);
|
||||
};
|
||||
|
||||
const fileTreePanel = treeOpen ? (
|
||||
<div className="w-56 shrink-0 bg-[var(--color-surface-2)] border border-r-0 border-[var(--color-border)] rounded-l-lg overflow-hidden flex flex-col animate-slide-in-left">
|
||||
<div className="px-3 py-2.5 border-b border-[var(--color-border)] flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setTreeOpen(false)}
|
||||
className="text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-surface-3)] p-1 rounded-md shrink-0 transition-all duration-150 active:scale-95"
|
||||
title="Close file tree"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
|
||||
</svg>
|
||||
</button>
|
||||
<span className="text-[11px] font-bold text-[var(--color-text-primary)] truncate flex-1">{skillName}</span>
|
||||
<span className="text-[10px] font-semibold text-[var(--color-text-tertiary)] bg-[var(--color-surface-3)] px-1.5 py-0.5 rounded-md shrink-0">{references.length + 1}</span>
|
||||
</div>
|
||||
<div className="px-2.5 py-2 border-b border-[var(--color-border)]">
|
||||
<div className="relative">
|
||||
<svg className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-[var(--color-text-tertiary)] pointer-events-none" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
|
||||
</svg>
|
||||
<input type="text" value={treeFilter} onChange={(e) => setTreeFilter(e.target.value)}
|
||||
placeholder="Filter files..." className="w-full bg-[var(--color-surface-3)] border border-[var(--color-border)] rounded-lg pl-8 pr-2.5 py-1.5 text-[11px] text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] focus:outline-none focus:border-[var(--color-primary-500)]/40 focus:ring-1 focus:ring-[var(--color-primary-500)]/20 transition-all" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto py-1" style={{ scrollbarWidth: 'thin' }}>
|
||||
<TreeNodes nodes={filteredTree} depth={0} forceOpen={!!treeFilter} selectedFile={selectedFile} onSelect={handleFileClick} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setTreeOpen(true)}
|
||||
className="shrink-0 w-10 bg-[var(--color-surface-2)] border border-r-0 border-[var(--color-border)] rounded-l-lg flex flex-col items-center justify-start pt-4 gap-2.5 hover:bg-[var(--color-surface-3)] hover:w-11 hover:shadow-sm transition-all duration-200 group active:scale-95"
|
||||
title={`Browse files (${references.length + 1})`}
|
||||
>
|
||||
<svg className="w-5 h-5 text-[var(--color-text-tertiary)] group-hover:text-[var(--color-text-secondary)] transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 12.75V12A2.25 2.25 0 014.5 9.75h15A2.25 2.25 0 0121.75 12v.75m-8.69-6.44l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-5.379a1.5 1.5 0 01-1.06-.44z" />
|
||||
</svg>
|
||||
<span className="text-[10px] font-semibold text-[var(--color-text-tertiary)] group-hover:text-[var(--color-text-secondary)] transition-colors" style={{ writingMode: 'vertical-lr' }}>Files</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* File indicator when viewing non-SKILL file */}
|
||||
{selectedFile !== 'SKILL.md' && (
|
||||
<div className="flex items-center gap-2 mb-2 text-[12px] text-[var(--color-text-secondary)]">
|
||||
<svg className="w-3.5 h-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke={EXT_COLORS[selectedFile.split('.').pop() || ''] || 'var(--color-text-tertiary)'} strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
|
||||
</svg>
|
||||
<span className="font-mono">{selectedFile}</span>
|
||||
<button onClick={() => handleFileClick('SKILL.md')} className="text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)] ml-1">
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Toolbar — identical to MarkdownViewer */}
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{isMarkdown && (
|
||||
<div className="flex items-center gap-1 bg-[var(--color-surface-3)] rounded-lg p-0.5">
|
||||
<button onClick={() => setMode('code')}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${mode === 'code' ? 'bg-[var(--color-surface-2)] text-[var(--color-text-primary)] shadow-sm' : 'text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)]'}`}>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M17.25 6.75L22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3l-4.5 16.5" />
|
||||
</svg>
|
||||
Code
|
||||
</button>
|
||||
<button onClick={() => setMode('preview')}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${mode === 'preview' ? 'bg-[var(--color-surface-2)] text-[var(--color-text-primary)] shadow-sm' : 'text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)]'}`}>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
Preview
|
||||
</button>
|
||||
{selectedFile === 'SKILL.md' && (
|
||||
<button onClick={() => setMode('slides')}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${mode === 'slides' ? 'bg-surface-2 text-text-primary shadow-sm' : 'text-text-tertiary hover:text-text-secondary'}`}>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 3v11.25A2.25 2.25 0 006 16.5h2.25M3.75 3h-1.5m1.5 0h16.5m0 0h1.5m-1.5 0v11.25A2.25 2.25 0 0118 16.5h-2.25m-7.5 0h7.5m-7.5 0l-1 3m8.5-3l1 3m0 0l.5 1.5m-.5-1.5h-9.5m0 0l-.5 1.5" />
|
||||
</svg>
|
||||
Slides
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{isMarkdown && (
|
||||
<button onClick={() => { setSearchOpen(!searchOpen); if (!searchOpen) setTimeout(() => searchInputRef.current?.focus(), 50); }}
|
||||
className={`flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs transition-colors ${searchOpen ? 'bg-accent-500/15 text-accent-400' : 'text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)] hover:bg-white/[0.04]'}`}
|
||||
title="Search in document (Cmd+F)">
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
|
||||
</svg>
|
||||
<span className="hidden sm:inline">Search</span>
|
||||
<kbd className="hidden sm:inline text-[9px] px-1 py-0.5 rounded bg-white/[0.06] text-[var(--color-text-tertiary)] font-mono ml-1">
|
||||
{typeof navigator !== 'undefined' && navigator.platform?.includes('Mac') ? '\u2318' : 'Ctrl'}F
|
||||
</kbd>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button onClick={handleCopy}
|
||||
className="text-[11px] px-2.5 py-1 rounded-md bg-white/[0.06] hover:bg-white/[0.12] text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)] transition-colors">
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search bar */}
|
||||
{searchOpen && isMarkdown && mode !== 'slides' && (
|
||||
<div className="mb-3 relative">
|
||||
<div className="relative">
|
||||
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-[var(--color-text-tertiary)] pointer-events-none" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
|
||||
</svg>
|
||||
<input ref={searchInputRef} type="text" value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={handleSearchKeyDown}
|
||||
placeholder="Search sections and content..."
|
||||
className="w-full bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded-lg pl-9 pr-20 py-2 text-sm text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] focus:outline-none focus:border-accent-400/50 focus:ring-1 focus:ring-accent-400/20 font-sans" autoFocus />
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2 flex items-center gap-2">
|
||||
{searchQuery.length >= 2 && (
|
||||
<span className="text-[10px] text-[var(--color-text-tertiary)]">
|
||||
{highlightCount > 0 ? `${highlightCount} match${highlightCount !== 1 ? 'es' : ''}` : 'No matches'}
|
||||
</span>
|
||||
)}
|
||||
<button onClick={() => { setSearchOpen(false); setSearchQuery(''); }} className="text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)]">
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{searchResults.length > 0 && (
|
||||
<div className="absolute z-20 top-full left-0 right-0 mt-1 bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded-lg shadow-xl overflow-hidden max-h-72 overflow-y-auto">
|
||||
{searchResults.map((result, i) => (
|
||||
<button key={`${result.id}-${i}`} onClick={() => navigateToResult(result)} onMouseEnter={() => setSelectedIdx(i)}
|
||||
className={`w-full text-left px-3 py-2.5 flex items-start gap-2.5 transition-colors ${i === selectedIdx ? 'bg-white/[0.06]' : 'hover:bg-white/[0.03]'}`}>
|
||||
{result.type === 'heading' ? (
|
||||
<svg className="w-3.5 h-3.5 mt-0.5 shrink-0 text-accent-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5.25 7.5h13.5m-13.5 4.5h7.5m-7.5 4.5h13.5" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-3.5 h-3.5 mt-0.5 shrink-0 text-[var(--color-text-tertiary)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
|
||||
</svg>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
{result.type === 'heading' ? (
|
||||
<HighlightText text={result.text} query={searchQuery} className="text-sm text-[var(--color-text-primary)] font-medium" />
|
||||
) : (
|
||||
<>
|
||||
<div className="text-[11px] text-[var(--color-text-tertiary)] mb-0.5">{currentHeadings.find((h) => h.id === result.id)?.text ?? ''}</div>
|
||||
<HighlightText text={result.context || ''} query={searchQuery} className="text-xs text-[var(--color-text-secondary)] leading-relaxed" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{i === selectedIdx && <kbd className="text-[9px] px-1 py-0.5 rounded bg-white/[0.06] text-[var(--color-text-tertiary)] font-mono self-center shrink-0">Enter</kbd>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Slides mode */}
|
||||
{mode === 'slides' && selectedFile === 'SKILL.md' ? (
|
||||
<SkillSlideView content={skillContent} skillName={skillName} />
|
||||
) : (
|
||||
<>
|
||||
{/* Content + TOC — identical layout to MarkdownViewer */}
|
||||
<div className="flex gap-0 items-stretch">
|
||||
{/* File tree panel */}
|
||||
{fileTreePanel}
|
||||
|
||||
<div className="flex-1 min-w-0 flex gap-4">
|
||||
<div className="flex-1 min-w-0 relative">
|
||||
{loading && (
|
||||
<div className="absolute inset-0 bg-[var(--color-surface-2)]/80 z-10 flex items-center justify-center rounded-r-lg">
|
||||
<div className="flex items-center gap-2 text-sm text-[var(--color-text-secondary)]">
|
||||
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24"><circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" /><path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" /></svg>
|
||||
Loading...
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{error ? (
|
||||
<div className="bg-[var(--color-surface-2)] border border-l-0 border-[var(--color-border)] rounded-lg rounded-l-none p-6 text-sm text-red-400">{error}</div>
|
||||
) : isMarkdown && mode === 'preview' ? (
|
||||
<div ref={previewRef}
|
||||
className={`md-preview bg-[var(--color-surface-2)] border border-l-0 border-[var(--color-border)] rounded-lg rounded-l-none p-6 overflow-hidden transition-[max-height] duration-300 ${expanded ? '' : 'max-h-[32rem]'}`}
|
||||
dangerouslySetInnerHTML={{ __html: html }} />
|
||||
) : (
|
||||
<div ref={codeRef}
|
||||
className={`bg-[var(--color-surface-2)] border border-l-0 border-[var(--color-border)] rounded-lg rounded-l-none p-6 text-sm text-[var(--color-text-primary)] leading-relaxed whitespace-pre-wrap font-mono overflow-hidden transition-[max-height] duration-300 ${expanded ? '' : 'max-h-[32rem]'}`}>
|
||||
{fileContent || 'No content available'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Gradient fade + expand */}
|
||||
{!expanded && (
|
||||
<div className="absolute bottom-0 left-0 right-0 rounded-br-lg overflow-hidden">
|
||||
<div className="h-24 bg-gradient-to-t from-[var(--color-surface-2)] to-transparent pointer-events-none" />
|
||||
<div className="bg-[var(--color-surface-2)] px-4 pb-3 flex justify-center border-r border-b border-[var(--color-border)] rounded-br-lg -mt-px">
|
||||
<button onClick={() => setExpanded(true)}
|
||||
className="text-[13px] text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] flex items-center gap-1.5 px-3 py-1.5 rounded-md hover:bg-[var(--color-surface-3)] transition-colors">
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
Show full {mode === 'preview' ? 'document' : 'source'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{expanded && (
|
||||
<div className="flex justify-center py-2 bg-[var(--color-surface-2)] border border-l-0 border-t-0 border-[var(--color-border)] rounded-br-lg">
|
||||
<button onClick={() => setExpanded(false)}
|
||||
className="text-[13px] text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] flex items-center gap-1.5 px-3 py-1.5 rounded-md hover:bg-[var(--color-surface-3)] transition-colors">
|
||||
<svg className="w-3.5 h-3.5 rotate-180" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
Collapse
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* TOC sidebar — identical to MarkdownViewer */}
|
||||
{isMarkdown && currentHeadings.length >= 1 && mode === 'preview' && (
|
||||
<nav className="hidden lg:block w-52 shrink-0">
|
||||
<div className="sticky top-24">
|
||||
<h4 className="text-[10px] font-semibold text-[var(--color-text-tertiary)] uppercase tracking-wider mb-2">On this page</h4>
|
||||
<ul className="space-y-0.5 text-[12px] border-l border-[var(--color-border)] max-h-[calc(100vh-10rem)] overflow-y-auto">
|
||||
{currentHeadings.map((h) => (
|
||||
<li key={h.id}>
|
||||
<button onClick={() => scrollToHeading(h.id)}
|
||||
className={`block w-full text-left py-1 transition-colors border-l-2 -ml-px ${
|
||||
h.level === 1 ? 'pl-3' : h.level === 2 ? 'pl-5' : 'pl-7'
|
||||
} ${activeHeading === h.id ? 'border-accent-400 text-accent-400' : 'border-transparent text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)] hover:border-[var(--color-border)]'}`}
|
||||
title={h.text}>
|
||||
<span className="line-clamp-1">{h.text}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Tree components ───────────────────────────────────────────────
|
||||
function TreeNodes({ nodes, depth, forceOpen, selectedFile, onSelect }: { nodes: TreeNode[]; depth: number; forceOpen?: boolean; selectedFile: string; onSelect: (path: string) => void }) {
|
||||
return (
|
||||
<>
|
||||
{nodes.map((node) =>
|
||||
node.isFile ? (
|
||||
<FileNode key={node.path} node={node} depth={depth} selected={selectedFile === node.path} onSelect={onSelect} />
|
||||
) : (
|
||||
<FolderNode key={node.path} node={node} depth={depth} forceOpen={forceOpen} selectedFile={selectedFile} onSelect={onSelect} />
|
||||
)
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function FolderNode({ node, depth, forceOpen, selectedFile, onSelect }: { node: TreeNode; depth: number; forceOpen?: boolean; selectedFile: string; onSelect: (path: string) => void }) {
|
||||
const [open, setOpen] = useState(depth < 1);
|
||||
const isOpen = forceOpen || open;
|
||||
const fc = useMemo(() => countFiles(node.children), [node.children]);
|
||||
return (
|
||||
<div>
|
||||
<button onClick={() => setOpen(!open)}
|
||||
className="w-full flex items-center gap-1 py-[3px] pr-2 hover:bg-white/[0.04] transition-colors group/f"
|
||||
style={{ paddingLeft: `${depth * 10 + 6}px` }}>
|
||||
<svg className={`w-2.5 h-2.5 text-[var(--color-text-tertiary)] shrink-0 transition-transform duration-100 ${isOpen ? 'rotate-90' : ''}`}
|
||||
fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
|
||||
</svg>
|
||||
<svg className="w-3.5 h-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke={isOpen ? '#facc15' : 'var(--color-text-tertiary)'} strokeWidth={1.5}>
|
||||
{isOpen
|
||||
? <path strokeLinecap="round" strokeLinejoin="round" d="M3.75 9.776c.112-.017.227-.026.344-.026h15.812c.117 0 .232.009.344.026m-16.5 0a2.25 2.25 0 00-1.883 2.542l.857 6a2.25 2.25 0 002.227 1.932H19.05a2.25 2.25 0 002.227-1.932l.857-6a2.25 2.25 0 00-1.883-2.542m-16.5 0V6A2.25 2.25 0 016 3.75h3.879a1.5 1.5 0 011.06.44l2.122 2.12a1.5 1.5 0 001.06.44H18A2.25 2.25 0 0120.25 9v.776" />
|
||||
: <path strokeLinecap="round" strokeLinejoin="round" d="M2.25 12.75V12A2.25 2.25 0 014.5 9.75h15A2.25 2.25 0 0121.75 12v.75m-8.69-6.44l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-5.379a1.5 1.5 0 01-1.06-.44z" />}
|
||||
</svg>
|
||||
<span className="text-[11px] text-[var(--color-text-secondary)] truncate">{node.name}</span>
|
||||
<span className="text-[8px] text-[var(--color-text-tertiary)] ml-auto opacity-0 group-hover/f:opacity-100">{fc}</span>
|
||||
</button>
|
||||
{isOpen && <TreeNodes nodes={node.children} depth={depth + 1} forceOpen={forceOpen} selectedFile={selectedFile} onSelect={onSelect} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FileNode({ node, depth, selected, onSelect }: { node: TreeNode; depth: number; selected: boolean; onSelect: (path: string) => void }) {
|
||||
const color = EXT_COLORS[node.ext ?? ''] ?? 'var(--color-text-tertiary)';
|
||||
return (
|
||||
<button onClick={() => onSelect(node.path)}
|
||||
className={`w-full flex items-center gap-1.5 py-[3px] pr-2 transition-colors ${selected ? 'bg-accent-400/10 text-accent-300' : 'hover:bg-white/[0.04] text-[var(--color-text-primary)]'}`}
|
||||
style={{ paddingLeft: `${depth * 10 + 16}px` }} title={node.path}>
|
||||
<svg className="w-3.5 h-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke={selected ? 'currentColor' : color} strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
|
||||
</svg>
|
||||
<span className="text-[11px] truncate font-mono">{node.name}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function HighlightText({ text, query, className }: { text: string; query: string; className?: string }) {
|
||||
if (!query.trim()) return <span className={className}>{text}</span>;
|
||||
const parts: { text: string; match: boolean }[] = [];
|
||||
const lower = text.toLowerCase();
|
||||
const q = query.toLowerCase();
|
||||
let lastIdx = 0, pos = 0;
|
||||
while ((pos = lower.indexOf(q, lastIdx)) !== -1) {
|
||||
if (pos > lastIdx) parts.push({ text: text.slice(lastIdx, pos), match: false });
|
||||
parts.push({ text: text.slice(pos, pos + q.length), match: true });
|
||||
lastIdx = pos + q.length;
|
||||
}
|
||||
if (lastIdx < text.length) parts.push({ text: text.slice(lastIdx), match: false });
|
||||
return (
|
||||
<span className={className}>
|
||||
{parts.map((p, i) => p.match ? <mark key={i} className="bg-accent-400/25 text-accent-300 rounded-sm px-px">{p.text}</mark> : <span key={i}>{p.text}</span>)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,492 @@
|
||||
import { useState, useEffect, useRef, useMemo, useCallback } from 'react';
|
||||
import { marked } from 'marked';
|
||||
|
||||
// Lightweight token-count approximation: ~4 characters per token for English prose.
|
||||
// Avoids shipping the heavy gpt-tokenizer BPE table to the browser.
|
||||
function estimateTokens(text: string): number {
|
||||
return Math.ceil(text.length / 4);
|
||||
}
|
||||
|
||||
interface Slide {
|
||||
type: 'title' | 'content';
|
||||
title: string;
|
||||
body: string;
|
||||
html: string;
|
||||
metadata?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface SkillSlideViewProps {
|
||||
content: string;
|
||||
skillName: string;
|
||||
}
|
||||
|
||||
// ─── YAML frontmatter parsing ─────────────────────────────────────
|
||||
function parseFrontmatter(content: string): { meta: Record<string, string>; body: string } {
|
||||
const match = content.match(/^---\s*\n([\s\S]*?)\n---\s*\n?([\s\S]*)$/);
|
||||
if (!match) return { meta: {}, body: content };
|
||||
|
||||
const meta: Record<string, string> = {};
|
||||
let currentKey = '';
|
||||
let currentValue = '';
|
||||
|
||||
for (const line of match[1].split('\n')) {
|
||||
const kv = line.match(/^(\S[\w-]*)\s*:\s*(.*)/);
|
||||
if (kv) {
|
||||
if (currentKey) meta[currentKey] = currentValue.trim();
|
||||
currentKey = kv[1];
|
||||
currentValue = kv[2].replace(/^["']|["']$/g, '');
|
||||
} else if (currentKey && (line.startsWith(' ') || line.startsWith('\t'))) {
|
||||
currentValue += ' ' + line.trim();
|
||||
}
|
||||
}
|
||||
if (currentKey) meta[currentKey] = currentValue.trim();
|
||||
|
||||
return { meta, body: match[2] || '' };
|
||||
}
|
||||
|
||||
// ─── Markdown rendering ───────────────────────────────────────────
|
||||
function renderSlideMarkdown(md: string): string {
|
||||
marked.setOptions({ gfm: true, breaks: false });
|
||||
const renderer = new marked.Renderer();
|
||||
renderer.heading = ({ text, depth }: { text: string; depth: number }) => {
|
||||
const tag = `h${Math.min(depth + 1, 6)}`;
|
||||
return `<${tag} class="slide-heading">${text}</${tag}>`;
|
||||
};
|
||||
renderer.code = ({ text, lang }: { text: string; lang?: string }) => {
|
||||
return `<div class="slide-code-block"><div class="slide-code-lang">${lang || ''}</div><pre><code class="language-${lang || ''}">${text.replace(/</g, '<').replace(/>/g, '>')}</code></pre></div>`;
|
||||
};
|
||||
return marked.parse(md, { renderer }) as string;
|
||||
}
|
||||
|
||||
// ─── Slide building ───────────────────────────────────────────────
|
||||
function buildSlides(content: string, skillName: string): Slide[] {
|
||||
const { meta, body } = parseFrontmatter(content);
|
||||
const slides: Slide[] = [];
|
||||
|
||||
// Title slide
|
||||
const titleName = meta.name || skillName.replace(/[-_]/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
// Get the first H1 for subtitle if available
|
||||
const h1Match = body.match(/^#\s+(.+)$/m);
|
||||
const subtitle = h1Match ? h1Match[1] : '';
|
||||
|
||||
// Build description - truncate if too long
|
||||
let desc = meta.description || '';
|
||||
if (desc.length > 300) {
|
||||
desc = desc.slice(0, 300).replace(/\s+\S*$/, '') + '...';
|
||||
}
|
||||
// Remove <example> blocks from description for cleaner display
|
||||
desc = desc.replace(/<example>[\s\S]*?<\/example>/g, '').trim();
|
||||
|
||||
const metaFields: Record<string, string> = {};
|
||||
if (meta.version) metaFields['Version'] = meta.version;
|
||||
if (meta.tools) metaFields['Tools'] = meta.tools;
|
||||
if (meta['allowed-tools']) metaFields['Tools'] = meta['allowed-tools'];
|
||||
if (meta.model) metaFields['Model'] = meta.model;
|
||||
if (meta.color) metaFields['Color'] = meta.color;
|
||||
if (meta.author) metaFields['Author'] = meta.author;
|
||||
if (meta.license) metaFields['License'] = meta.license;
|
||||
|
||||
slides.push({
|
||||
type: 'title',
|
||||
title: titleName,
|
||||
body: subtitle ? `${subtitle}\n\n${desc}` : desc,
|
||||
html: '',
|
||||
metadata: metaFields,
|
||||
});
|
||||
|
||||
// Split body by H2 sections
|
||||
const bodyWithoutH1 = body.replace(/^#\s+.+$/m, '').trim();
|
||||
const sections = bodyWithoutH1.split(/(?=^##\s)/m).filter((s) => s.trim());
|
||||
|
||||
for (const section of sections) {
|
||||
const headingMatch = section.match(/^##\s+(.+)$/m);
|
||||
if (!headingMatch) {
|
||||
// Content before first H2 — add as intro slide if substantial
|
||||
if (section.trim().length > 50) {
|
||||
slides.push({
|
||||
type: 'content',
|
||||
title: 'Introduction',
|
||||
body: section.trim(),
|
||||
html: renderSlideMarkdown(section.trim()),
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const title = headingMatch[1].replace(/[*_`\[\]]/g, '').trim();
|
||||
const sectionBody = section.replace(/^##\s+.+$/m, '').trim();
|
||||
|
||||
slides.push({
|
||||
type: 'content',
|
||||
title,
|
||||
body: sectionBody,
|
||||
html: renderSlideMarkdown(sectionBody),
|
||||
});
|
||||
}
|
||||
|
||||
// If no H2 sections found, create a single content slide with full body
|
||||
if (slides.length === 1 && bodyWithoutH1.trim()) {
|
||||
slides.push({
|
||||
type: 'content',
|
||||
title: 'Content',
|
||||
body: bodyWithoutH1.trim(),
|
||||
html: renderSlideMarkdown(bodyWithoutH1.trim()),
|
||||
});
|
||||
}
|
||||
|
||||
return slides;
|
||||
}
|
||||
|
||||
// ─── Main Component ───────────────────────────────────────────────
|
||||
const SLIDE_HEIGHT = '32rem';
|
||||
|
||||
export default function SkillSlideView({ content, skillName }: SkillSlideViewProps) {
|
||||
const [currentSlide, setCurrentSlide] = useState(0);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const slides = useMemo(() => buildSlides(content, skillName), [content, skillName]);
|
||||
const totalSlides = slides.length;
|
||||
|
||||
// Token counting (approximation: ~4 chars per token)
|
||||
const totalTokens = useMemo(() => estimateTokens(content), [content]);
|
||||
const slideTokens = useMemo(() => slides.map((s) => estimateTokens(s.body)), [slides]);
|
||||
|
||||
const goTo = useCallback((idx: number) => {
|
||||
setCurrentSlide(Math.max(0, Math.min(idx, totalSlides - 1)));
|
||||
}, [totalSlides]);
|
||||
|
||||
const goNext = useCallback(() => goTo(currentSlide + 1), [currentSlide, goTo]);
|
||||
const goPrev = useCallback(() => goTo(currentSlide - 1), [currentSlide, goTo]);
|
||||
|
||||
// Keyboard navigation
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { e.preventDefault(); goNext(); }
|
||||
else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') { e.preventDefault(); goPrev(); }
|
||||
else if (e.key === 'Home') { e.preventDefault(); goTo(0); }
|
||||
else if (e.key === 'End') { e.preventDefault(); goTo(totalSlides - 1); }
|
||||
else if (e.key === 'Escape' && isFullscreen) { toggleFullscreen(); }
|
||||
};
|
||||
document.addEventListener('keydown', handler);
|
||||
return () => document.removeEventListener('keydown', handler);
|
||||
}, [goNext, goPrev, goTo, totalSlides, isFullscreen]);
|
||||
|
||||
// Fullscreen API
|
||||
const toggleFullscreen = useCallback(() => {
|
||||
if (!containerRef.current) return;
|
||||
if (!document.fullscreenElement) {
|
||||
containerRef.current.requestFullscreen().then(() => setIsFullscreen(true)).catch(() => {});
|
||||
} else {
|
||||
document.exitFullscreen().then(() => setIsFullscreen(false)).catch(() => {});
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => setIsFullscreen(!!document.fullscreenElement);
|
||||
document.addEventListener('fullscreenchange', handler);
|
||||
return () => document.removeEventListener('fullscreenchange', handler);
|
||||
}, []);
|
||||
|
||||
const slide = slides[currentSlide];
|
||||
if (!slide) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`flex flex-col ${isFullscreen ? 'bg-[#0d0d0f] h-screen' : ''}`}
|
||||
>
|
||||
{/* Slide area */}
|
||||
<div className={`relative bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded-xl overflow-hidden flex flex-col ${isFullscreen ? 'flex-1 rounded-none border-0' : ''}`}>
|
||||
{/* Slide content */}
|
||||
<div
|
||||
className={`${isFullscreen ? 'flex-1 px-16 py-12 overflow-y-auto' : 'px-8 py-8'}`}
|
||||
style={!isFullscreen && !expanded ? { height: SLIDE_HEIGHT, overflow: 'hidden' } : undefined}
|
||||
>
|
||||
{slide.type === 'title' ? (
|
||||
<TitleSlide slide={slide} isFullscreen={isFullscreen} totalTokens={totalTokens} />
|
||||
) : (
|
||||
<ContentSlide slide={slide} isFullscreen={isFullscreen} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Show more / Collapse */}
|
||||
{!isFullscreen && (
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="flex items-center justify-center gap-1.5 w-full py-2 text-xs font-medium text-[var(--color-accent-400)] hover:text-[var(--color-text-primary)] border-t border-[var(--color-border)] hover:bg-[var(--color-surface-3)] transition-colors"
|
||||
>
|
||||
{expanded ? (
|
||||
<>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 15.75l7.5-7.5 7.5 7.5" />
|
||||
</svg>
|
||||
Collapse
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
||||
</svg>
|
||||
Show full document
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Navigation bar */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t border-[var(--color-border)] bg-[var(--color-surface-1)]/50 backdrop-blur-sm">
|
||||
{/* Left: prev button + token info */}
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={goPrev}
|
||||
disabled={currentSlide === 0}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors disabled:opacity-30 disabled:cursor-not-allowed text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-white/[0.06]"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Prev
|
||||
</button>
|
||||
<div className="hidden sm:flex items-center gap-1.5 font-mono">
|
||||
<span title="Tokens in this slide" className="flex items-center gap-1 px-2 py-0.5 rounded-md bg-[var(--color-accent-400)]/10 text-[var(--color-accent-400)] text-[10px] font-bold">
|
||||
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M17.25 6.75L22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3l-4.5 16.5" />
|
||||
</svg>
|
||||
{slideTokens[currentSlide]?.toLocaleString()}
|
||||
</span>
|
||||
<span className="text-[var(--color-text-tertiary)] text-[9px]">/</span>
|
||||
<span title="Total tokens" className="text-[10px] text-[var(--color-text-tertiary)] font-medium">{totalTokens.toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Center: slide dots + counter */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-1">
|
||||
{slides.map((_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => goTo(i)}
|
||||
className={`rounded-full transition-all duration-200 ${
|
||||
i === currentSlide
|
||||
? 'w-6 h-1.5 bg-[var(--color-accent-400)]'
|
||||
: 'w-1.5 h-1.5 bg-[var(--color-text-tertiary)]/40 hover:bg-[var(--color-text-tertiary)]'
|
||||
}`}
|
||||
title={`Slide ${i + 1}: ${slides[i].title}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-[11px] text-[var(--color-text-tertiary)] font-mono tabular-nums">
|
||||
{currentSlide + 1} / {totalSlides}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Right: next + fullscreen */}
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={goNext}
|
||||
disabled={currentSlide === totalSlides - 1}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors disabled:opacity-30 disabled:cursor-not-allowed text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-white/[0.06]"
|
||||
>
|
||||
Next
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={toggleFullscreen}
|
||||
className="p-1.5 rounded-lg text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)] hover:bg-white/[0.06] transition-colors"
|
||||
title={isFullscreen ? 'Exit fullscreen (Esc)' : 'Fullscreen'}
|
||||
>
|
||||
{isFullscreen ? (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 9V4.5M9 9H4.5M9 9L3.75 3.75M9 15v4.5M9 15H4.5M9 15l-5.25 5.25M15 9h4.5M15 9V4.5M15 9l5.25-5.25M15 15h4.5M15 15v4.5m0-4.5l5.25 5.25" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 3.75v4.5m0-4.5h4.5m-4.5 0L9 9M3.75 20.25v-4.5m0 4.5h4.5m-4.5 0L9 15M20.25 3.75h-4.5m4.5 0v4.5m0-4.5L15 9m5.25 11.25h-4.5m4.5 0v-4.5m0 4.5L15 15" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Keyboard hints */}
|
||||
<div className="flex justify-center gap-3 mt-3 text-[10px] text-[var(--color-text-tertiary)]">
|
||||
<span className="flex items-center gap-1">
|
||||
<kbd className="px-1 py-0.5 rounded bg-[var(--color-surface-3)] font-mono text-[9px]">←</kbd>
|
||||
<kbd className="px-1 py-0.5 rounded bg-[var(--color-surface-3)] font-mono text-[9px]">→</kbd>
|
||||
Navigate
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<kbd className="px-1 py-0.5 rounded bg-[var(--color-surface-3)] font-mono text-[9px]">Home</kbd>
|
||||
<kbd className="px-1 py-0.5 rounded bg-[var(--color-surface-3)] font-mono text-[9px]">End</kbd>
|
||||
First / Last
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Title Slide ──────────────────────────────────────────────────
|
||||
function TitleSlide({ slide, isFullscreen, totalTokens }: { slide: Slide; isFullscreen: boolean; totalTokens: number }) {
|
||||
const metaEntries = Object.entries(slide.metadata || {});
|
||||
const allBadges = [...metaEntries, ['Tokens', totalTokens.toLocaleString()]];
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col items-center justify-center text-center h-full min-h-[20rem] ${isFullscreen ? 'min-h-[60vh]' : ''}`}>
|
||||
{/* Icon */}
|
||||
<div className="w-16 h-16 rounded-2xl flex items-center justify-center mb-6" style={{ backgroundColor: 'rgba(213, 116, 85, 0.15)' }}>
|
||||
<svg className="w-8 h-8" style={{ color: 'var(--color-accent-400)' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.455 2.456L21.75 6l-1.036.259a3.375 3.375 0 00-2.455 2.456z" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h1 className={`font-bold text-[var(--color-text-primary)] mb-3 leading-tight ${isFullscreen ? 'text-4xl' : 'text-2xl'}`}>
|
||||
{slide.title}
|
||||
</h1>
|
||||
|
||||
{/* Description */}
|
||||
{slide.body && (
|
||||
<p className={`text-[var(--color-text-secondary)] leading-relaxed max-w-2xl ${isFullscreen ? 'text-lg' : 'text-sm'}`}>
|
||||
{slide.body}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Metadata badges */}
|
||||
{allBadges.length > 0 && (
|
||||
<div className="flex flex-wrap justify-center gap-2.5 mt-8">
|
||||
{allBadges.map(([key, value]) => {
|
||||
const isTokens = key === 'Tokens';
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className={`flex flex-col items-center px-4 py-2.5 rounded-xl border text-center min-w-[5rem] ${
|
||||
isTokens
|
||||
? 'bg-[var(--color-accent-400)]/10 border-[var(--color-accent-400)]/25'
|
||||
: 'bg-[var(--color-surface-3)]/60 border-[var(--color-border)]'
|
||||
}`}
|
||||
>
|
||||
<span className={`text-[10px] uppercase tracking-widest font-semibold mb-0.5 ${
|
||||
isTokens ? 'text-[var(--color-accent-400)]' : 'text-[var(--color-text-tertiary)]'
|
||||
}`}>{key}</span>
|
||||
<span className={`text-sm font-bold font-mono ${
|
||||
isTokens ? 'text-[var(--color-accent-400)]' : 'text-[var(--color-text-primary)]'
|
||||
}`}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Content Slide ────────────────────────────────────────────────
|
||||
function ContentSlide({ slide, isFullscreen }: { slide: Slide; isFullscreen: boolean }) {
|
||||
return (
|
||||
<div className={`h-full ${isFullscreen ? 'max-w-4xl mx-auto' : ''}`}>
|
||||
{/* Slide title */}
|
||||
<h2 className={`font-bold text-[var(--color-text-primary)] mb-6 pb-3 border-b border-[var(--color-border)] ${isFullscreen ? 'text-3xl' : 'text-xl'}`}>
|
||||
{slide.title}
|
||||
</h2>
|
||||
|
||||
{/* Rendered markdown content */}
|
||||
<div
|
||||
className={`slide-md-content ${isFullscreen ? 'text-base' : 'text-sm'}`}
|
||||
dangerouslySetInnerHTML={{ __html: slide.html }}
|
||||
/>
|
||||
|
||||
{/* Inline styles for slide markdown content */}
|
||||
<style>{`
|
||||
.slide-md-content {
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.7;
|
||||
}
|
||||
.slide-md-content .slide-heading {
|
||||
color: var(--color-text-primary);
|
||||
font-weight: 600;
|
||||
margin-top: 1.25rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.slide-md-content h3.slide-heading { font-size: 1.1em; }
|
||||
.slide-md-content h4.slide-heading { font-size: 1em; }
|
||||
.slide-md-content p { margin-bottom: 0.75rem; }
|
||||
.slide-md-content ul, .slide-md-content ol {
|
||||
margin-bottom: 0.75rem;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
.slide-md-content li { margin-bottom: 0.25rem; }
|
||||
.slide-md-content ul li { list-style: disc; }
|
||||
.slide-md-content ol li { list-style: decimal; }
|
||||
.slide-md-content strong { color: var(--color-text-primary); font-weight: 600; }
|
||||
.slide-md-content code {
|
||||
background: var(--color-surface-3);
|
||||
padding: 0.15em 0.4em;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9em;
|
||||
font-family: ui-monospace, monospace;
|
||||
color: var(--color-accent-400, #f97316);
|
||||
}
|
||||
.slide-md-content .slide-code-block {
|
||||
background: var(--color-surface-1, #0d0d0f);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
margin: 0.75rem 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.slide-md-content .slide-code-lang {
|
||||
padding: 0.35rem 0.75rem;
|
||||
font-size: 0.7em;
|
||||
color: var(--color-text-tertiary);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
font-family: ui-monospace, monospace;
|
||||
}
|
||||
.slide-md-content .slide-code-lang:empty { display: none; }
|
||||
.slide-md-content pre {
|
||||
padding: 0.75rem;
|
||||
overflow-x: auto;
|
||||
font-size: 0.85em;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.slide-md-content pre code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.slide-md-content a {
|
||||
color: var(--color-accent-400, #f97316);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
.slide-md-content blockquote {
|
||||
border-left: 3px solid var(--color-border);
|
||||
padding-left: 1rem;
|
||||
margin: 0.75rem 0;
|
||||
color: var(--color-text-tertiary);
|
||||
font-style: italic;
|
||||
}
|
||||
.slide-md-content table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 0.75rem 0;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.slide-md-content th, .slide-md-content td {
|
||||
border: 1px solid var(--color-border);
|
||||
padding: 0.4rem 0.75rem;
|
||||
text-align: left;
|
||||
}
|
||||
.slide-md-content th {
|
||||
background: var(--color-surface-3);
|
||||
color: var(--color-text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { toggleTheme, getTheme } from '../lib/theme';
|
||||
|
||||
export default function ThemeToggle() {
|
||||
const [theme, setTheme] = useState<'light' | 'dark'>('dark');
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
setTheme(getTheme());
|
||||
}, []);
|
||||
|
||||
const handleToggle = () => {
|
||||
const newTheme = toggleTheme();
|
||||
setTheme(newTheme);
|
||||
};
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleToggle}
|
||||
className="relative group p-2 rounded-lg transition-all duration-200 hover:bg-[var(--color-surface-2)] active:scale-95 flex items-center justify-center"
|
||||
aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
|
||||
title={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
|
||||
>
|
||||
<div className="relative w-5 h-5">
|
||||
{/* Sun icon for light mode */}
|
||||
<svg
|
||||
className={`absolute inset-0 w-5 h-5 transition-all duration-300 ease-out ${
|
||||
theme === 'light'
|
||||
? 'opacity-100 rotate-0 scale-100'
|
||||
: 'opacity-0 rotate-90 scale-75'
|
||||
}`}
|
||||
style={{ color: 'var(--color-text-primary)' }}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path d="M12 2v2m0 16v2M4.93 4.93l1.41 1.41m11.32 11.32l1.41 1.41M2 12h2m16 0h2M4.93 19.07l1.41-1.41m11.32-11.32l1.41-1.41" />
|
||||
</svg>
|
||||
|
||||
{/* Moon icon for dark mode */}
|
||||
<svg
|
||||
className={`absolute inset-0 w-5 h-5 transition-all duration-300 ease-out ${
|
||||
theme === 'dark'
|
||||
? 'opacity-100 rotate-0 scale-100'
|
||||
: 'opacity-0 -rotate-90 scale-75'
|
||||
}`}
|
||||
style={{ color: 'var(--color-text-primary)' }}
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M21.64 13a1 1 0 00-1.05-.14 8.05 8.05 0 01-3.37.73 8.15 8.15 0 01-8.14-8.1 8.59 8.59 0 01.25-2A1 1 0 008 2.36a10.14 10.14 0 1014 11.69 1 1 0 00-.36-1.05z" />
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
---
|
||||
import ThemeToggle from './ThemeToggle.tsx';
|
||||
---
|
||||
|
||||
<header class="sticky top-0 z-20 bg-[var(--color-surface-0)] border-b border-[var(--color-border)] transition-all duration-200">
|
||||
<div class="flex items-center gap-3 justify-between px-6 h-14">
|
||||
<!-- Mobile menu button -->
|
||||
<button
|
||||
class="md:hidden shrink-0 p-2 rounded border border-[var(--color-border)] hover:bg-[var(--color-surface-2)] text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)] transition-all duration-150 flex items-center justify-center"
|
||||
onclick="toggleSidebar()"
|
||||
aria-label="Toggle menu"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Search trigger — terminal style -->
|
||||
<button
|
||||
id="searchTrigger"
|
||||
class="flex items-center gap-2 px-4 py-2 border border-[var(--color-border)] rounded text-[13px] text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:border-[var(--color-accent)] hover:bg-[var(--color-surface-1)] transition-all duration-150 flex-1 min-w-0 max-w-md group font-ui-mono"
|
||||
>
|
||||
<span class="terminal-prompt text-[13px]">></span>
|
||||
<span class="text-[var(--color-text-tertiary)] group-hover:text-[var(--color-text-secondary)]">Search components...</span>
|
||||
<kbd class="hidden sm:inline-flex ml-auto items-center gap-1 px-2 py-0.5 text-[10px] font-mono font-bold border border-[var(--color-border)] rounded bg-[var(--color-surface-2)] text-[var(--color-text-tertiary)] group-hover:border-[var(--color-accent)] transition-all">
|
||||
<span class="text-[11px]">⌘</span>K
|
||||
</kbd>
|
||||
</button>
|
||||
|
||||
<!-- Right actions -->
|
||||
<div class="flex items-center gap-2 ml-4">
|
||||
<a
|
||||
href="https://github.com/davila7/claude-code-templates"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex items-center gap-2 px-3 py-2 rounded border border-[var(--color-border)] text-[13px] font-ui-mono font-semibold text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:border-[var(--color-accent)] hover:bg-[var(--color-surface-1)] transition-all duration-150 group"
|
||||
aria-label="View on GitHub"
|
||||
>
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" />
|
||||
</svg>
|
||||
<span class="hidden sm:inline">GitHub</span>
|
||||
</a>
|
||||
<ThemeToggle client:load />
|
||||
<div id="cart-button-mount"></div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
@@ -0,0 +1,446 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { TYPE_CONFIG } from '../lib/icons';
|
||||
import TypeIcon from './TypeIcon';
|
||||
import CountryFlag from './CountryFlag';
|
||||
|
||||
interface TrendingItem {
|
||||
id: string;
|
||||
name: string;
|
||||
category: string;
|
||||
downloadsToday: number;
|
||||
downloadsWeek: number;
|
||||
downloadsMonth: number;
|
||||
downloadsTotal: number;
|
||||
}
|
||||
|
||||
interface GlobalStats {
|
||||
totalComponents: number;
|
||||
totalDownloads: number;
|
||||
monthlyDownloads: number;
|
||||
weeklyDownloads: number;
|
||||
todayDownloads: number;
|
||||
totalCountries: number;
|
||||
}
|
||||
|
||||
interface TopCountry {
|
||||
code: string;
|
||||
name: string;
|
||||
flag: string;
|
||||
downloads: number;
|
||||
percentage: number;
|
||||
}
|
||||
|
||||
interface TrendingData {
|
||||
lastUpdated: string;
|
||||
globalStats: GlobalStats;
|
||||
topCountries: TopCountry[];
|
||||
trending: Record<string, TrendingItem[]>;
|
||||
}
|
||||
|
||||
function formatName(name: string): string {
|
||||
return name.replace(/[-_]/g, ' ').split(' ').map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
|
||||
}
|
||||
|
||||
function formatNumber(n: number): string {
|
||||
if (n >= 1000000) return (n / 1000000).toFixed(1).replace(/\.0$/, '') + 'M';
|
||||
if (n >= 1000) return (n / 1000).toFixed(1).replace(/\.0$/, '') + 'k';
|
||||
return String(n);
|
||||
}
|
||||
|
||||
const TRENDING_TYPES = ['all', 'skills', 'agents', 'commands', 'settings', 'hooks', 'mcps'] as const;
|
||||
|
||||
export default function TrendingView() {
|
||||
const [data, setData] = useState<TrendingData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activeType, setActiveType] = useState<string>('all');
|
||||
const [period, setPeriod] = useState<'downloadsWeek' | 'downloadsMonth' | 'downloadsTotal'>('downloadsWeek');
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/trending-data.json')
|
||||
.then((r) => r.json())
|
||||
.then((d) => { setData(d); setLoading(false); })
|
||||
.catch(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const items = useMemo(() => {
|
||||
if (!data) return [];
|
||||
const list = data.trending[activeType] ?? [];
|
||||
return [...list].sort((a, b) => (b[period] ?? 0) - (a[period] ?? 0));
|
||||
}, [data, activeType, period]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="px-6 py-20 flex flex-col items-center gap-3">
|
||||
<div className="w-5 h-5 border-2 border-[var(--color-text-tertiary)] border-t-transparent rounded-full animate-spin" />
|
||||
<span className="text-[13px] text-[var(--color-text-tertiary)]">Loading trending data...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<div className="px-6 py-20 text-center">
|
||||
<p className="text-[13px] text-red-400">Failed to load trending data</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const stats = data.globalStats;
|
||||
|
||||
return (
|
||||
<div className="pb-8">
|
||||
{/* Hero Section with Gradient */}
|
||||
<div className="relative px-6 pt-6 pb-8 overflow-hidden">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-[var(--color-accent)]/5 via-transparent to-transparent pointer-events-none" />
|
||||
<div className="relative">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--color-accent)] to-[var(--color-accent)]/70 flex items-center justify-center shadow-lg shadow-[var(--color-accent)]/20">
|
||||
<svg className="w-5 h-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-[20px] font-semibold text-[var(--color-text-primary)]">Trending Components</h1>
|
||||
<p className="text-[12px] text-[var(--color-text-tertiary)] mt-0.5">Most popular downloads this week</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats cards with enhanced design */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3">
|
||||
{[
|
||||
{
|
||||
label: 'Total Downloads',
|
||||
value: formatNumber(stats.totalDownloads),
|
||||
icon: (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M9 19l3 3m0 0l3-3m-3 3V10" />
|
||||
</svg>
|
||||
),
|
||||
colorClass: 'stat-blue'
|
||||
},
|
||||
{
|
||||
label: 'This Month',
|
||||
value: formatNumber(stats.monthlyDownloads),
|
||||
icon: (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
),
|
||||
colorClass: 'stat-purple'
|
||||
},
|
||||
{
|
||||
label: 'This Week',
|
||||
value: formatNumber(stats.weeklyDownloads),
|
||||
icon: (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
|
||||
</svg>
|
||||
),
|
||||
colorClass: 'stat-emerald'
|
||||
},
|
||||
{
|
||||
label: 'Today',
|
||||
value: formatNumber(stats.todayDownloads),
|
||||
icon: (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
),
|
||||
colorClass: 'stat-orange'
|
||||
},
|
||||
{
|
||||
label: 'Components',
|
||||
value: formatNumber(stats.totalComponents),
|
||||
icon: (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
),
|
||||
colorClass: 'stat-pink'
|
||||
},
|
||||
{
|
||||
label: 'Countries',
|
||||
value: String(stats.totalCountries),
|
||||
icon: (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
),
|
||||
colorClass: 'stat-cyan'
|
||||
},
|
||||
].map((s) => (
|
||||
<div
|
||||
key={s.label}
|
||||
className="group relative bg-[var(--color-surface-1)] border border-[var(--color-border)] rounded-xl px-4 py-3.5 hover:border-[var(--color-accent)]/30 transition-all duration-200 hover:shadow-lg hover:shadow-[var(--color-accent)]/5 hover:-translate-y-0.5"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className={`stat-icon ${s.colorClass}`}>
|
||||
{s.icon}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-[22px] font-bold text-[var(--color-text-primary)] tabular-nums leading-none mb-1.5">{s.value}</div>
|
||||
<div className="text-[11px] text-[var(--color-text-tertiary)] font-medium">{s.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Top countries with icons */}
|
||||
<div className="px-6 pb-6">
|
||||
<div className="bg-[var(--color-surface-1)] border border-[var(--color-border)] rounded-xl p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<svg className="w-4 h-4 text-[var(--color-accent)]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span className="text-[12px] text-[var(--color-text-secondary)] font-semibold uppercase tracking-wider">Top Countries</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 overflow-x-auto pb-1">
|
||||
{data.topCountries.map((c, idx) => (
|
||||
<div
|
||||
key={c.code}
|
||||
className="flex items-center gap-2.5 shrink-0 bg-[var(--color-surface-2)] rounded-lg px-3 py-2.5 border border-[var(--color-border)] hover:border-[var(--color-accent)]/30 transition-all duration-200 hover:shadow-md"
|
||||
>
|
||||
<div className="flex items-center justify-center w-6 h-6 rounded-full bg-gradient-to-br from-[var(--color-accent)]/20 to-[var(--color-accent)]/10 text-[10px] font-bold text-[var(--color-accent)]">
|
||||
{idx + 1}
|
||||
</div>
|
||||
<div className="w-7 h-7 rounded-md overflow-hidden shadow-sm border border-[var(--color-border)]">
|
||||
<CountryFlag code={c.code} className="w-full h-full object-cover" />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[12px] text-[var(--color-text-primary)] font-medium">{c.name}</span>
|
||||
<span className="text-[10px] text-[var(--color-text-tertiary)] tabular-nums">{formatNumber(c.downloads)} downloads</span>
|
||||
</div>
|
||||
<span className="text-[11px] text-[var(--color-accent)] font-semibold tabular-nums ml-1">{c.percentage}%</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="border-t border-[var(--color-border)] mx-6" />
|
||||
|
||||
{/* Filter bar with enhanced design */}
|
||||
<div className="px-6 py-4">
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-3">
|
||||
{/* Type filter */}
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
{TRENDING_TYPES.map((type) => {
|
||||
const config = TYPE_CONFIG[type];
|
||||
return (
|
||||
<button
|
||||
key={type}
|
||||
onClick={() => setActiveType(type)}
|
||||
className={`group flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[12px] font-medium transition-all duration-200 ${
|
||||
activeType === type
|
||||
? 'bg-[var(--color-accent)] text-white shadow-md shadow-[var(--color-accent)]/20'
|
||||
: 'bg-[var(--color-surface-2)] text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-surface-3)] border border-[var(--color-border)]'
|
||||
}`}
|
||||
>
|
||||
{type !== 'all' && (
|
||||
<div className={`w-4 h-4 flex items-center justify-center ${activeType === type ? 'text-white' : ''}`}>
|
||||
<TypeIcon type={type} size={14} />
|
||||
</div>
|
||||
)}
|
||||
{type === 'all' ? 'All' : TYPE_CONFIG[type]?.label ?? type}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Period selector with icon */}
|
||||
<div className="flex items-center gap-2 ml-auto">
|
||||
<svg className="w-4 h-4 text-[var(--color-text-tertiary)]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" />
|
||||
</svg>
|
||||
<select
|
||||
value={period}
|
||||
onChange={(e) => setPeriod(e.target.value as any)}
|
||||
className="bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded-lg text-[12px] text-[var(--color-text-primary)] font-medium px-3 py-1.5 outline-none cursor-pointer hover:border-[var(--color-accent)]/30 transition-colors"
|
||||
>
|
||||
<option value="downloadsWeek">This Week</option>
|
||||
<option value="downloadsMonth">This Month</option>
|
||||
<option value="downloadsTotal">All Time</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results count with icon */}
|
||||
<div className="flex items-center gap-2 mt-3">
|
||||
<svg className="w-3.5 h-3.5 text-[var(--color-text-tertiary)]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
||||
</svg>
|
||||
<span className="text-[11px] text-[var(--color-text-tertiary)] font-medium">
|
||||
{items.length} trending component{items.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Trending list with enhanced design */}
|
||||
<div className="px-6 pb-8">
|
||||
<div className="bg-[var(--color-surface-1)] border border-[var(--color-border)] rounded-xl overflow-hidden shadow-sm">
|
||||
{/* Table header */}
|
||||
<div className="hidden md:grid grid-cols-[50px_1fr_110px_110px_110px_110px] gap-3 px-5 py-3 bg-[var(--color-surface-2)] border-b border-[var(--color-border)]">
|
||||
<div className="flex items-center gap-1.5 text-[11px] text-[var(--color-text-tertiary)] font-semibold uppercase tracking-wider">
|
||||
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 20l4-16m2 16l4-16M6 9h14M4 15h14" />
|
||||
</svg>
|
||||
Rank
|
||||
</div>
|
||||
<span className="text-[11px] text-[var(--color-text-tertiary)] font-semibold uppercase tracking-wider">Component</span>
|
||||
<div className="flex items-center justify-end gap-1.5 text-[11px] text-[var(--color-text-tertiary)] font-semibold uppercase tracking-wider">
|
||||
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
Today
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-1.5 text-[11px] text-[var(--color-text-tertiary)] font-semibold uppercase tracking-wider">
|
||||
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
Week
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-1.5 text-[11px] text-[var(--color-text-tertiary)] font-semibold uppercase tracking-wider">
|
||||
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
Month
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-1.5 text-[11px] text-[var(--color-text-tertiary)] font-semibold uppercase tracking-wider">
|
||||
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
Total
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table rows */}
|
||||
<div className="divide-y divide-[var(--color-border)]">
|
||||
{items.map((item, idx) => {
|
||||
// Extract type from id (e.g. "command-generate-tests" -> "commands")
|
||||
const typeKey = item.id.split('-')[0];
|
||||
const typePlural = typeKey === 'mcp' ? 'mcps' : typeKey + 's';
|
||||
const config = TYPE_CONFIG[typePlural];
|
||||
|
||||
// Medal colors for top 3
|
||||
const getMedalClass = (rank: number) => {
|
||||
if (rank === 1) return 'medal-gold';
|
||||
if (rank === 2) return 'medal-silver';
|
||||
if (rank === 3) return 'medal-bronze';
|
||||
return 'medal-default';
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className="grid md:grid-cols-[50px_1fr_110px_110px_110px_110px] gap-3 px-5 py-3.5 hover:bg-[var(--color-surface-2)] transition-all duration-150 group"
|
||||
>
|
||||
{/* Rank with medal for top 3 */}
|
||||
<div className="flex items-center">
|
||||
<div className={`w-8 h-8 rounded-lg flex items-center justify-center text-[13px] font-bold ${getMedalClass(idx + 1)}`}>
|
||||
{idx + 1}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Component info */}
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div
|
||||
className="w-9 h-9 rounded-lg flex items-center justify-center shrink-0 shadow-sm group-hover:scale-110 transition-transform duration-200"
|
||||
style={{
|
||||
backgroundColor: config?.color ? `${config.color}15` : 'rgba(115, 115, 115, 0.15)',
|
||||
color: config?.color ?? 'var(--color-text-tertiary)'
|
||||
}}
|
||||
>
|
||||
<TypeIcon type={typePlural} size={16} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[13px] text-[var(--color-text-primary)] font-medium truncate block group-hover:text-[var(--color-accent)] transition-colors">
|
||||
{formatName(item.name)}
|
||||
</span>
|
||||
{item.downloadsToday > 0 && (
|
||||
<span className="hot-badge">
|
||||
<svg className="w-2.5 h-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 10l7-7m0 0l7 7m-7-7v18" />
|
||||
</svg>
|
||||
Hot
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<span className="text-[10px] text-[var(--color-text-tertiary)] px-1.5 py-0.5 bg-[var(--color-surface-2)] rounded">{item.category}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats - Desktop */}
|
||||
<div className="hidden md:flex items-center justify-end">
|
||||
<span className={`text-[13px] font-semibold tabular-nums ${item.downloadsToday > 0 ? 'stat-today-active' : 'text-[var(--color-text-tertiary)]'}`}>
|
||||
{item.downloadsToday > 0 ? `+${item.downloadsToday}` : '—'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="hidden md:flex items-center justify-end">
|
||||
<span className="text-[13px] font-medium tabular-nums text-[var(--color-text-secondary)]">
|
||||
{formatNumber(item.downloadsWeek)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="hidden md:flex items-center justify-end">
|
||||
<span className="text-[13px] font-medium tabular-nums text-[var(--color-text-secondary)]">
|
||||
{formatNumber(item.downloadsMonth)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="hidden md:flex items-center justify-end">
|
||||
<span className="text-[13px] font-bold tabular-nums text-[var(--color-text-primary)]">
|
||||
{formatNumber(item.downloadsTotal)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Stats - Mobile */}
|
||||
<div className="md:hidden col-span-full grid grid-cols-4 gap-2 mt-2 pt-2 border-t border-[var(--color-border)]">
|
||||
<div className="text-center">
|
||||
<div className="text-[10px] text-[var(--color-text-tertiary)] mb-0.5">Today</div>
|
||||
<div className={`text-[12px] font-semibold tabular-nums ${item.downloadsToday > 0 ? 'stat-today-active' : 'text-[var(--color-text-tertiary)]'}`}>
|
||||
{item.downloadsToday > 0 ? `+${item.downloadsToday}` : '—'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-[10px] text-[var(--color-text-tertiary)] mb-0.5">Week</div>
|
||||
<div className="text-[12px] font-medium tabular-nums text-[var(--color-text-secondary)]">
|
||||
{formatNumber(item.downloadsWeek)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-[10px] text-[var(--color-text-tertiary)] mb-0.5">Month</div>
|
||||
<div className="text-[12px] font-medium tabular-nums text-[var(--color-text-secondary)]">
|
||||
{formatNumber(item.downloadsMonth)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-[10px] text-[var(--color-text-tertiary)] mb-0.5">Total</div>
|
||||
<div className="text-[12px] font-bold tabular-nums text-[var(--color-text-primary)]">
|
||||
{formatNumber(item.downloadsTotal)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Last updated with icon */}
|
||||
<div className="px-6 pb-6">
|
||||
<div className="flex items-center justify-center gap-2 text-[11px] text-[var(--color-text-tertiary)]">
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>
|
||||
Last updated: {new Date(data.lastUpdated.replace(/\+00:00Z$/, 'Z')).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { ICONS } from '../lib/icons';
|
||||
|
||||
/**
|
||||
* Renders a type icon SVG inline.
|
||||
* Safe to use dangerouslySetInnerHTML here because ICONS are hardcoded
|
||||
* SVG strings defined in icons.ts — not user-supplied data.
|
||||
*/
|
||||
export default function TypeIcon({ type, size = 16, className = '' }: { type: string; size?: number; className?: string }) {
|
||||
const svg = ICONS[type];
|
||||
if (!svg) return <span className={className} />;
|
||||
|
||||
const sized = svg.replace(/width="16"/, `width="${size}"`).replace(/height="16"/, `height="${size}"`);
|
||||
|
||||
return <span className={className} dangerouslySetInnerHTML={{ __html: sized }} />;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
---
|
||||
import { TYPE_CONFIG } from '../lib/icons';
|
||||
|
||||
interface Props {
|
||||
activeType: string;
|
||||
counts?: Record<string, number>;
|
||||
}
|
||||
|
||||
const { activeType, counts = {} } = Astro.props;
|
||||
---
|
||||
|
||||
<nav class="flex items-center gap-1 px-6 py-2 border-b border-[var(--color-border)] overflow-x-auto">
|
||||
{Object.entries(TYPE_CONFIG).map(([type, config]) => {
|
||||
const isActive = type === activeType;
|
||||
const count = counts[type] ?? 0;
|
||||
return (
|
||||
<a
|
||||
href={`/${type}`}
|
||||
class:list={[
|
||||
'flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm whitespace-nowrap transition-colors',
|
||||
isActive
|
||||
? 'bg-primary-500/15 text-primary-400 font-medium'
|
||||
: 'text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-surface-3)]',
|
||||
]}
|
||||
data-astro-prefetch
|
||||
>
|
||||
<span>{config.icon}</span>
|
||||
<span>{config.label}</span>
|
||||
{count > 0 && (
|
||||
<span
|
||||
class:list={[
|
||||
'text-xs px-1.5 py-0.5 rounded-full',
|
||||
isActive
|
||||
? 'bg-primary-500/20 text-primary-400'
|
||||
: 'bg-[var(--color-surface-3)] text-[var(--color-text-tertiary)]',
|
||||
]}
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
@@ -0,0 +1,161 @@
|
||||
import type { ReviewCycle } from '../../lib/live-task/types';
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
active: '#22c55e',
|
||||
completed: '#6b7280',
|
||||
merged: '#3b82f6',
|
||||
failed: '#ef4444',
|
||||
};
|
||||
|
||||
const phaseLabels: Record<string, string> = {
|
||||
selection: 'Selecting',
|
||||
research: 'Researching',
|
||||
improve: 'Improving',
|
||||
pr_created: 'PR Created',
|
||||
reporting: 'Reporting',
|
||||
merging: 'Merging',
|
||||
};
|
||||
|
||||
function timeAgo(dateStr: string): string {
|
||||
const diff = Date.now() - new Date(dateStr).getTime();
|
||||
const mins = Math.floor(diff / 60000);
|
||||
if (mins < 1) return 'just now';
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
const hours = Math.floor(mins / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
return `${Math.floor(hours / 24)}d ago`;
|
||||
}
|
||||
|
||||
function duration(start: string, end: string | null): string {
|
||||
const endTime = end ? new Date(end).getTime() : Date.now();
|
||||
const diff = endTime - new Date(start).getTime();
|
||||
const mins = Math.floor(diff / 60000);
|
||||
const secs = Math.floor((diff % 60000) / 1000);
|
||||
if (mins === 0) return `${secs}s`;
|
||||
return `${mins}m ${secs}s`;
|
||||
}
|
||||
|
||||
function shortPath(path: string): string {
|
||||
const parts = path.split('/');
|
||||
return parts.slice(-2).join('/');
|
||||
}
|
||||
|
||||
interface Props {
|
||||
cycles: ReviewCycle[];
|
||||
selectedId: string | null;
|
||||
onSelect: (id: string) => void;
|
||||
}
|
||||
|
||||
export default function CycleTable({ cycles, selectedId, onSelect }: Props) {
|
||||
if (cycles.length === 0) {
|
||||
return (
|
||||
<div style={{
|
||||
padding: '32px 16px',
|
||||
textAlign: 'center',
|
||||
color: '#6b7280',
|
||||
fontSize: 14,
|
||||
fontFamily: 'monospace',
|
||||
}}>
|
||||
No review cycles yet. The daily scheduled task will create the first one.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{
|
||||
width: '100%',
|
||||
borderCollapse: 'collapse',
|
||||
fontSize: 13,
|
||||
fontFamily: "'Geist Mono', 'JetBrains Mono', monospace",
|
||||
}}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '1px solid #2a2a2a' }}>
|
||||
{['Status', 'Component', 'Type', 'Phase', 'PR', 'Started', 'Duration'].map(h => (
|
||||
<th key={h} style={{
|
||||
padding: '8px 12px',
|
||||
textAlign: 'left',
|
||||
color: '#6b7280',
|
||||
fontWeight: 500,
|
||||
fontSize: 11,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
}}>{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{cycles.map(cycle => {
|
||||
const isSelected = cycle.id === selectedId;
|
||||
const isActive = cycle.status === 'active';
|
||||
return (
|
||||
<tr
|
||||
key={cycle.id}
|
||||
onClick={() => onSelect(cycle.id)}
|
||||
style={{
|
||||
borderBottom: '1px solid #1a1a1a',
|
||||
cursor: 'pointer',
|
||||
background: isSelected ? '#1a1a2e' : 'transparent',
|
||||
borderLeft: isSelected ? '2px solid #3b82f6' : '2px solid transparent',
|
||||
transition: 'background 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => { if (!isSelected) e.currentTarget.style.background = '#111'; }}
|
||||
onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = 'transparent'; }}
|
||||
>
|
||||
<td style={{ padding: '10px 12px' }}>
|
||||
<span style={{
|
||||
display: 'inline-block',
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: '50%',
|
||||
background: statusColors[cycle.status] || '#6b7280',
|
||||
animation: isActive ? 'pulse-dot 2s infinite' : 'none',
|
||||
}} />
|
||||
<style>{`@keyframes pulse-dot { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }`}</style>
|
||||
</td>
|
||||
<td style={{ padding: '10px 12px', color: '#e5e7eb' }} title={cycle.component_path}>
|
||||
{shortPath(cycle.component_path)}
|
||||
</td>
|
||||
<td style={{ padding: '10px 12px', color: '#9ca3af' }}>
|
||||
{cycle.component_type}
|
||||
</td>
|
||||
<td style={{ padding: '10px 12px' }}>
|
||||
<span style={{
|
||||
padding: '2px 8px',
|
||||
borderRadius: 4,
|
||||
fontSize: 11,
|
||||
background: isActive ? '#1e3a5f' : '#1a1a1a',
|
||||
color: isActive ? '#60a5fa' : '#6b7280',
|
||||
}}>
|
||||
{phaseLabels[cycle.phase] || cycle.phase}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ padding: '10px 12px' }}>
|
||||
{cycle.pr_url && cycle.pr_url.startsWith('https://') ? (
|
||||
<a
|
||||
href={cycle.pr_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={e => e.stopPropagation()}
|
||||
style={{ color: '#3b82f6', textDecoration: 'none', fontSize: 12 }}
|
||||
>
|
||||
#{cycle.pr_number}
|
||||
</a>
|
||||
) : (
|
||||
<span style={{ color: '#4b5563' }}>-</span>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ padding: '10px 12px', color: '#6b7280', fontSize: 12 }}>
|
||||
{timeAgo(cycle.started_at)}
|
||||
</td>
|
||||
<td style={{ padding: '10px 12px', color: '#6b7280', fontSize: 12 }}>
|
||||
{duration(cycle.started_at, cycle.completed_at)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import type { ReviewCycle, ToolExecution, CycleControl } from '../../lib/live-task/types';
|
||||
import CycleTable from './CycleTable';
|
||||
import ToolTimeline from './ToolTimeline';
|
||||
|
||||
const POLL_INTERVAL = 5000;
|
||||
|
||||
type FilterMode = 'open' | 'all' | 'done';
|
||||
|
||||
const FILTER_LABELS: Record<FilterMode, string> = {
|
||||
open: 'Open',
|
||||
all: 'All',
|
||||
done: 'Done',
|
||||
};
|
||||
|
||||
const OPEN_STATUSES = new Set(['active', 'completed']);
|
||||
const DONE_STATUSES = new Set(['merged', 'failed']);
|
||||
|
||||
export default function LiveTaskPanel() {
|
||||
const [cycles, setCycles] = useState<ReviewCycle[]>([]);
|
||||
const [selectedCycleId, setSelectedCycleId] = useState<string | null>(null);
|
||||
const [tools, setTools] = useState<ToolExecution[]>([]);
|
||||
const [control, setControl] = useState<CycleControl | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [toggling, setToggling] = useState(false);
|
||||
const [filter, setFilter] = useState<FilterMode>('open');
|
||||
|
||||
const fetchCycles = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/live-task/cycles?limit=20');
|
||||
const data = await res.json();
|
||||
setCycles(data.cycles || []);
|
||||
} catch { /* silent */ }
|
||||
}, []);
|
||||
|
||||
const fetchControl = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/live-task/control');
|
||||
const data = await res.json();
|
||||
setControl(data.control || null);
|
||||
} catch { /* silent */ }
|
||||
}, []);
|
||||
|
||||
const fetchTools = useCallback(async (cycleId: string) => {
|
||||
try {
|
||||
const res = await fetch(`/api/live-task/tools?cycle_id=${cycleId}`);
|
||||
const data = await res.json();
|
||||
setTools(data.tools || []);
|
||||
} catch { /* silent */ }
|
||||
}, []);
|
||||
|
||||
// Initial load
|
||||
useEffect(() => {
|
||||
Promise.all([fetchCycles(), fetchControl()]).then(() => setLoading(false));
|
||||
}, [fetchCycles, fetchControl]);
|
||||
|
||||
// Polling
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
fetchCycles();
|
||||
fetchControl();
|
||||
if (selectedCycleId) fetchTools(selectedCycleId);
|
||||
}, POLL_INTERVAL);
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchCycles, fetchControl, fetchTools, selectedCycleId]);
|
||||
|
||||
// Fetch tools when selection changes
|
||||
useEffect(() => {
|
||||
if (selectedCycleId) {
|
||||
fetchTools(selectedCycleId);
|
||||
} else {
|
||||
setTools([]);
|
||||
}
|
||||
}, [selectedCycleId, fetchTools]);
|
||||
|
||||
const [authError, setAuthError] = useState<string | null>(null);
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
|
||||
// Check if current user is the authorized admin
|
||||
useEffect(() => {
|
||||
const checkAdmin = () => {
|
||||
const clerk = (window as any).Clerk;
|
||||
if (!clerk?.user) {
|
||||
setIsAdmin(false);
|
||||
return;
|
||||
}
|
||||
const email = clerk.user.primaryEmailAddress?.emailAddress;
|
||||
setIsAdmin(email === 'dan.avila7@gmail.com');
|
||||
};
|
||||
// Clerk may not be loaded yet
|
||||
const interval = setInterval(() => {
|
||||
if ((window as any).Clerk?.loaded) {
|
||||
checkAdmin();
|
||||
clearInterval(interval);
|
||||
}
|
||||
}, 500);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const handleTogglePause = async () => {
|
||||
if (!control) return;
|
||||
setToggling(true);
|
||||
setAuthError(null);
|
||||
try {
|
||||
const clerk = (window as any).Clerk;
|
||||
if (!clerk?.session) {
|
||||
setAuthError('Sign in required');
|
||||
setToggling(false);
|
||||
return;
|
||||
}
|
||||
const token = await clerk.session.getToken();
|
||||
const res = await fetch('/api/live-task/control', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
is_paused: !control.is_paused,
|
||||
reason: control.is_paused ? 'Resumed from dashboard' : 'Paused from dashboard',
|
||||
}),
|
||||
});
|
||||
if (res.status === 401) {
|
||||
setAuthError('Sign in required');
|
||||
} else if (res.status === 403) {
|
||||
setAuthError('Only the project admin can control the review cycle');
|
||||
} else if (!res.ok) {
|
||||
setAuthError('Failed to update control');
|
||||
}
|
||||
await fetchControl();
|
||||
} catch { setAuthError('Network error'); }
|
||||
setToggling(false);
|
||||
};
|
||||
|
||||
const selectedCycle = cycles.find(c => c.id === selectedCycleId) || null;
|
||||
const activeCycles = cycles.filter(c => c.status === 'active').length;
|
||||
|
||||
const filteredCycles = cycles.filter(c => {
|
||||
if (filter === 'open') return OPEN_STATUSES.has(c.status);
|
||||
if (filter === 'done') return DONE_STATUSES.has(c.status);
|
||||
return true;
|
||||
});
|
||||
|
||||
const filterCounts: Record<FilterMode, number> = {
|
||||
open: cycles.filter(c => OPEN_STATUSES.has(c.status)).length,
|
||||
all: cycles.length,
|
||||
done: cycles.filter(c => DONE_STATUSES.has(c.status)).length,
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
maxWidth: 1200,
|
||||
margin: '0 auto',
|
||||
padding: '24px 16px',
|
||||
fontFamily: "'Geist', -apple-system, sans-serif",
|
||||
}}>
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 24,
|
||||
}}>
|
||||
<div>
|
||||
<h1 style={{
|
||||
fontSize: 22,
|
||||
fontWeight: 600,
|
||||
color: '#e5e7eb',
|
||||
margin: 0,
|
||||
fontFamily: "'Geist Mono', monospace",
|
||||
}}>
|
||||
Component Review Loop
|
||||
</h1>
|
||||
<p style={{ color: '#6b7280', fontSize: 13, margin: '4px 0 0' }}>
|
||||
{loading ? 'Loading...' : `${cycles.length} cycles total | ${activeCycles} active`}
|
||||
</p>
|
||||
<p style={{ color: '#4b5563', fontSize: 11, margin: '2px 0 0', fontFamily: 'monospace' }}>
|
||||
{loading ? '' : `showing ${filteredCycles.length} of ${cycles.length}`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
{control && (
|
||||
<>
|
||||
<span style={{
|
||||
fontSize: 12,
|
||||
color: control.is_paused ? '#f59e0b' : '#22c55e',
|
||||
fontFamily: 'monospace',
|
||||
}}>
|
||||
{control.is_paused ? 'PAUSED' : 'RUNNING'}
|
||||
</span>
|
||||
{isAdmin && (
|
||||
<button
|
||||
onClick={handleTogglePause}
|
||||
disabled={toggling}
|
||||
style={{
|
||||
padding: '6px 16px',
|
||||
borderRadius: 6,
|
||||
border: '1px solid #2a2a2a',
|
||||
background: control.is_paused ? '#1e3a5f' : '#1a1a1a',
|
||||
color: control.is_paused ? '#60a5fa' : '#9ca3af',
|
||||
fontSize: 13,
|
||||
cursor: toggling ? 'wait' : 'pointer',
|
||||
fontFamily: "'Geist Mono', monospace",
|
||||
opacity: toggling ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
{control.is_paused ? 'Resume' : 'Pause'}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{authError && (
|
||||
<span style={{ fontSize: 12, color: '#ef4444', fontFamily: 'monospace' }}>
|
||||
{authError}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cycles Table */}
|
||||
<div style={{
|
||||
background: '#0a0a0a',
|
||||
border: '1px solid #1a1a1a',
|
||||
borderRadius: 8,
|
||||
marginBottom: 16,
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<div style={{
|
||||
padding: '8px 16px',
|
||||
borderBottom: '1px solid #1a1a1a',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
}}>
|
||||
<span style={{
|
||||
fontSize: 12,
|
||||
color: '#6b7280',
|
||||
fontFamily: "'Geist Mono', monospace",
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
}}>
|
||||
Review Cycles
|
||||
</span>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{(['open', 'all', 'done'] as FilterMode[]).map(f => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setFilter(f)}
|
||||
style={{
|
||||
padding: '3px 10px',
|
||||
borderRadius: 4,
|
||||
border: '1px solid',
|
||||
borderColor: filter === f ? '#3b82f6' : '#2a2a2a',
|
||||
background: filter === f ? '#1e3a5f' : 'transparent',
|
||||
color: filter === f ? '#60a5fa' : '#6b7280',
|
||||
fontSize: 11,
|
||||
fontFamily: "'Geist Mono', monospace",
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
>
|
||||
{FILTER_LABELS[f]}
|
||||
<span style={{
|
||||
marginLeft: 5,
|
||||
padding: '0 4px',
|
||||
borderRadius: 3,
|
||||
background: filter === f ? '#2563eb33' : '#ffffff0d',
|
||||
color: filter === f ? '#93c5fd' : '#4b5563',
|
||||
fontSize: 10,
|
||||
}}>
|
||||
{filterCounts[f]}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<CycleTable
|
||||
cycles={filteredCycles}
|
||||
selectedId={selectedCycleId}
|
||||
onSelect={setSelectedCycleId}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tool Timeline */}
|
||||
<div style={{
|
||||
background: '#0a0a0a',
|
||||
border: '1px solid #1a1a1a',
|
||||
borderRadius: 8,
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<div style={{
|
||||
padding: '10px 16px',
|
||||
borderBottom: '1px solid #1a1a1a',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
}}>
|
||||
<span style={{
|
||||
fontSize: 12,
|
||||
color: '#6b7280',
|
||||
fontFamily: "'Geist Mono', monospace",
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
}}>
|
||||
Tool Executions
|
||||
</span>
|
||||
{selectedCycle && (
|
||||
<span style={{
|
||||
fontSize: 11,
|
||||
color: '#4b5563',
|
||||
fontFamily: 'monospace',
|
||||
}}>
|
||||
{tools.length} tools | {selectedCycle.component_path.split('/').pop()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<ToolTimeline
|
||||
tools={tools}
|
||||
cycleStartTime={selectedCycle?.started_at || null}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Error display for selected cycle */}
|
||||
{selectedCycle?.error_message && (
|
||||
<div style={{
|
||||
marginTop: 16,
|
||||
padding: '12px 16px',
|
||||
background: '#1a0a0a',
|
||||
border: '1px solid #3f1515',
|
||||
borderRadius: 8,
|
||||
fontSize: 13,
|
||||
color: '#ef4444',
|
||||
fontFamily: "'Geist Mono', monospace",
|
||||
}}>
|
||||
Error: {selectedCycle.error_message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Improvements summary for selected cycle */}
|
||||
{selectedCycle?.improvements_summary && (
|
||||
<div style={{
|
||||
marginTop: 16,
|
||||
padding: '12px 16px',
|
||||
background: '#0a1a0a',
|
||||
border: '1px solid #153f15',
|
||||
borderRadius: 8,
|
||||
fontSize: 13,
|
||||
color: '#9ca3af',
|
||||
fontFamily: "'Geist Mono', monospace",
|
||||
whiteSpace: 'pre-wrap',
|
||||
}}>
|
||||
{selectedCycle.improvements_summary}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import type { ToolExecution } from '../../lib/live-task/types';
|
||||
|
||||
const toolColors: Record<string, string> = {
|
||||
Read: '#22c55e',
|
||||
Write: '#f59e0b',
|
||||
Edit: '#f59e0b',
|
||||
Bash: '#ef4444',
|
||||
Grep: '#8b5cf6',
|
||||
Glob: '#8b5cf6',
|
||||
WebSearch: '#3b82f6',
|
||||
WebFetch: '#3b82f6',
|
||||
Agent: '#ec4899',
|
||||
};
|
||||
|
||||
function sanitizePaths(s: string): string {
|
||||
return s
|
||||
.replace(/\/Users\/[^/]+\/[^"' ]*(Proyectos|Projects|github|repos)\/[^/]+\//gi, '')
|
||||
.replace(/\/Users\/[^/]+\//g, '~/');
|
||||
}
|
||||
|
||||
function relativeTime(baseTime: string, eventTime: string): string {
|
||||
const diff = new Date(eventTime).getTime() - new Date(baseTime).getTime();
|
||||
const totalSecs = Math.floor(diff / 1000);
|
||||
if (totalSecs < 0) return '+0s';
|
||||
const mins = Math.floor(totalSecs / 60);
|
||||
const secs = totalSecs % 60;
|
||||
if (mins === 0) return `+${secs}s`;
|
||||
return `+${mins}m ${secs}s`;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
tools: ToolExecution[];
|
||||
cycleStartTime: string | null;
|
||||
}
|
||||
|
||||
export default function ToolTimeline({ tools, cycleStartTime }: Props) {
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [tools.length, cycleStartTime]);
|
||||
|
||||
if (!cycleStartTime) {
|
||||
return (
|
||||
<div style={{
|
||||
padding: '32px 16px',
|
||||
textAlign: 'center',
|
||||
color: '#6b7280',
|
||||
fontSize: 14,
|
||||
fontFamily: 'monospace',
|
||||
}}>
|
||||
Select a cycle to view tool executions
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (tools.length === 0) {
|
||||
return (
|
||||
<div style={{
|
||||
padding: '32px 16px',
|
||||
textAlign: 'center',
|
||||
color: '#6b7280',
|
||||
fontSize: 14,
|
||||
fontFamily: 'monospace',
|
||||
}}>
|
||||
No tool executions recorded yet for this cycle
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
let currentPhase = '';
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
maxHeight: 400,
|
||||
overflowY: 'auto',
|
||||
padding: '8px 0',
|
||||
}}>
|
||||
{tools.map((tool, i) => {
|
||||
const showPhaseSeparator = tool.phase && tool.phase !== currentPhase;
|
||||
if (tool.phase) currentPhase = tool.phase;
|
||||
|
||||
return (
|
||||
<div key={tool.id}>
|
||||
{showPhaseSeparator && (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
padding: '12px 16px 4px',
|
||||
}}>
|
||||
<div style={{ height: 1, flex: 1, background: '#2a2a2a' }} />
|
||||
<span style={{
|
||||
fontSize: 10,
|
||||
color: '#6b7280',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.1em',
|
||||
fontFamily: 'monospace',
|
||||
}}>
|
||||
{tool.phase}
|
||||
</span>
|
||||
<div style={{ height: 1, flex: 1, background: '#2a2a2a' }} />
|
||||
</div>
|
||||
)}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: 12,
|
||||
padding: '6px 16px',
|
||||
fontSize: 13,
|
||||
fontFamily: "'Geist Mono', 'JetBrains Mono', monospace",
|
||||
}}>
|
||||
{/* Timestamp */}
|
||||
<span style={{ color: '#4b5563', fontSize: 11, minWidth: 70, textAlign: 'right', flexShrink: 0 }}>
|
||||
{relativeTime(cycleStartTime, tool.created_at)}
|
||||
</span>
|
||||
|
||||
{/* Timeline dot + line */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', flexShrink: 0 }}>
|
||||
<div style={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: '50%',
|
||||
background: tool.result_status === 'error' ? '#ef4444' : (toolColors[tool.tool_name] || '#6b7280'),
|
||||
marginTop: 4,
|
||||
}} />
|
||||
{i < tools.length - 1 && (
|
||||
<div style={{ width: 1, height: 20, background: '#2a2a2a', marginTop: 2 }} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tool info */}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<span style={{
|
||||
display: 'inline-block',
|
||||
padding: '1px 6px',
|
||||
borderRadius: 3,
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
background: `${toolColors[tool.tool_name] || '#6b7280'}20`,
|
||||
color: toolColors[tool.tool_name] || '#6b7280',
|
||||
marginRight: 8,
|
||||
}}>
|
||||
{tool.tool_name}
|
||||
</span>
|
||||
<span style={{
|
||||
color: '#9ca3af',
|
||||
fontSize: 12,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{sanitizePaths(tool.tool_args_summary || '')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Status icon */}
|
||||
<span style={{ fontSize: 12, flexShrink: 0 }}>
|
||||
{tool.result_status === 'error' ? '!' : ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user