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:
airano
2026-02-12 06:08:51 +03:30
commit 97b427831a
227 changed files with 48411 additions and 0 deletions

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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">&quot;{query}&quot;</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>
);
}

View 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>
);
}

View 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}</>;
}

View 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>
);
}

View 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>
);
}

View 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&apos;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;

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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"
>
&times;
</button>
</div>
</div>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}