Initial release v1.0.0
Open-source marketplace for AI Agent skills. Features: - Next.js 15 web app with i18n (en/fa) - CLI tool for skill installation (npx skillhub) - GitHub crawler/indexer with multi-strategy discovery - Security scanning for all indexed skills - Self-hostable with Docker Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
214
apps/web/components/ApiEndpointSection.tsx
Normal file
214
apps/web/components/ApiEndpointSection.tsx
Normal file
@@ -0,0 +1,214 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { ChevronDown, Lock } from 'lucide-react';
|
||||
|
||||
export interface EndpointParam {
|
||||
name: string;
|
||||
type: string;
|
||||
required: boolean;
|
||||
description: string;
|
||||
default?: string;
|
||||
}
|
||||
|
||||
export interface EndpointDef {
|
||||
method: 'GET' | 'POST' | 'DELETE';
|
||||
path: string;
|
||||
description: string;
|
||||
auth: boolean;
|
||||
rateLimit?: string;
|
||||
cacheTTL?: string;
|
||||
params?: EndpointParam[];
|
||||
bodyParams?: EndpointParam[];
|
||||
responseExample?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
interface ApiEndpointSectionProps {
|
||||
title: string;
|
||||
description: string;
|
||||
endpoints: EndpointDef[];
|
||||
labels: {
|
||||
parameters: string;
|
||||
requestBody: string;
|
||||
required: string;
|
||||
optional: string;
|
||||
default: string;
|
||||
responseExample: string;
|
||||
rateLimit: string;
|
||||
cache: string;
|
||||
authRequired: string;
|
||||
notes: string;
|
||||
};
|
||||
}
|
||||
|
||||
const METHOD_STYLES: Record<string, string> = {
|
||||
GET: 'bg-success/20 text-success',
|
||||
POST: 'bg-primary-100 text-primary-600',
|
||||
DELETE: 'bg-red-100 text-red-600',
|
||||
};
|
||||
|
||||
export function ApiEndpointSection({ title, description, endpoints, labels }: ApiEndpointSectionProps) {
|
||||
const [expandedIndexes, setExpandedIndexes] = useState<Set<number>>(new Set());
|
||||
|
||||
const toggle = (index: number) => {
|
||||
setExpandedIndexes(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(index)) {
|
||||
next.delete(index);
|
||||
} else {
|
||||
next.add(index);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-10">
|
||||
<h3 className="text-2xl font-bold mb-2">{title}</h3>
|
||||
<p className="text-text-secondary mb-4">{description}</p>
|
||||
<div className="space-y-3" dir="ltr">
|
||||
{endpoints.map((ep, index) => {
|
||||
const expanded = expandedIndexes.has(index);
|
||||
const hasDetails = ep.params?.length || ep.bodyParams?.length || ep.responseExample || ep.notes;
|
||||
|
||||
return (
|
||||
<div key={index} className="glass-card overflow-hidden text-left">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => hasDetails && toggle(index)}
|
||||
className={`w-full p-4 flex items-center gap-3 ${hasDetails ? 'cursor-pointer hover:bg-surface-hover' : 'cursor-default'}`}
|
||||
>
|
||||
<span className={`px-2 py-1 rounded text-xs font-bold shrink-0 ${METHOD_STYLES[ep.method] || ''}`}>
|
||||
{ep.method}
|
||||
</span>
|
||||
<code className="text-sm font-mono shrink-0">{ep.path}</code>
|
||||
{ep.auth && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-amber-100 text-amber-700 shrink-0">
|
||||
<Lock className="w-3 h-3" />
|
||||
{labels.authRequired}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-text-secondary text-sm ml-auto hidden sm:block truncate">{ep.description}</span>
|
||||
{hasDetails && (
|
||||
<ChevronDown className={`w-4 h-4 text-text-secondary shrink-0 transition-transform ${expanded ? 'rotate-180' : ''}`} />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Description visible on mobile (below sm) */}
|
||||
<div className="px-4 pb-2 sm:hidden">
|
||||
<p className="text-text-secondary text-sm">{ep.description}</p>
|
||||
</div>
|
||||
|
||||
{expanded && hasDetails && (
|
||||
<div className="px-4 pb-4 border-t border-border/50 pt-4 space-y-4">
|
||||
{/* Rate limit & cache badges */}
|
||||
{(ep.rateLimit || ep.cacheTTL) && (
|
||||
<div className="flex flex-wrap gap-3 text-xs">
|
||||
{ep.rateLimit && (
|
||||
<span className="text-text-secondary">
|
||||
<span className="font-semibold">{labels.rateLimit}:</span> {ep.rateLimit}
|
||||
</span>
|
||||
)}
|
||||
{ep.cacheTTL && (
|
||||
<span className="text-text-secondary">
|
||||
<span className="font-semibold">{labels.cache}:</span> {ep.cacheTTL}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Query parameters */}
|
||||
{ep.params && ep.params.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold mb-2">{labels.parameters}</h4>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border/50 text-left">
|
||||
<th className="py-1.5 pr-3 font-medium text-text-secondary">Name</th>
|
||||
<th className="py-1.5 pr-3 font-medium text-text-secondary">Type</th>
|
||||
<th className="py-1.5 pr-3 font-medium text-text-secondary"></th>
|
||||
<th className="py-1.5 font-medium text-text-secondary">Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ep.params.map((p) => (
|
||||
<tr key={p.name} className="border-b border-border/30">
|
||||
<td className="py-1.5 pr-3 font-mono text-xs">{p.name}</td>
|
||||
<td className="py-1.5 pr-3 text-text-secondary">{p.type}</td>
|
||||
<td className="py-1.5 pr-3">
|
||||
<span className={`text-xs ${p.required ? 'text-red-500' : 'text-text-tertiary'}`}>
|
||||
{p.required ? labels.required : labels.optional}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-1.5 text-text-secondary">
|
||||
{p.description}
|
||||
{p.default && <span className="text-text-tertiary ml-1">(default: {p.default})</span>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Body parameters */}
|
||||
{ep.bodyParams && ep.bodyParams.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold mb-2">{labels.requestBody}</h4>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border/50 text-left">
|
||||
<th className="py-1.5 pr-3 font-medium text-text-secondary">Name</th>
|
||||
<th className="py-1.5 pr-3 font-medium text-text-secondary">Type</th>
|
||||
<th className="py-1.5 pr-3 font-medium text-text-secondary"></th>
|
||||
<th className="py-1.5 font-medium text-text-secondary">Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ep.bodyParams.map((p) => (
|
||||
<tr key={p.name} className="border-b border-border/30">
|
||||
<td className="py-1.5 pr-3 font-mono text-xs">{p.name}</td>
|
||||
<td className="py-1.5 pr-3 text-text-secondary">{p.type}</td>
|
||||
<td className="py-1.5 pr-3">
|
||||
<span className={`text-xs ${p.required ? 'text-red-500' : 'text-text-tertiary'}`}>
|
||||
{p.required ? labels.required : labels.optional}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-1.5 text-text-secondary">{p.description}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Response example */}
|
||||
{ep.responseExample && (
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold mb-2">{labels.responseExample}</h4>
|
||||
<div className="bg-gray-900 rounded-lg p-3 overflow-x-auto">
|
||||
<pre className="text-sm font-mono text-gray-100 whitespace-pre">{ep.responseExample}</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notes */}
|
||||
{ep.notes && (
|
||||
<div className="text-sm text-text-secondary bg-primary-50/50 rounded-lg p-3">
|
||||
<span className="font-semibold">{labels.notes}:</span> {ep.notes}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
114
apps/web/components/AuthButton.tsx
Normal file
114
apps/web/components/AuthButton.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useSession, signIn, signOut } from 'next-auth/react';
|
||||
import { useTranslations, useLocale } from 'next-intl';
|
||||
import Link from 'next/link';
|
||||
import { LogIn, LogOut, User, Heart, ChevronDown, Settings } from 'lucide-react';
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
export function AuthButton() {
|
||||
const { data: session, status } = useSession();
|
||||
const t = useTranslations('auth');
|
||||
const locale = useLocale();
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setDropdownOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
if (status === 'loading') {
|
||||
return (
|
||||
<div className="w-8 h-8 rounded-full bg-surface-subtle animate-pulse" />
|
||||
);
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => signIn('github')}
|
||||
className="flex items-center gap-2 px-3 py-2 rounded-lg text-text-secondary hover:text-text-primary hover:bg-surface-subtle transition-colors"
|
||||
>
|
||||
<LogIn className="w-4 h-4" />
|
||||
<span className="text-sm font-medium hidden sm:inline">{t('signIn')}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<button
|
||||
onClick={() => setDropdownOpen(!dropdownOpen)}
|
||||
className="flex items-center gap-2 p-1.5 rounded-lg hover:bg-surface-subtle transition-colors"
|
||||
>
|
||||
{session.user?.avatarUrl ? (
|
||||
<img
|
||||
src={session.user.avatarUrl}
|
||||
alt={session.user.username || 'User'}
|
||||
className="w-7 h-7 rounded-full"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-7 h-7 rounded-full bg-primary-100 flex items-center justify-center">
|
||||
<User className="w-4 h-4 text-primary-600" />
|
||||
</div>
|
||||
)}
|
||||
<ChevronDown className={clsx(
|
||||
'w-4 h-4 text-text-muted transition-transform',
|
||||
dropdownOpen && 'rotate-180'
|
||||
)} />
|
||||
</button>
|
||||
|
||||
{dropdownOpen && (
|
||||
<div className="absolute end-0 top-full mt-2 w-48 bg-surface-elevated rounded-lg shadow-lg border border-border py-1 z-50">
|
||||
{/* User info */}
|
||||
<div className="px-4 py-2 border-b border-border">
|
||||
<p className="text-sm font-medium text-text-primary truncate">
|
||||
{session.user?.name || session.user?.username}
|
||||
</p>
|
||||
<p className="text-xs text-text-muted truncate">
|
||||
@{session.user?.username}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Menu items */}
|
||||
<Link
|
||||
href={`/${locale}/favorites`}
|
||||
onClick={() => setDropdownOpen(false)}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm text-text-secondary hover:bg-surface-subtle transition-colors"
|
||||
>
|
||||
<Heart className="w-4 h-4" />
|
||||
{t('favorites')}
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href={`/${locale}/claim`}
|
||||
onClick={() => setDropdownOpen(false)}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm text-text-secondary hover:bg-surface-subtle transition-colors"
|
||||
>
|
||||
<Settings className="w-4 h-4" />
|
||||
{t('manageSkills')}
|
||||
</Link>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setDropdownOpen(false);
|
||||
signOut();
|
||||
}}
|
||||
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-text-secondary hover:bg-surface-subtle transition-colors"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
{t('signOut')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
62
apps/web/components/BetaBanner.tsx
Normal file
62
apps/web/components/BetaBanner.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { X, Zap } from 'lucide-react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import Link from 'next/link';
|
||||
import { formatCompactNumber } from '@/lib/format-number';
|
||||
|
||||
export function BetaBanner() {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [skillCount, setSkillCount] = useState<string | null>(null);
|
||||
const locale = useLocale();
|
||||
const t = useTranslations('banner');
|
||||
|
||||
useEffect(() => {
|
||||
const dismissed = localStorage.getItem('promo-banner-dismissed');
|
||||
if (!dismissed) {
|
||||
setIsVisible(true);
|
||||
}
|
||||
|
||||
fetch('/api/stats')
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (data.totalSkills) {
|
||||
setSkillCount(formatCompactNumber(data.totalSkills, locale));
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [locale]);
|
||||
|
||||
const handleDismiss = () => {
|
||||
setIsVisible(false);
|
||||
localStorage.setItem('promo-banner-dismissed', 'true');
|
||||
};
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
const count = skillCount || (locale === 'fa' ? '۱۷۲k' : '172k');
|
||||
|
||||
return (
|
||||
<div className="relative bg-gradient-to-r from-primary-600 to-primary-500 text-white">
|
||||
<div className="container-main py-2 px-4 flex items-center justify-center gap-2 text-sm">
|
||||
<Zap className="w-4 h-4 flex-shrink-0" />
|
||||
<span className="font-medium">{t('text', { count })}</span>
|
||||
<span className="hidden sm:inline text-primary-100">|</span>
|
||||
<Link
|
||||
href={`/${locale}/support`}
|
||||
className="hidden sm:inline text-white underline underline-offset-2 hover:text-primary-100 transition-colors"
|
||||
>
|
||||
{t('feedback')}
|
||||
</Link>
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
className="absolute end-2 sm:end-4 p-1 hover:bg-white/10 rounded transition-colors"
|
||||
aria-label={t('dismiss')}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
661
apps/web/components/BrowseFilters.tsx
Normal file
661
apps/web/components/BrowseFilters.tsx
Normal file
@@ -0,0 +1,661 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter, useSearchParams, usePathname } from 'next/navigation';
|
||||
import { useCallback, useState, useTransition, useEffect, type FormEvent } from 'react';
|
||||
import { Search, Filter, ChevronLeft, ChevronRight, Loader2, X, Star, SlidersHorizontal, Sparkles } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
// Persian number conversion
|
||||
const persianDigits = ['۰', '۱', '۲', '۳', '۴', '۵', '۶', '۷', '۸', '۹'];
|
||||
function toPersianNumber(num: number | string): string {
|
||||
return String(num).replace(/\d/g, (d) => persianDigits[parseInt(d, 10)]);
|
||||
}
|
||||
|
||||
interface SortOption {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface Category {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
skillCount: number;
|
||||
parentId?: string | null;
|
||||
}
|
||||
|
||||
interface HierarchicalCategory extends Category {
|
||||
children?: Category[];
|
||||
}
|
||||
|
||||
interface BrowseFiltersProps {
|
||||
sortOptions: SortOption[];
|
||||
categories: Category[] | HierarchicalCategory[];
|
||||
locale?: string;
|
||||
translations: {
|
||||
category: string;
|
||||
allCategories: string;
|
||||
sort: string;
|
||||
format?: string;
|
||||
allFormats?: string;
|
||||
agentSkills?: string;
|
||||
searching: string;
|
||||
viewFeatured?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function BrowseFilters({
|
||||
sortOptions,
|
||||
categories,
|
||||
locale = 'en',
|
||||
translations,
|
||||
}: BrowseFiltersProps) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
// Get current values from URL
|
||||
const currentCategory = searchParams.get('category') || '';
|
||||
const currentSort = searchParams.get('sort') || 'lastDownloaded';
|
||||
const currentFormat = searchParams.get('format') || '';
|
||||
|
||||
// Count active filters for mobile badge
|
||||
const activeFilterCount = [
|
||||
currentCategory ? 1 : 0,
|
||||
currentSort !== 'lastDownloaded' ? 1 : 0,
|
||||
currentFormat ? 1 : 0,
|
||||
].reduce((a, b) => a + b, 0);
|
||||
|
||||
// Update URL with new params
|
||||
const updateParams = useCallback(
|
||||
(updates: Record<string, string | null>) => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
|
||||
Object.entries(updates).forEach(([key, value]) => {
|
||||
if (value === null || value === '' || value === 'all' || value === 'false') {
|
||||
params.delete(key);
|
||||
} else {
|
||||
params.set(key, value);
|
||||
}
|
||||
});
|
||||
|
||||
// Reset page when filters change
|
||||
params.delete('page');
|
||||
|
||||
startTransition(() => {
|
||||
router.push(`${pathname}?${params.toString()}`, { scroll: false });
|
||||
});
|
||||
},
|
||||
[pathname, router, searchParams]
|
||||
);
|
||||
|
||||
// Handle category change
|
||||
const handleCategoryChange = (categoryId: string) => {
|
||||
updateParams({ category: categoryId === '' ? null : categoryId });
|
||||
};
|
||||
|
||||
// Handle sort change
|
||||
const handleSortChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
updateParams({ sort: e.target.value === 'lastDownloaded' ? null : e.target.value });
|
||||
};
|
||||
|
||||
// Handle format change
|
||||
const handleFormatChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
updateParams({ format: e.target.value === '' ? null : e.target.value });
|
||||
};
|
||||
|
||||
// Filter content (shared between mobile and desktop)
|
||||
const filterContent = (
|
||||
<>
|
||||
<h2 className="font-semibold text-text-primary mb-4 flex items-center gap-2">
|
||||
<Filter className="w-4 h-4" />
|
||||
{translations.category}
|
||||
</h2>
|
||||
|
||||
{/* Category Filter */}
|
||||
<div className="relative mb-6">
|
||||
<select
|
||||
className="input-field text-sm disabled:opacity-50 w-full pe-10"
|
||||
value={currentCategory}
|
||||
onChange={(e) => handleCategoryChange(e.target.value)}
|
||||
disabled={isPending}
|
||||
>
|
||||
<option value="">{translations.allCategories}</option>
|
||||
{categories.length > 0 && 'children' in categories[0] ? (
|
||||
(categories as HierarchicalCategory[]).map((parent) => (
|
||||
<optgroup key={parent.id} label={parent.name}>
|
||||
{parent.children?.map((cat) => (
|
||||
<option key={cat.id} value={cat.id}>
|
||||
{cat.name} ({locale === 'fa' ? toPersianNumber(cat.skillCount) : cat.skillCount})
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
))
|
||||
) : (
|
||||
(categories as Category[]).map((cat) => (
|
||||
<option key={cat.id} value={cat.id}>
|
||||
{cat.name} ({locale === 'fa' ? toPersianNumber(cat.skillCount) : cat.skillCount})
|
||||
</option>
|
||||
))
|
||||
)}
|
||||
</select>
|
||||
{currentCategory && (
|
||||
<button
|
||||
onClick={() => handleCategoryChange('')}
|
||||
className="absolute end-8 top-1/2 -translate-y-1/2 text-text-muted hover:text-text-primary p-1 rounded"
|
||||
type="button"
|
||||
title="Clear category"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sort */}
|
||||
<h3 className="font-semibold text-text-primary mb-3">
|
||||
{translations.sort}
|
||||
</h3>
|
||||
<select
|
||||
className="input-field text-sm mb-6 disabled:opacity-50"
|
||||
value={currentSort}
|
||||
onChange={handleSortChange}
|
||||
disabled={isPending}
|
||||
>
|
||||
{sortOptions.map((option) => (
|
||||
<option key={option.id} value={option.id}>
|
||||
{option.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Format Filter */}
|
||||
{translations.format && (
|
||||
<>
|
||||
<h3 className="font-semibold text-text-primary mb-3">
|
||||
{translations.format}
|
||||
</h3>
|
||||
<select
|
||||
className="input-field text-sm mb-6 disabled:opacity-50"
|
||||
value={currentFormat}
|
||||
onChange={handleFormatChange}
|
||||
disabled={isPending}
|
||||
>
|
||||
<option value="">{translations.agentSkills || 'Agent Skills (SKILL.md)'}</option>
|
||||
<option value="all">{translations.allFormats || 'All Formats'}</option>
|
||||
<option value="agents.md">AGENTS.md (Codex)</option>
|
||||
<option value="cursorrules">.cursorrules (Cursor)</option>
|
||||
<option value="windsurfrules">.windsurfrules (Windsurf)</option>
|
||||
<option value="copilot-instructions">Copilot Instructions</option>
|
||||
</select>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* View Featured Link */}
|
||||
{translations.viewFeatured && (
|
||||
<Link
|
||||
href={`/${locale}/featured`}
|
||||
className="flex items-center gap-2 text-sm text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300 transition-colors mt-4 pt-4 border-t border-border"
|
||||
>
|
||||
<Star className="w-4 h-4" />
|
||||
{translations.viewFeatured}
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* Loading indicator */}
|
||||
{isPending && (
|
||||
<div className="mt-4 flex items-center gap-2 text-primary-500 text-sm">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
{translations.searching}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile Filter Toggle */}
|
||||
<div className="lg:hidden mb-4">
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="flex items-center gap-2 px-4 py-2.5 bg-surface-elevated border border-border rounded-xl text-text-primary hover:bg-surface-subtle transition-colors w-full justify-center"
|
||||
>
|
||||
<SlidersHorizontal className="w-4 h-4" />
|
||||
<span>{translations.category}</span>
|
||||
{activeFilterCount > 0 && (
|
||||
<span className="bg-primary-500 text-white text-xs px-2 py-0.5 rounded-full">
|
||||
{locale === 'fa' ? toPersianNumber(activeFilterCount) : activeFilterCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Mobile Filter Panel */}
|
||||
{isOpen && (
|
||||
<div className="mt-4 bg-surface-elevated rounded-2xl p-6 shadow-sm border border-border">
|
||||
{filterContent}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Desktop Sidebar */}
|
||||
<aside className="hidden lg:block lg:w-64 flex-shrink-0">
|
||||
<div className="bg-surface-elevated rounded-2xl p-6 shadow-sm sticky top-24">
|
||||
{filterContent}
|
||||
</div>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Search Bar Component with Debounce
|
||||
interface SearchBarProps {
|
||||
placeholder: string;
|
||||
defaultValue?: string;
|
||||
}
|
||||
|
||||
export function SearchBar({ placeholder, defaultValue = '' }: SearchBarProps) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [searchQuery, setSearchQuery] = useState(defaultValue);
|
||||
|
||||
// Sync internal state when URL changes externally (e.g., ActiveFilters clearAll)
|
||||
useEffect(() => {
|
||||
setSearchQuery(defaultValue);
|
||||
}, [defaultValue]);
|
||||
|
||||
const handleSearch = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
|
||||
if (searchQuery) {
|
||||
params.set('q', searchQuery);
|
||||
} else {
|
||||
params.delete('q');
|
||||
}
|
||||
params.delete('page');
|
||||
|
||||
startTransition(() => {
|
||||
router.push(`${pathname}?${params.toString()}`, { scroll: false });
|
||||
});
|
||||
};
|
||||
|
||||
const clearSearch = () => {
|
||||
setSearchQuery('');
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.delete('q');
|
||||
params.delete('page');
|
||||
startTransition(() => {
|
||||
router.push(`${pathname}?${params.toString()}`, { scroll: false });
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSearch} className="mb-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute start-4 top-1/2 -translate-y-1/2 w-5 h-5 text-text-muted" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder={placeholder}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
disabled={isPending}
|
||||
className="input-field ps-12 pe-24 disabled:opacity-50"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearSearch}
|
||||
className="absolute end-14 top-1/2 -translate-y-1/2 p-1 text-text-muted hover:text-text-primary rounded"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isPending}
|
||||
className="absolute end-2 top-1/2 -translate-y-1/2 btn-primary py-1.5 px-4 text-sm disabled:opacity-50"
|
||||
>
|
||||
{isPending ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Search className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
// Active Filters Display - Shows applied filters as removable chips
|
||||
interface ActiveFiltersProps {
|
||||
query?: string;
|
||||
categoryId?: string;
|
||||
categoryName?: string;
|
||||
sortBy?: string;
|
||||
sortName?: string;
|
||||
translations: {
|
||||
search: string;
|
||||
category: string;
|
||||
sortBy: string;
|
||||
clearAll: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function ActiveFilters({
|
||||
query,
|
||||
categoryId,
|
||||
categoryName,
|
||||
sortBy,
|
||||
sortName,
|
||||
translations,
|
||||
}: ActiveFiltersProps) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const hasFilters = query || categoryId || (sortBy && sortBy !== 'lastDownloaded');
|
||||
|
||||
if (!hasFilters) return null;
|
||||
|
||||
const removeFilter = (key: string) => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.delete(key);
|
||||
params.delete('page');
|
||||
startTransition(() => {
|
||||
router.push(`${pathname}?${params.toString()}`, { scroll: false });
|
||||
});
|
||||
};
|
||||
|
||||
const clearAll = () => {
|
||||
startTransition(() => {
|
||||
router.push(pathname, { scroll: false });
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2 mb-4">
|
||||
{query && (
|
||||
<span className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-primary-50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300 text-sm rounded-full">
|
||||
<Search className="w-3 h-3" />
|
||||
<span className="max-w-[150px] truncate">"{query}"</span>
|
||||
<button
|
||||
onClick={() => removeFilter('q')}
|
||||
disabled={isPending}
|
||||
className="hover:bg-primary-100 dark:hover:bg-primary-800/50 rounded-full p-0.5"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{categoryId && categoryName && (
|
||||
<span className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-success/10 text-success text-sm rounded-full">
|
||||
<Filter className="w-3 h-3" />
|
||||
<span>{categoryName}</span>
|
||||
<button
|
||||
onClick={() => removeFilter('category')}
|
||||
disabled={isPending}
|
||||
className="hover:bg-success/20 rounded-full p-0.5"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{sortBy && sortBy !== 'lastDownloaded' && sortName && (
|
||||
<span className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-surface-subtle text-text-secondary text-sm rounded-full">
|
||||
<SlidersHorizontal className="w-3 h-3" />
|
||||
<span>{sortName}</span>
|
||||
<button
|
||||
onClick={() => removeFilter('sort')}
|
||||
disabled={isPending}
|
||||
className="hover:bg-surface-muted rounded-full p-0.5"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{(query || categoryId) && (
|
||||
<button
|
||||
onClick={clearAll}
|
||||
disabled={isPending}
|
||||
className="text-sm text-text-muted hover:text-error transition-colors"
|
||||
>
|
||||
{translations.clearAll}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isPending && <Loader2 className="w-4 h-4 animate-spin text-primary-500" />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Empty State Component
|
||||
interface EmptyStateProps {
|
||||
query?: string;
|
||||
hasFilters: boolean;
|
||||
locale?: string;
|
||||
translations: {
|
||||
noResults: string;
|
||||
noResultsWithQuery: string;
|
||||
tryDifferent: string;
|
||||
clearFilters: string;
|
||||
browseAll: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function EmptyState({ query, hasFilters, locale = 'en', translations }: EmptyStateProps) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const clearFilters = () => {
|
||||
startTransition(() => {
|
||||
router.push(pathname, { scroll: false });
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="text-center py-16">
|
||||
<div className="w-20 h-20 mx-auto mb-6 rounded-full bg-surface-elevated flex items-center justify-center">
|
||||
<Sparkles className="w-10 h-10 text-text-muted" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-text-primary mb-2">
|
||||
{query ? translations.noResultsWithQuery.replace('{query}', query) : translations.noResults}
|
||||
</h3>
|
||||
<p className="text-text-secondary mb-6 max-w-md mx-auto">
|
||||
{translations.tryDifferent}
|
||||
</p>
|
||||
{hasFilters && (
|
||||
<button
|
||||
onClick={clearFilters}
|
||||
disabled={isPending}
|
||||
className="btn-secondary gap-2"
|
||||
>
|
||||
{isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <X className="w-4 h-4" />}
|
||||
{translations.clearFilters}
|
||||
</button>
|
||||
)}
|
||||
{!hasFilters && (
|
||||
<Link href={`/${locale}/featured`} className="btn-primary gap-2">
|
||||
<Star className="w-4 h-4" />
|
||||
{translations.browseAll}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Pagination Component
|
||||
interface PaginationProps {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
locale?: string;
|
||||
translations: {
|
||||
previous: string;
|
||||
next: string;
|
||||
page: string;
|
||||
of: string;
|
||||
};
|
||||
}
|
||||
|
||||
// Helper: Generate page numbers with ellipsis
|
||||
function generatePageNumbers(current: number, total: number): (number | '...')[] {
|
||||
if (total <= 7) {
|
||||
return Array.from({ length: total }, (_, i) => i + 1);
|
||||
}
|
||||
|
||||
if (current <= 3) {
|
||||
return [1, 2, 3, 4, '...', total];
|
||||
}
|
||||
|
||||
if (current >= total - 2) {
|
||||
return [1, '...', total - 3, total - 2, total - 1, total];
|
||||
}
|
||||
|
||||
return [1, '...', current - 1, current, current + 1, '...', total];
|
||||
}
|
||||
|
||||
export function Pagination({ currentPage, totalPages, locale = 'en', translations }: PaginationProps) {
|
||||
// Helper to format numbers based on locale
|
||||
const formatPageNumber = (num: number | string) => locale === 'fa' ? toPersianNumber(num) : String(num);
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const goToPage = (page: number) => {
|
||||
if (page < 1 || page > totalPages || page === currentPage) return;
|
||||
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
if (page === 1) {
|
||||
params.delete('page');
|
||||
} else {
|
||||
params.set('page', String(page));
|
||||
}
|
||||
|
||||
startTransition(() => {
|
||||
router.push(`${pathname}?${params.toString()}`);
|
||||
});
|
||||
};
|
||||
|
||||
// Don't show pagination if only one page
|
||||
if (totalPages <= 1) return null;
|
||||
|
||||
const pages = generatePageNumbers(currentPage, totalPages);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-4 mt-8">
|
||||
{/* Page indicator */}
|
||||
<p className="text-sm text-text-muted">
|
||||
{translations.page} {formatPageNumber(currentPage)} {translations.of} {formatPageNumber(totalPages)}
|
||||
</p>
|
||||
|
||||
{/* Pagination controls */}
|
||||
<div className="flex items-center gap-1 sm:gap-2">
|
||||
{/* Previous Button */}
|
||||
<button
|
||||
onClick={() => goToPage(currentPage - 1)}
|
||||
disabled={currentPage <= 1 || isPending}
|
||||
className="flex items-center gap-1 px-2 sm:px-3 py-2 text-sm border border-border rounded-lg hover:bg-surface-subtle disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
aria-label={translations.previous}
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">{translations.previous}</span>
|
||||
</button>
|
||||
|
||||
{/* Page Numbers */}
|
||||
<div className="flex items-center gap-1">
|
||||
{pages.map((page, index) => (
|
||||
page === '...' ? (
|
||||
<span
|
||||
key={`ellipsis-${index}`}
|
||||
className="px-2 py-2 text-text-muted"
|
||||
>
|
||||
...
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
key={page}
|
||||
onClick={() => goToPage(page)}
|
||||
disabled={isPending}
|
||||
className={`min-w-[40px] px-3 py-2 text-sm border rounded-lg transition-colors disabled:cursor-wait ${
|
||||
page === currentPage
|
||||
? 'bg-primary-500 text-white border-primary-500'
|
||||
: 'border-border hover:bg-surface-subtle'
|
||||
}`}
|
||||
aria-current={page === currentPage ? 'page' : undefined}
|
||||
>
|
||||
{formatPageNumber(page)}
|
||||
</button>
|
||||
)
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Next Button */}
|
||||
<button
|
||||
onClick={() => goToPage(currentPage + 1)}
|
||||
disabled={currentPage >= totalPages || isPending}
|
||||
className="flex items-center gap-1 px-2 sm:px-3 py-2 text-sm border border-border rounded-lg hover:bg-surface-subtle disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
aria-label={translations.next}
|
||||
>
|
||||
<span className="hidden sm:inline">{translations.next}</span>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Loading indicator */}
|
||||
{isPending && (
|
||||
<div className="flex items-center gap-2 text-primary-500 text-sm">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Legacy LoadMoreButton for backward compatibility (if needed elsewhere)
|
||||
interface LoadMoreButtonProps {
|
||||
hasMore: boolean;
|
||||
currentPage: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export function LoadMoreButton({ hasMore, currentPage, label }: LoadMoreButtonProps) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const handleLoadMore = () => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set('page', String(currentPage + 1));
|
||||
|
||||
startTransition(() => {
|
||||
router.push(`${pathname}?${params.toString()}`);
|
||||
});
|
||||
};
|
||||
|
||||
if (!hasMore) return null;
|
||||
|
||||
return (
|
||||
<div className="text-center mt-8">
|
||||
<button
|
||||
onClick={handleLoadMore}
|
||||
disabled={isPending}
|
||||
className="btn-secondary gap-2 disabled:opacity-50"
|
||||
>
|
||||
{isPending ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
label
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
831
apps/web/components/ClaimForm.tsx
Normal file
831
apps/web/components/ClaimForm.tsx
Normal file
@@ -0,0 +1,831 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useSession, signIn } from 'next-auth/react';
|
||||
import { Loader2, CheckCircle, AlertCircle, Github, Clock, Plus, Minus, ChevronDown, ChevronUp, ExternalLink } from 'lucide-react';
|
||||
import { fetchWithCsrf } from '@/lib/csrf-client';
|
||||
|
||||
const isMirror = process.env.NEXT_PUBLIC_IS_PRIMARY === 'false';
|
||||
const PRIMARY_URL = process.env.NEXT_PUBLIC_PRIMARY_URL || process.env.NEXT_PUBLIC_APP_URL || 'https://skills.palebluedot.live';
|
||||
|
||||
// Validate GitHub URL format
|
||||
function isValidGitHubUrl(url: string): boolean {
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
return urlObj.hostname === 'github.com' && urlObj.pathname.split('/').filter(Boolean).length >= 2;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
interface ClaimFormProps {
|
||||
translations: {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
loginRequired: string;
|
||||
signIn: string;
|
||||
optional: string;
|
||||
mirror: {
|
||||
title: string;
|
||||
description: string;
|
||||
button: string;
|
||||
};
|
||||
tabs: {
|
||||
remove: string;
|
||||
add: string;
|
||||
};
|
||||
form: {
|
||||
skillId: string;
|
||||
skillIdPlaceholder: string;
|
||||
skillIdHelp: string;
|
||||
reason: string;
|
||||
reasonPlaceholder: string;
|
||||
submit: string;
|
||||
submitting: string;
|
||||
};
|
||||
addForm: {
|
||||
repositoryUrl: string;
|
||||
repositoryUrlPlaceholder: string;
|
||||
repositoryUrlHelp: string;
|
||||
reason: string;
|
||||
reasonPlaceholder: string;
|
||||
submit: string;
|
||||
submitting: string;
|
||||
};
|
||||
success: {
|
||||
title: string;
|
||||
description: string;
|
||||
viewRequests: string;
|
||||
};
|
||||
addSuccess: {
|
||||
title: string;
|
||||
description: string;
|
||||
descriptionNoSkillMd: string;
|
||||
descriptionMultiplePrefix: string;
|
||||
descriptionMultipleSuffix: string;
|
||||
viewRequests: string;
|
||||
foundSkillsIn: string;
|
||||
root: string;
|
||||
andMore: string;
|
||||
};
|
||||
error: {
|
||||
notOwner: string;
|
||||
skillNotFound: string;
|
||||
alreadyPending: string;
|
||||
githubError: string;
|
||||
invalidSkill: string;
|
||||
invalidUrl: string;
|
||||
invalidRepo: string;
|
||||
rateLimitExceeded: string;
|
||||
networkTimeout: string;
|
||||
generic: string;
|
||||
};
|
||||
myRequests: {
|
||||
title: string;
|
||||
empty: string;
|
||||
status: {
|
||||
pending: string;
|
||||
approved: string;
|
||||
rejected: string;
|
||||
indexed: string;
|
||||
};
|
||||
skillsFoundPrefix: string;
|
||||
skillsFoundSuffix: string;
|
||||
showLess: string;
|
||||
showAllPrefix: string;
|
||||
showAllSuffix: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
interface RemovalRequest {
|
||||
id: string;
|
||||
skillId: string;
|
||||
reason: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface AddRequest {
|
||||
id: string;
|
||||
repositoryUrl: string;
|
||||
skillPath: string | null;
|
||||
reason: string;
|
||||
status: string;
|
||||
hasSkillMd: boolean;
|
||||
createdAt: string;
|
||||
indexedSkillId: string | null;
|
||||
errorMessage: string | null;
|
||||
}
|
||||
|
||||
export function ClaimForm({ translations }: ClaimFormProps) {
|
||||
const { data: session, status } = useSession();
|
||||
const [activeTab, setActiveTab] = useState<'remove' | 'add'>('add'); // Default to 'add' tab
|
||||
const [expandedRequests, setExpandedRequests] = useState<Set<string>>(new Set());
|
||||
|
||||
// Read tab from URL hash on mount and update on hash change
|
||||
useEffect(() => {
|
||||
const readHashTab = () => {
|
||||
const hash = window.location.hash.slice(1);
|
||||
if (hash === 'remove' || hash === 'add') {
|
||||
setActiveTab(hash);
|
||||
}
|
||||
};
|
||||
readHashTab();
|
||||
window.addEventListener('hashchange', readHashTab);
|
||||
return () => window.removeEventListener('hashchange', readHashTab);
|
||||
}, []);
|
||||
|
||||
// Update URL hash when tab changes
|
||||
const handleTabChange = useCallback((tab: 'remove' | 'add') => {
|
||||
setActiveTab(tab);
|
||||
window.history.replaceState(null, '', `#${tab}`);
|
||||
}, []);
|
||||
|
||||
// Toggle expanded state for a request
|
||||
const toggleExpanded = useCallback((requestId: string) => {
|
||||
setExpandedRequests(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(requestId)) {
|
||||
next.delete(requestId);
|
||||
} else {
|
||||
next.add(requestId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Remove form state
|
||||
const [skillId, setSkillId] = useState('');
|
||||
const [removeReason, setRemoveReason] = useState('');
|
||||
const [isSubmittingRemove, setIsSubmittingRemove] = useState(false);
|
||||
const [removeError, setRemoveError] = useState('');
|
||||
const [removeSuccess, setRemoveSuccess] = useState(false);
|
||||
const [removalRequests, setRemovalRequests] = useState<RemovalRequest[]>([]);
|
||||
const [loadingRemovalRequests, setLoadingRemovalRequests] = useState(false);
|
||||
|
||||
// Add form state
|
||||
const [repositoryUrl, setRepositoryUrl] = useState('');
|
||||
const [repositoryUrlError, setRepositoryUrlError] = useState('');
|
||||
const [addReason, setAddReason] = useState('');
|
||||
const [isSubmittingAdd, setIsSubmittingAdd] = useState(false);
|
||||
const [addError, setAddError] = useState('');
|
||||
const [addSuccess, setAddSuccess] = useState(false);
|
||||
const [addHasSkillMd, setAddHasSkillMd] = useState(true);
|
||||
const [addSkillCount, setAddSkillCount] = useState(0);
|
||||
const [addSkillPaths, setAddSkillPaths] = useState<string[]>([]);
|
||||
const [addRequests, setAddRequests] = useState<AddRequest[]>([]);
|
||||
const [loadingAddRequests, setLoadingAddRequests] = useState(false);
|
||||
|
||||
// Fetch user's existing requests
|
||||
useEffect(() => {
|
||||
if (session) {
|
||||
fetchRemovalRequests();
|
||||
fetchAddRequests();
|
||||
}
|
||||
}, [session]);
|
||||
|
||||
const fetchRemovalRequests = async () => {
|
||||
setLoadingRemovalRequests(true);
|
||||
try {
|
||||
const res = await fetch('/api/skills/removal-request');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setRemovalRequests(data.requests || []);
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors
|
||||
} finally {
|
||||
setLoadingRemovalRequests(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchAddRequests = async () => {
|
||||
setLoadingAddRequests(true);
|
||||
try {
|
||||
const res = await fetch('/api/skills/add-request');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setAddRequests(data.requests || []);
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors
|
||||
} finally {
|
||||
setLoadingAddRequests(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!session) {
|
||||
signIn('github');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!skillId.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmittingRemove(true);
|
||||
setRemoveError('');
|
||||
setRemoveSuccess(false);
|
||||
|
||||
try {
|
||||
const res = await fetchWithCsrf('/api/skills/removal-request', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ skillId: skillId.trim(), reason: removeReason.trim() }),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
switch (data.code) {
|
||||
case 'NOT_OWNER':
|
||||
setRemoveError(translations.error.notOwner);
|
||||
break;
|
||||
case 'SKILL_NOT_FOUND':
|
||||
setRemoveError(translations.error.skillNotFound);
|
||||
break;
|
||||
case 'ALREADY_PENDING':
|
||||
setRemoveError(translations.error.alreadyPending);
|
||||
break;
|
||||
case 'GITHUB_ERROR':
|
||||
setRemoveError(translations.error.githubError);
|
||||
break;
|
||||
case 'INVALID_SKILL':
|
||||
setRemoveError(translations.error.invalidSkill);
|
||||
break;
|
||||
case 'AUTH_REQUIRED':
|
||||
signIn('github');
|
||||
return;
|
||||
default:
|
||||
console.error('Claim form error:', data);
|
||||
setRemoveError(translations.error.generic);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setRemoveSuccess(true);
|
||||
setSkillId('');
|
||||
setRemoveReason('');
|
||||
fetchRemovalRequests();
|
||||
} catch {
|
||||
setRemoveError(translations.error.generic);
|
||||
} finally {
|
||||
setIsSubmittingRemove(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!session) {
|
||||
signIn('github');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!repositoryUrl.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Client-side validation
|
||||
if (!isValidGitHubUrl(repositoryUrl.trim())) {
|
||||
setAddError(translations.error.invalidUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmittingAdd(true);
|
||||
setAddError('');
|
||||
setAddSuccess(false);
|
||||
|
||||
try {
|
||||
const res = await fetchWithCsrf('/api/skills/add-request', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
repositoryUrl: repositoryUrl.trim(),
|
||||
reason: addReason.trim() || 'No reason provided',
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
switch (data.code) {
|
||||
case 'INVALID_URL':
|
||||
setAddError(translations.error.invalidUrl);
|
||||
break;
|
||||
case 'INVALID_REPO':
|
||||
setAddError(translations.error.invalidRepo);
|
||||
break;
|
||||
case 'RATE_LIMIT_EXCEEDED':
|
||||
setAddError(translations.error.rateLimitExceeded);
|
||||
break;
|
||||
case 'NETWORK_TIMEOUT':
|
||||
setAddError(translations.error.networkTimeout);
|
||||
break;
|
||||
case 'ALREADY_PENDING':
|
||||
setAddError(translations.error.alreadyPending);
|
||||
break;
|
||||
case 'AUTH_REQUIRED':
|
||||
signIn('github');
|
||||
return;
|
||||
default:
|
||||
console.error('Add form error:', data);
|
||||
setAddError(translations.error.generic);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setAddSuccess(true);
|
||||
setAddHasSkillMd(data.hasSkillMd ?? true);
|
||||
setAddSkillCount(data.skillCount ?? 0);
|
||||
setAddSkillPaths(data.skillPaths ?? []);
|
||||
setRepositoryUrl('');
|
||||
setAddReason('');
|
||||
fetchAddRequests();
|
||||
} catch {
|
||||
setAddError(translations.error.generic);
|
||||
} finally {
|
||||
setIsSubmittingAdd(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (requestStatus: string) => {
|
||||
switch (requestStatus) {
|
||||
case 'pending':
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium rounded-full bg-warning-bg text-warning">
|
||||
<Clock className="w-3 h-3" />
|
||||
{translations.myRequests.status.pending}
|
||||
</span>
|
||||
);
|
||||
case 'approved':
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium rounded-full bg-success-bg text-success">
|
||||
<CheckCircle className="w-3 h-3" />
|
||||
{translations.myRequests.status.approved}
|
||||
</span>
|
||||
);
|
||||
case 'rejected':
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium rounded-full bg-error-bg text-error">
|
||||
<AlertCircle className="w-3 h-3" />
|
||||
{translations.myRequests.status.rejected}
|
||||
</span>
|
||||
);
|
||||
case 'indexed':
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium rounded-full bg-primary-50 text-primary-600">
|
||||
<CheckCircle className="w-3 h-3" />
|
||||
{translations.myRequests.status.indexed}
|
||||
</span>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Loading state
|
||||
if (status === 'loading') {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-primary-600" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Mirror server - redirect to primary
|
||||
if (isMirror) {
|
||||
return (
|
||||
<div className="card p-8 text-center">
|
||||
<ExternalLink className="w-12 h-12 mx-auto mb-4 text-text-muted" />
|
||||
<h2 className="text-xl font-semibold text-text-primary mb-2">{translations.mirror.title}</h2>
|
||||
<p className="text-text-secondary mb-6">{translations.mirror.description}</p>
|
||||
<a
|
||||
href={`${PRIMARY_URL}/claim`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="btn-primary inline-flex items-center gap-2"
|
||||
>
|
||||
<ExternalLink className="w-5 h-5" />
|
||||
{translations.mirror.button}
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Not logged in
|
||||
if (!session) {
|
||||
return (
|
||||
<div className="card p-8 text-center">
|
||||
<Github className="w-12 h-12 mx-auto mb-4 text-text-muted" />
|
||||
<p className="text-text-secondary mb-6">{translations.loginRequired}</p>
|
||||
<button
|
||||
onClick={() => signIn('github')}
|
||||
className="btn-primary inline-flex items-center gap-2"
|
||||
>
|
||||
<Github className="w-5 h-5" />
|
||||
{translations.signIn}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Remove success state
|
||||
if (removeSuccess) {
|
||||
return (
|
||||
<div className="card p-8 text-center">
|
||||
<CheckCircle className="w-12 h-12 mx-auto mb-4 text-success" />
|
||||
<h2 className="text-xl font-semibold text-text-primary mb-2">
|
||||
{translations.success.title}
|
||||
</h2>
|
||||
<p className="text-text-secondary mb-6">{translations.success.description}</p>
|
||||
<button
|
||||
onClick={() => setRemoveSuccess(false)}
|
||||
className="btn-secondary"
|
||||
>
|
||||
{translations.success.viewRequests}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Add success state
|
||||
if (addSuccess) {
|
||||
// Determine which message to show
|
||||
let successDescription: string | React.ReactNode;
|
||||
if (addSkillCount > 1) {
|
||||
successDescription = (
|
||||
<>
|
||||
{translations.addSuccess.descriptionMultiplePrefix}
|
||||
{addSkillCount}
|
||||
{translations.addSuccess.descriptionMultipleSuffix}
|
||||
</>
|
||||
);
|
||||
} else if (addHasSkillMd) {
|
||||
successDescription = translations.addSuccess.description;
|
||||
} else {
|
||||
successDescription = translations.addSuccess.descriptionNoSkillMd;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card p-8 text-center">
|
||||
<CheckCircle className="w-12 h-12 mx-auto mb-4 text-success" />
|
||||
<h2 className="text-xl font-semibold text-text-primary mb-2">
|
||||
{translations.addSuccess.title}
|
||||
</h2>
|
||||
<p className="text-text-secondary mb-4">
|
||||
{successDescription}
|
||||
</p>
|
||||
{addSkillCount > 1 && addSkillPaths.length > 0 && (
|
||||
<div className="mb-6 text-start max-w-md mx-auto">
|
||||
<p className="text-sm text-text-muted mb-2" dir="ltr">{translations.addSuccess.foundSkillsIn}</p>
|
||||
<ul className="text-sm text-text-secondary space-y-1 bg-surface-subtle rounded-lg p-3 max-h-40 overflow-y-auto" dir="ltr">
|
||||
{addSkillPaths.slice(0, 10).map((path, index) => (
|
||||
<li key={index} className="font-mono truncate">
|
||||
{path === '' ? translations.addSuccess.root : path}
|
||||
</li>
|
||||
))}
|
||||
{addSkillPaths.length > 10 && (
|
||||
<li className="text-text-muted">{translations.addSuccess.andMore.replace('{count}', String(addSkillPaths.length - 10))}</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setAddSuccess(false)}
|
||||
className="btn-secondary"
|
||||
>
|
||||
{translations.addSuccess.viewRequests}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-border">
|
||||
<button
|
||||
onClick={() => handleTabChange('remove')}
|
||||
className={`flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === 'remove'
|
||||
? 'border-primary-500 text-primary-600'
|
||||
: 'border-transparent text-text-muted hover:text-text-primary'
|
||||
}`}
|
||||
>
|
||||
<Minus className="w-4 h-4" />
|
||||
{translations.tabs.remove}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleTabChange('add')}
|
||||
className={`flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === 'add'
|
||||
? 'border-primary-500 text-primary-600'
|
||||
: 'border-transparent text-text-muted hover:text-text-primary'
|
||||
}`}
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
{translations.tabs.add}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Remove Form */}
|
||||
{activeTab === 'remove' && (
|
||||
<>
|
||||
<div className="card p-6">
|
||||
<form onSubmit={handleRemoveSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="skillId"
|
||||
className="block text-sm font-medium text-text-primary mb-2"
|
||||
>
|
||||
{translations.form.skillId}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="skillId"
|
||||
value={skillId}
|
||||
onChange={(e) => setSkillId(e.target.value)}
|
||||
placeholder={translations.form.skillIdPlaceholder}
|
||||
className="w-full px-4 py-2 border border-border rounded-lg bg-surface text-text-primary placeholder-text-muted focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
dir="ltr"
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-text-muted mt-1">
|
||||
{translations.form.skillIdHelp}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="removeReason"
|
||||
className="block text-sm font-medium text-text-primary mb-2"
|
||||
>
|
||||
{translations.form.reason} <span className="text-text-muted">{translations.optional}</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="removeReason"
|
||||
value={removeReason}
|
||||
onChange={(e) => setRemoveReason(e.target.value)}
|
||||
placeholder={translations.form.reasonPlaceholder}
|
||||
rows={4}
|
||||
className="w-full px-4 py-2 border border-border rounded-lg bg-surface text-text-primary placeholder-text-muted focus:outline-none focus:ring-2 focus:ring-primary-500 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{removeError && (
|
||||
<div className="p-3 bg-error-bg border border-error/30 rounded-lg">
|
||||
<div className="flex items-center gap-2 text-error">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
<p className="text-sm">{removeError}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmittingRemove || !skillId.trim()}
|
||||
className="btn-primary w-full justify-center disabled:opacity-50"
|
||||
>
|
||||
{isSubmittingRemove ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
{translations.form.submitting}
|
||||
</>
|
||||
) : (
|
||||
translations.form.submit
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Removal Requests */}
|
||||
<div className="card p-6">
|
||||
<h2 className="text-lg font-semibold text-text-primary mb-4">
|
||||
{translations.myRequests.title}
|
||||
</h2>
|
||||
|
||||
{loadingRemovalRequests ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-text-muted" />
|
||||
</div>
|
||||
) : removalRequests.length === 0 ? (
|
||||
<p className="text-text-muted text-center py-8">
|
||||
{translations.myRequests.empty}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{removalRequests.map((request) => (
|
||||
<div
|
||||
key={request.id}
|
||||
className="p-4 bg-surface-subtle rounded-lg"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<code className="text-sm font-mono text-text-primary break-all" dir="ltr">
|
||||
{request.skillId}
|
||||
</code>
|
||||
<p className="text-sm text-text-secondary mt-1 line-clamp-2" dir="auto">
|
||||
{request.reason}
|
||||
</p>
|
||||
<p className="text-xs text-text-muted mt-1" dir="ltr">
|
||||
{new Date(request.createdAt).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-shrink-0">
|
||||
{getStatusBadge(request.status)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Add Form */}
|
||||
{activeTab === 'add' && (
|
||||
<>
|
||||
<div className="card p-6">
|
||||
<form onSubmit={handleAddSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="repositoryUrl"
|
||||
className="block text-sm font-medium text-text-primary mb-2"
|
||||
>
|
||||
{translations.addForm.repositoryUrl}
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
id="repositoryUrl"
|
||||
value={repositoryUrl}
|
||||
onChange={(e) => {
|
||||
setRepositoryUrl(e.target.value);
|
||||
// Clear error when user types
|
||||
if (repositoryUrlError) {
|
||||
setRepositoryUrlError('');
|
||||
}
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
const url = e.target.value.trim();
|
||||
if (url && !isValidGitHubUrl(url)) {
|
||||
setRepositoryUrlError(translations.error.invalidUrl);
|
||||
}
|
||||
}}
|
||||
placeholder={translations.addForm.repositoryUrlPlaceholder}
|
||||
className={`w-full px-4 py-2 border rounded-lg bg-surface text-text-primary placeholder-text-muted focus:outline-none focus:ring-2 ${
|
||||
repositoryUrlError ? 'border-error focus:ring-error' : 'border-border focus:ring-primary-500'
|
||||
}`}
|
||||
dir="ltr"
|
||||
required
|
||||
/>
|
||||
{repositoryUrlError && (
|
||||
<p className="text-xs text-error mt-1">{repositoryUrlError}</p>
|
||||
)}
|
||||
{!repositoryUrlError && (
|
||||
<p className="text-xs text-text-muted mt-1">
|
||||
{translations.addForm.repositoryUrlHelp}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="addReason"
|
||||
className="block text-sm font-medium text-text-primary mb-2"
|
||||
>
|
||||
{translations.addForm.reason} <span className="text-text-muted">{translations.optional}</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="addReason"
|
||||
value={addReason}
|
||||
onChange={(e) => setAddReason(e.target.value)}
|
||||
placeholder={translations.addForm.reasonPlaceholder}
|
||||
rows={4}
|
||||
className="w-full px-4 py-2 border border-border rounded-lg bg-surface text-text-primary placeholder-text-muted focus:outline-none focus:ring-2 focus:ring-primary-500 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{addError && (
|
||||
<div className="p-3 bg-error-bg border border-error/30 rounded-lg">
|
||||
<div className="flex items-center gap-2 text-error">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
<p className="text-sm">{addError}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmittingAdd || !repositoryUrl.trim()}
|
||||
className="btn-primary w-full justify-center disabled:opacity-50"
|
||||
>
|
||||
{isSubmittingAdd ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
{translations.addForm.submitting}
|
||||
</>
|
||||
) : (
|
||||
translations.addForm.submit
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Add Requests */}
|
||||
<div className="card p-6">
|
||||
<h2 className="text-lg font-semibold text-text-primary mb-4">
|
||||
{translations.myRequests.title}
|
||||
</h2>
|
||||
|
||||
{loadingAddRequests ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-text-muted" />
|
||||
</div>
|
||||
) : addRequests.length === 0 ? (
|
||||
<p className="text-text-muted text-center py-8">
|
||||
{translations.myRequests.empty}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{addRequests.map((request) => {
|
||||
// Parse skillPath - could be comma-separated list of paths
|
||||
const skillPaths = request.skillPath?.split(',').map(p => p.trim()).filter(Boolean) || [];
|
||||
const hasMultiplePaths = skillPaths.length > 3;
|
||||
const isExpanded = expandedRequests.has(request.id);
|
||||
const displayPaths = isExpanded ? skillPaths : skillPaths.slice(0, 3);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={request.id}
|
||||
className="p-4 bg-surface-subtle rounded-lg"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<code className="text-sm font-mono text-text-primary break-all" dir="ltr">
|
||||
{request.repositoryUrl}
|
||||
</code>
|
||||
{skillPaths.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<div className="text-xs text-text-muted mb-1">
|
||||
{translations.myRequests.skillsFoundPrefix}{skillPaths.length}{translations.myRequests.skillsFoundSuffix}
|
||||
</div>
|
||||
<div className="bg-surface rounded p-2 max-h-32 overflow-y-auto" dir="ltr">
|
||||
<ul className="text-xs font-mono text-text-secondary space-y-0.5">
|
||||
{displayPaths.map((path, idx) => (
|
||||
<li key={idx} className="truncate">
|
||||
{path || '(root)'}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{hasMultiplePaths && (
|
||||
<button
|
||||
onClick={() => toggleExpanded(request.id)}
|
||||
className="flex items-center gap-1 text-xs text-primary-500 hover:text-primary-600 mt-1"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<>
|
||||
<ChevronUp className="w-3 h-3" />
|
||||
{translations.myRequests.showLess}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronDown className="w-3 h-3" />
|
||||
{translations.myRequests.showAllPrefix}{skillPaths.length}{translations.myRequests.showAllSuffix}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-sm text-text-secondary mt-2 line-clamp-2" dir="auto">
|
||||
{request.reason}
|
||||
</p>
|
||||
{request.errorMessage && (
|
||||
<p className="text-xs text-error mt-1" dir="ltr">
|
||||
{request.errorMessage}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-text-muted mt-1" dir="ltr">
|
||||
{new Date(request.createdAt).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-shrink-0">
|
||||
{getStatusBadge(request.status)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
21
apps/web/components/CsrfProvider.tsx
Normal file
21
apps/web/components/CsrfProvider.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { initializeCsrfToken } from '@/lib/csrf-client';
|
||||
|
||||
interface CsrfProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider component that initializes the CSRF token on mount.
|
||||
* This ensures the token is available for protected API requests.
|
||||
*/
|
||||
export function CsrfProvider({ children }: CsrfProviderProps) {
|
||||
useEffect(() => {
|
||||
// Initialize CSRF token on mount
|
||||
initializeCsrfToken();
|
||||
}, []);
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
127
apps/web/components/DiscoveryModes.tsx
Normal file
127
apps/web/components/DiscoveryModes.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { Copy, Check } from 'lucide-react';
|
||||
|
||||
interface DiscoveryMode {
|
||||
key: string;
|
||||
letter: string;
|
||||
title: string;
|
||||
description: string;
|
||||
latency: string;
|
||||
quality: string;
|
||||
bestFor: string;
|
||||
recommended?: boolean;
|
||||
prompt: string;
|
||||
}
|
||||
|
||||
interface DiscoveryModesProps {
|
||||
modes: DiscoveryMode[];
|
||||
labels: {
|
||||
latency: string;
|
||||
quality: string;
|
||||
bestFor: string;
|
||||
recommended: string;
|
||||
copyPrompt: string;
|
||||
copied: string;
|
||||
selectMode: string;
|
||||
addToFile: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function DiscoveryModes({ modes, labels }: DiscoveryModesProps) {
|
||||
const [selectedMode, setSelectedMode] = useState<string>('standard');
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = useCallback(async (text: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
// Fallback for older browsers
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = text;
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(textarea);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const selected = modes.find((m) => m.key === selectedMode) || modes[0];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 my-6 not-prose">
|
||||
{modes.map((mode) => (
|
||||
<button
|
||||
key={mode.key}
|
||||
onClick={() => setSelectedMode(mode.key)}
|
||||
className={`glass-card p-4 text-start transition-all cursor-pointer ${
|
||||
selectedMode === mode.key
|
||||
? 'ring-2 ring-primary-500 shadow-lg'
|
||||
: 'hover:ring-1 hover:ring-primary-300 opacity-70 hover:opacity-100'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-semibold text-text-primary">
|
||||
{mode.letter}. {mode.title}
|
||||
</span>
|
||||
{mode.recommended && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-300 font-medium">
|
||||
{labels.recommended}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-text-secondary mb-3">{mode.description}</p>
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-text-tertiary">
|
||||
<span>
|
||||
<span className="font-medium">{labels.latency}:</span> {mode.latency}
|
||||
</span>
|
||||
<span>
|
||||
<span className="font-medium">{labels.quality}:</span> {mode.quality}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Selected mode details */}
|
||||
<div className="glass-card p-5 my-6 not-prose">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<h4 className="font-semibold text-text-primary text-lg">
|
||||
{selected.letter}. {selected.title}
|
||||
</h4>
|
||||
<p className="text-sm text-text-secondary">
|
||||
{labels.bestFor}: {selected.bestFor}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleCopy(selected.prompt)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors bg-primary-100 text-primary-700 hover:bg-primary-200 dark:bg-primary-900/30 dark:text-primary-300 dark:hover:bg-primary-900/50"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="w-4 h-4" />
|
||||
{labels.copied}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="w-4 h-4" />
|
||||
{labels.copyPrompt}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm text-text-secondary mb-3">{labels.addToFile}</p>
|
||||
<div className="bg-surface-secondary rounded-lg p-4 overflow-x-auto" dir="ltr">
|
||||
<pre className="text-sm font-mono text-left whitespace-pre-wrap">{selected.prompt}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
107
apps/web/components/EarlyAccessForm.tsx
Normal file
107
apps/web/components/EarlyAccessForm.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Mail, CheckCircle, Loader2 } from 'lucide-react';
|
||||
|
||||
interface EarlyAccessFormProps {
|
||||
variant: 'a' | 'b';
|
||||
locale: string;
|
||||
}
|
||||
|
||||
export function EarlyAccessForm({ variant, locale }: EarlyAccessFormProps) {
|
||||
const t = useTranslations('claudePlugin');
|
||||
const [email, setEmail] = useState('');
|
||||
const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!email || !email.includes('@')) {
|
||||
setErrorMessage(t('form.invalidEmail'));
|
||||
setStatus('error');
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus('loading');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/early-access', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
email,
|
||||
variant,
|
||||
locale,
|
||||
source: 'claude-plugin-landing',
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || 'Failed to submit');
|
||||
}
|
||||
|
||||
setStatus('success');
|
||||
setEmail('');
|
||||
|
||||
} catch (err) {
|
||||
setErrorMessage(err instanceof Error ? err.message : t('form.genericError'));
|
||||
setStatus('error');
|
||||
}
|
||||
};
|
||||
|
||||
if (status === 'success') {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-3 p-6 rounded-xl bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800">
|
||||
<CheckCircle className="w-8 h-8 text-green-600 dark:text-green-400" />
|
||||
<p className="text-green-800 dark:text-green-300 font-medium text-center">
|
||||
{t('form.successMessage')}
|
||||
</p>
|
||||
<p className="text-green-600 dark:text-green-400 text-sm text-center">
|
||||
{t('form.successDescription')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<div className="relative flex-1">
|
||||
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-text-secondary" />
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => {
|
||||
setEmail(e.target.value);
|
||||
if (status === 'error') setStatus('idle');
|
||||
}}
|
||||
placeholder={t('form.emailPlaceholder')}
|
||||
className="w-full pl-10 pr-4 py-3 rounded-lg border border-border bg-surface text-text-primary placeholder:text-text-secondary focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary"
|
||||
disabled={status === 'loading'}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={status === 'loading'}
|
||||
className="px-6 py-3 rounded-lg bg-primary text-white font-medium hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
{status === 'loading' ? (
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
) : null}
|
||||
{variant === 'a' ? t('form.ctaA') : t('form.ctaB')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{status === 'error' && (
|
||||
<p className="text-red-500 text-sm text-center">{errorMessage}</p>
|
||||
)}
|
||||
|
||||
<p className="text-text-secondary text-xs text-center">
|
||||
{t('form.privacyNote')}
|
||||
</p>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
134
apps/web/components/ErrorBoundary.tsx
Normal file
134
apps/web/components/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
"use client";
|
||||
|
||||
import { Component, useState, useCallback } from "react";
|
||||
import type { ReactNode, ErrorInfo } from "react";
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { AlertTriangle, RefreshCw } from "lucide-react";
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
eventId: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* React Error Boundary with Sentry integration
|
||||
* Catches rendering errors in child components and reports them to Sentry
|
||||
*/
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null, eventId: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): Partial<State> {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
// Report to Sentry with React component stack
|
||||
const eventId = Sentry.captureException(error, {
|
||||
contexts: {
|
||||
react: {
|
||||
componentStack: errorInfo.componentStack,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
this.setState({ eventId });
|
||||
|
||||
// Also log to console in development
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
console.error("Error caught by boundary:", error, errorInfo);
|
||||
}
|
||||
}
|
||||
|
||||
handleRetry = () => {
|
||||
this.setState({ hasError: false, error: null, eventId: null });
|
||||
};
|
||||
|
||||
handleReportFeedback = () => {
|
||||
if (this.state.eventId) {
|
||||
Sentry.showReportDialog({ eventId: this.state.eventId });
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[400px] flex-col items-center justify-center p-8">
|
||||
<div className="mx-auto max-w-md text-center">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-error-bg">
|
||||
<AlertTriangle className="h-8 w-8 text-error" />
|
||||
</div>
|
||||
|
||||
<h2 className="mb-2 text-xl font-semibold text-text-primary">
|
||||
Something went wrong
|
||||
</h2>
|
||||
|
||||
<p className="mb-6 text-text-secondary">
|
||||
We've been notified and are working on a fix. Please try
|
||||
again or contact support if the problem persists.
|
||||
</p>
|
||||
|
||||
{process.env.NODE_ENV === "development" && this.state.error && (
|
||||
<div className="mb-6 rounded-lg bg-error-bg p-4 text-left">
|
||||
<p className="font-mono text-sm text-error">
|
||||
{this.state.error.message}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:justify-center">
|
||||
<button
|
||||
onClick={this.handleRetry}
|
||||
className="inline-flex items-center justify-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
Try again
|
||||
</button>
|
||||
|
||||
{this.state.eventId && (
|
||||
<button
|
||||
onClick={this.handleReportFeedback}
|
||||
className="inline-flex items-center justify-center rounded-lg border border-border bg-surface px-4 py-2 text-sm font-medium text-text-primary hover:bg-surface-subtle transition-colors"
|
||||
>
|
||||
Report feedback
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to throw errors from async code so they can be caught by ErrorBoundary
|
||||
* Usage:
|
||||
* const throwError = useAsyncError();
|
||||
* fetchData().catch(throwError);
|
||||
*/
|
||||
export function useAsyncError() {
|
||||
const [, setError] = useState<Error | null>(null);
|
||||
|
||||
return useCallback((error: Error) => {
|
||||
setError(() => {
|
||||
throw error;
|
||||
});
|
||||
}, []);
|
||||
}
|
||||
|
||||
export default ErrorBoundary;
|
||||
164
apps/web/components/FavoriteButton.tsx
Normal file
164
apps/web/components/FavoriteButton.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import { useSession, signIn } from 'next-auth/react';
|
||||
import { useTranslations, useLocale } from 'next-intl';
|
||||
import { Heart, ExternalLink } from 'lucide-react';
|
||||
import { fetchWithCsrf } from '@/lib/csrf-client';
|
||||
|
||||
const isMirror = process.env.NEXT_PUBLIC_IS_PRIMARY === 'false';
|
||||
const PRIMARY_URL = process.env.NEXT_PUBLIC_PRIMARY_URL || process.env.NEXT_PUBLIC_APP_URL || 'https://skills.palebluedot.live';
|
||||
|
||||
interface FavoriteButtonProps {
|
||||
skillId: string;
|
||||
initialFavorited?: boolean;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
showLabel?: boolean;
|
||||
onToggle?: (favorited: boolean) => void;
|
||||
}
|
||||
|
||||
export function FavoriteButton({
|
||||
skillId,
|
||||
initialFavorited = false,
|
||||
size = 'md',
|
||||
showLabel = false,
|
||||
onToggle,
|
||||
}: FavoriteButtonProps) {
|
||||
const { data: session, status } = useSession();
|
||||
const t = useTranslations('favorites');
|
||||
const locale = useLocale();
|
||||
const [isFavorited, setIsFavorited] = useState(initialFavorited);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [mirrorNotice, setMirrorNotice] = useState(false);
|
||||
const pendingRef = useRef(false);
|
||||
|
||||
// Fetch actual favorite status when user is authenticated
|
||||
useEffect(() => {
|
||||
if (initialFavorited || status !== 'authenticated') return;
|
||||
let cancelled = false;
|
||||
fetchWithCsrf('/api/favorites/check', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ skillIds: [skillId] }),
|
||||
})
|
||||
.then((res) => res.ok ? res.json() : null)
|
||||
.then((data) => {
|
||||
if (!cancelled && data?.favorited?.[skillId]) {
|
||||
setIsFavorited(true);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => { cancelled = true; };
|
||||
}, [skillId, status, initialFavorited]);
|
||||
|
||||
// Auto-dismiss mirror notice
|
||||
useEffect(() => {
|
||||
if (!mirrorNotice) return;
|
||||
const timer = setTimeout(() => setMirrorNotice(false), 4000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [mirrorNotice]);
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'w-4 h-4',
|
||||
md: 'w-5 h-5',
|
||||
lg: 'w-6 h-6',
|
||||
};
|
||||
|
||||
const buttonSizeClasses = {
|
||||
sm: 'p-1.5',
|
||||
md: 'p-2',
|
||||
lg: 'p-2.5',
|
||||
};
|
||||
|
||||
const handleToggle = useCallback(async () => {
|
||||
if (pendingRef.current) return;
|
||||
if (status === 'loading') return;
|
||||
|
||||
if (!session) {
|
||||
signIn('github');
|
||||
return;
|
||||
}
|
||||
|
||||
if (isMirror) {
|
||||
setMirrorNotice(true);
|
||||
return;
|
||||
}
|
||||
|
||||
pendingRef.current = true;
|
||||
const newState = !isFavorited;
|
||||
setIsFavorited(newState); // Optimistic update
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const res = await fetchWithCsrf('/api/favorites', {
|
||||
method: newState ? 'POST' : 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ skillId }),
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error('Failed to update favorite');
|
||||
|
||||
onToggle?.(newState);
|
||||
} catch (error) {
|
||||
setIsFavorited(!newState); // Rollback on error
|
||||
console.error('Favorite error:', error);
|
||||
} finally {
|
||||
pendingRef.current = false;
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [session, status, isFavorited, skillId, onToggle]);
|
||||
|
||||
const mirrorMsg = locale === 'fa'
|
||||
? 'برای افزودن به علاقهمندیها، از سایت اصلی استفاده کنید'
|
||||
: 'To add favorites, visit the main site';
|
||||
|
||||
return (
|
||||
<span className="relative inline-block">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleToggle}
|
||||
disabled={isLoading || status === 'loading'}
|
||||
aria-label={isFavorited ? t('remove') : t('add')}
|
||||
aria-pressed={isFavorited}
|
||||
title={!session && status !== 'loading' ? t('loginRequired') : (isFavorited ? t('remove') : t('add'))}
|
||||
className={`
|
||||
${buttonSizeClasses[size]}
|
||||
rounded-lg transition-all duration-200
|
||||
hover:bg-red-50 dark:hover:bg-red-950 group
|
||||
${isLoading ? 'opacity-50 cursor-wait' : ''}
|
||||
${showLabel ? 'flex items-center gap-2 px-3' : ''}
|
||||
`}
|
||||
>
|
||||
<Heart
|
||||
className={`
|
||||
${sizeClasses[size]}
|
||||
transition-all duration-200
|
||||
${
|
||||
isFavorited
|
||||
? 'fill-red-500 text-red-500'
|
||||
: 'fill-transparent text-text-muted group-hover:text-red-500'
|
||||
}
|
||||
`}
|
||||
/>
|
||||
{showLabel && (
|
||||
<span className="text-sm text-text-secondary">
|
||||
{isFavorited ? t('remove') : t('add')}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{mirrorNotice && (
|
||||
<div className="absolute z-50 bottom-full left-1/2 -translate-x-1/2 mb-2 whitespace-nowrap animate-in fade-in slide-in-from-bottom-2 duration-200">
|
||||
<a
|
||||
href={PRIMARY_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5 px-3 py-2 text-xs bg-surface-elevated border border-border rounded-lg shadow-lg text-text-primary hover:text-primary-500 transition-colors"
|
||||
>
|
||||
{mirrorMsg}
|
||||
<ExternalLink className="w-3 h-3 flex-shrink-0" />
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
156
apps/web/components/FavoritesList.tsx
Normal file
156
apps/web/components/FavoritesList.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
Heart,
|
||||
Star,
|
||||
Download,
|
||||
Shield,
|
||||
CheckCircle,
|
||||
ArrowRight,
|
||||
ArrowLeft,
|
||||
} from 'lucide-react';
|
||||
import { FavoriteButton } from './FavoriteButton';
|
||||
|
||||
interface Skill {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
githubOwner: string;
|
||||
githubStars: number | null;
|
||||
downloadCount: number | null;
|
||||
securityStatus: string | null;
|
||||
isVerified: boolean | null;
|
||||
compatibility: { platforms?: string[] } | null;
|
||||
}
|
||||
|
||||
interface FavoriteItem {
|
||||
skill: Skill;
|
||||
}
|
||||
|
||||
interface FavoritesListProps {
|
||||
initialFavorites: FavoriteItem[];
|
||||
locale: string;
|
||||
translations: {
|
||||
verified: string;
|
||||
emptyTitle: string;
|
||||
emptyDescription: string;
|
||||
emptyCta: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function FavoritesList({ initialFavorites, locale, translations }: FavoritesListProps) {
|
||||
const [favorites, setFavorites] = useState<FavoriteItem[]>(initialFavorites);
|
||||
|
||||
const isRTL = locale === 'fa';
|
||||
const ArrowIcon = isRTL ? ArrowLeft : ArrowRight;
|
||||
|
||||
const formatNumber = (num: number | null): string => {
|
||||
if (num === null) return '0';
|
||||
if (num >= 1000) {
|
||||
return (num / 1000).toFixed(1) + 'k';
|
||||
}
|
||||
return num.toString();
|
||||
};
|
||||
|
||||
const handleToggleFavorite = useCallback((skillId: string, isFavorited: boolean) => {
|
||||
if (!isFavorited) {
|
||||
// Remove from list when unfavorited
|
||||
setFavorites(prev => prev.filter(item => item.skill.id !== skillId));
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (favorites.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-16">
|
||||
<Heart className="w-16 h-16 text-text-muted mx-auto mb-4" />
|
||||
<h2 className="text-xl font-semibold text-text-primary mb-2">
|
||||
{translations.emptyTitle}
|
||||
</h2>
|
||||
<p className="text-text-secondary mb-6">{translations.emptyDescription}</p>
|
||||
<Link href={`/${locale}/browse`} className="btn-primary gap-2">
|
||||
{translations.emptyCta}
|
||||
<ArrowIcon className="w-4 h-4" />
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{favorites.map(({ skill }) => (
|
||||
<div
|
||||
key={skill.id}
|
||||
className="card p-6 relative border border-transparent hover:border-primary-200 transition-all"
|
||||
>
|
||||
{/* Favorite button in corner */}
|
||||
<div className="absolute top-4 end-4">
|
||||
<FavoriteButton
|
||||
skillId={skill.id}
|
||||
initialFavorited={true}
|
||||
size="sm"
|
||||
onToggle={(isFavorited) => handleToggleFavorite(skill.id, isFavorited)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Link href={`/${locale}/skill/${skill.id}`}>
|
||||
<div className="flex items-start gap-3 mb-3 pe-8">
|
||||
<div className="w-12 h-12 bg-primary-50 rounded-xl flex items-center justify-center flex-shrink-0">
|
||||
<span className="text-xl font-bold text-primary-600">
|
||||
{skill.name.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h3 className="font-semibold text-text-primary truncate">
|
||||
{skill.name}
|
||||
</h3>
|
||||
<p className="text-sm text-text-muted">@{skill.githubOwner}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-text-secondary line-clamp-2 mb-4" dir="auto">
|
||||
{skill.description ?? ''}
|
||||
</p>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="flex items-center gap-4 text-sm text-text-muted">
|
||||
<span className="flex items-center gap-1 ltr-nums">
|
||||
<Star className="w-4 h-4 text-warning" />
|
||||
{formatNumber(skill.githubStars)}
|
||||
</span>
|
||||
<span className="flex items-center gap-1 ltr-nums">
|
||||
<Download className="w-4 h-4 text-primary-500" />
|
||||
{formatNumber(skill.downloadCount)}
|
||||
</span>
|
||||
<span className={`flex items-center gap-1 ${skill.securityStatus === 'pass' ? 'text-success' : skill.securityStatus === 'warning' ? 'text-warning' : 'text-text-muted'}`}>
|
||||
<Shield className="w-4 h-4" />
|
||||
{skill.securityStatus === 'pass' ? '✓' : skill.securityStatus === 'warning' ? '⚠' : '-'}
|
||||
</span>
|
||||
{skill.isVerified && (
|
||||
<span className="flex items-center gap-1 text-success">
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
{translations.verified}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Platforms */}
|
||||
{skill.compatibility?.platforms && skill.compatibility.platforms.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mt-3">
|
||||
{skill.compatibility.platforms.map((platform) => (
|
||||
<span
|
||||
key={platform}
|
||||
className="px-2 py-0.5 bg-primary-50 text-primary-600 text-xs font-medium rounded"
|
||||
>
|
||||
{platform}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
27
apps/web/components/FavoritesSignIn.tsx
Normal file
27
apps/web/components/FavoritesSignIn.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
'use client';
|
||||
|
||||
import { signIn } from 'next-auth/react';
|
||||
import { Github } from 'lucide-react';
|
||||
|
||||
interface FavoritesSignInProps {
|
||||
translations: {
|
||||
loginRequired: string;
|
||||
signIn: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function FavoritesSignIn({ translations }: FavoritesSignInProps) {
|
||||
return (
|
||||
<div className="card p-8 text-center">
|
||||
<Github className="w-12 h-12 mx-auto mb-4 text-text-muted" />
|
||||
<p className="text-text-secondary mb-6">{translations.loginRequired}</p>
|
||||
<button
|
||||
onClick={() => signIn('github')}
|
||||
className="btn-primary inline-flex items-center gap-2"
|
||||
>
|
||||
<Github className="w-5 h-5" />
|
||||
{translations.signIn}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
172
apps/web/components/Footer.tsx
Normal file
172
apps/web/components/Footer.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useTranslations, useLocale } from 'next-intl';
|
||||
import { Github, Heart } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
|
||||
export function Footer() {
|
||||
const t = useTranslations('footer');
|
||||
const locale = useLocale();
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
const footerLinks = {
|
||||
product: [
|
||||
{ name: t('links.browseSkills'), href: `/${locale}/browse` },
|
||||
{ name: t('links.categories'), href: `/${locale}/categories` },
|
||||
{ name: t('links.featured'), href: `/${locale}/featured` },
|
||||
{ name: t('links.newSkills'), href: `/${locale}/new` },
|
||||
],
|
||||
resources: [
|
||||
{ name: t('links.documentation'), href: `/${locale}/docs` },
|
||||
{ name: t('links.gettingStarted'), href: `/${locale}/docs/getting-started` },
|
||||
{ name: t('links.apiReference'), href: `/${locale}/docs/api` },
|
||||
{ name: t('links.cliTool'), href: `/${locale}/docs/cli` },
|
||||
],
|
||||
company: [
|
||||
{ name: t('links.about'), href: `/${locale}/about`, external: false },
|
||||
{ name: t('links.airano'), href: 'https://airano.ir', external: true },
|
||||
{ name: t('links.paleBlueDot'), href: 'https://palebluedot.live', external: true },
|
||||
{ name: t('links.support'), href: `/${locale}/support`, external: false, isSupport: true },
|
||||
],
|
||||
legal: [
|
||||
{ name: t('links.privacyPolicy'), href: `/${locale}/privacy` },
|
||||
{ name: t('links.termsOfService'), href: `/${locale}/terms` },
|
||||
{ name: t('links.attribution'), href: `/${locale}/attribution` },
|
||||
{ name: t('links.manageSkills'), href: `/${locale}/claim` },
|
||||
],
|
||||
};
|
||||
|
||||
return (
|
||||
<footer className="bg-surface-muted border-t border-border">
|
||||
<div className="container-main py-12 lg:py-16">
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-8 lg:gap-12">
|
||||
{/* Brand */}
|
||||
<div className="col-span-2 md:col-span-1">
|
||||
<Link href={`/${locale}`} className="flex items-center gap-2 mb-4">
|
||||
<Image src="/logo.svg" alt="SkillHub" width={36} height={36} className="rounded-xl" />
|
||||
<span className="text-xl font-bold text-text-primary">
|
||||
Skill<span className="text-primary-500">Hub</span>
|
||||
</span>
|
||||
</Link>
|
||||
<p className="text-text-secondary text-sm mb-4">
|
||||
{t('description')}
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<a
|
||||
href="https://github.com/airano-ir/skillhub"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="p-2 rounded-lg text-text-muted hover:text-text-primary hover:bg-surface-subtle transition-colors"
|
||||
title={t('sourceCode')}
|
||||
>
|
||||
<Github className="w-5 h-5" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Product */}
|
||||
<div>
|
||||
<h3 className="font-semibold text-text-primary mb-4">
|
||||
{t('links.product')}
|
||||
</h3>
|
||||
<ul className="space-y-3">
|
||||
{footerLinks.product.map((link) => (
|
||||
<li key={link.href}>
|
||||
<Link
|
||||
href={link.href}
|
||||
className="text-text-secondary hover:text-primary-600 transition-colors text-sm"
|
||||
>
|
||||
{link.name}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Resources */}
|
||||
<div>
|
||||
<h3 className="font-semibold text-text-primary mb-4">
|
||||
{t('links.resources')}
|
||||
</h3>
|
||||
<ul className="space-y-3">
|
||||
{footerLinks.resources.map((link) => (
|
||||
<li key={link.href}>
|
||||
<Link
|
||||
href={link.href}
|
||||
className="text-text-secondary hover:text-primary-600 transition-colors text-sm"
|
||||
>
|
||||
{link.name}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Company */}
|
||||
<div>
|
||||
<h3 className="font-semibold text-text-primary mb-4">
|
||||
{t('links.company')}
|
||||
</h3>
|
||||
<ul className="space-y-3">
|
||||
{footerLinks.company.map((link) => (
|
||||
<li key={link.href}>
|
||||
{link.external ? (
|
||||
<a
|
||||
href={link.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-text-secondary hover:text-primary-600 transition-colors text-sm"
|
||||
>
|
||||
{link.name}
|
||||
</a>
|
||||
) : (
|
||||
<Link
|
||||
href={link.href}
|
||||
className="text-text-secondary hover:text-primary-600 transition-colors text-sm"
|
||||
>
|
||||
{link.isSupport ? (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{link.name}
|
||||
<Heart className="w-3 h-3 text-error fill-current" />
|
||||
</span>
|
||||
) : (
|
||||
link.name
|
||||
)}
|
||||
</Link>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Legal */}
|
||||
<div>
|
||||
<h3 className="font-semibold text-text-primary mb-4">
|
||||
{t('links.legal')}
|
||||
</h3>
|
||||
<ul className="space-y-3">
|
||||
{footerLinks.legal.map((link) => (
|
||||
<li key={link.href}>
|
||||
<Link
|
||||
href={link.href}
|
||||
className="text-text-secondary hover:text-primary-600 transition-colors text-sm"
|
||||
>
|
||||
{link.name}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom */}
|
||||
<div className="mt-12 pt-8 border-t border-border">
|
||||
<p className="text-text-muted text-sm text-center">
|
||||
{t('copyright', { year: currentYear })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
142
apps/web/components/Header.tsx
Normal file
142
apps/web/components/Header.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useTranslations, useLocale } from 'next-intl';
|
||||
import { useRouter, usePathname } from '@/i18n/navigation';
|
||||
import { Menu, X, Globe, Github, Search } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import { clsx } from 'clsx';
|
||||
import { AuthButton } from './AuthButton';
|
||||
import { ThemeToggle } from './ThemeToggle';
|
||||
|
||||
export function Header() {
|
||||
const t = useTranslations('nav');
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
|
||||
const navigation = [
|
||||
{ name: t('home'), href: `/${locale}` },
|
||||
{ name: t('browse'), href: `/${locale}/browse` },
|
||||
{ name: t('categories'), href: `/${locale}/categories` },
|
||||
{ name: t('docs'), href: `/${locale}/docs` },
|
||||
];
|
||||
|
||||
const otherLocale = locale === 'fa' ? 'en' : 'fa';
|
||||
const otherLocaleName = locale === 'fa' ? 'EN' : 'فا';
|
||||
|
||||
const switchLanguage = () => {
|
||||
router.replace(pathname, { locale: otherLocale });
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-50 bg-surface/80 backdrop-blur-lg border-b border-border">
|
||||
<nav className="container-main">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
{/* Logo */}
|
||||
<Link href={`/${locale}`} className="flex items-center gap-2">
|
||||
<Image src="/logo.svg" alt="SkillHub" width={36} height={36} className="rounded-xl" />
|
||||
<span className="text-xl font-bold text-text-primary">
|
||||
Skill<span className="text-primary-500">Hub</span>
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
{/* Desktop Navigation */}
|
||||
<div className="hidden md:flex items-center gap-1">
|
||||
{navigation.map((item) => (
|
||||
<Link
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
className="px-4 py-2 rounded-lg text-text-secondary hover:text-text-primary hover:bg-surface-subtle transition-colors"
|
||||
>
|
||||
{item.name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Right side */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Search button - navigates to browse page (hidden on mobile) */}
|
||||
<Link
|
||||
href={`/${locale}/browse`}
|
||||
className="hidden sm:flex p-2 rounded-lg text-text-muted hover:text-text-primary hover:bg-surface-subtle transition-colors"
|
||||
title={t('browse')}
|
||||
>
|
||||
<Search className="w-5 h-5" />
|
||||
</Link>
|
||||
|
||||
{/* Language switcher */}
|
||||
<button
|
||||
onClick={switchLanguage}
|
||||
data-testid="lang-switch"
|
||||
aria-label={t('switchLanguage')}
|
||||
className="flex items-center gap-1.5 px-3 py-2 rounded-lg text-text-secondary hover:text-text-primary hover:bg-surface-subtle transition-colors"
|
||||
>
|
||||
<Globe className="w-4 h-4" />
|
||||
<span className="text-sm font-medium">{otherLocaleName}</span>
|
||||
</button>
|
||||
|
||||
{/* Theme toggle */}
|
||||
<ThemeToggle />
|
||||
|
||||
{/* Auth button */}
|
||||
<AuthButton />
|
||||
|
||||
{/* GitHub repo link - hidden on mobile, visible on desktop */}
|
||||
<a
|
||||
href="https://github.com/airano-ir/skillhub"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hidden sm:flex p-2 rounded-lg text-text-muted hover:text-text-primary hover:bg-surface-subtle transition-colors"
|
||||
title={t('sourceCode')}
|
||||
>
|
||||
<Github className="w-5 h-5" />
|
||||
</a>
|
||||
|
||||
{/* Mobile menu button */}
|
||||
<button
|
||||
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||
className="md:hidden p-2 rounded-lg text-text-muted hover:text-text-primary hover:bg-surface-subtle"
|
||||
>
|
||||
{mobileMenuOpen ? <X className="w-5 h-5" /> : <Menu className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Navigation */}
|
||||
<div
|
||||
className={clsx(
|
||||
'md:hidden overflow-hidden transition-all duration-300',
|
||||
mobileMenuOpen ? 'max-h-80 pb-4' : 'max-h-0'
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-1 pt-2">
|
||||
{navigation.map((item) => (
|
||||
<Link
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
className="px-4 py-3 rounded-lg text-text-secondary hover:text-text-primary hover:bg-surface-subtle transition-colors"
|
||||
>
|
||||
{item.name}
|
||||
</Link>
|
||||
))}
|
||||
{/* GitHub repo link in mobile menu */}
|
||||
<a
|
||||
href="https://github.com/airano-ir/skillhub"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
className="flex items-center gap-2 px-4 py-3 rounded-lg text-text-secondary hover:text-text-primary hover:bg-surface-subtle transition-colors sm:hidden"
|
||||
>
|
||||
<Github className="w-5 h-5" />
|
||||
{t('sourceCodeShort')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
45
apps/web/components/HeroSearch.tsx
Normal file
45
apps/web/components/HeroSearch.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
'use client';
|
||||
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Search } from 'lucide-react';
|
||||
|
||||
interface HeroSearchProps {
|
||||
placeholder: string;
|
||||
locale: string;
|
||||
}
|
||||
|
||||
export function HeroSearch({ placeholder, locale }: HeroSearchProps) {
|
||||
const [query, setQuery] = useState('');
|
||||
const router = useRouter();
|
||||
|
||||
const handleSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (query.trim()) {
|
||||
router.push(`/${locale}/browse?q=${encodeURIComponent(query.trim())}`);
|
||||
} else {
|
||||
router.push(`/${locale}/browse`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="max-w-xl mx-auto mb-8 animate-fade-up animation-delay-300">
|
||||
<div className="relative">
|
||||
<Search className="absolute start-4 top-1/2 -translate-y-1/2 w-5 h-5 text-text-muted" />
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="input-field ps-12 pe-24"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="absolute end-2 top-1/2 -translate-y-1/2 btn-primary py-1.5 px-4 text-sm"
|
||||
>
|
||||
<Search className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
689
apps/web/components/InstallSection.tsx
Normal file
689
apps/web/components/InstallSection.tsx
Normal file
@@ -0,0 +1,689 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { Copy, Terminal, FolderOpen, Download, Check, Loader2, AlertTriangle } from 'lucide-react';
|
||||
|
||||
type Platform = 'claude' | 'codex' | 'copilot' | 'cursor' | 'windsurf';
|
||||
|
||||
// Flat-file platforms where the skill file goes directly into the rules/instructions dir
|
||||
const FLAT_FILE_PLATFORMS: Platform[] = ['cursor', 'windsurf', 'copilot'];
|
||||
|
||||
// All known main instruction file names across platforms
|
||||
const MAIN_FILE_NAMES = ['SKILL.md', 'AGENTS.md', '.cursorrules', '.windsurfrules', 'copilot-instructions.md'];
|
||||
|
||||
// Map sourceFormat to its native platform (skip transformation when source matches target)
|
||||
const FORMAT_NATIVE_PLATFORM: Record<string, Platform> = {
|
||||
'skill.md': 'claude',
|
||||
'agents.md': 'codex',
|
||||
'cursorrules': 'cursor',
|
||||
'windsurfrules': 'windsurf',
|
||||
'copilot-instructions': 'copilot',
|
||||
};
|
||||
|
||||
interface InstallCommand {
|
||||
cli: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface SkillFile {
|
||||
name: string;
|
||||
path: string;
|
||||
type: 'file' | 'dir';
|
||||
size: number;
|
||||
content?: string;
|
||||
downloadUrl?: string;
|
||||
}
|
||||
|
||||
// JSZip type for dynamic loading
|
||||
interface JSZipInstance {
|
||||
file(name: string, content: string | ArrayBuffer): void;
|
||||
generateAsync(options: { type: 'blob' }): Promise<Blob>;
|
||||
}
|
||||
|
||||
interface JSZipConstructor {
|
||||
new(): JSZipInstance;
|
||||
}
|
||||
|
||||
// CDN URLs for JSZip (with fallbacks)
|
||||
const JSZIP_CDN_URLS = [
|
||||
'https://cdn.jsdelivr.net/npm/jszip@3.10.1/dist/jszip.min.js',
|
||||
'https://unpkg.com/jszip@3.10.1/dist/jszip.min.js',
|
||||
'https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js',
|
||||
];
|
||||
|
||||
// Dynamically load JSZip from CDN with fallbacks
|
||||
let jsZipPromise: Promise<JSZipConstructor> | null = null;
|
||||
|
||||
function loadJSZip(): Promise<JSZipConstructor> {
|
||||
if (jsZipPromise) return jsZipPromise;
|
||||
|
||||
jsZipPromise = new Promise((resolve, reject) => {
|
||||
// Check if already loaded
|
||||
if (typeof window !== 'undefined' && (window as unknown as { JSZip?: JSZipConstructor }).JSZip) {
|
||||
resolve((window as unknown as { JSZip: JSZipConstructor }).JSZip);
|
||||
return;
|
||||
}
|
||||
|
||||
// Try loading from CDN with fallbacks
|
||||
let cdnIndex = 0;
|
||||
|
||||
function tryLoadFromCDN() {
|
||||
if (cdnIndex >= JSZIP_CDN_URLS.length) {
|
||||
reject(new Error('Failed to load JSZip from all CDNs. Please check your internet connection.'));
|
||||
return;
|
||||
}
|
||||
|
||||
const script = document.createElement('script');
|
||||
script.src = JSZIP_CDN_URLS[cdnIndex];
|
||||
script.async = true;
|
||||
script.onload = () => {
|
||||
const JSZip = (window as unknown as { JSZip?: JSZipConstructor }).JSZip;
|
||||
if (JSZip) {
|
||||
resolve(JSZip);
|
||||
} else {
|
||||
// Try next CDN
|
||||
cdnIndex++;
|
||||
tryLoadFromCDN();
|
||||
}
|
||||
};
|
||||
script.onerror = () => {
|
||||
console.warn(`Failed to load JSZip from ${JSZIP_CDN_URLS[cdnIndex]}, trying fallback...`);
|
||||
cdnIndex++;
|
||||
tryLoadFromCDN();
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
|
||||
tryLoadFromCDN();
|
||||
});
|
||||
|
||||
return jsZipPromise;
|
||||
}
|
||||
|
||||
// --- Platform-specific file transformation for downloads ---
|
||||
|
||||
function stripFrontmatter(content: string): { body: string; description?: string; filePatterns?: string[] } {
|
||||
const match = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
|
||||
if (!match) return { body: content };
|
||||
|
||||
const yaml = match[1];
|
||||
const body = match[2].trim();
|
||||
|
||||
// Extract description from YAML
|
||||
const descMatch = yaml.match(/^description:\s*(.+)$/m);
|
||||
const description = descMatch ? descMatch[1].trim() : undefined;
|
||||
|
||||
// Extract file patterns from triggers.filePatterns
|
||||
const patternsMatch = yaml.match(/filePatterns:\s*\n((?:\s+-\s+.+\n?)+)/);
|
||||
let filePatterns: string[] | undefined;
|
||||
if (patternsMatch) {
|
||||
filePatterns = patternsMatch[1]
|
||||
.split('\n')
|
||||
.map(l => l.replace(/^\s+-\s+/, '').replace(/["']/g, '').trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
return { body, description, filePatterns };
|
||||
}
|
||||
|
||||
function getPlatformFileName(platform: Platform, skillName: string): string {
|
||||
switch (platform) {
|
||||
case 'claude':
|
||||
case 'codex':
|
||||
return 'SKILL.md';
|
||||
case 'cursor':
|
||||
return `${skillName}.mdc`;
|
||||
case 'windsurf':
|
||||
return `${skillName}.md`;
|
||||
case 'copilot':
|
||||
return `${skillName}.instructions.md`;
|
||||
}
|
||||
}
|
||||
|
||||
function transformSkillContent(platform: Platform, content: string, skillName: string, srcFormat?: string): string {
|
||||
// If the source is already in the target platform's native format, skip transformation
|
||||
if (srcFormat && FORMAT_NATIVE_PLATFORM[srcFormat] === platform) return content;
|
||||
|
||||
if (platform === 'claude' || platform === 'codex') return content;
|
||||
|
||||
const { body, description, filePatterns } = stripFrontmatter(content);
|
||||
|
||||
if (platform === 'cursor') {
|
||||
const mdcFields: string[] = [];
|
||||
if (description) mdcFields.push(`description: ${description}`);
|
||||
if (filePatterns && filePatterns.length > 0) {
|
||||
mdcFields.push(`globs: ${filePatterns.join(', ')}`);
|
||||
mdcFields.push('alwaysApply: false');
|
||||
} else {
|
||||
mdcFields.push('alwaysApply: true');
|
||||
}
|
||||
return `---\n${mdcFields.join('\n')}\n---\n${body}\n`;
|
||||
}
|
||||
|
||||
// windsurf / copilot: plain markdown
|
||||
let plainBody = body;
|
||||
if (!plainBody.startsWith('# ')) {
|
||||
plainBody = `# ${skillName}\n\n${plainBody}`;
|
||||
}
|
||||
return plainBody + '\n';
|
||||
}
|
||||
|
||||
interface InstallSectionProps {
|
||||
skillId: string;
|
||||
skillName: string;
|
||||
repositoryUrl: string;
|
||||
sourceFormat?: string;
|
||||
installCommands: Record<Platform, InstallCommand>;
|
||||
translations: {
|
||||
title: string;
|
||||
cli: string;
|
||||
cliGlobal?: string;
|
||||
cliProject?: string;
|
||||
selectFolder: string;
|
||||
suggestedPath: string;
|
||||
copied: string;
|
||||
downloadZip: string;
|
||||
copyCommand: string;
|
||||
downloading: string;
|
||||
installing: string;
|
||||
installed: string;
|
||||
downloadFailed: string;
|
||||
browserNotSupported: string;
|
||||
rateLimitError: string;
|
||||
timeoutError: string;
|
||||
notFoundError: string;
|
||||
noFilesError: string;
|
||||
disclaimer: string;
|
||||
folderNotePrefix: string;
|
||||
folderNoteSuffix: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function InstallSection({
|
||||
skillId,
|
||||
skillName,
|
||||
repositoryUrl: _repositoryUrl,
|
||||
sourceFormat,
|
||||
installCommands,
|
||||
translations,
|
||||
}: InstallSectionProps) {
|
||||
const [selectedPlatform, setSelectedPlatform] = useState<Platform>('claude');
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [installStatus, setInstallStatus] = useState<'idle' | 'downloading' | 'installing' | 'done' | 'error'>('idle');
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
|
||||
// Check if File System Access API is supported (after mount to avoid hydration mismatch)
|
||||
const [isFileSystemSupported, setIsFileSystemSupported] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsFileSystemSupported('showDirectoryPicker' in window);
|
||||
}, []);
|
||||
|
||||
const currentCommand = installCommands[selectedPlatform];
|
||||
|
||||
// Copy command to clipboard
|
||||
const handleCopyCommand = useCallback(async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(currentCommand.cli);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err);
|
||||
}
|
||||
}, [currentCommand.cli]);
|
||||
|
||||
// Download ZIP using server-side generation (fallback)
|
||||
const downloadFromServer = useCallback(async () => {
|
||||
const response = await fetch(`/api/skill-files/zip?id=${encodeURIComponent(skillId)}&platform=${selectedPlatform}`);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
const errorCode = errorData.code || 'UNKNOWN';
|
||||
|
||||
switch (errorCode) {
|
||||
case 'RATE_LIMIT':
|
||||
throw new Error(translations.rateLimitError);
|
||||
case 'TIMEOUT':
|
||||
throw new Error(translations.timeoutError);
|
||||
case 'NOT_FOUND':
|
||||
case 'GITHUB_NOT_FOUND':
|
||||
throw new Error(translations.notFoundError);
|
||||
case 'NO_FILES':
|
||||
throw new Error(translations.noFilesError);
|
||||
default:
|
||||
throw new Error(errorData.error || translations.downloadFailed);
|
||||
}
|
||||
}
|
||||
|
||||
// Server returns ZIP directly - trigger download
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${skillName}.zip`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.URL.revokeObjectURL(url);
|
||||
}, [skillId, skillName, selectedPlatform, translations]);
|
||||
|
||||
// Download ZIP containing only the skill folder
|
||||
// Tries client-side generation first, falls back to server-side if CDNs fail
|
||||
const handleDownloadZip = useCallback(async () => {
|
||||
try {
|
||||
setInstallStatus('downloading');
|
||||
setErrorMessage('');
|
||||
|
||||
let useServerFallback = false;
|
||||
|
||||
// Try client-side ZIP generation first
|
||||
try {
|
||||
// Fetch skill files from API
|
||||
const response = await fetch(`/api/skill-files?id=${encodeURIComponent(skillId)}`);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
const errorCode = errorData.code || 'UNKNOWN';
|
||||
|
||||
switch (errorCode) {
|
||||
case 'RATE_LIMIT':
|
||||
throw new Error(translations.rateLimitError);
|
||||
case 'TIMEOUT':
|
||||
throw new Error(translations.timeoutError);
|
||||
case 'NOT_FOUND':
|
||||
case 'GITHUB_NOT_FOUND':
|
||||
throw new Error(translations.notFoundError);
|
||||
default:
|
||||
throw new Error(errorData.error || translations.downloadFailed);
|
||||
}
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const files: SkillFile[] = data.files;
|
||||
|
||||
if (!files || files.length === 0) {
|
||||
throw new Error(translations.noFilesError);
|
||||
}
|
||||
|
||||
// Dynamically load JSZip from CDN
|
||||
const JSZip = await loadJSZip();
|
||||
const zip = new JSZip();
|
||||
|
||||
// Add files to ZIP with platform-specific transformation
|
||||
const isFlatPlatform = FLAT_FILE_PLATFORMS.includes(selectedPlatform);
|
||||
for (const file of files) {
|
||||
if (file.type === 'file') {
|
||||
let content = file.content;
|
||||
|
||||
// If no content but has downloadUrl, fetch it
|
||||
if (!content && file.downloadUrl) {
|
||||
try {
|
||||
const fileResponse = await fetch(file.downloadUrl);
|
||||
if (fileResponse.ok) {
|
||||
content = await fileResponse.text();
|
||||
}
|
||||
} catch {
|
||||
console.warn(`Failed to download: ${file.path}`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (content) {
|
||||
const isMainSkillFile = MAIN_FILE_NAMES.includes(file.name) && (file.path === file.name);
|
||||
|
||||
if (isMainSkillFile) {
|
||||
const platformFileName = getPlatformFileName(selectedPlatform, skillName);
|
||||
const transformed = transformSkillContent(selectedPlatform, content, skillName, sourceFormat);
|
||||
if (isFlatPlatform) {
|
||||
// Flat platform: skill file at root, supporting files in subfolder
|
||||
zip.file(platformFileName, transformed);
|
||||
} else {
|
||||
zip.file(`${skillName}/${platformFileName}`, transformed);
|
||||
}
|
||||
} else {
|
||||
// Supporting files (scripts, references) always go in subfolder
|
||||
zip.file(`${skillName}/${file.path || file.name}`, content);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Generate ZIP and download
|
||||
const zipBlob = await zip.generateAsync({ type: 'blob' });
|
||||
const url = window.URL.createObjectURL(zipBlob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${skillName}.zip`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.URL.revokeObjectURL(url);
|
||||
} catch (clientErr) {
|
||||
// If JSZip failed to load from CDNs, use server-side fallback
|
||||
const errorMsg = clientErr instanceof Error ? clientErr.message : '';
|
||||
if (errorMsg.includes('Failed to load JSZip')) {
|
||||
console.warn('JSZip CDN failed, falling back to server-side ZIP generation');
|
||||
useServerFallback = true;
|
||||
} else {
|
||||
// Re-throw non-CDN errors
|
||||
throw clientErr;
|
||||
}
|
||||
}
|
||||
|
||||
// Server-side fallback
|
||||
if (useServerFallback) {
|
||||
await downloadFromServer();
|
||||
}
|
||||
|
||||
// Track the download
|
||||
fetch(`/api/skills/install`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ skillId, platform: selectedPlatform, method: useServerFallback ? 'web-zip-server' : 'web-zip' }),
|
||||
}).catch(() => {}); // Silently fail - tracking is not critical
|
||||
|
||||
setInstallStatus('done');
|
||||
setTimeout(() => setInstallStatus('idle'), 3000);
|
||||
} catch (err) {
|
||||
console.error('Download failed:', err);
|
||||
const errorMsg = err instanceof Error ? err.message : translations.downloadFailed;
|
||||
setErrorMessage(errorMsg);
|
||||
setInstallStatus('error');
|
||||
setTimeout(() => setInstallStatus('idle'), 5000);
|
||||
}
|
||||
}, [skillId, skillName, selectedPlatform, translations, downloadFromServer]);
|
||||
|
||||
// Install using File System Access API
|
||||
const handleSelectFolder = useCallback(async () => {
|
||||
if (!isFileSystemSupported) {
|
||||
setErrorMessage(translations.browserNotSupported);
|
||||
setInstallStatus('error');
|
||||
setTimeout(() => setInstallStatus('idle'), 3000);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setInstallStatus('downloading');
|
||||
setErrorMessage('');
|
||||
|
||||
// Fetch skill files from API
|
||||
const response = await fetch(`/api/skill-files?id=${encodeURIComponent(skillId)}`);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
const errorCode = errorData.code || 'UNKNOWN';
|
||||
|
||||
// Show specific error messages based on error code
|
||||
switch (errorCode) {
|
||||
case 'RATE_LIMIT':
|
||||
throw new Error(translations.rateLimitError);
|
||||
case 'TIMEOUT':
|
||||
throw new Error(translations.timeoutError);
|
||||
case 'NOT_FOUND':
|
||||
case 'GITHUB_NOT_FOUND':
|
||||
throw new Error(translations.notFoundError);
|
||||
default:
|
||||
throw new Error(errorData.error || translations.downloadFailed);
|
||||
}
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const files: SkillFile[] = data.files;
|
||||
|
||||
if (!files || files.length === 0) {
|
||||
throw new Error(translations.noFilesError);
|
||||
}
|
||||
|
||||
setInstallStatus('installing');
|
||||
|
||||
// Request directory access
|
||||
const dirHandle = await (window as unknown as { showDirectoryPicker: () => Promise<FileSystemDirectoryHandle> }).showDirectoryPicker();
|
||||
|
||||
// For flat-file platforms, the main skill file goes directly into the selected folder
|
||||
// Supporting files go in a subfolder
|
||||
const isFlatPlatform = FLAT_FILE_PLATFORMS.includes(selectedPlatform);
|
||||
const skillDir = isFlatPlatform ? dirHandle : await dirHandle.getDirectoryHandle(skillName, { create: true });
|
||||
// For flat platforms, supporting files (scripts/references) still need a subfolder
|
||||
const trackingDir = isFlatPlatform ? await dirHandle.getDirectoryHandle(skillName, { create: true }) : skillDir;
|
||||
|
||||
// Write all skill files with platform-specific transformation
|
||||
for (const file of files) {
|
||||
if (file.type === 'dir') {
|
||||
// Create subdirectory
|
||||
await getOrCreateDir(trackingDir, file.path);
|
||||
} else if (file.type === 'file') {
|
||||
let content = file.content;
|
||||
|
||||
// If no content but has downloadUrl, fetch it
|
||||
if (!content && file.downloadUrl) {
|
||||
try {
|
||||
const fileResponse = await fetch(file.downloadUrl);
|
||||
if (fileResponse.ok) {
|
||||
content = await fileResponse.text();
|
||||
}
|
||||
} catch {
|
||||
console.warn(`Failed to download: ${file.path}`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (content) {
|
||||
const isMainSkillFile = MAIN_FILE_NAMES.includes(file.name) && (file.path === file.name);
|
||||
|
||||
if (isMainSkillFile) {
|
||||
// Transform and rename the main skill file
|
||||
const platformFileName = getPlatformFileName(selectedPlatform, skillName);
|
||||
const transformed = transformSkillContent(selectedPlatform, content, skillName, sourceFormat);
|
||||
const fileHandle = await skillDir.getFileHandle(platformFileName, { create: true });
|
||||
const writable = await fileHandle.createWritable();
|
||||
await writable.write(transformed);
|
||||
await writable.close();
|
||||
} else {
|
||||
// Supporting files go into the tracking/skill directory
|
||||
const pathParts = file.path.split('/');
|
||||
const fileName = pathParts.pop() || file.name;
|
||||
let targetDir = trackingDir;
|
||||
|
||||
if (pathParts.length > 0) {
|
||||
targetDir = await getOrCreateDir(trackingDir, pathParts.join('/'));
|
||||
}
|
||||
|
||||
const fileHandle = await targetDir.getFileHandle(fileName, { create: true });
|
||||
const writable = await fileHandle.createWritable();
|
||||
await writable.write(content);
|
||||
await writable.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Track the installation
|
||||
fetch(`/api/skills/install`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ skillId, platform: selectedPlatform, method: 'web-folder' }),
|
||||
}).catch(() => {}); // Silently fail - tracking is not critical
|
||||
|
||||
setInstallStatus('done');
|
||||
setTimeout(() => setInstallStatus('idle'), 3000);
|
||||
} catch (err) {
|
||||
if ((err as Error).name === 'AbortError') {
|
||||
// User cancelled the picker
|
||||
setInstallStatus('idle');
|
||||
return;
|
||||
}
|
||||
console.error('Install failed:', err);
|
||||
// Show the specific error message if available
|
||||
const errorMsg = err instanceof Error ? err.message : translations.downloadFailed;
|
||||
setErrorMessage(errorMsg);
|
||||
setInstallStatus('error');
|
||||
setTimeout(() => setInstallStatus('idle'), 5000); // Show error longer (5s)
|
||||
}
|
||||
}, [isFileSystemSupported, skillName, skillId, selectedPlatform, translations]);
|
||||
|
||||
// Helper function to get or create nested directories
|
||||
async function getOrCreateDir(
|
||||
parentDir: FileSystemDirectoryHandle,
|
||||
path: string
|
||||
): Promise<FileSystemDirectoryHandle> {
|
||||
const parts = path.split('/').filter(Boolean);
|
||||
let currentDir = parentDir;
|
||||
|
||||
for (const part of parts) {
|
||||
currentDir = await currentDir.getDirectoryHandle(part, { create: true });
|
||||
}
|
||||
|
||||
return currentDir;
|
||||
}
|
||||
|
||||
const platforms: Platform[] = ['claude', 'codex', 'copilot', 'cursor', 'windsurf'];
|
||||
|
||||
return (
|
||||
<div className="bg-surface-elevated rounded-2xl p-6 shadow-sm sticky top-24">
|
||||
<h2 className="font-semibold text-lg text-text-primary mb-4 flex items-center gap-2">
|
||||
<Terminal className="w-5 h-5" />
|
||||
{translations.title}
|
||||
</h2>
|
||||
|
||||
{/* Platform Tabs */}
|
||||
<div className="flex gap-1 mb-4 p-1 bg-surface-subtle rounded-lg overflow-x-auto">
|
||||
{platforms.map((platform) => (
|
||||
<button
|
||||
key={platform}
|
||||
onClick={() => setSelectedPlatform(platform)}
|
||||
className={`flex-shrink-0 px-2.5 py-2 text-sm font-medium rounded-md transition-colors whitespace-nowrap ${
|
||||
selectedPlatform === platform
|
||||
? 'bg-surface-elevated shadow-sm text-text-primary'
|
||||
: 'text-text-secondary hover:text-text-primary'
|
||||
}`}
|
||||
>
|
||||
{platform.charAt(0).toUpperCase() + platform.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Security Disclaimer */}
|
||||
<div className="mb-4 p-3 bg-warning-bg border border-warning/30 rounded-lg">
|
||||
<div className="flex gap-2">
|
||||
<AlertTriangle className="w-4 h-4 text-warning flex-shrink-0 mt-0.5" />
|
||||
<p className="text-xs text-text-secondary" dir="auto">
|
||||
{translations.disclaimer}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CLI Install */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-text-secondary mb-2">
|
||||
{translations.cli}
|
||||
</label>
|
||||
|
||||
{/* Global Install */}
|
||||
<div className="mb-3">
|
||||
<p className="text-xs text-text-muted mb-1" dir="ltr">
|
||||
{translations.cliGlobal || 'Install globally (user-level):'}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code
|
||||
data-testid="install-command"
|
||||
className="flex-1 px-3 py-2 bg-surface-subtle rounded-lg text-sm font-mono text-text-primary overflow-x-auto"
|
||||
dir="ltr"
|
||||
>
|
||||
{currentCommand.cli}
|
||||
</code>
|
||||
<button
|
||||
onClick={handleCopyCommand}
|
||||
className="p-2 text-text-muted hover:text-primary-600 hover:bg-primary-50 rounded-lg transition-colors"
|
||||
title={translations.copyCommand}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="w-4 h-4 text-success" />
|
||||
) : (
|
||||
<Copy className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Project Install */}
|
||||
<div>
|
||||
<p className="text-xs text-text-muted mb-1" dir="ltr">
|
||||
{translations.cliProject || 'Install in current project:'}
|
||||
</p>
|
||||
<code className="block px-3 py-2 bg-surface-subtle rounded-lg text-sm font-mono text-text-primary overflow-x-auto" dir="ltr">
|
||||
{currentCommand.cli} --project
|
||||
</code>
|
||||
</div>
|
||||
|
||||
{copied && (
|
||||
<p className="text-xs text-success mt-2">{translations.copied}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Install Options */}
|
||||
<div className="space-y-2 mb-4">
|
||||
{/* Download ZIP */}
|
||||
<button
|
||||
onClick={handleDownloadZip}
|
||||
disabled={installStatus !== 'idle'}
|
||||
className="btn-secondary w-full gap-2 justify-center disabled:opacity-50"
|
||||
>
|
||||
{installStatus === 'downloading' ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
{translations.downloading}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="w-4 h-4" />
|
||||
{translations.downloadZip}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Select Folder (File System API) */}
|
||||
{isFileSystemSupported && (
|
||||
<button
|
||||
onClick={handleSelectFolder}
|
||||
disabled={installStatus !== 'idle'}
|
||||
className="btn-primary w-full gap-2 justify-center disabled:opacity-50"
|
||||
>
|
||||
{installStatus === 'installing' ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
{translations.installing}
|
||||
</>
|
||||
) : installStatus === 'done' ? (
|
||||
<>
|
||||
<Check className="w-4 h-4" />
|
||||
{translations.installed}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FolderOpen className="w-4 h-4" />
|
||||
{translations.selectFolder}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{installStatus === 'error' && errorMessage && (
|
||||
<p className="text-xs text-error mb-2">{errorMessage}</p>
|
||||
)}
|
||||
|
||||
{/* Suggested Path & Folder Note */}
|
||||
<div className="text-xs text-text-muted space-y-1">
|
||||
<p>
|
||||
{translations.suggestedPath}: <code className="ltr-nums">{currentCommand.path}</code>
|
||||
</p>
|
||||
{isFileSystemSupported && (
|
||||
<p className="text-text-muted/70">
|
||||
{translations.folderNotePrefix}{skillName}{translations.folderNoteSuffix}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
100
apps/web/components/NewSkillsTabs.tsx
Normal file
100
apps/web/components/NewSkillsTabs.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { Sparkles, RefreshCw } from 'lucide-react';
|
||||
|
||||
interface NewSkillsTabsProps {
|
||||
activeTab: 'new' | 'updated';
|
||||
newCount: number;
|
||||
updatedCount: number;
|
||||
locale: string;
|
||||
translations: {
|
||||
new: string;
|
||||
updated: string;
|
||||
newDescription: string;
|
||||
updatedDescription: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function NewSkillsTabs({
|
||||
activeTab,
|
||||
newCount,
|
||||
updatedCount,
|
||||
locale,
|
||||
translations,
|
||||
}: NewSkillsTabsProps) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const handleTabChange = (tab: 'new' | 'updated') => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set('tab', tab);
|
||||
params.delete('page'); // Reset to page 1 when switching tabs
|
||||
router.push(`/${locale}/new?${params.toString()}`);
|
||||
};
|
||||
|
||||
const formatCount = (count: number) => {
|
||||
if (locale === 'fa') {
|
||||
return count.toLocaleString('fa-IR');
|
||||
}
|
||||
return count.toLocaleString('en-US');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-8">
|
||||
{/* Tabs */}
|
||||
<div className="flex justify-center gap-2 mb-4">
|
||||
<button
|
||||
onClick={() => handleTabChange('new')}
|
||||
className={`
|
||||
flex items-center gap-2 px-6 py-3 rounded-full font-medium transition-all
|
||||
${activeTab === 'new'
|
||||
? 'bg-primary-600 text-white shadow-lg shadow-primary-500/25'
|
||||
: 'bg-surface-elevated text-text-secondary hover:bg-surface-subtle hover:text-text-primary'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<Sparkles className="w-4 h-4" />
|
||||
<span>{translations.new}</span>
|
||||
<span className={`
|
||||
px-2 py-0.5 rounded-full text-xs
|
||||
${activeTab === 'new'
|
||||
? 'bg-white/20 text-white'
|
||||
: 'bg-primary-100 dark:bg-primary-900/30 text-primary-600 dark:text-primary-400'
|
||||
}
|
||||
`}>
|
||||
{formatCount(newCount)}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleTabChange('updated')}
|
||||
className={`
|
||||
flex items-center gap-2 px-6 py-3 rounded-full font-medium transition-all
|
||||
${activeTab === 'updated'
|
||||
? 'bg-primary-600 text-white shadow-lg shadow-primary-500/25'
|
||||
: 'bg-surface-elevated text-text-secondary hover:bg-surface-subtle hover:text-text-primary'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
<span>{translations.updated}</span>
|
||||
<span className={`
|
||||
px-2 py-0.5 rounded-full text-xs
|
||||
${activeTab === 'updated'
|
||||
? 'bg-white/20 text-white'
|
||||
: 'bg-primary-100 dark:bg-primary-900/30 text-primary-600 dark:text-primary-400'
|
||||
}
|
||||
`}>
|
||||
{formatCount(updatedCount)}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-center text-text-muted text-sm">
|
||||
{activeTab === 'new' ? translations.newDescription : translations.updatedDescription}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
80
apps/web/components/QueryNotification.tsx
Normal file
80
apps/web/components/QueryNotification.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
'use client';
|
||||
|
||||
import { useSearchParams, useRouter, usePathname } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { CheckCircle } from 'lucide-react';
|
||||
|
||||
const NOTIFICATIONS: Record<string, { en: string; fa: string }> = {
|
||||
unsubscribed: {
|
||||
en: 'You have been unsubscribed. You will no longer receive these emails.',
|
||||
fa: '\u0627\u0634\u062a\u0631\u0627\u06a9 \u0634\u0645\u0627 \u0644\u063a\u0648 \u0634\u062f. \u062f\u06cc\u06af\u0631 \u0627\u06cc\u0646 \u0627\u06cc\u0645\u06cc\u0644\u200c\u0647\u0627 \u0631\u0627 \u062f\u0631\u06cc\u0627\u0641\u062a \u0646\u062e\u0648\u0627\u0647\u06cc\u062f \u06a9\u0631\u062f.',
|
||||
},
|
||||
subscribed: {
|
||||
en: 'You have been subscribed to the newsletter. Welcome!',
|
||||
fa: '\u0634\u0645\u0627 \u062f\u0631 \u062e\u0628\u0631\u0646\u0627\u0645\u0647 \u0639\u0636\u0648 \u0634\u062f\u06cc\u062f. \u062e\u0648\u0634 \u0622\u0645\u062f\u06cc\u062f!',
|
||||
},
|
||||
};
|
||||
|
||||
export function QueryNotification() {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const locale = pathname.startsWith('/fa') ? 'fa' : 'en';
|
||||
|
||||
for (const [key, texts] of Object.entries(NOTIFICATIONS)) {
|
||||
if (searchParams.get(key) === 'true') {
|
||||
setMessage(texts[locale]);
|
||||
// Trigger entrance animation after mount
|
||||
requestAnimationFrame(() => setVisible(true));
|
||||
|
||||
// Clean URL without reloading
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.delete(key);
|
||||
const newUrl = params.toString() ? `${pathname}?${params}` : pathname;
|
||||
router.replace(newUrl, { scroll: false });
|
||||
break;
|
||||
}
|
||||
}
|
||||
}, [searchParams, pathname, router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (message) {
|
||||
const timer = setTimeout(() => {
|
||||
setVisible(false);
|
||||
setTimeout(() => setMessage(null), 300);
|
||||
}, 6000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [message]);
|
||||
|
||||
if (!message) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed top-20 left-1/2 z-[60] transition-all duration-300"
|
||||
style={{
|
||||
transform: visible ? 'translate(-50%, 0)' : 'translate(-50%, -20px)',
|
||||
opacity: visible ? 1 : 0,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-3 bg-success-bg border border-success/30 rounded-lg px-5 py-3 shadow-lg backdrop-blur-sm">
|
||||
<CheckCircle className="w-5 h-5 text-success shrink-0" />
|
||||
<p className="text-sm font-medium text-text-primary">{message}</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
setVisible(false);
|
||||
setTimeout(() => setMessage(null), 300);
|
||||
}}
|
||||
className="ml-2 text-text-muted hover:text-text-primary transition-colors text-lg leading-none"
|
||||
aria-label="Close"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
152
apps/web/components/RatingStars.tsx
Normal file
152
apps/web/components/RatingStars.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { useSession, signIn } from 'next-auth/react';
|
||||
import { useLocale } from 'next-intl';
|
||||
import { Star, ExternalLink } from 'lucide-react';
|
||||
import { fetchWithCsrf } from '@/lib/csrf-client';
|
||||
|
||||
const isMirror = process.env.NEXT_PUBLIC_IS_PRIMARY === 'false';
|
||||
const PRIMARY_URL = process.env.NEXT_PUBLIC_PRIMARY_URL || process.env.NEXT_PUBLIC_APP_URL || 'https://skills.palebluedot.live';
|
||||
|
||||
interface RatingStarsProps {
|
||||
skillId: string;
|
||||
initialRating?: number;
|
||||
averageRating?: number;
|
||||
ratingCount?: number;
|
||||
readOnly?: boolean;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
showCount?: boolean;
|
||||
onRatingChange?: (rating: number) => void;
|
||||
}
|
||||
|
||||
export function RatingStars({
|
||||
skillId,
|
||||
initialRating = 0,
|
||||
averageRating = 0,
|
||||
ratingCount = 0,
|
||||
readOnly = false,
|
||||
size = 'md',
|
||||
showCount = true,
|
||||
onRatingChange,
|
||||
}: RatingStarsProps) {
|
||||
const { data: session } = useSession();
|
||||
const locale = useLocale();
|
||||
const [userRating, setUserRating] = useState(initialRating);
|
||||
const [hoverRating, setHoverRating] = useState(0);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [mirrorNotice, setMirrorNotice] = useState(false);
|
||||
|
||||
// Auto-dismiss mirror notice
|
||||
useEffect(() => {
|
||||
if (!mirrorNotice) return;
|
||||
const timer = setTimeout(() => setMirrorNotice(false), 4000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [mirrorNotice]);
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'w-4 h-4',
|
||||
md: 'w-5 h-5',
|
||||
lg: 'w-6 h-6',
|
||||
};
|
||||
|
||||
const displayRating = readOnly
|
||||
? averageRating
|
||||
: hoverRating || userRating || averageRating;
|
||||
|
||||
const handleClick = useCallback(
|
||||
async (rating: number) => {
|
||||
if (readOnly) return;
|
||||
|
||||
if (!session) {
|
||||
signIn('github');
|
||||
return;
|
||||
}
|
||||
|
||||
if (isMirror) {
|
||||
setMirrorNotice(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const previousRating = userRating;
|
||||
setUserRating(rating); // Optimistic update
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
const res = await fetchWithCsrf('/api/ratings', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ skillId, rating }),
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error('Failed to submit rating');
|
||||
|
||||
onRatingChange?.(rating);
|
||||
} catch (error) {
|
||||
setUserRating(previousRating); // Rollback on error
|
||||
console.error('Rating error:', error);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
},
|
||||
[readOnly, session, skillId, userRating, onRatingChange]
|
||||
);
|
||||
|
||||
const mirrorMsg = locale === 'fa'
|
||||
? 'برای امتیازدهی، از سایت اصلی استفاده کنید'
|
||||
: 'To rate skills, visit the main site';
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
dir="ltr"
|
||||
className={`flex items-center gap-0.5 ${!readOnly ? 'cursor-pointer' : ''}`}
|
||||
onMouseLeave={() => !readOnly && setHoverRating(0)}
|
||||
>
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<button
|
||||
key={star}
|
||||
type="button"
|
||||
disabled={readOnly || isSubmitting}
|
||||
onClick={() => handleClick(star)}
|
||||
onMouseEnter={() => !readOnly && setHoverRating(star)}
|
||||
className={`
|
||||
transition-colors
|
||||
${readOnly ? 'cursor-default' : 'hover:scale-110'}
|
||||
${isSubmitting ? 'opacity-50' : ''}
|
||||
`}
|
||||
>
|
||||
<Star
|
||||
className={`
|
||||
${sizeClasses[size]}
|
||||
${
|
||||
star <= displayRating
|
||||
? 'fill-amber-400 text-amber-400'
|
||||
: 'fill-transparent text-text-muted'
|
||||
}
|
||||
`}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{showCount && (
|
||||
<span className="text-sm text-text-secondary ltr-nums">({ratingCount})</span>
|
||||
)}
|
||||
</div>
|
||||
{mirrorNotice && (
|
||||
<div className="absolute z-50 bottom-full left-1/2 -translate-x-1/2 mb-2 whitespace-nowrap animate-in fade-in slide-in-from-bottom-2 duration-200">
|
||||
<a
|
||||
href={PRIMARY_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5 px-3 py-2 text-xs bg-surface-elevated border border-border rounded-lg shadow-lg text-text-primary hover:text-primary-500 transition-colors"
|
||||
>
|
||||
{mirrorMsg}
|
||||
<ExternalLink className="w-3 h-3 flex-shrink-0" />
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
71
apps/web/components/ShareButton.tsx
Normal file
71
apps/web/components/ShareButton.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
'use client';
|
||||
|
||||
import { Share2, Check } from 'lucide-react';
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
|
||||
interface ShareButtonProps {
|
||||
title: string;
|
||||
path: string; // Relative path like /en/skill/anthropic/skills/pdf
|
||||
translations: {
|
||||
share: string;
|
||||
copied: string;
|
||||
copyLink: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function ShareButton({ title, path, translations }: ShareButtonProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
// Construct full URL on client side
|
||||
const fullUrl = useMemo(() => {
|
||||
if (typeof window === 'undefined') return path;
|
||||
return `${window.location.origin}${path}`;
|
||||
}, [path]);
|
||||
|
||||
const handleShare = useCallback(async () => {
|
||||
// Try native share API first (mobile + some desktop browsers)
|
||||
if (navigator.share) {
|
||||
try {
|
||||
await navigator.share({
|
||||
title,
|
||||
url: fullUrl,
|
||||
});
|
||||
return;
|
||||
} catch (err) {
|
||||
// User cancelled or share failed - fall back to clipboard
|
||||
if (err instanceof Error && err.name === 'AbortError') {
|
||||
return; // User cancelled, don't fall back
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to clipboard
|
||||
try {
|
||||
await navigator.clipboard.writeText(fullUrl);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err);
|
||||
}
|
||||
}, [title, fullUrl]);
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleShare}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm border border-border rounded-lg hover:bg-surface-subtle transition-colors"
|
||||
title={copied ? translations.copied : translations.share}
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="w-4 h-4 text-success" />
|
||||
<span className="text-success">{translations.copied}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Share2 className="w-4 h-4" />
|
||||
<span>{translations.share}</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
129
apps/web/components/SkillCard.tsx
Normal file
129
apps/web/components/SkillCard.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import Link from 'next/link';
|
||||
import { Star, Download, Shield, CheckCircle, Clock, RefreshCw } from 'lucide-react';
|
||||
import { formatCompactNumber } from '@/lib/format-number';
|
||||
|
||||
const FORMAT_BADGE_LABELS: Record<string, string> = {
|
||||
'agents.md': 'AGENTS.md',
|
||||
'cursorrules': '.cursorrules',
|
||||
'windsurfrules': '.windsurfrules',
|
||||
'copilot-instructions': 'Copilot',
|
||||
};
|
||||
|
||||
interface SkillCardProps {
|
||||
skill: {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
githubOwner: string;
|
||||
githubStars: number | null;
|
||||
downloadCount: number | null;
|
||||
rating: number | null;
|
||||
ratingCount: number | null;
|
||||
securityStatus: string | null;
|
||||
isVerified: boolean | null;
|
||||
sourceFormat?: string | null;
|
||||
createdAt?: Date | null;
|
||||
updatedAt?: Date | null;
|
||||
};
|
||||
locale: string;
|
||||
/** Show time badge (for New Skills page) */
|
||||
showTimeBadge?: 'created' | 'updated' | null;
|
||||
/** Format time as "X days ago" */
|
||||
formatTimeAgo?: (date: Date | null, locale: string) => string;
|
||||
}
|
||||
|
||||
export function SkillCard({ skill, locale, showTimeBadge, formatTimeAgo }: SkillCardProps) {
|
||||
const getSecurityColor = (status: string | null) => {
|
||||
switch (status) {
|
||||
case 'pass': return 'text-success';
|
||||
case 'warning': return 'text-warning';
|
||||
case 'fail': return 'text-error';
|
||||
default: return 'text-text-muted';
|
||||
}
|
||||
};
|
||||
|
||||
const getSecurityLabel = (status: string | null) => {
|
||||
switch (status) {
|
||||
case 'pass': return '✓';
|
||||
case 'warning': return '⚠';
|
||||
case 'fail': return '✕';
|
||||
default: return '-';
|
||||
}
|
||||
};
|
||||
|
||||
const showRating = (skill.ratingCount ?? 0) >= 3;
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/${locale}/skill/${skill.id}`}
|
||||
className="card p-6 border border-transparent hover:border-primary-200 transition-colors"
|
||||
>
|
||||
{/* Time Badge (for New Skills page) */}
|
||||
{showTimeBadge && formatTimeAgo && (
|
||||
<div className="flex items-center gap-2 text-xs mb-3">
|
||||
{showTimeBadge === 'created' ? (
|
||||
<span className="flex items-center gap-1 text-success">
|
||||
<Clock className="w-3 h-3" />
|
||||
<span>{formatTimeAgo(skill.createdAt ?? null, locale)}</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1 text-primary-500">
|
||||
<RefreshCw className="w-3 h-3" />
|
||||
<span>{formatTimeAgo(skill.updatedAt ?? null, locale)}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header: Name + Verified + Format Badge + Security */}
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-lg text-text-primary truncate">
|
||||
{skill.name}
|
||||
</h3>
|
||||
{skill.isVerified && (
|
||||
<CheckCircle className="w-4 h-4 text-success flex-shrink-0" />
|
||||
)}
|
||||
{skill.sourceFormat && skill.sourceFormat !== 'skill.md' && FORMAT_BADGE_LABELS[skill.sourceFormat] && (
|
||||
<span className="inline-flex items-center px-1.5 py-0.5 text-[10px] font-medium rounded bg-surface-subtle text-text-muted border border-border flex-shrink-0">
|
||||
{FORMAT_BADGE_LABELS[skill.sourceFormat]}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className={`flex items-center gap-1 flex-shrink-0 ${getSecurityColor(skill.securityStatus)}`}>
|
||||
<Shield className="w-4 h-4" />
|
||||
<span className="text-sm font-medium">{getSecurityLabel(skill.securityStatus)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-text-secondary text-sm line-clamp-2 mb-4" dir="auto">
|
||||
{skill.description}
|
||||
</p>
|
||||
|
||||
{/* Metadata Row: Stars + Downloads + Rating */}
|
||||
<div className="flex flex-wrap items-center gap-4 text-sm text-text-muted mb-2">
|
||||
<span className="flex items-center gap-1">
|
||||
<Star className="w-4 h-4" />
|
||||
<span className="ltr-nums">{formatCompactNumber(skill.githubStars || 0, locale)}</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Download className="w-4 h-4" />
|
||||
<span className="ltr-nums">{formatCompactNumber(skill.downloadCount || 0, locale)}</span>
|
||||
</span>
|
||||
{showRating && (
|
||||
<span className="flex items-center gap-1 text-gold">
|
||||
<Star className="w-4 h-4 fill-current" />
|
||||
<span className="ltr-nums">{skill.rating?.toFixed(1)}</span>
|
||||
<span className="text-text-muted">({skill.ratingCount})</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Author */}
|
||||
<div className="text-sm text-text-muted">
|
||||
@{skill.githubOwner}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
62
apps/web/components/ThemeToggle.tsx
Normal file
62
apps/web/components/ThemeToggle.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
'use client';
|
||||
|
||||
import { useTheme } from 'next-themes';
|
||||
import { Sun, Moon, Monitor } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
// Avoid hydration mismatch
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
if (!mounted) {
|
||||
return (
|
||||
<button className="p-2 rounded-lg text-text-muted" aria-label="Toggle theme">
|
||||
<Monitor className="w-5 h-5" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const cycleTheme = () => {
|
||||
if (theme === 'light') setTheme('dark');
|
||||
else if (theme === 'dark') setTheme('system');
|
||||
else setTheme('light');
|
||||
};
|
||||
|
||||
const getIcon = () => {
|
||||
switch (theme) {
|
||||
case 'dark':
|
||||
return <Moon className="w-5 h-5" />;
|
||||
case 'light':
|
||||
return <Sun className="w-5 h-5" />;
|
||||
default:
|
||||
return <Monitor className="w-5 h-5" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getTitle = () => {
|
||||
switch (theme) {
|
||||
case 'dark':
|
||||
return 'Dark mode';
|
||||
case 'light':
|
||||
return 'Light mode';
|
||||
default:
|
||||
return 'System theme';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={cycleTheme}
|
||||
className="p-2 rounded-lg text-text-muted hover:text-text-primary hover:bg-surface-subtle transition-colors"
|
||||
title={getTitle()}
|
||||
aria-label={getTitle()}
|
||||
>
|
||||
{getIcon()}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user