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,69 @@
import { getTranslations, setRequestLocale } from 'next-intl/server';
import { Header } from '@/components/Header';
import { Footer } from '@/components/Footer';
import { Code2, Globe, Shield } from 'lucide-react';
export default async function AboutPage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations('about');
const features = [
{
icon: Code2,
title: t('features.openSource'),
description: t('features.openSourceDesc'),
},
{
icon: Globe,
title: t('features.multiPlatform'),
description: t('features.multiPlatformDesc'),
},
{
icon: Shield,
title: t('features.secure'),
description: t('features.secureDesc'),
},
];
return (
<div className="min-h-screen flex flex-col">
<Header />
<main className="flex-1">
<section className="section-header bg-gradient-subtle">
<div className="container-main text-center">
<h1 className="hero-title mb-4">{t('title')}</h1>
<p className="hero-subtitle max-w-2xl mx-auto">{t('subtitle')}</p>
</div>
</section>
<section className="section bg-surface">
<div className="container-main">
<div className="max-w-3xl mx-auto text-center mb-12">
<h2 className="section-title mb-4">{t('mission.title')}</h2>
<p className="text-lg text-text-secondary">{t('mission.description')}</p>
</div>
<h2 className="section-title mb-8">{t('features.title')}</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
{features.map((feature, index) => (
<div key={index} className="card p-6 text-center">
<div className="inline-flex items-center justify-center w-14 h-14 rounded-xl bg-primary-50 text-primary-600 mb-4">
<feature.icon className="w-7 h-7" />
</div>
<h3 className="text-xl font-semibold mb-2">{feature.title}</h3>
<p className="text-text-secondary">{feature.description}</p>
</div>
))}
</div>
</div>
</section>
</main>
<Footer />
</div>
);
}

View File

@@ -0,0 +1,311 @@
import { getTranslations, setRequestLocale } from 'next-intl/server';
import Link from 'next/link';
import { Header } from '@/components/Header';
import { Footer } from '@/components/Footer';
import { Github, Heart, Users, Code, GitFork, ExternalLink, Database, Clock } from 'lucide-react';
export const dynamic = 'force-dynamic';
interface AttributionStats {
totalSkills: number;
totalContributors: number;
totalRepos: number;
awesomeLists: {
count: number;
totalRepos: number;
};
forkNetworks: number;
licenseDistribution: Array<{
license: string;
count: number;
percentage: number;
}>;
discoveryBySource: Array<{
source: string;
count: number;
withSkills: number;
}>;
lastUpdated: string;
}
async function getAttributionStats(): Promise<AttributionStats | null> {
try {
const baseUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
const res = await fetch(`${baseUrl}/api/attribution`, {
next: { revalidate: 3600 }, // Cache for 1 hour
});
if (!res.ok) return null;
return res.json();
} catch {
return null;
}
}
function formatNumber(num: number): string {
if (num >= 1000000) {
return `${(num / 1000000).toFixed(1)}M`;
}
if (num >= 1000) {
return `${(num / 1000).toFixed(1)}K`;
}
return num.toLocaleString();
}
function formatLicenseName(license: string, locale: string): string {
const licenseNames: Record<string, { en: string; fa: string }> = {
'Unspecified': { en: 'Not Specified', fa: 'مشخص نشده' },
'NOASSERTION': { en: 'Not Declared', fa: 'اعلام نشده' },
'Complete terms in LICENSE.txt': { en: 'Custom License', fa: 'لایسنس سفارشی' },
'Proprietary. LICENSE.txt has complete terms': { en: 'Proprietary', fa: 'اختصاصی' },
'MIT license': { en: 'MIT', fa: 'MIT' },
'BSD-3-Clause license': { en: 'BSD-3-Clause', fa: 'BSD-3-Clause' },
'Unknown': { en: 'Unknown', fa: 'نامشخص' },
};
const names = licenseNames[license];
return names ? names[locale as 'en' | 'fa'] || names.en : license;
}
export default async function AttributionPage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations('attribution');
const stats = await getAttributionStats();
// Fallback data if API fails
const sources = [
{
name: locale === 'fa' ? 'مهارت‌های ایندکس شده' : 'Indexed Skills',
icon: Database,
description: locale === 'fa'
? 'مهارت‌های ایندکس شده در تمام پلتفرم‌ها'
: 'Skills indexed across all supported platforms',
count: stats ? formatNumber(stats.totalSkills) : '170K+',
},
{
name: locale === 'fa' ? 'مخازن کشف شده' : 'Repositories Discovered',
icon: Github,
description: locale === 'fa'
? 'مخازن عمومی GitHub اسکن شده برای مهارت‌ها'
: 'Public GitHub repositories scanned for skills',
count: stats ? formatNumber(stats.totalRepos) : '50K+',
},
{
name: locale === 'fa' ? 'شبکه Forkها' : 'Fork Networks',
icon: GitFork,
description: locale === 'fa'
? 'شبکه Forkهای مخازن معروف مهارت'
: 'Fork networks of popular skill repositories',
count: stats ? formatNumber(stats.forkNetworks) : '500+',
},
{
name: locale === 'fa' ? 'مشارکت‌کنندگان' : 'Contributors',
icon: Users,
description: locale === 'fa'
? 'توسعه‌دهندگانی که مهارت‌ها را ایجاد و نگهداری می‌کنند'
: 'Developers who create and maintain skills',
count: stats ? formatNumber(stats.totalContributors) : '6K+',
},
];
// Use real license data if available, otherwise fallback
const licenses = stats?.licenseDistribution.slice(0, 5).map((l) => ({
name: formatLicenseName(l.license, locale),
percentage: l.percentage,
count: l.count,
})) || [
{ name: 'MIT', percentage: 65, count: 0 },
{ name: 'Apache 2.0', percentage: 20, count: 0 },
{ name: 'BSD', percentage: 8, count: 0 },
{ name: 'GPL', percentage: 5, count: 0 },
{ name: locale === 'fa' ? 'سایر' : 'Other', percentage: 2, count: 0 },
];
return (
<div className="min-h-screen flex flex-col">
<Header />
<main className="flex-1">
{/* Hero Section */}
<section className="section-header bg-gradient-subtle">
<div className="container-main text-center">
<h1 className="hero-title mb-4">{t('title')}</h1>
<p className="hero-subtitle max-w-2xl mx-auto">{t('subtitle')}</p>
{stats && (
<div className="mt-4 flex items-center justify-center gap-2 text-sm text-text-muted">
<Clock className="w-4 h-4" />
<span>
{locale === 'fa' ? 'آخرین به‌روزرسانی: ' : 'Last updated: '}
{new Date(stats.lastUpdated).toLocaleDateString(locale === 'fa' ? 'fa-IR' : 'en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</span>
</div>
)}
</div>
</section>
{/* Main Content */}
<section className="section bg-surface">
<div className="container-main max-w-4xl">
{/* Sources Section */}
<div className="mb-12">
<h2 className="text-2xl font-bold text-text-primary mb-6">
{t('sources.title')}
</h2>
<div className="grid gap-4 md:grid-cols-2">
{sources.map((source) => {
const Icon = source.icon;
return (
<div key={source.name} className="card p-5 hover:border-primary-300 transition-colors">
<div className="flex items-start gap-4">
<div className="flex-shrink-0 w-10 h-10 rounded-lg bg-primary-50 dark:bg-primary-900/20 flex items-center justify-center">
<Icon className="w-5 h-5 text-primary-600 dark:text-primary-400" />
</div>
<div className="flex-1">
<div className="flex items-center justify-between mb-1">
<h3 className="font-semibold text-text-primary">{source.name}</h3>
<span className="text-sm font-medium text-primary-600 dark:text-primary-400">
{source.count}
</span>
</div>
<p className="text-sm text-text-secondary">{source.description}</p>
</div>
</div>
</div>
);
})}
</div>
</div>
{/* License Compliance Section */}
<div className="mb-12">
<h2 className="text-2xl font-bold text-text-primary mb-6">
{t('licenses.title')}
</h2>
<div className="card p-6">
<p className="text-text-secondary mb-6">{t('licenses.description')}</p>
<div className="space-y-3">
{licenses.map((license) => (
<div key={license.name} className="flex items-center gap-4">
<span className="w-24 text-sm font-medium text-text-primary">{license.name}</span>
<div className="flex-1 h-2 bg-surface-subtle rounded-full overflow-hidden">
<div
className="h-full bg-primary-500 rounded-full transition-all"
style={{ width: `${license.percentage}%` }}
/>
</div>
<span className="w-16 text-sm text-text-muted text-right">
{license.percentage}%
{stats && license.count > 0 && (
<span className="block text-xs">({formatNumber(license.count)})</span>
)}
</span>
</div>
))}
</div>
</div>
</div>
{/* How It Works Section */}
<div className="mb-12">
<h2 className="text-2xl font-bold text-text-primary mb-6">
{t('howItWorks.title')}
</h2>
<div className="card p-6">
<div className="space-y-4">
<div className="flex items-start gap-3">
<div className="flex-shrink-0 w-6 h-6 rounded-full bg-primary-100 dark:bg-primary-900/30 flex items-center justify-center text-xs font-bold text-primary-600">1</div>
<p className="text-text-secondary">{t('howItWorks.step1')}</p>
</div>
<div className="flex items-start gap-3">
<div className="flex-shrink-0 w-6 h-6 rounded-full bg-primary-100 dark:bg-primary-900/30 flex items-center justify-center text-xs font-bold text-primary-600">2</div>
<p className="text-text-secondary">{t('howItWorks.step2')}</p>
</div>
<div className="flex items-start gap-3">
<div className="flex-shrink-0 w-6 h-6 rounded-full bg-primary-100 dark:bg-primary-900/30 flex items-center justify-center text-xs font-bold text-primary-600">3</div>
<p className="text-text-secondary">{t('howItWorks.step3')}</p>
</div>
<div className="flex items-start gap-3">
<div className="flex-shrink-0 w-6 h-6 rounded-full bg-primary-100 dark:bg-primary-900/30 flex items-center justify-center text-xs font-bold text-primary-600">4</div>
<p className="text-text-secondary">{t('howItWorks.step4')}</p>
</div>
</div>
</div>
</div>
{/* Special Thanks Section */}
<div className="mb-12">
<h2 className="text-2xl font-bold text-text-primary mb-6">
{t('thanks.title')}
</h2>
<div className="card p-6">
<div className="flex items-center gap-3 mb-4">
<Heart className="w-5 h-5 text-red-500" />
<span className="font-medium text-text-primary">{t('thanks.subtitle')}</span>
</div>
<ul className="space-y-2 text-text-secondary">
<li className="flex items-center gap-2">
<Code className="w-4 h-4" />
<Link href="https://anthropic.com" target="_blank" className="hover:text-primary-600 transition-colors">
Anthropic
</Link>
<span className="text-text-muted">- {locale === 'fa' ? 'استاندارد SKILL.md و Agent Skills' : 'SKILL.md and Agent Skills standard'}</span>
</li>
<li className="flex items-center gap-2">
<Github className="w-4 h-4" />
<Link href="https://github.com/anthropics/skills" target="_blank" className="hover:text-primary-600 transition-colors">
anthropics/skills
</Link>
<span className="text-text-muted">- {locale === 'fa' ? 'مخزن رسمی مهارت‌ها' : 'Official skills repository'}</span>
</li>
<li className="flex items-center gap-2">
<Code className="w-4 h-4" />
<span>
{locale === 'fa'
? 'OpenAI، GitHub، Cursor و Windsurf'
: 'OpenAI, GitHub, Cursor & Windsurf'}
</span>
<span className="text-text-muted">- {locale === 'fa' ? 'پلتفرم‌های پشتیبانی شده' : 'Supported platforms'}</span>
</li>
<li className="flex items-center gap-2">
<Users className="w-4 h-4" />
<span>
{locale === 'fa'
? `همه ${stats ? formatNumber(stats.totalContributors) : ''} مشارکت‌کنندگان متن‌باز`
: `All ${stats ? formatNumber(stats.totalContributors) : ''} open-source contributors`}
</span>
</li>
</ul>
</div>
</div>
{/* Your Rights Section */}
<div>
<h2 className="text-2xl font-bold text-text-primary mb-6">
{t('rights.title')}
</h2>
<div className="card p-6 bg-amber-50 dark:bg-amber-900/10 border-amber-200 dark:border-amber-800">
<p className="text-text-secondary mb-4">{t('rights.description')}</p>
<Link
href={`/${locale}/claim`}
className="inline-flex items-center gap-2 text-primary-600 hover:text-primary-700 font-medium transition-colors"
>
{t('rights.claimLink')}
<ExternalLink className="w-4 h-4" />
</Link>
</div>
</div>
</div>
</section>
</main>
<Footer />
</div>
);
}

View File

@@ -0,0 +1,286 @@
import { getTranslations, setRequestLocale } from 'next-intl/server';
import { Header } from '@/components/Header';
import { Footer } from '@/components/Footer';
import { createDb, skillQueries, categoryQueries } from '@skillhub/db';
import { BrowseFilters, SearchBar, Pagination, ActiveFilters, EmptyState } from '@/components/BrowseFilters';
import { SkillCard } from '@/components/SkillCard';
import { toPersianNumber } from '@/lib/format-number';
// Force dynamic rendering to fetch fresh data from database
export const dynamic = 'force-dynamic';
interface BrowsePageProps {
params: Promise<{ locale: string }>;
searchParams: Promise<{
q?: string;
category?: string;
platform?: string;
format?: string;
sort?: string;
page?: string;
}>;
}
// Get skills directly from database with filters - all filtering at database level
async function getSkills(params: {
q?: string;
platform?: string;
format?: string;
sort?: string;
page?: string;
category?: string;
}) {
try {
const db = createDb();
const limit = 20;
const page = parseInt(params.page || '1');
const offset = (page - 1) * limit;
const sortMap: Record<string, 'stars' | 'downloads' | 'rating' | 'updated' | 'lastDownloaded'> = {
'stars': 'stars',
'downloads': 'downloads',
'recent': 'updated',
'rating': 'rating',
'lastDownloaded': 'lastDownloaded',
};
// Build filter options - push ALL filters to database level
const filterOptions = {
query: params.q,
category: params.category,
platform: params.platform && params.platform !== 'all' ? params.platform : undefined,
sourceFormat: params.format || 'skill.md',
sortBy: sortMap[params.sort || 'lastDownloaded'] || 'lastDownloaded',
sortOrder: 'desc' as const,
limit,
offset,
};
// Fetch paginated results directly from database
const skills = await skillQueries.search(db, filterOptions);
// Get accurate total count for pagination
const total = await skillQueries.count(db, {
query: params.q,
category: params.category,
platform: params.platform && params.platform !== 'all' ? params.platform : undefined,
sourceFormat: params.format || 'skill.md',
});
const totalPages = Math.ceil(total / limit);
return {
skills,
pagination: { total, page, totalPages },
};
} catch (error) {
console.error('Error fetching skills:', error);
return { skills: [], pagination: { total: 0, page: 1, totalPages: 1 } };
}
}
export default async function BrowsePage({ params, searchParams }: BrowsePageProps) {
const { locale } = await params;
setRequestLocale(locale);
const searchParamsResolved = await searchParams;
const t = await getTranslations('browse');
const tCommon = await getTranslations('common');
const sortOptions = [
{ id: 'lastDownloaded', name: t('filters.sortOptions.lastDownloaded') },
{ id: 'downloads', name: t('filters.sortOptions.downloads') },
{ id: 'stars', name: t('filters.sortOptions.stars') },
{ id: 'recent', name: t('filters.sortOptions.recent') },
{ id: 'rating', name: t('filters.sortOptions.rating') },
];
// Fetch categories hierarchically for filter dropdown with translations
const tCategories = await getTranslations('categories');
type HierarchicalCategory = {
id: string;
name: string;
slug: string;
skillCount: number;
children?: { id: string; name: string; slug: string; skillCount: number }[];
};
let categories: HierarchicalCategory[] = [];
try {
const db = createDb();
const rawCategories = await categoryQueries.getHierarchical(db);
categories = rawCategories.map(parent => ({
id: parent.id,
name: tCategories(`parents.${parent.slug}`) || parent.name,
slug: parent.slug,
skillCount: parent.skillCount ?? 0,
children: parent.children?.map(cat => ({
id: cat.id,
name: tCategories(`names.${cat.slug}`) || cat.name,
slug: cat.slug,
skillCount: cat.skillCount ?? 0,
})),
}));
} catch (error) {
console.error('Error fetching categories:', error);
}
const filterTranslations = {
category: t('filters.category') || 'Category',
allCategories: t('filters.allCategories') || 'All Categories',
sort: t('filters.sort'),
format: t('filters.format') || 'Format',
allFormats: t('filters.allFormats') || 'All Formats',
agentSkills: t('filters.agentSkills') || 'Agent Skills (SKILL.md)',
searching: t('searching') || 'Searching...',
viewFeatured: t('filters.viewFeatured') || 'View Featured Skills',
};
const paginationTranslations = {
previous: t('pagination.previous') || 'Previous',
next: t('pagination.next') || 'Next',
page: t('pagination.page') || 'Page',
of: t('pagination.of') || 'of',
};
const activeFiltersTranslations = {
search: t('activeFilters.search') || 'Search',
category: t('activeFilters.category') || 'Category',
sortBy: t('activeFilters.sortBy') || 'Sorted by',
clearAll: t('activeFilters.clearAll') || 'Clear all',
};
const emptyStateTranslations = {
noResults: t('noResults') || 'No skills found',
noResultsWithQuery: t('noResultsWithQuery') || 'No results for "{query}"',
tryDifferent: t('emptyState.tryDifferent') || 'Try different search terms or adjust your filters',
clearFilters: t('emptyState.clearFilters') || 'Clear filters',
browseAll: t('emptyState.browseAll') || 'Browse Featured Skills',
};
const searchPlaceholder = tCommon('search');
// Fetch skills from API with all filters
const { skills, pagination } = await getSkills(searchParamsResolved);
const limit = 20;
const startItem = (pagination.page - 1) * limit + 1;
const endItem = Math.min(pagination.page * limit, pagination.total);
// Get category name for active filters display
const currentCategory = searchParamsResolved.category;
const currentSort = searchParamsResolved.sort || 'lastDownloaded';
const currentFormat = searchParamsResolved.format || '';
const hasActiveFilters = !!(searchParamsResolved.q || currentCategory || (currentSort && currentSort !== 'lastDownloaded') || currentFormat);
// Find category name from hierarchical categories
let categoryName = '';
if (currentCategory) {
for (const parent of categories) {
if (parent.id === currentCategory) {
categoryName = parent.name;
break;
}
const child = parent.children?.find(c => c.id === currentCategory);
if (child) {
categoryName = child.name;
break;
}
}
}
// Find sort option name
const sortName = sortOptions.find(o => o.id === currentSort)?.name || '';
return (
<div className="min-h-screen flex flex-col bg-surface-muted">
<Header />
<main className="flex-1">
{/* Page Header */}
<div className="bg-surface-elevated border-b border-border">
<div className="container-main py-8">
<h1 className="text-3xl font-bold text-text-primary mb-2">
{t('title')}
</h1>
<p className="text-text-secondary">
{t('subtitle')}
</p>
</div>
</div>
<div className="container-main py-8">
<div className="flex flex-col lg:flex-row gap-8">
{/* Filters Sidebar - Client Component */}
<BrowseFilters
sortOptions={sortOptions}
categories={categories}
locale={locale}
translations={filterTranslations}
/>
{/* Skills Grid */}
<div className="flex-1">
{/* Search Bar - Client Component */}
<SearchBar
placeholder={searchPlaceholder}
defaultValue={searchParamsResolved.q}
/>
{/* Active Filters - Shows applied filters as removable chips */}
<ActiveFilters
query={searchParamsResolved.q}
categoryId={currentCategory}
categoryName={categoryName}
sortBy={currentSort}
sortName={sortName}
translations={activeFiltersTranslations}
/>
{/* Results count with range */}
{pagination.total > 0 && (
<p className="text-text-secondary mb-6">
{t('resultsRange', {
start: locale === 'fa' ? toPersianNumber(startItem) : startItem,
end: locale === 'fa' ? toPersianNumber(endItem) : endItem,
total: locale === 'fa' ? toPersianNumber(pagination.total) : pagination.total
}) || `Showing ${startItem}-${endItem} of ${pagination.total} skills`}
</p>
)}
{/* Skills Grid or Empty State */}
{skills.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{skills.map((skill) => (
<SkillCard
key={skill.id}
skill={skill}
locale={locale}
/>
))}
</div>
) : (
<EmptyState
query={searchParamsResolved.q}
hasFilters={hasActiveFilters}
locale={locale}
translations={emptyStateTranslations}
/>
)}
{/* Pagination */}
{skills.length > 0 && (
<Pagination
currentPage={pagination.page}
totalPages={pagination.totalPages}
locale={locale}
translations={paginationTranslations}
/>
)}
</div>
</div>
</div>
</main>
<Footer />
</div>
);
}

View File

@@ -0,0 +1,176 @@
import { getTranslations, setRequestLocale } from 'next-intl/server';
import { Header } from '@/components/Header';
import { Footer } from '@/components/Footer';
import Link from 'next/link';
import {
Brain,
Bot,
Sparkles,
Monitor,
Server,
Cloud,
Database,
GitBranch,
CheckCircle,
Shield,
FileText,
PenTool,
Smartphone,
Layers,
Code,
Code2,
Package,
StickyNote,
Home,
Music,
MessageCircle,
Briefcase,
Calculator,
Coins,
type LucideIcon,
} from 'lucide-react';
import { createDb, categoryQueries } from '@skillhub/db';
import { formatNumber } from '@/lib/format-number';
// Force dynamic rendering to fetch fresh data from database
export const dynamic = 'force-dynamic';
// Map category slugs to Lucide icons (23 categories + 7 parents)
const iconMap: Record<string, LucideIcon> = {
// Original 16 categories
'ai-llm': Brain,
'git-version-control': GitBranch,
'data-database': Database,
'backend-apis': Server,
'frontend-ui': Monitor,
'agents-orchestration': Bot,
'testing-qa': CheckCircle,
'devops-cloud': Cloud,
'programming-languages': Code,
'documents-files': FileText,
'security-auth': Shield,
'mcp-skills': Layers,
'prompts-instructions': Sparkles,
'content-writing': PenTool,
'mobile-development': Smartphone,
'other-utilities': Package,
// New categories from Phase 1
'productivity-notes': StickyNote,
'smart-home-iot': Home,
'multimedia-audio-video': Music,
'social-communications': MessageCircle,
'business-finance': Briefcase,
'science-mathematics': Calculator,
'blockchain-web3': Coins,
// Parent categories from Phase 2
'development': Code2,
'ai-automation': Brain,
'data-documents': Database,
'devops-security': Cloud,
'business-productivity': Briefcase,
'media-iot': Music,
'specialized': Sparkles,
};
// Color map for parent categories
const parentColorMap: Record<string, string> = {
'development': 'bg-blue-50 text-blue-600 dark:bg-blue-950 dark:text-blue-400',
'ai-automation': 'bg-purple-50 text-purple-600 dark:bg-purple-950 dark:text-purple-400',
'data-documents': 'bg-emerald-50 text-emerald-600 dark:bg-emerald-950 dark:text-emerald-400',
'devops-security': 'bg-orange-50 text-orange-600 dark:bg-orange-950 dark:text-orange-400',
'business-productivity': 'bg-green-50 text-green-600 dark:bg-green-950 dark:text-green-400',
'media-iot': 'bg-rose-50 text-rose-600 dark:bg-rose-950 dark:text-rose-400',
'specialized': 'bg-gray-50 text-gray-600 dark:bg-gray-800 dark:text-gray-400',
};
// Get categories hierarchically from database
async function getHierarchicalCategories() {
try {
const db = createDb();
return await categoryQueries.getHierarchical(db);
} catch (error) {
console.error('Error fetching categories:', error);
return [];
}
}
export default async function CategoriesPage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations('categories');
const hierarchicalCategories = await getHierarchicalCategories();
return (
<div className="min-h-screen flex flex-col">
<Header />
<main className="flex-1">
<section className="section-header bg-gradient-subtle">
<div className="container-main text-center">
<h1 className="hero-title mb-4">{t('title')}</h1>
<p className="hero-subtitle max-w-2xl mx-auto">{t('subtitle')}</p>
</div>
</section>
<section className="section bg-surface">
<div className="container-main">
{hierarchicalCategories.map((parent) => {
const ParentIcon = iconMap[parent.slug] || Code;
const colorClass = parentColorMap[parent.slug] || 'bg-primary-50 text-primary-600';
return (
<div key={parent.id} className="mb-12 last:mb-0">
{/* Parent Section Header */}
<div className="flex items-center gap-4 mb-6">
<div className={`flex-shrink-0 w-12 h-12 rounded-xl ${colorClass} flex items-center justify-center`}>
<ParentIcon className="w-6 h-6" />
</div>
<div>
<h2 className="text-2xl font-bold text-text-primary">
{t(`parents.${parent.slug}`) || parent.name}
</h2>
<p className="text-text-muted text-sm">{parent.description}</p>
</div>
</div>
{/* Child Categories Grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{parent.children?.map((category) => {
const IconComponent = iconMap[category.slug] || Code;
return (
<Link
key={category.id}
href={`/${locale}/browse?category=${category.id}`}
className="card p-5 flex items-center gap-4 border border-transparent hover:border-primary-500 transition-all hover:shadow-md"
>
<div className="flex-shrink-0 w-12 h-12 rounded-xl bg-primary-50 dark:bg-primary-950 text-primary-600 dark:text-primary-400 flex items-center justify-center">
<IconComponent className="w-6 h-6" />
</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-text-primary truncate">
{t(`names.${category.slug}`) || category.name}
</h3>
<p className="text-text-muted text-sm ltr-nums">
{formatNumber(category.skillCount || 0, locale)} {t('skillCount')}
</p>
</div>
</Link>
);
})}
</div>
</div>
);
})}
</div>
</section>
</main>
<Footer />
</div>
);
}

View File

@@ -0,0 +1,190 @@
import { describe, it, expect } from 'vitest';
/**
* Claim Form Validation Tests
*
* These tests validate the URL validation logic used in the claim form.
* Full component tests require React Testing Library setup with jsdom environment.
*/
// Validate GitHub URL format (duplicated from ClaimForm for testing)
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;
}
}
describe('Claim Form - URL Validation', () => {
describe('isValidGitHubUrl', () => {
it('should accept valid GitHub repository URL', () => {
expect(isValidGitHubUrl('https://github.com/owner/repo')).toBe(true);
});
it('should accept valid GitHub repository URL with trailing slash', () => {
expect(isValidGitHubUrl('https://github.com/owner/repo/')).toBe(true);
});
it('should accept valid GitHub repository URL with tree path', () => {
expect(isValidGitHubUrl('https://github.com/owner/repo/tree/main/path')).toBe(true);
});
it('should reject non-GitHub URLs', () => {
expect(isValidGitHubUrl('https://example.com/owner/repo')).toBe(false);
});
it('should reject GitHub URL without owner', () => {
expect(isValidGitHubUrl('https://github.com/owner')).toBe(false);
});
it('should reject GitHub homepage', () => {
expect(isValidGitHubUrl('https://github.com')).toBe(false);
});
it('should reject invalid URLs', () => {
expect(isValidGitHubUrl('not a url')).toBe(false);
});
it('should reject empty string', () => {
expect(isValidGitHubUrl('')).toBe(false);
});
it('should reject relative paths', () => {
expect(isValidGitHubUrl('/owner/repo')).toBe(false);
});
it('should reject GitHub gist URLs', () => {
expect(isValidGitHubUrl('https://gist.github.com/user/abc123')).toBe(false);
});
});
});
describe('Claim Form - Error Code Handling', () => {
it('should map RATE_LIMIT_EXCEEDED error code correctly', () => {
const errorCodes = {
'RATE_LIMIT_EXCEEDED': 'GitHub API rate limit exceeded. Please try again in a few minutes.',
'INVALID_REPO': 'The repository was not found or is not accessible.',
'NETWORK_TIMEOUT': 'Request timed out while checking the repository.',
'INVALID_URL': 'Please enter a valid GitHub repository URL.',
};
expect(errorCodes['RATE_LIMIT_EXCEEDED']).toBe('GitHub API rate limit exceeded. Please try again in a few minutes.');
});
it('should have distinct error messages for each error code', () => {
const errorCodes = [
'RATE_LIMIT_EXCEEDED',
'INVALID_REPO',
'NETWORK_TIMEOUT',
'INVALID_URL',
'ALREADY_PENDING',
];
// All error codes should be unique
expect(new Set(errorCodes).size).toBe(errorCodes.length);
});
});
describe('Claim Form - API Response Scenarios', () => {
describe('Add Request Success Scenarios', () => {
it('should handle single skill found', () => {
const response = {
success: true,
hasSkillMd: true,
skillCount: 1,
skillPaths: ['skills/my-skill'],
};
expect(response.skillCount).toBe(1);
expect(response.hasSkillMd).toBe(true);
});
it('should handle multiple skills found', () => {
const response = {
success: true,
hasSkillMd: true,
skillCount: 3,
skillPaths: ['skills/skill1', 'skills/skill2', 'skills/skill3'],
};
expect(response.skillCount).toBe(3);
expect(response.skillPaths.length).toBe(3);
});
it('should handle no skills found', () => {
const response = {
success: true,
hasSkillMd: false,
skillCount: 0,
skillPaths: [],
};
expect(response.skillCount).toBe(0);
expect(response.hasSkillMd).toBe(false);
expect(response.skillPaths.length).toBe(0);
});
});
describe('Add Request Error Scenarios', () => {
it('should handle rate limit error', () => {
const errorResponse = {
error: 'GitHub API rate limit exceeded',
code: 'RATE_LIMIT_EXCEEDED',
};
expect(errorResponse.code).toBe('RATE_LIMIT_EXCEEDED');
});
it('should handle invalid repository error', () => {
const errorResponse = {
error: 'Repository not found',
code: 'INVALID_REPO',
};
expect(errorResponse.code).toBe('INVALID_REPO');
});
it('should handle network timeout error', () => {
const errorResponse = {
error: 'Request timed out',
code: 'NETWORK_TIMEOUT',
};
expect(errorResponse.code).toBe('NETWORK_TIMEOUT');
});
});
});
describe('Repository Validation - Error Message Clarity', () => {
it('should provide specific error for 404 (not found)', () => {
const error = 'Repository not found. Please check the URL and ensure the repository exists.';
expect(error).toContain('not found');
expect(error).toContain('check the URL');
});
it('should provide specific error for rate limit', () => {
const error = 'GitHub API rate limit exceeded. Please try again later.';
expect(error).toContain('rate limit');
expect(error).toContain('try again');
});
it('should provide specific error for private repository', () => {
const error = 'Repository is private or you do not have access. Please ensure the repository is public.';
expect(error).toContain('private');
expect(error).toContain('public');
});
it('should provide specific error for timeout', () => {
const error = 'Request timed out while checking repository. Please try again.';
expect(error).toContain('timed out');
expect(error).toContain('try again');
});
it('should provide generic error for network issues', () => {
const error = 'Network error while verifying repository. Please check your connection and try again.';
expect(error).toContain('Network error');
expect(error).toContain('connection');
});
});

View File

@@ -0,0 +1,115 @@
import { getTranslations, setRequestLocale } from 'next-intl/server';
import { Header } from '@/components/Header';
import { Footer } from '@/components/Footer';
import { ClaimForm } from '@/components/ClaimForm';
export const dynamic = 'force-dynamic';
export default async function ClaimPage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations('claim');
const translations = {
title: t('title'),
subtitle: t('subtitle'),
loginRequired: t('loginRequired'),
signIn: t('signIn'),
optional: t('optional'),
mirror: {
title: t('mirror.title'),
description: t('mirror.description'),
button: t('mirror.button'),
},
tabs: {
remove: t('tabs.remove'),
add: t('tabs.add'),
},
form: {
skillId: t('form.skillId'),
skillIdPlaceholder: t('form.skillIdPlaceholder'),
skillIdHelp: t('form.skillIdHelp'),
reason: t('form.reason'),
reasonPlaceholder: t('form.reasonPlaceholder'),
submit: t('form.submit'),
submitting: t('form.submitting'),
},
addForm: {
repositoryUrl: t('addForm.repositoryUrl'),
repositoryUrlPlaceholder: t('addForm.repositoryUrlPlaceholder'),
repositoryUrlHelp: t('addForm.repositoryUrlHelp'),
reason: t('addForm.reason'),
reasonPlaceholder: t('addForm.reasonPlaceholder'),
submit: t('addForm.submit'),
submitting: t('addForm.submitting'),
},
success: {
title: t('success.title'),
description: t('success.description'),
viewRequests: t('success.viewRequests'),
},
addSuccess: {
title: t('addSuccess.title'),
description: t('addSuccess.description'),
descriptionNoSkillMd: t('addSuccess.descriptionNoSkillMd'),
descriptionMultiplePrefix: t('addSuccess.descriptionMultiplePrefix'),
descriptionMultipleSuffix: t('addSuccess.descriptionMultipleSuffix'),
viewRequests: t('addSuccess.viewRequests'),
foundSkillsIn: t('addSuccess.foundSkillsIn'),
root: t('addSuccess.root'),
andMore: t.raw('addSuccess.andMore') as string,
},
error: {
notOwner: t('error.notOwner'),
skillNotFound: t('error.skillNotFound'),
alreadyPending: t('error.alreadyPending'),
githubError: t('error.githubError'),
invalidSkill: t('error.invalidSkill'),
invalidUrl: t('error.invalidUrl'),
invalidRepo: t('error.invalidRepo'),
rateLimitExceeded: t('error.rateLimitExceeded'),
networkTimeout: t('error.networkTimeout'),
generic: t('error.generic'),
},
myRequests: {
title: t('myRequests.title'),
empty: t('myRequests.empty'),
status: {
pending: t('myRequests.status.pending'),
approved: t('myRequests.status.approved'),
rejected: t('myRequests.status.rejected'),
indexed: t('myRequests.status.indexed'),
},
skillsFoundPrefix: t('myRequests.skillsFoundPrefix'),
skillsFoundSuffix: t('myRequests.skillsFoundSuffix'),
showLess: t('myRequests.showLess'),
showAllPrefix: t('myRequests.showAllPrefix'),
showAllSuffix: t('myRequests.showAllSuffix'),
},
};
return (
<div className="min-h-screen flex flex-col">
<Header />
<main className="flex-1">
<section className="section-header bg-gradient-subtle">
<div className="container-main text-center">
<h1 className="hero-title mb-4">{translations.title}</h1>
<p className="hero-subtitle max-w-2xl mx-auto">{translations.subtitle}</p>
</div>
</section>
<section className="section bg-surface">
<div className="container-main max-w-2xl">
<ClaimForm translations={translations} />
</div>
</section>
</main>
<Footer />
</div>
);
}

View File

@@ -0,0 +1,285 @@
import { getTranslations, setRequestLocale } from 'next-intl/server';
import Link from 'next/link';
import type { Metadata } from 'next';
import {
Zap,
Search,
Shield,
ArrowRight,
ArrowLeft,
Sparkles,
Terminal,
Clock,
Star,
} from 'lucide-react';
import { Header } from '@/components/Header';
import { Footer } from '@/components/Footer';
import { EarlyAccessForm } from '@/components/EarlyAccessForm';
import { createDb, skills, sql } from '@skillhub/db';
import { formatCompactNumber } from '@/lib/format-number';
export const dynamic = 'force-dynamic';
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'claudePlugin' });
return {
title: t('metadata.title'),
description: t('metadata.description'),
openGraph: {
title: t('metadata.title'),
description: t('metadata.description'),
},
};
}
async function getStats() {
try {
const db = createDb();
const skillsResult = await db
.select({ count: sql<number>`count(*)::int` })
.from(skills);
return skillsResult[0]?.count ?? 0;
} catch {
return 119000;
}
}
export default async function ClaudePluginPage({
params,
searchParams,
}: {
params: Promise<{ locale: string }>;
searchParams: Promise<{ variant?: string }>;
}) {
const { locale } = await params;
const { variant } = await searchParams;
setRequestLocale(locale);
const t = await getTranslations('claudePlugin');
const isRTL = locale === 'fa';
const ArrowIcon = isRTL ? ArrowLeft : ArrowRight;
const totalSkills = await getStats();
// A/B Test: variant A (default) = "Get early access", variant B = "Help us prioritize"
const isVariantB = variant === 'b';
const benefits = [
{
icon: Search,
title: t('benefits.discovery.title'),
description: t('benefits.discovery.description'),
},
{
icon: Terminal,
title: t('benefits.install.title'),
description: t('benefits.install.description'),
},
{
icon: Zap,
title: t('benefits.instant.title'),
description: t('benefits.instant.description'),
},
{
icon: Shield,
title: t('benefits.secure.title'),
description: t('benefits.secure.description'),
},
];
const stats = [
{
value: formatCompactNumber(totalSkills, locale),
label: t('stats.skills'),
icon: Sparkles,
},
{
value: '5+',
label: t('stats.platforms'),
icon: Terminal,
},
{
value: '4.9',
label: t('stats.rating'),
icon: Star,
},
];
return (
<div className="min-h-screen bg-gradient-to-b from-surface to-surface-elevated">
<Header />
<main>
{/* Hero Section */}
<section className="relative overflow-hidden">
{/* Background decoration */}
<div className="absolute inset-0 -z-10">
<div className="absolute top-20 left-1/4 w-72 h-72 bg-primary/10 rounded-full blur-3xl" />
<div className="absolute bottom-20 right-1/4 w-96 h-96 bg-accent/10 rounded-full blur-3xl" />
</div>
<div className="container mx-auto px-4 py-16 md:py-24">
<div className="max-w-4xl mx-auto text-center">
{/* Badge */}
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-primary/10 text-primary text-sm font-medium mb-6">
<Clock className="w-4 h-4" />
{t('hero.badge')}
</div>
{/* Title */}
<h1 className="text-4xl md:text-5xl lg:text-6xl font-bold text-text-primary mb-6 leading-tight">
{t('hero.title')}
</h1>
{/* Subtitle */}
<p className="text-lg md:text-xl text-text-secondary mb-8 max-w-2xl mx-auto">
{isVariantB ? t('hero.subtitleB') : t('hero.subtitleA', { count: formatCompactNumber(totalSkills, locale) })}
</p>
{/* Stats */}
<div className="flex flex-wrap justify-center gap-8 mb-12">
{stats.map((stat, i) => (
<div key={i} className="text-center">
<div className="flex items-center justify-center gap-2 text-3xl font-bold text-text-primary">
<stat.icon className="w-6 h-6 text-primary" />
{stat.value}
</div>
<div className="text-sm text-text-secondary">{stat.label}</div>
</div>
))}
</div>
{/* Email Signup Form */}
<div className="max-w-md mx-auto">
<EarlyAccessForm variant={isVariantB ? 'b' : 'a'} locale={locale} />
</div>
</div>
</div>
</section>
{/* Benefits Section */}
<section className="py-16 bg-surface-elevated">
<div className="container mx-auto px-4">
<h2 className="text-2xl md:text-3xl font-bold text-text-primary text-center mb-12">
{t('benefits.title')}
</h2>
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-6 max-w-6xl mx-auto">
{benefits.map((benefit, i) => (
<div
key={i}
className="p-6 rounded-xl bg-surface border border-border hover:border-primary/50 transition-colors"
>
<div className="w-12 h-12 rounded-lg bg-primary/10 flex items-center justify-center mb-4">
<benefit.icon className="w-6 h-6 text-primary" />
</div>
<h3 className="text-lg font-semibold text-text-primary mb-2">
{benefit.title}
</h3>
<p className="text-text-secondary text-sm">{benefit.description}</p>
</div>
))}
</div>
</div>
</section>
{/* How It Works Preview */}
<section className="py-16">
<div className="container mx-auto px-4">
<h2 className="text-2xl md:text-3xl font-bold text-text-primary text-center mb-4">
{t('howItWorks.title')}
</h2>
<p className="text-text-secondary text-center mb-12 max-w-2xl mx-auto">
{t('howItWorks.subtitle')}
</p>
<div className="max-w-4xl mx-auto">
{/* Code example */}
<div className="bg-gray-900 rounded-xl p-6 font-mono text-sm overflow-x-auto">
<div className="text-gray-400 mb-2"># {t('howItWorks.example.comment')}</div>
<div className="text-green-400 mb-4">
<span className="text-purple-400">User:</span> {t('howItWorks.example.userMessage')}
</div>
<div className="text-gray-400 mb-2"># {t('howItWorks.example.claudeComment')}</div>
<div className="text-blue-400">
<span className="text-purple-400">Claude:</span>{' '}
<span className="text-yellow-400">search_skills</span>(
<span className="text-orange-400">query</span>=
<span className="text-green-400">&quot;pdf&quot;</span>)
</div>
<div className="text-gray-500 mt-4 border-t border-gray-700 pt-4">
{t('howItWorks.example.result')}
</div>
</div>
</div>
</div>
</section>
{/* FAQ Section */}
<section className="py-16 bg-surface-elevated">
<div className="container mx-auto px-4">
<h2 className="text-2xl md:text-3xl font-bold text-text-primary text-center mb-12">
{t('faq.title')}
</h2>
<div className="max-w-3xl mx-auto space-y-6">
{[1, 2, 3].map((i) => (
<details
key={i}
className="group p-6 rounded-xl bg-surface border border-border"
>
<summary className="flex items-center justify-between cursor-pointer list-none">
<h3 className="text-lg font-medium text-text-primary">
{t(`faq.q${i}.question`)}
</h3>
<ArrowIcon className="w-5 h-5 text-text-secondary group-open:rotate-90 transition-transform" />
</summary>
<p className="mt-4 text-text-secondary">{t(`faq.q${i}.answer`)}</p>
</details>
))}
</div>
</div>
</section>
{/* CTA Section */}
<section className="py-16">
<div className="container mx-auto px-4">
<div className="max-w-4xl mx-auto text-center bg-gradient-to-r from-primary/10 to-accent/10 rounded-2xl p-8 md:p-12">
<h2 className="text-2xl md:text-3xl font-bold text-text-primary mb-4">
{t('cta.title')}
</h2>
<p className="text-text-secondary mb-8 max-w-xl mx-auto">
{t('cta.description', { count: formatCompactNumber(totalSkills, locale) })}
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<Link
href="/browse"
className="inline-flex items-center justify-center gap-2 px-6 py-3 rounded-lg bg-primary text-white font-medium hover:bg-primary/90 transition-colors"
>
{t('cta.browseSkills')}
<ArrowIcon className="w-4 h-4" />
</Link>
<a
href="https://github.com/anthropics/skills"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center justify-center gap-2 px-6 py-3 rounded-lg border border-border text-text-primary font-medium hover:bg-surface-subtle transition-colors"
>
{t('cta.learnMore')}
</a>
</div>
</div>
</div>
</section>
</main>
<Footer />
</div>
);
}

View File

@@ -0,0 +1,10 @@
import { redirect } from 'next/navigation';
export default async function ContactPage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
redirect(`/${locale}/support`);
}

View File

@@ -0,0 +1,556 @@
import { getTranslations, setRequestLocale } from 'next-intl/server';
import { Header } from '@/components/Header';
import { Footer } from '@/components/Footer';
import { ApiEndpointSection } from '@/components/ApiEndpointSection';
import type { EndpointDef } from '@/components/ApiEndpointSection';
import Link from 'next/link';
import { ArrowLeft, ArrowRight, Search, FileCode, Users, Compass, Mail } from 'lucide-react';
export default async function ApiDocsPage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations('docs');
const isRTL = locale === 'fa';
const ArrowIcon = isRTL ? ArrowLeft : ArrowRight;
const siteUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://skills.palebluedot.live';
const labels = {
parameters: t('api.labels.parameters'),
requestBody: t('api.labels.requestBody'),
required: t('api.labels.required'),
optional: t('api.labels.optional'),
default: t('api.labels.default'),
responseExample: t('api.labels.responseExample'),
rateLimit: t('api.labels.rateLimit'),
cache: t('api.labels.cache'),
authRequired: t('api.labels.authRequired'),
notes: t('api.labels.notes'),
};
// --- Section 1: Skills ---
const skillsEndpoints: EndpointDef[] = [
{
method: 'GET',
path: '/api/skills',
description: t('api.endpoints.searchSkills'),
auth: false,
rateLimit: '60 req/min',
cacheTTL: '5 min',
params: [
{ name: 'q', type: 'string', required: false, description: t('api.params.q') },
{ name: 'category', type: 'string', required: false, description: t('api.params.category') },
{ name: 'platform', type: 'string', required: false, description: t('api.params.platform') + ' (claude, codex, copilot, cursor, windsurf)' },
{ name: 'format', type: 'string', required: false, description: t('api.params.format'), default: 'skill.md' },
{ name: 'verified', type: 'boolean', required: false, description: t('api.params.verified') },
{ name: 'minStars', type: 'number', required: false, description: t('api.params.minStars') },
{ name: 'sort', type: 'string', required: false, description: t('api.params.sort') + ' (stars, downloads, rating, recent)', default: 'downloads' },
{ name: 'page', type: 'number', required: false, description: t('api.params.page'), default: '1' },
{ name: 'limit', type: 'number', required: false, description: t('api.params.limit'), default: '20' },
],
responseExample: `{
"skills": [
{
"id": "anthropic/skills/code-review",
"name": "code-review",
"description": "AI-powered code review assistant",
"githubOwner": "anthropic",
"githubRepo": "skills",
"githubStars": 1234,
"downloadCount": 567,
"securityStatus": "PASS",
"rating": 4.5,
"ratingCount": 23,
"isVerified": true,
"compatibility": { "platforms": ["claude", "cursor"] }
}
],
"pagination": {
"page": 1,
"limit": 20,
"total": 150,
"totalPages": 8
},
"searchEngine": "meilisearch"
}`,
notes: t('api.notes.searchFallback'),
},
{
method: 'GET',
path: '/api/skills/:id',
description: t('api.endpoints.getSkill'),
auth: false,
rateLimit: '120 req/min',
responseExample: `{
"id": "anthropic/skills/code-review",
"name": "code-review",
"description": "AI-powered code review assistant",
"githubOwner": "anthropic",
"githubRepo": "skills",
"skillPath": "code-review",
"branch": "main",
"version": "1.0.0",
"license": "MIT",
"githubStars": 1234,
"downloadCount": 567,
"securityScore": 95,
"securityStatus": "PASS",
"isVerified": true,
"compatibility": { "platforms": ["claude"] },
"rawContent": "# Code Review\\n...",
"sourceFormat": "skill.md"
}`,
notes: t('api.notes.viewCount'),
},
{
method: 'GET',
path: '/api/skills/featured',
description: t('api.endpoints.featuredSkills'),
auth: false,
rateLimit: '120 req/min',
cacheTTL: '2 hours',
params: [
{ name: 'limit', type: 'number', required: false, description: t('api.params.limitNum'), default: '6' },
],
},
{
method: 'GET',
path: '/api/skills/recent',
description: t('api.endpoints.recentSkills'),
auth: false,
rateLimit: '120 req/min',
cacheTTL: '1 hour',
params: [
{ name: 'limit', type: 'number', required: false, description: t('api.params.limitNum'), default: '10' },
],
},
{
method: 'POST',
path: '/api/skills/install',
description: t('api.endpoints.trackInstall'),
auth: false,
bodyParams: [
{ name: 'skillId', type: 'string', required: true, description: t('api.params.skillId') },
{ name: 'platform', type: 'string', required: false, description: t('api.params.platform') },
{ name: 'method', type: 'string', required: false, description: t('api.params.method') },
],
responseExample: `{ "success": true, "skillId": "owner/repo/skill", "platform": "claude", "method": "cli" }`,
notes: t('api.notes.installDedup'),
},
{
method: 'POST',
path: '/api/skills/add-request',
description: t('api.endpoints.addRequest'),
auth: true,
bodyParams: [
{ name: 'repositoryUrl', type: 'string', required: true, description: t('api.params.repositoryUrl') },
{ name: 'reason', type: 'string', required: false, description: t('api.params.reason') },
],
responseExample: `{ "success": true, "requestId": "uuid", "hasSkillMd": true, "skillCount": 3 }`,
},
{
method: 'POST',
path: '/api/skills/removal-request',
description: t('api.endpoints.removalRequest'),
auth: true,
bodyParams: [
{ name: 'skillId', type: 'string', required: true, description: t('api.params.skillId') },
{ name: 'reason', type: 'string', required: false, description: t('api.params.reason') },
],
responseExample: `{ "success": true, "requestId": "uuid", "blocked": true }`,
},
];
// --- Section 2: Skill Files ---
const skillFilesEndpoints: EndpointDef[] = [
{
method: 'GET',
path: '/api/skill-files',
description: t('api.endpoints.getSkillFiles'),
auth: false,
rateLimit: '60 req/min',
params: [
{ name: 'id', type: 'string', required: true, description: t('api.params.skillId') },
],
responseExample: `{
"skillId": "owner/repo/skill",
"githubOwner": "owner",
"githubRepo": "repo",
"skillPath": "skill",
"branch": "main",
"files": [
{
"name": "SKILL.md",
"path": "SKILL.md",
"type": "file",
"size": 2048,
"content": "# Skill Name\\n..."
}
],
"fromCache": true,
"cachedAt": "2026-01-15T10:30:00Z"
}`,
notes: t('api.notes.cacheInDB'),
},
{
method: 'GET',
path: '/api/skill-files/zip',
description: t('api.endpoints.downloadZip'),
auth: false,
rateLimit: '60 req/min',
params: [
{ name: 'id', type: 'string', required: true, description: t('api.params.skillId') },
{ name: 'platform', type: 'string', required: false, description: t('api.params.platformZip'), default: 'claude' },
],
notes: t('api.notes.zipTransform'),
},
];
// --- Section 3: User Actions ---
const userActionsEndpoints: EndpointDef[] = [
{
method: 'GET',
path: '/api/ratings',
description: t('api.endpoints.getRatings'),
auth: false,
rateLimit: '120 req/min',
params: [
{ name: 'skillId', type: 'string', required: true, description: t('api.params.skillId') },
{ name: 'limit', type: 'number', required: false, description: t('api.params.limitNum'), default: '10' },
{ name: 'offset', type: 'number', required: false, description: t('api.params.offset'), default: '0' },
],
responseExample: `{
"ratings": [
{
"id": "uuid",
"rating": 5,
"review": "Great skill!",
"createdAt": "2026-01-10T00:00:00Z",
"user": { "id": "uid", "username": "user1", "avatarUrl": "https://..." }
}
],
"summary": { "average": 4.5, "count": 23 }
}`,
},
{
method: 'POST',
path: '/api/ratings',
description: t('api.endpoints.createRating'),
auth: true,
rateLimit: '600 req/min',
bodyParams: [
{ name: 'skillId', type: 'string', required: true, description: t('api.params.skillId') },
{ name: 'rating', type: 'number', required: true, description: t('api.params.rating') },
{ name: 'review', type: 'string', required: false, description: t('api.params.review') },
],
responseExample: `{ "rating": { "id": "uuid", "rating": 5, "review": "..." }, "summary": { "average": 4.5, "count": 24 } }`,
},
{
method: 'GET',
path: '/api/ratings/me',
description: t('api.endpoints.getMyRating'),
auth: true,
rateLimit: '600 req/min',
params: [
{ name: 'skillId', type: 'string', required: true, description: t('api.params.skillId') },
],
responseExample: `{ "rating": { "id": "uuid", "rating": 5, "review": "Great!" } }`,
},
{
method: 'GET',
path: '/api/favorites',
description: t('api.endpoints.getFavorites'),
auth: true,
rateLimit: '600 req/min',
responseExample: `{
"favorites": [
{
"id": "owner/repo/skill",
"name": "skill-name",
"description": "...",
"githubStars": 100,
"downloadCount": 50,
"securityStatus": "PASS",
"isVerified": true,
"rating": 4.5,
"ratingCount": 10
}
]
}`,
},
{
method: 'POST',
path: '/api/favorites',
description: t('api.endpoints.addFavorite'),
auth: true,
rateLimit: '600 req/min',
bodyParams: [
{ name: 'skillId', type: 'string', required: true, description: t('api.params.skillId') },
],
responseExample: `{ "success": true, "favorited": true }`,
},
{
method: 'DELETE',
path: '/api/favorites',
description: t('api.endpoints.removeFavorite'),
auth: true,
rateLimit: '600 req/min',
bodyParams: [
{ name: 'skillId', type: 'string', required: true, description: t('api.params.skillId') },
],
responseExample: `{ "success": true, "favorited": false }`,
},
{
method: 'POST',
path: '/api/favorites/check',
description: t('api.endpoints.checkFavorites'),
auth: true,
rateLimit: '600 req/min',
bodyParams: [
{ name: 'skillIds', type: 'string[]', required: true, description: t('api.params.skillIds') },
],
responseExample: `{ "favorited": { "owner/repo/skill-a": true, "owner/repo/skill-b": false } }`,
},
];
// --- Section 4: Discovery & Metadata ---
const discoveryEndpoints: EndpointDef[] = [
{
method: 'GET',
path: '/api/categories',
description: t('api.endpoints.getCategories'),
auth: false,
rateLimit: '120 req/min',
cacheTTL: '12 hours',
responseExample: `{
"categories": [
{
"id": "cat-id",
"name": "Code Quality",
"slug": "code-quality",
"description": "...",
"icon": "🔍",
"skillCount": 15,
"sortOrder": 1
}
]
}`,
},
{
method: 'GET',
path: '/api/stats',
description: t('api.endpoints.getStats'),
auth: false,
rateLimit: '120 req/min',
cacheTTL: '1 hour',
responseExample: `{
"totalSkills": 850,
"totalDownloads": 12500,
"totalCategories": 23,
"totalContributors": 320,
"platforms": 5
}`,
},
{
method: 'GET',
path: '/api/health',
description: t('api.endpoints.healthCheck'),
auth: false,
responseExample: `{
"status": "healthy",
"timestamp": "2026-02-11T12:00:00Z",
"version": "0.2.4",
"isPrimary": true,
"services": {
"database": { "status": "healthy", "latency": 5 },
"meilisearch": { "status": "healthy", "latency": 12 },
"redis": { "status": "healthy", "latency": 3 }
}
}`,
},
{
method: 'GET',
path: '/api/attribution',
description: t('api.endpoints.getAttribution'),
auth: false,
rateLimit: '120 req/min',
cacheTTL: '1 hour',
responseExample: `{
"totalSkills": 850,
"totalContributors": 320,
"totalRepos": 150,
"licenseDistribution": [
{ "license": "MIT", "count": 50, "percentage": 50 }
],
"lastUpdated": "2026-02-11T00:00:00Z"
}`,
},
];
// --- Section 5: Newsletter ---
const newsletterEndpoints: EndpointDef[] = [
{
method: 'POST',
path: '/api/newsletter/subscribe',
description: t('api.endpoints.subscribe'),
auth: false,
rateLimit: '60 req/min',
bodyParams: [
{ name: 'email', type: 'string', required: true, description: t('api.params.email') },
{ name: 'source', type: 'string', required: false, description: t('api.params.source') },
{ name: 'locale', type: 'string', required: false, description: t('api.params.locale'), default: 'en' },
],
responseExample: `{ "success": true, "subscribed": true }`,
},
{
method: 'POST',
path: '/api/newsletter/unsubscribe',
description: t('api.endpoints.unsubscribe'),
auth: false,
rateLimit: '60 req/min',
bodyParams: [
{ name: 'email', type: 'string', required: true, description: t('api.params.email') },
],
responseExample: `{ "success": true, "unsubscribed": true }`,
notes: t('api.notes.alwaysSuccess'),
},
];
const sections = [
{ title: t('api.sections.skills.title'), description: t('api.sections.skills.description'), icon: Search, endpoints: skillsEndpoints },
{ title: t('api.sections.skillFiles.title'), description: t('api.sections.skillFiles.description'), icon: FileCode, endpoints: skillFilesEndpoints },
{ title: t('api.sections.userActions.title'), description: t('api.sections.userActions.description'), icon: Users, endpoints: userActionsEndpoints },
{ title: t('api.sections.discovery.title'), description: t('api.sections.discovery.description'), icon: Compass, endpoints: discoveryEndpoints },
{ title: t('api.sections.newsletter.title'), description: t('api.sections.newsletter.description'), icon: Mail, endpoints: newsletterEndpoints },
];
return (
<div className="min-h-screen flex flex-col">
<Header />
<main className="flex-1">
<section className="section bg-surface">
<div className="container-main">
<div className="max-w-4xl mx-auto">
<Link
href={`/${locale}/docs`}
className="inline-flex items-center gap-2 text-primary-600 hover:text-primary-700 mb-8"
>
<ArrowIcon className="w-4 h-4 rotate-180" />
{t('title')}
</Link>
<h1 className="text-4xl font-bold mb-4">{t('api.title')}</h1>
<p className="text-lg text-text-secondary mb-10">{t('api.description')}</p>
{/* Base URL */}
<div className="mb-10">
<h2 className="text-2xl font-bold mb-3">{t('api.baseUrl')}</h2>
<div className="glass-card p-4" dir="ltr">
<code className="text-sm font-mono text-left block">{siteUrl}</code>
</div>
</div>
{/* Authentication */}
<div className="mb-10">
<h2 className="text-2xl font-bold mb-3">{t('api.authSection.title')}</h2>
<p className="text-text-secondary mb-2">{t('api.authSection.description')}</p>
<p className="text-text-secondary text-sm">{t('api.authSection.howTo')}</p>
</div>
{/* Rate Limiting */}
<div className="mb-10">
<h2 className="text-2xl font-bold mb-3">{t('api.rateLimitSection.title')}</h2>
<p className="text-text-secondary mb-4">{t('api.rateLimitSection.description')}</p>
<div className="glass-card overflow-hidden mb-4" dir="ltr">
<table className="w-full text-sm text-left">
<thead>
<tr className="border-b border-border/50 bg-surface-hover/50">
<th className="py-2.5 px-4 font-medium">{t('api.rateLimitSection.tier')}</th>
<th className="py-2.5 px-4 font-medium">{t('api.rateLimitSection.limit')}</th>
<th className="py-2.5 px-4 font-medium">{t('api.rateLimitSection.use')}</th>
</tr>
</thead>
<tbody>
<tr className="border-b border-border/30">
<td className="py-2 px-4 font-mono text-xs">{t('api.rateLimitSection.anonymous')}</td>
<td className="py-2 px-4">120 req/min</td>
<td className="py-2 px-4 text-text-secondary">{t('api.rateLimitSection.anonymousUse')}</td>
</tr>
<tr className="border-b border-border/30">
<td className="py-2 px-4 font-mono text-xs">{t('api.rateLimitSection.search')}</td>
<td className="py-2 px-4">60 req/min</td>
<td className="py-2 px-4 text-text-secondary">{t('api.rateLimitSection.searchUse')}</td>
</tr>
<tr>
<td className="py-2 px-4 font-mono text-xs">{t('api.rateLimitSection.authenticated')}</td>
<td className="py-2 px-4">600 req/min</td>
<td className="py-2 px-4 text-text-secondary">{t('api.rateLimitSection.authenticatedUse')}</td>
</tr>
</tbody>
</table>
</div>
<div dir="ltr">
<h3 className="text-sm font-semibold mb-2 text-left">{t('api.rateLimitSection.headersTitle')}</h3>
<div className="space-y-1 text-sm text-left">
<p><code className="font-mono text-xs bg-surface-hover px-1.5 py-0.5 rounded">X-RateLimit-Limit</code> {t('api.rateLimitSection.headerLimit')}</p>
<p><code className="font-mono text-xs bg-surface-hover px-1.5 py-0.5 rounded">X-RateLimit-Remaining</code> {t('api.rateLimitSection.headerRemaining')}</p>
<p><code className="font-mono text-xs bg-surface-hover px-1.5 py-0.5 rounded">X-RateLimit-Reset</code> {t('api.rateLimitSection.headerReset')}</p>
</div>
</div>
</div>
{/* Error Responses */}
<div className="mb-10">
<h2 className="text-2xl font-bold mb-3">{t('api.errorSection.title')}</h2>
<p className="text-text-secondary mb-4">{t('api.errorSection.description')}</p>
<div className="glass-card p-4 mb-4" dir="ltr">
<pre className="text-sm font-mono overflow-x-auto text-left">{`{
"error": "Too Many Requests",
"message": "Rate limit exceeded",
"retryAfter": 45,
"limit": 60
}`}</pre>
</div>
<div dir="ltr">
<h3 className="text-sm font-semibold mb-2 text-left">{t('api.errorSection.commonCodes')}</h3>
<div className="space-y-1 text-sm text-left">
<p><code className="font-mono text-xs bg-surface-hover px-1.5 py-0.5 rounded">400</code> {t('api.errorSection.code400')}</p>
<p><code className="font-mono text-xs bg-surface-hover px-1.5 py-0.5 rounded">401</code> {t('api.errorSection.code401')}</p>
<p><code className="font-mono text-xs bg-surface-hover px-1.5 py-0.5 rounded">404</code> {t('api.errorSection.code404')}</p>
<p><code className="font-mono text-xs bg-surface-hover px-1.5 py-0.5 rounded">429</code> {t('api.errorSection.code429')}</p>
<p><code className="font-mono text-xs bg-surface-hover px-1.5 py-0.5 rounded">500</code> {t('api.errorSection.code500')}</p>
</div>
</div>
</div>
{/* Quick example */}
<div className="mb-10">
<h2 className="text-2xl font-bold mb-3">Quick Example</h2>
<div className="glass-card p-4" dir="ltr">
<code className="text-sm font-mono whitespace-pre-wrap text-left block">{`curl "${siteUrl}/api/skills?q=code-review&limit=5"`}</code>
</div>
</div>
<hr className="border-border/50 mb-10" />
{/* Endpoint Sections */}
{sections.map((section, index) => (
<ApiEndpointSection
key={index}
title={section.title}
description={section.description}
endpoints={section.endpoints}
labels={labels}
/>
))}
</div>
</div>
</section>
</main>
<Footer />
</div>
);
}

View File

@@ -0,0 +1,152 @@
import { getTranslations, setRequestLocale } from 'next-intl/server';
import { Header } from '@/components/Header';
import { Footer } from '@/components/Footer';
import Link from 'next/link';
import { ArrowLeft, ArrowRight } from 'lucide-react';
export default async function CliDocsPage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations('docs');
const tContent = await getTranslations('docs.content');
const isRTL = locale === 'fa';
const ArrowIcon = isRTL ? ArrowLeft : ArrowRight;
const commands = [
{ name: 'install <skill-id>', desc: tContent('cmdInstallDesc') },
{ name: 'search <query>', desc: tContent('cmdSearchDesc') },
{ name: 'list', desc: tContent('cmdListDesc') },
{ name: 'update [skill-name]', desc: tContent('cmdUpdateDesc') },
{ name: 'uninstall <skill-name>', desc: tContent('cmdUninstallDesc') },
{ name: 'config', desc: tContent('cmdConfigDesc') },
];
const platforms = [
{ name: 'Claude', path: tContent('platformClaudePath') },
{ name: 'Codex', path: tContent('platformCodexPath') },
{ name: 'Copilot', path: tContent('platformCopilotPath') },
{ name: 'Cursor', path: tContent('platformCursorPath') },
{ name: 'Windsurf', path: tContent('platformWindsurfPath') },
];
const configKeys = [
{ key: 'defaultPlatform', desc: tContent('configDefaultPlatform') },
{ key: 'apiUrl', desc: tContent('configApiUrl') },
{ key: 'githubToken', desc: tContent('configGithubToken') },
];
return (
<div className="min-h-screen flex flex-col">
<Header />
<main className="flex-1">
<section className="section bg-surface">
<div className="container-main">
<div className="max-w-3xl mx-auto">
<Link
href={`/${locale}/docs`}
className="inline-flex items-center gap-2 text-primary-600 hover:text-primary-700 mb-8"
>
<ArrowIcon className="w-4 h-4 rotate-180" />
{t('title')}
</Link>
<h1 className="text-4xl font-bold mb-6">{t('cli.title')}</h1>
<p className="text-lg text-text-secondary mb-8">{t('cli.description')}</p>
<div className="prose prose-lg max-w-none">
<h2>{tContent('installation')}</h2>
<div className="glass-card p-4 my-4" dir="ltr">
<code className="text-sm font-mono text-left block">npm install -g skillhub</code>
</div>
<h2>{tContent('commands')}</h2>
<div className="space-y-4 my-6" dir="ltr">
{commands.map((cmd, index) => (
<div key={index} className="glass-card p-4 text-left">
<code className="text-sm font-mono text-primary-600">skillhub {cmd.name}</code>
<p className="text-text-secondary mt-2">{cmd.desc}</p>
</div>
))}
</div>
<h2>{tContent('examples')}</h2>
<div className="glass-card p-4 my-4 space-y-4" dir="ltr">
{/* Install globally */}
<div>
<p className="text-sm text-text-muted mb-1">{tContent('exInstallGlobal')}</p>
<code className="block text-sm font-mono">
<span className="text-text-muted">$</span> npx skillhub install anthropics/skills/pdf
</code>
<code className="block text-sm font-mono text-success">
Skill installed to ~/.claude/skills/pdf/
</code>
</div>
{/* Install in project */}
<div>
<p className="text-sm text-text-muted mb-1">{tContent('exInstallProject')}</p>
<code className="block text-sm font-mono">
<span className="text-text-muted">$</span> npx skillhub install anthropics/skills/pdf --project
</code>
<code className="block text-sm font-mono text-success">
Skill installed to ./.claude/skills/pdf/
</code>
</div>
{/* Search */}
<div>
<p className="text-sm text-text-muted mb-1">{tContent('exSearch')}</p>
<code className="block text-sm font-mono">
<span className="text-text-muted">$</span> npx skillhub search pdf
</code>
</div>
{/* Search with sort */}
<div>
<p className="text-sm text-text-muted mb-1">{tContent('exSearchSort')}</p>
<code className="block text-sm font-mono">
<span className="text-text-muted">$</span> npx skillhub search &quot;code review&quot; --sort stars --limit 5
</code>
</div>
{/* Update all */}
<div>
<p className="text-sm text-text-muted mb-1">{tContent('exUpdateAll')}</p>
<code className="block text-sm font-mono">
<span className="text-text-muted">$</span> npx skillhub update --all
</code>
</div>
</div>
{/* Supported Platforms */}
<h2>{tContent('platformsTitle')}</h2>
<p>{tContent('platformsDesc')}</p>
<div className="space-y-3 my-6" dir="ltr">
{platforms.map((p) => (
<div key={p.name} className="glass-card p-4 text-left flex items-center justify-between">
<span className="text-sm font-semibold text-text-primary">{p.name}</span>
<code className="text-sm font-mono text-text-secondary">{p.path}</code>
</div>
))}
</div>
{/* Configuration */}
<h2>{tContent('configTitle')}</h2>
<p>{tContent('configDesc')}</p>
<div className="space-y-3 my-6" dir="ltr">
{configKeys.map((c) => (
<div key={c.key} className="glass-card p-4 text-left">
<code className="text-sm font-mono text-primary-600">{c.key}</code>
<p className="text-text-secondary mt-1 text-sm">{c.desc}</p>
</div>
))}
</div>
</div>
</div>
</div>
</section>
</main>
<Footer />
</div>
);
}

View File

@@ -0,0 +1,206 @@
import { getTranslations, setRequestLocale } from 'next-intl/server';
import { Header } from '@/components/Header';
import { Footer } from '@/components/Footer';
import { DiscoveryModes } from '@/components/DiscoveryModes';
import Link from 'next/link';
import { ArrowLeft, ArrowRight } from 'lucide-react';
import { createDb, skills, sql } from '@skillhub/db';
import { formatPromptSkillCount } from '@/lib/format-number';
export const dynamic = 'force-dynamic';
async function getSkillCount(): Promise<string> {
try {
const db = createDb();
const result = await db
.select({ count: sql<number>`count(*)::int` })
.from(skills);
return formatPromptSkillCount(result[0]?.count ?? 170000);
} catch {
return '170,000+';
}
}
function getDiscoveryPrompts(count: string) {
const siteUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://skills.palebluedot.live';
return {
standard: `## SkillHub - AI Agent Skills
${count} skills available at ${siteUrl}
Search when working with unfamiliar tech, complex workflows, or after a failed attempt:
\`\`\`bash
npx skillhub search "<query>" --limit 5
npx skillhub install <skill-id> --project
npx skillhub uninstall <skill-name> # when done
\`\`\`
Rules:
- Only install skills with security status "Pass"
- After installing, read the SKILL.md and follow its instructions
- Search during planning phase for best results`,
compact: `## SkillHub (${count} skills) -- ${siteUrl}
\`\`\`bash
npx skillhub search "<query>" --limit 5
npx skillhub install <skill-id> --project
npx skillhub uninstall <skill-name>
\`\`\`
Search for unfamiliar tech or complex tasks. Only install "Pass" security status. Read SKILL.md after install.`,
oneshot: `You have access to SkillHub (${count} AI agent skills, ${siteUrl}). For unfamiliar or complex tasks: \`npx skillhub search "<query>" --limit 5\` then \`npx skillhub install <id> --project\`. Only "Pass" security. Read SKILL.md after install. Uninstall when done.`,
};
}
export default async function GettingStartedPage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations('docs');
const tContent = await getTranslations('docs.content');
const isRTL = locale === 'fa';
const ArrowIcon = isRTL ? ArrowLeft : ArrowRight;
const skillCount = await getSkillCount();
const prompts = getDiscoveryPrompts(skillCount);
return (
<div className="min-h-screen flex flex-col">
<Header />
<main className="flex-1">
<section className="section bg-surface">
<div className="container-main">
<div className="max-w-3xl mx-auto">
<Link
href={`/${locale}/docs`}
className="inline-flex items-center gap-2 text-primary-600 hover:text-primary-700 mb-8"
>
<ArrowIcon className="w-4 h-4 rotate-180" />
{t('title')}
</Link>
<h1 className="text-4xl font-bold mb-6">{t('gettingStarted.title')}</h1>
<p className="text-lg text-text-secondary mb-8">{t('gettingStarted.description')}</p>
<div className="prose prose-lg max-w-none">
<h2>{tContent('installation')}</h2>
<p>{tContent('installCli')}</p>
<div className="glass-card p-4 my-4" dir="ltr">
<code className="text-sm font-mono text-left block">npm install -g skillhub</code>
</div>
<h2>{tContent('usage')}</h2>
<h3>{tContent('searchSkills')}</h3>
<div className="glass-card p-4 my-4" dir="ltr">
<code className="text-sm font-mono text-left block">skillhub search code-review</code>
</div>
<h3>{tContent('installSkill')}</h3>
<div className="glass-card p-4 my-4" dir="ltr">
<code className="text-sm font-mono text-left block">skillhub install anthropic/skills/code-review</code>
</div>
<h3>{tContent('listInstalled')}</h3>
<div className="glass-card p-4 my-4" dir="ltr">
<code className="text-sm font-mono text-left block">skillhub list</code>
</div>
{/* Web-based Installation */}
<hr className="my-8 border-border" />
<h2>{tContent('webInstallTitle')}</h2>
<p>{tContent('webInstallDesc')}</p>
<div className="not-prose my-4 space-y-3">
<p className="text-text-primary">{tContent('webInstallStep1')}</p>
<p className="text-text-primary">{tContent('webInstallStep2')}</p>
<p className="text-text-primary">{tContent('webInstallStep3')}</p>
<ul className="list-disc list-inside space-y-2 ms-4">
<li className="text-text-secondary">{tContent('webInstallMethodFolder')}</li>
<li className="text-text-secondary">{tContent('webInstallMethodZip')}</li>
</ul>
</div>
{/* Dynamic Skill Discovery */}
<hr className="my-8 border-border" />
<h2>{tContent('discoveryTitle')}</h2>
<p>{tContent('discoveryIntro')}</p>
<h3>{tContent('discoveryModesTitle')}</h3>
<p className="text-text-secondary">{tContent('selectMode')}</p>
<DiscoveryModes
modes={[
{
key: 'standard',
letter: 'A',
title: tContent('modeStandard'),
description: tContent('modeStandardDesc'),
latency: tContent('modeStandardLatency'),
quality: tContent('modeStandardQuality'),
bestFor: tContent('modeStandardBestFor'),
recommended: true,
prompt: prompts.standard,
},
{
key: 'compact',
letter: 'B',
title: tContent('modeCompact'),
description: tContent('modeCompactDesc'),
latency: tContent('modeCompactLatency'),
quality: tContent('modeCompactQuality'),
bestFor: tContent('modeCompactBestFor'),
prompt: prompts.compact,
},
{
key: 'oneshot',
letter: 'C',
title: tContent('modeOneShot'),
description: tContent('modeOneShotDesc'),
latency: tContent('modeOneShotLatency'),
quality: tContent('modeOneShotQuality'),
bestFor: tContent('modeOneShotBestFor'),
prompt: prompts.oneshot,
},
]}
labels={{
latency: tContent('latency'),
quality: tContent('qualityBoost'),
bestFor: tContent('bestFor'),
recommended: tContent('recommended'),
copyPrompt: tContent('copyPrompt'),
copied: tContent('copied'),
selectMode: tContent('selectMode'),
addToFile: tContent('addToFile'),
}}
/>
<h3>{tContent('crossPlatformTitle')}</h3>
<p>{tContent('crossPlatformDesc')}</p>
<div className="not-prose my-4 space-y-2" dir="ltr">
<div className="glass-card p-3 text-sm font-mono">{tContent('platformClaude')}</div>
<div className="glass-card p-3 text-sm font-mono">{tContent('platformCodex')}</div>
<div className="glass-card p-3 text-sm font-mono">{tContent('platformCopilot')}</div>
<div className="glass-card p-3 text-sm font-mono">{tContent('platformCursor')}</div>
<div className="glass-card p-3 text-sm font-mono">{tContent('platformWindsurf')}</div>
</div>
<p>
{tContent('fullDocsLink')}{' '}
<Link href={`/${locale}/docs/cli`} className="text-primary-600 hover:text-primary-700 no-underline">
CLI Reference <ArrowIcon className="inline w-4 h-4" />
</Link>
</p>
</div>
</div>
</div>
</section>
</main>
<Footer />
</div>
);
}

View File

@@ -0,0 +1,71 @@
import { getTranslations, setRequestLocale } from 'next-intl/server';
import { Header } from '@/components/Header';
import { Footer } from '@/components/Footer';
import { BookOpen, Terminal, Code } from 'lucide-react';
import Link from 'next/link';
export default async function DocsPage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations('docs');
const docs = [
{
icon: BookOpen,
title: t('gettingStarted.title'),
description: t('gettingStarted.description'),
href: `/${locale}/docs/getting-started`,
},
{
icon: Terminal,
title: t('cli.title'),
description: t('cli.description'),
href: `/${locale}/docs/cli`,
},
{
icon: Code,
title: t('api.title'),
description: t('api.description'),
href: `/${locale}/docs/api`,
},
];
return (
<div className="min-h-screen flex flex-col">
<Header />
<main className="flex-1">
<section className="section-header bg-gradient-subtle">
<div className="container-main text-center">
<h1 className="hero-title mb-4">{t('title')}</h1>
<p className="hero-subtitle max-w-2xl mx-auto">{t('subtitle')}</p>
</div>
</section>
<section className="section bg-surface">
<div className="container-main">
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 max-w-4xl mx-auto">
{docs.map((doc, index) => (
<Link
key={index}
href={doc.href}
className="card p-6 text-center hover:border-primary-500 transition-colors"
>
<div className="inline-flex items-center justify-center w-14 h-14 rounded-xl bg-primary-50 text-primary-600 mb-4">
<doc.icon className="w-7 h-7" />
</div>
<h3 className="text-xl font-semibold mb-2">{doc.title}</h3>
<p className="text-text-secondary">{doc.description}</p>
</Link>
))}
</div>
</div>
</section>
</main>
<Footer />
</div>
);
}

View File

@@ -0,0 +1,76 @@
import { getTranslations, setRequestLocale } from 'next-intl/server';
import { Heart } from 'lucide-react';
import { auth } from '@/lib/auth';
import { Header } from '@/components/Header';
import { Footer } from '@/components/Footer';
import { FavoritesList } from '@/components/FavoritesList';
import { FavoritesSignIn } from '@/components/FavoritesSignIn';
import { createDb, userQueries } from '@skillhub/db';
// Force dynamic rendering
export const dynamic = 'force-dynamic';
interface FavoritesPageProps {
params: Promise<{ locale: string }>;
}
export default async function FavoritesPage({ params }: FavoritesPageProps) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations('favorites');
const tCommon = await getTranslations('common');
const session = await auth();
const isLoggedIn = !!session?.user?.githubId;
// Get favorites only if logged in
let favorites: Awaited<ReturnType<typeof userQueries.getFavorites>> = [];
if (isLoggedIn) {
const db = createDb();
const dbUser = await userQueries.getByGithubId(db, session.user.githubId!);
favorites = dbUser ? await userQueries.getFavorites(db, dbUser.id) : [];
}
return (
<div className="min-h-screen flex flex-col bg-surface-muted">
<Header />
<main className="flex-1">
<div className="bg-surface border-b border-border">
<div className="container-main py-8">
<div className="flex items-center gap-3 mb-2">
<Heart className="w-8 h-8 text-red-500 fill-red-500" />
<h1 className="text-3xl font-bold text-text-primary">{t('title')}</h1>
</div>
<p className="text-text-secondary">{t('subtitle')}</p>
</div>
</div>
<div className="container-main py-8">
{isLoggedIn ? (
<FavoritesList
initialFavorites={favorites}
locale={locale}
translations={{
verified: tCommon('verified'),
emptyTitle: t('empty.title'),
emptyDescription: t('empty.description'),
emptyCta: t('empty.cta'),
}}
/>
) : (
<FavoritesSignIn
translations={{
loginRequired: t('loginRequired'),
signIn: t('signIn'),
}}
/>
)}
</div>
</main>
<Footer />
</div>
);
}

View File

@@ -0,0 +1,112 @@
import { getTranslations, setRequestLocale } from 'next-intl/server';
import { Header } from '@/components/Header';
import { Footer } from '@/components/Footer';
import { createDb, skillQueries } from '@skillhub/db';
import { toPersianNumber } from '@/lib/format-number';
import { Pagination } from '@/components/BrowseFilters';
import { SkillCard } from '@/components/SkillCard';
// Force dynamic rendering to fetch fresh data from database
export const dynamic = 'force-dynamic';
interface FeaturedPageProps {
params: Promise<{ locale: string }>;
searchParams: Promise<{ page?: string }>;
}
// Get featured skills with pagination
async function getFeaturedSkills(page: number, limit: number) {
try {
const db = createDb();
const offset = (page - 1) * limit;
// Try featured first, fall back to combined popularity score
let featuredSkills = await skillQueries.getFeatured(db, limit, offset);
let total = await skillQueries.countFeatured(db);
// If no featured skills, use adaptive popularity with owner/repo diversity
if (total === 0) {
featuredSkills = await skillQueries.getFeaturedWithDiversity(db, limit, 2, 3);
total = await skillQueries.countAll(db);
}
return { skills: featuredSkills, total };
} catch (error) {
console.error('Error fetching featured skills:', error);
return { skills: [], total: 0 };
}
}
export default async function FeaturedPage({
params,
searchParams,
}: FeaturedPageProps) {
const { locale } = await params;
const searchParamsResolved = await searchParams;
setRequestLocale(locale);
const t = await getTranslations('featured');
const tBrowse = await getTranslations('browse');
const limit = 12;
const page = parseInt(searchParamsResolved.page || '1');
const { skills: featuredSkills, total } = await getFeaturedSkills(page, limit);
const totalPages = Math.ceil(total / limit);
const startItem = (page - 1) * limit + 1;
const endItem = Math.min(page * limit, total);
const paginationTranslations = {
previous: tBrowse('pagination.previous'),
next: tBrowse('pagination.next'),
page: tBrowse('pagination.page'),
of: tBrowse('pagination.of'),
};
return (
<div className="min-h-screen flex flex-col">
<Header />
<main className="flex-1">
<section className="section-header bg-gradient-subtle">
<div className="container-main text-center">
<h1 className="hero-title mb-4">{t('title')}</h1>
<p className="hero-subtitle max-w-2xl mx-auto">{t('subtitle')}</p>
</div>
</section>
<section className="section bg-surface">
<div className="container-main">
{/* Results count */}
{total > 0 && (
<p className="text-text-secondary mb-6 text-center">
{tBrowse('resultsRange', {
start: locale === 'fa' ? toPersianNumber(startItem) : startItem,
end: locale === 'fa' ? toPersianNumber(endItem) : endItem,
total: locale === 'fa' ? toPersianNumber(total) : total
})}
</p>
)}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{featuredSkills.map((skill) => (
<SkillCard
key={skill.id}
skill={skill}
locale={locale}
/>
))}
</div>
{/* Pagination */}
<Pagination
currentPage={page}
totalPages={totalPages}
locale={locale}
translations={paginationTranslations}
/>
</div>
</section>
</main>
<Footer />
</div>
);
}

View File

@@ -0,0 +1,79 @@
import type { Metadata } from 'next';
import { NextIntlClientProvider } from 'next-intl';
import { getMessages, getTranslations, setRequestLocale } from 'next-intl/server';
import { notFound } from 'next/navigation';
import { locales, localeDirection, type Locale } from '@/i18n';
import { Providers } from '../providers';
import { Suspense } from 'react';
import { QueryNotification } from '@/components/QueryNotification';
import '../globals.css';
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'metadata' });
const primaryDomain = process.env.NEXT_PUBLIC_APP_URL || 'https://skills.palebluedot.live';
return {
title: {
default: t('title'),
template: `%s | SkillHub`,
},
description: t('description'),
keywords: ['AI', 'Agent', 'Skills', 'Claude', 'Codex', 'Copilot', 'Marketplace'],
authors: [{ name: 'SkillHub' }],
icons: {
icon: '/logo.svg',
shortcut: '/logo.svg',
apple: '/logo.svg',
},
openGraph: {
title: t('title'),
description: t('description'),
type: 'website',
locale: locale === 'fa' ? 'fa_IR' : 'en_US',
url: `${primaryDomain}/${locale}`,
},
};
}
export function generateStaticParams() {
return locales.map((locale) => ({ locale }));
}
export default async function LocaleLayout({
children,
params,
}: {
children: React.ReactNode;
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
if (!locales.includes(locale as Locale)) {
notFound();
}
setRequestLocale(locale);
const messages = await getMessages();
const dir = localeDirection[locale as Locale];
return (
<html lang={locale} dir={dir} suppressHydrationWarning>
<body className="min-h-screen bg-surface">
<Providers>
<NextIntlClientProvider messages={messages}>
<Suspense fallback={null}>
<QueryNotification />
</Suspense>
{children}
</NextIntlClientProvider>
</Providers>
</body>
</html>
);
}

View File

@@ -0,0 +1,193 @@
import { getTranslations, setRequestLocale } from 'next-intl/server';
import { Header } from '@/components/Header';
import { Footer } from '@/components/Footer';
import { NewSkillsTabs } from '@/components/NewSkillsTabs';
import { SkillCard } from '@/components/SkillCard';
import { Clock, RefreshCw } from 'lucide-react';
import { createDb, skillQueries } from '@skillhub/db';
import { toPersianNumber } from '@/lib/format-number';
import { Pagination } from '@/components/BrowseFilters';
// Force dynamic rendering to fetch fresh data from database
export const dynamic = 'force-dynamic';
interface NewSkillsPageProps {
params: Promise<{ locale: string }>;
searchParams: Promise<{ page?: string; tab?: string }>;
}
// Format date to "X hours/days ago" with locale support
function formatTimeAgo(date: Date | null, locale: string): string {
if (!date) return locale === 'fa' ? 'اخیراً' : 'Recently';
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
const diffDays = Math.floor(diffHours / 24);
if (locale === 'fa') {
if (diffDays > 0) return `${toPersianNumber(diffDays)} روز پیش`;
if (diffHours > 0) return `${toPersianNumber(diffHours)} ساعت پیش`;
return 'همین الان';
}
if (diffDays > 0) {
return `${diffDays} day${diffDays > 1 ? 's' : ''} ago`;
}
if (diffHours > 0) {
return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago`;
}
return 'Just now';
}
// Get skills based on tab with pagination
async function getSkillsForTab(tab: 'new' | 'updated', page: number, limit: number) {
try {
const db = createDb();
const offset = (page - 1) * limit;
if (tab === 'new') {
const skills = await skillQueries.getNewSkills(db, limit, offset);
const total = await skillQueries.countNewSkills(db);
return { skills, total };
} else {
const skills = await skillQueries.getUpdatedSkills(db, limit, offset);
const total = await skillQueries.countUpdatedSkills(db);
return { skills, total };
}
} catch (error) {
console.error('Error fetching skills:', error);
return { skills: [], total: 0 };
}
}
// Get counts for both tabs
async function getTabCounts() {
try {
const db = createDb();
const [newCount, updatedCount] = await Promise.all([
skillQueries.countNewSkills(db),
skillQueries.countUpdatedSkills(db),
]);
return { newCount, updatedCount };
} catch (error) {
console.error('Error fetching counts:', error);
return { newCount: 0, updatedCount: 0 };
}
}
export default async function NewSkillsPage({
params,
searchParams,
}: NewSkillsPageProps) {
const { locale } = await params;
const searchParamsResolved = await searchParams;
setRequestLocale(locale);
const t = await getTranslations('new');
const tBrowse = await getTranslations('browse');
const limit = 12;
const page = parseInt(searchParamsResolved.page || '1');
const tab = (searchParamsResolved.tab as 'new' | 'updated') || 'new';
// Fetch data in parallel
const [{ skills, total }, { newCount, updatedCount }] = await Promise.all([
getSkillsForTab(tab, page, limit),
getTabCounts(),
]);
const totalPages = Math.ceil(total / limit);
const startItem = total > 0 ? (page - 1) * limit + 1 : 0;
const endItem = Math.min(page * limit, total);
const paginationTranslations = {
previous: tBrowse('pagination.previous'),
next: tBrowse('pagination.next'),
page: tBrowse('pagination.page'),
of: tBrowse('pagination.of'),
};
const tabsTranslations = {
new: t('tabs.new'),
updated: t('tabs.updated'),
newDescription: t('newDescription'),
updatedDescription: t('updatedDescription'),
};
const noSkillsMessage = tab === 'new' ? t('noNewSkills') : t('noUpdatedSkills');
return (
<div className="min-h-screen flex flex-col">
<Header />
<main className="flex-1">
<section className="section-header bg-gradient-subtle">
<div className="container-main text-center">
<h1 className="hero-title mb-4">{t('title')}</h1>
<p className="hero-subtitle max-w-2xl mx-auto">{t('subtitle')}</p>
</div>
</section>
<section className="section bg-surface">
<div className="container-main">
{/* Tabs Component */}
<NewSkillsTabs
activeTab={tab}
newCount={newCount}
updatedCount={updatedCount}
locale={locale}
translations={tabsTranslations}
/>
{/* Results count */}
{total > 0 && (
<p className="text-text-secondary mb-6 text-center">
{tBrowse('resultsRange', {
start: locale === 'fa' ? toPersianNumber(startItem) : startItem,
end: locale === 'fa' ? toPersianNumber(endItem) : endItem,
total: locale === 'fa' ? toPersianNumber(total) : total
})}
</p>
)}
{/* Skills Grid */}
{skills.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{skills.map((skill) => (
<SkillCard
key={skill.id}
skill={skill}
locale={locale}
showTimeBadge={tab === 'new' ? 'created' : 'updated'}
formatTimeAgo={formatTimeAgo}
/>
))}
</div>
) : (
<div className="text-center py-16">
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-surface-elevated flex items-center justify-center">
{tab === 'new' ? (
<Clock className="w-8 h-8 text-text-muted" />
) : (
<RefreshCw className="w-8 h-8 text-text-muted" />
)}
</div>
<p className="text-text-secondary text-lg">{noSkillsMessage}</p>
</div>
)}
{/* Pagination */}
{totalPages > 1 && (
<Pagination
currentPage={page}
totalPages={totalPages}
locale={locale}
translations={paginationTranslations}
/>
)}
</div>
</section>
</main>
<Footer />
</div>
);
}

View File

@@ -0,0 +1,307 @@
import { getTranslations, setRequestLocale } from 'next-intl/server';
import { Header } from '@/components/Header';
import { Footer } from '@/components/Footer';
import { SkillCard } from '@/components/SkillCard';
import { Pagination } from '@/components/BrowseFilters';
import { createDb, skillQueries } from '@skillhub/db';
import { formatCompactNumber, toPersianNumber } from '@/lib/format-number';
import { ExternalLink, Download, Package, Eye, GitFork, ArrowUpDown, FolderGit2 } from 'lucide-react';
import Link from 'next/link';
export const dynamic = 'force-dynamic';
const ITEMS_PER_PAGE = 24;
type SortOption = 'popularity' | 'downloads' | 'stars';
interface OwnerPageProps {
params: Promise<{ locale: string; username: string }>;
searchParams: Promise<{ page?: string; sort?: string; repo?: string }>;
}
export default async function OwnerPage({ params, searchParams }: OwnerPageProps) {
const { locale, username: rawUsername } = await params;
const searchParamsResolved = await searchParams;
setRequestLocale(locale);
const t = await getTranslations('owner');
const tBrowse = await getTranslations('browse');
const username = decodeURIComponent(rawUsername);
const page = Math.max(1, parseInt(searchParamsResolved.page || '1'));
const sort = (['popularity', 'downloads', 'stars'].includes(searchParamsResolved.sort || '')
? searchParamsResolved.sort
: 'popularity') as SortOption;
const activeRepo = searchParamsResolved.repo || '';
const db = createDb();
// Fetch stats, count, and repo list in parallel
const [stats, totalSkills, ownerRepos] = await Promise.all([
skillQueries.getOwnerStats(db, username),
skillQueries.countByOwner(db, username, activeRepo || undefined),
skillQueries.getOwnerRepos(db, username),
]);
if (stats.totalSkills === 0) {
return (
<div className="min-h-screen flex flex-col">
<Header />
<main className="flex-1 container mx-auto px-4 py-16 text-center">
<h1 className="text-2xl font-bold mb-4">{t('notFound')}</h1>
<p className="text-text-secondary mb-8">
{t('notFoundDescription', { username })}
</p>
<Link href={`/${locale}/browse`} className="text-primary hover:underline">
{t('browseAll')}
</Link>
</main>
<Footer />
</div>
);
}
const offset = (page - 1) * ITEMS_PER_PAGE;
const totalPages = Math.ceil(totalSkills / ITEMS_PER_PAGE);
const skills = await skillQueries.getByOwner(db, username, {
limit: ITEMS_PER_PAGE,
offset,
sortBy: sort,
repo: activeRepo || undefined,
});
// Group fetched skills by repo for display
const repoMap = new Map<string, {
name: string;
stars: number;
skills: typeof skills;
}>();
for (const skill of skills) {
const repo = skill.githubRepo;
if (!repoMap.has(repo)) {
repoMap.set(repo, {
name: repo,
stars: skill.githubStars ?? 0,
skills: [],
});
}
repoMap.get(repo)!.skills.push(skill);
}
const repos = Array.from(repoMap.values());
const formatNum = (n: number) =>
locale === 'fa' ? toPersianNumber(formatCompactNumber(n, locale)) : formatCompactNumber(n, locale);
const startItem = offset + 1;
const endItem = Math.min(offset + ITEMS_PER_PAGE, totalSkills);
const paginationTranslations = {
previous: tBrowse('pagination.previous'),
next: tBrowse('pagination.next'),
page: tBrowse('pagination.page'),
of: tBrowse('pagination.of'),
};
const sortOptions: { value: SortOption; label: string }[] = [
{ value: 'popularity', label: t('sort.popularity') },
{ value: 'downloads', label: t('sort.downloads') },
{ value: 'stars', label: t('sort.stars') },
];
// Build URL helper preserving sort/repo params
const buildUrl = (overrides: { sort?: string; repo?: string; page?: number }) => {
const params = new URLSearchParams();
const s = overrides.sort ?? sort;
const r = overrides.repo ?? activeRepo;
const p = overrides.page ?? 1;
if (s && s !== 'popularity') params.set('sort', s);
if (r) params.set('repo', r);
if (p > 1) params.set('page', String(p));
const qs = params.toString();
return `/${locale}/owner/${rawUsername}${qs ? `?${qs}` : ''}`;
};
return (
<div className="min-h-screen flex flex-col">
<Header />
<main className="flex-1">
{/* Owner Header */}
<section className="bg-gradient-subtle border-b border-border">
<div className="container mx-auto px-4 py-8">
<div className="flex flex-col sm:flex-row items-center sm:items-start gap-5">
<img
src={`https://github.com/${username}.png?size=96`}
alt={username}
width={96}
height={96}
className="rounded-full border-2 border-border shadow-sm"
/>
<div className="text-center sm:text-start flex-1">
<h1 className="text-2xl md:text-3xl font-bold flex items-center justify-center sm:justify-start gap-2 text-text-primary">
{t('title', { username })}
<a
href={`https://github.com/${username}`}
target="_blank"
rel="noopener noreferrer"
className="text-text-muted hover:text-text-primary transition-colors"
>
<ExternalLink className="w-4 h-4" />
</a>
</h1>
{/* Stats */}
<div className="flex flex-wrap items-center justify-center sm:justify-start gap-4 mt-3 text-sm text-text-secondary">
<span className="flex items-center gap-1.5">
<Package className="w-4 h-4 text-primary" />
{t('stats.skills', { count: stats.totalSkills })}
</span>
<span className="flex items-center gap-1.5">
<Download className="w-4 h-4 text-primary" />
{formatNum(stats.totalDownloads)} {t('stats.downloads')}
</span>
<span className="flex items-center gap-1.5">
<Eye className="w-4 h-4 text-primary" />
{formatNum(stats.totalViews)} {t('stats.views')}
</span>
<span className="flex items-center gap-1.5">
<GitFork className="w-4 h-4 text-primary" />
{t('stats.repos', { count: stats.totalRepos })}
</span>
</div>
</div>
</div>
</div>
</section>
<section className="container mx-auto px-4 py-6">
{/* Repo filter chips (only if > 1 repo) */}
{ownerRepos.length > 1 && (
<div className="flex flex-wrap items-center gap-2 mb-4">
<FolderGit2 className="w-4 h-4 text-text-muted" />
<span className="text-sm text-text-secondary">{t('repo.filter')}:</span>
<Link
href={buildUrl({ repo: '', page: 1 })}
className={`px-3 py-1 text-xs rounded-full transition-colors ${
!activeRepo
? 'bg-primary text-primary-foreground font-medium'
: 'bg-surface-elevated text-text-secondary hover:bg-surface-subtle border border-border'
}`}
>
{t('repo.all')} ({locale === 'fa' ? toPersianNumber(stats.totalSkills) : stats.totalSkills})
</Link>
{ownerRepos.map((r) => (
<Link
key={r.repo}
href={buildUrl({ repo: r.repo, page: 1 })}
className={`px-3 py-1 text-xs rounded-full transition-colors ${
activeRepo === r.repo
? 'bg-primary text-primary-foreground font-medium'
: 'bg-surface-elevated text-text-secondary hover:bg-surface-subtle border border-border'
}`}
>
{r.repo} ({locale === 'fa' ? toPersianNumber(r.skillCount) : r.skillCount})
</Link>
))}
</div>
)}
{/* Controls: Sort + Results count */}
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 mb-6">
{/* Results count */}
<p className="text-sm text-text-secondary">
{t('pagination.showing', {
start: locale === 'fa' ? toPersianNumber(startItem) : startItem,
end: locale === 'fa' ? toPersianNumber(endItem) : endItem,
total: locale === 'fa' ? toPersianNumber(totalSkills) : totalSkills,
})}
</p>
{/* Sort selector */}
<div className="flex items-center gap-2">
<ArrowUpDown className="w-4 h-4 text-text-muted" />
<span className="text-sm text-text-secondary">{t('sort.label')}:</span>
<div className="flex gap-1">
{sortOptions.map((opt) => (
<Link
key={opt.value}
href={buildUrl({ sort: opt.value, page: 1 })}
className={`px-3 py-1.5 text-sm rounded-lg transition-colors ${
sort === opt.value
? 'bg-primary text-primary-foreground font-medium'
: 'bg-surface-elevated text-text-secondary hover:bg-surface-subtle border border-border'
}`}
>
{opt.label}
</Link>
))}
</div>
</div>
</div>
{/* Claim CTA - subtle inline hint */}
<div className="flex items-center gap-2 mb-6 text-xs text-text-muted">
<span>{t('claimCta')}</span>
<Link
href={`/${locale}/claim`}
className="text-primary hover:underline font-medium whitespace-nowrap"
>
{t('claimButton')}
</Link>
</div>
{/* Repos + Skills */}
{repos.map((repo) => (
<section key={repo.name} className="mb-8">
<div className="flex items-center gap-3 mb-4 pb-2 border-b border-border">
<h2 className="text-lg font-semibold text-text-primary">{repo.name}</h2>
<span className="text-xs text-text-muted flex items-center gap-1 bg-surface-elevated px-2 py-0.5 rounded-full">
{t('repo.skills', { count: repo.skills.length })}
</span>
<a
href={`https://github.com/${username}/${repo.name}`}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-text-muted hover:text-primary flex items-center gap-1 ms-auto transition-colors"
>
<ExternalLink className="w-3 h-3" /> {t('repo.viewOnGithub')}
</a>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{repo.skills.map((skill) => (
<SkillCard
key={skill.id}
skill={skill}
locale={locale}
/>
))}
</div>
</section>
))}
{/* Pagination */}
<Pagination
currentPage={page}
totalPages={totalPages}
locale={locale}
translations={paginationTranslations}
/>
{/* Browse CTA - link to owner page and CLI for owners with long enough usernames */}
<div className="bg-surface-elevated border border-border rounded-xl p-6 mt-8 text-center">
<p className="text-sm text-text-secondary mb-3">
{t('installCta')}
</p>
{skills.length > 0 && (
<code className="bg-surface border border-border px-4 py-2 rounded-lg text-sm font-mono text-text-primary" dir="ltr">
npx skillhub install {skills[0].id}
</code>
)}
</div>
</section>
</main>
<Footer />
</div>
);
}

View File

@@ -0,0 +1,274 @@
import { getTranslations, setRequestLocale } from 'next-intl/server';
import Link from 'next/link';
import { Search, ArrowLeft, ArrowRight, Download, Users, Layers, Sparkles, Terminal, Zap } from 'lucide-react';
import { Header } from '@/components/Header';
import { Footer } from '@/components/Footer';
import { HeroSearch } from '@/components/HeroSearch';
import { SkillCard } from '@/components/SkillCard';
import { createDb, categoryQueries, skillQueries, skills, sql } from '@skillhub/db';
import { formatCompactNumber } from '@/lib/format-number';
// Force dynamic rendering to fetch fresh data from database
export const dynamic = 'force-dynamic';
// Get stats directly from database
async function getStats() {
try {
const db = createDb();
// Get total skills count (SKILL.md only - real reusable skills)
const skillsResult = await db
.select({ count: sql<number>`count(*)::int` })
.from(skills)
.where(sql`${skills.sourceFormat} = 'skill.md' AND ${skills.isBlocked} = false`);
const totalSkills = skillsResult[0]?.count ?? 0;
// Get total downloads
const downloadsResult = await db
.select({ sum: sql<number>`coalesce(sum(${skills.downloadCount}), 0)::int` })
.from(skills);
const totalDownloads = downloadsResult[0]?.sum ?? 0;
// Get total categories
const categories = await categoryQueries.getAll(db);
const totalCategories = categories.length;
// Get unique contributors (github owners)
const contributorsResult = await db
.select({ count: sql<number>`count(distinct ${skills.githubOwner})::int` })
.from(skills);
const totalContributors = contributorsResult[0]?.count ?? 0;
return {
totalSkills,
totalDownloads,
totalCategories,
totalContributors,
platforms: 5,
};
} catch (error) {
console.error('Error fetching stats:', error);
return null;
}
}
// Get featured skills directly from database
async function getFeaturedSkills() {
try {
const db = createDb();
// Get featured skills, or top skills by popularity if none are featured
let featuredSkills = await skillQueries.getFeatured(db, 6);
if (featuredSkills.length === 0) {
// Fallback to adaptive popularity with owner/repo diversity
featuredSkills = await skillQueries.getFeaturedWithDiversity(db, 6, 2, 3);
}
return featuredSkills;
} catch (error) {
console.error('Error fetching featured skills:', error);
return [];
}
}
export default async function HomePage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations('home');
const tCommon = await getTranslations('common');
const isRTL = locale === 'fa';
const ArrowIcon = isRTL ? ArrowLeft : ArrowRight;
// Fetch real data
const [statsData, featuredSkills] = await Promise.all([
getStats(),
getFeaturedSkills(),
]);
const stats = [
{ value: statsData ? formatCompactNumber(statsData.totalSkills, locale) : '۰', label: t('stats.skills'), icon: Layers },
{ value: statsData ? formatCompactNumber(statsData.totalDownloads, locale) : '۰', label: t('stats.downloads'), icon: Download },
{ value: statsData ? formatCompactNumber(statsData.totalContributors, locale) : '۰', label: t('stats.contributors'), icon: Users },
{ value: statsData ? formatCompactNumber(statsData.totalCategories || 8, locale) : '۸', label: t('stats.categories'), icon: Sparkles },
];
const steps = [
{
icon: Search,
title: t('howItWorks.step1.title'),
description: t('howItWorks.step1.description'),
},
{
icon: Terminal,
title: t('howItWorks.step2.title'),
description: t('howItWorks.step2.description'),
},
{
icon: Zap,
title: t('howItWorks.step3.title'),
description: t('howItWorks.step3.description'),
},
];
return (
<div className="min-h-screen flex flex-col">
<Header />
<main className="flex-1">
{/* Hero Section */}
<section className="relative overflow-hidden bg-gradient-subtle">
<div className="container-main py-20 lg:py-32">
<div className="max-w-3xl mx-auto text-center">
{/* Tagline */}
<p className="hero-tagline mb-4 animate-fade-up">
{t('hero.tagline')}
</p>
{/* Title */}
<h1 className="hero-title mb-6 animate-fade-up animation-delay-100 whitespace-pre-line">
{t('hero.title')}
</h1>
{/* Subtitle */}
<p className="hero-subtitle mb-8 animate-fade-up animation-delay-200">
{t('hero.subtitle')}
</p>
{/* Search - Client Component */}
<HeroSearch
placeholder={t('hero.searchPlaceholder')}
locale={locale}
/>
{/* CTA Buttons */}
<div className="flex flex-col sm:flex-row gap-4 justify-center animate-fade-up animation-delay-400">
<Link href={`/${locale}/browse`} className="btn-primary gap-2">
{t('hero.cta')}
<ArrowIcon className="w-4 h-4" />
</Link>
<Link href={`/${locale}/docs/getting-started`} className="btn-secondary">
{t('hero.ctaSecondary')}
</Link>
</div>
</div>
</div>
{/* Decorative elements */}
<div className="absolute inset-0 -z-10 overflow-hidden">
<div className="absolute top-1/4 start-1/4 w-96 h-96 bg-primary-200/30 rounded-full blur-3xl" />
<div className="absolute bottom-1/4 end-1/4 w-96 h-96 bg-gold/20 rounded-full blur-3xl" />
</div>
</section>
{/* Stats Section */}
<section className="py-12 bg-surface border-y border-border">
<div className="container-main">
<div className="grid grid-cols-2 md:grid-cols-4 gap-8">
{stats.map((stat, index) => (
<div key={index} className="text-center">
<div className="inline-flex items-center justify-center w-12 h-12 rounded-xl bg-primary-50 text-primary-600 mb-3">
<stat.icon className="w-6 h-6" />
</div>
<div className="text-3xl font-bold text-text-primary ltr-nums mb-1">
{stat.value}
</div>
<div className="text-text-secondary">
{stat.label}
</div>
</div>
))}
</div>
</div>
</section>
{/* Featured Skills */}
<section className="section bg-surface">
<div className="container-main">
<div className="text-center mb-12">
<h2 className="section-title">{t('featured.title')}</h2>
<p className="section-subtitle">{t('featured.subtitle')}</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{featuredSkills.length > 0 ? (
featuredSkills.map((skill) => (
<SkillCard
key={skill.id}
skill={skill}
locale={locale}
/>
))
) : (
// Fallback placeholder cards if no skills
[1, 2, 3, 4, 5, 6].map((i) => (
<div key={i} className="card p-6 animate-pulse">
<div className="h-6 bg-surface-subtle rounded mb-2 w-1/3"></div>
<div className="h-4 bg-surface-subtle rounded mb-4 w-2/3"></div>
<div className="flex gap-4">
<div className="h-4 bg-surface-subtle rounded w-16"></div>
<div className="h-4 bg-surface-subtle rounded w-16"></div>
</div>
</div>
))
)}
</div>
<div className="text-center mt-8">
<Link href={`/${locale}/featured`} className="btn-secondary gap-2">
{tCommon('viewAll')}
<ArrowIcon className="w-4 h-4" />
</Link>
</div>
</div>
</section>
{/* How It Works */}
<section className="section bg-surface-muted">
<div className="container-main">
<div className="text-center mb-12">
<h2 className="section-title">{t('howItWorks.title')}</h2>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
{steps.map((step, index) => (
<div key={index} className="text-center">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-gradient-primary text-white mb-6 shadow-primary">
<step.icon className="w-8 h-8" />
</div>
<h3 className="text-xl font-semibold text-text-primary mb-3">
{step.title}
</h3>
<p className="text-text-secondary">
{step.description}
</p>
</div>
))}
</div>
{/* CLI Example */}
<div className="max-w-2xl mx-auto mt-12" dir="ltr">
<div className="glass-card p-6">
<div className="flex items-center gap-2 mb-4">
<div className="w-3 h-3 rounded-full bg-error" />
<div className="w-3 h-3 rounded-full bg-warning" />
<div className="w-3 h-3 rounded-full bg-success" />
</div>
<code className="block text-sm font-mono text-text-primary text-start">
<span className="text-text-muted">$</span> npx skillhub install anthropics/skills/pdf
</code>
<code className="block text-sm font-mono text-success mt-2 text-start">
Skill installed to ~/.claude/skills/pdf/
</code>
</div>
</div>
</div>
</section>
</main>
<Footer />
</div>
);
}

View File

@@ -0,0 +1,90 @@
import { getTranslations, setRequestLocale } from 'next-intl/server';
import Link from 'next/link';
import { Header } from '@/components/Header';
import { Footer } from '@/components/Footer';
import { Shield, Database, Cookie, Users, Lock, Mail, ArrowRight, ArrowLeft } from 'lucide-react';
export const dynamic = 'force-dynamic';
const sectionIcons = {
dataCollected: Database,
cookies: Cookie,
thirdParty: Users,
retention: Lock,
rights: Shield,
contact: Mail,
};
export default async function PrivacyPage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations('privacy');
const isRTL = locale === 'fa';
const ArrowIcon = isRTL ? ArrowLeft : ArrowRight;
const sections = [
'dataCollected',
'cookies',
'thirdParty',
'retention',
'rights',
'contact',
] as const;
return (
<div className="min-h-screen flex flex-col">
<Header />
<main className="flex-1">
<section className="section-header bg-gradient-subtle">
<div className="container-main text-center">
<h1 className="hero-title mb-4">{t('title')}</h1>
<p className="hero-subtitle max-w-2xl mx-auto">{t('subtitle')}</p>
<p className="text-sm text-text-muted mt-4">{t('lastUpdated')}</p>
</div>
</section>
<section className="section bg-surface">
<div className="container-main max-w-4xl">
<div className="space-y-8">
{sections.map((sectionKey) => {
const Icon = sectionIcons[sectionKey];
const showClaimLink = sectionKey === 'rights';
return (
<div key={sectionKey} className="card p-6">
<div className="flex items-start gap-4">
<div className="flex-shrink-0 w-10 h-10 rounded-lg bg-primary-50 dark:bg-primary-900/20 flex items-center justify-center">
<Icon className="w-5 h-5 text-primary-600 dark:text-primary-400" />
</div>
<div className="flex-1">
<h2 className="text-lg font-semibold text-text-primary mb-2">
{t(`sections.${sectionKey}.title`)}
</h2>
<p className="text-text-secondary" dir={locale === 'fa' ? 'rtl' : 'ltr'}>
{t(`sections.${sectionKey}.description`)}
</p>
{showClaimLink && (
<Link
href={`/${locale}/claim`}
className="inline-flex items-center gap-1 mt-3 text-primary-600 hover:text-primary-700 text-sm font-medium transition-colors"
>
{locale === 'fa' ? 'صفحه مدیریت مهارت‌ها' : 'Manage Skills Page'}
<ArrowIcon className="w-3 h-3" />
</Link>
)}
</div>
</div>
</div>
);
})}
</div>
</div>
</section>
</main>
<Footer />
</div>
);
}

View File

@@ -0,0 +1,518 @@
import { getTranslations, setRequestLocale } from 'next-intl/server';
import Link from 'next/link';
import { notFound } from 'next/navigation';
import { headers } from 'next/headers';
import {
Star, Download, Shield, CheckCircle, Copy,
ExternalLink, Github, Calendar, User, Tag, ChevronRight, Eye
} from 'lucide-react';
import { Header } from '@/components/Header';
import { Footer } from '@/components/Footer';
import { FavoriteButton } from '@/components/FavoriteButton';
import { RatingStars } from '@/components/RatingStars';
import { InstallSection } from '@/components/InstallSection';
import { ShareButton } from '@/components/ShareButton';
import { createDb, skillQueries } from '@skillhub/db';
import { FORMAT_LABELS } from 'skillhub-core';
import { formatCompactNumber } from '@/lib/format-number';
import { shouldCountView } from '@/lib/cache';
// Force dynamic rendering to fetch fresh data from database
export const dynamic = 'force-dynamic';
interface SkillPageProps {
params: Promise<{ locale: string; id: string[] }>;
}
// Get skill directly from database
async function getSkill(skillId: string) {
try {
const db = createDb();
return await skillQueries.getById(db, skillId);
} catch (error) {
console.error('Error fetching skill:', error);
return null;
}
}
export default async function SkillPage({ params }: SkillPageProps) {
const { locale, id } = await params;
setRequestLocale(locale);
const t = await getTranslations('skill');
const tCommon = await getTranslations('common');
const skillId = id.join('/');
const isRTL = locale === 'fa';
// Get skill from database
const dbSkill = await getSkill(skillId);
if (!dbSkill) {
notFound();
}
// Check if skill is blocked (removed by owner request)
if (dbSkill.isBlocked) {
notFound();
}
// Track view count with IP-based rate limiting (1 hour cooldown per IP)
// Get client IP from headers (works with Cloudflare, nginx, etc.)
const headersList = await headers();
const clientIp =
headersList.get('cf-connecting-ip') ||
headersList.get('x-real-ip') ||
headersList.get('x-forwarded-for')?.split(',')[0].trim() ||
'unknown';
// Only increment on primary server (mirror DB is read-only)
const isPrimary = process.env.IS_PRIMARY_SERVER !== 'false';
if (isPrimary) {
const db = createDb();
shouldCountView(dbSkill.id, clientIp).then((shouldCount) => {
if (shouldCount) {
skillQueries.incrementViews(db, dbSkill.id).catch(() => {});
}
}).catch(() => {});
}
// Map database response to expected format
const skill = {
id: dbSkill.id,
name: dbSkill.name,
description: dbSkill.description,
longDescription: dbSkill.rawContent || dbSkill.description,
version: dbSkill.version || null,
license: dbSkill.license || 'MIT',
author: dbSkill.githubOwner,
repo: dbSkill.githubRepo,
repository: `https://github.com/${dbSkill.githubOwner}/${dbSkill.githubRepo}`,
homepage: dbSkill.homepage || null,
stars: dbSkill.githubStars || 0,
downloads: dbSkill.downloadCount || 0,
views: dbSkill.viewCount || 0,
securityStatus: dbSkill.securityStatus || 'pass',
isVerified: dbSkill.isVerified || false,
createdAt: dbSkill.createdAt,
updatedAt: dbSkill.updatedAt ? dbSkill.updatedAt.toLocaleDateString(locale === 'fa' ? 'fa-IR' : 'en-US') : 'N/A',
rating: dbSkill.rating || 0,
ratingCount: dbSkill.ratingCount || 0,
sourceFormat: dbSkill.sourceFormat || 'skill.md',
};
// Content section title based on source format (uses FORMAT_LABELS from skillhub-core)
const getContentTitle = (format: string) => {
const label = FORMAT_LABELS[format as keyof typeof FORMAT_LABELS] || FORMAT_LABELS['skill.md'];
if (format === 'copilot-instructions') {
return isRTL ? 'دستورالعمل Copilot' : label;
}
return isRTL ? `محتوای ${label}` : `${label} Content`;
};
// Source format badge configuration (for non-SKILL.md formats)
const FORMAT_PLATFORMS: Record<string, string> = {
'agents.md': 'Codex',
'cursorrules': 'Cursor',
'windsurfrules': 'Windsurf',
'copilot-instructions': 'Copilot',
};
const getSourceFormatBadge = (format: string) => {
const platform = FORMAT_PLATFORMS[format];
if (!platform) return null;
const label = FORMAT_LABELS[format as keyof typeof FORMAT_LABELS] || format;
return { label, platform };
};
const sourceFormatBadge = getSourceFormatBadge(skill.sourceFormat);
const getSecurityConfig = (status: string) => {
switch (status) {
case 'pass': return {
label: t('security.pass'),
icon: '✓',
bg: 'bg-success/10',
text: 'text-success',
border: 'border-success/20'
};
case 'warning': return {
label: t('security.warning'),
icon: '⚠',
bg: 'bg-warning/10',
text: 'text-warning',
border: 'border-warning/20'
};
case 'fail': return {
label: t('security.fail'),
icon: '✕',
bg: 'bg-error/10',
text: 'text-error',
border: 'border-error/20'
};
default: return {
label: t('security.pass'),
icon: '✓',
bg: 'bg-success/10',
text: 'text-success',
border: 'border-success/20'
};
}
};
const securityConfig = getSecurityConfig(skill.securityStatus);
const installCommands = {
claude: {
cli: `npx skillhub install ${skillId}`,
path: `~/.claude/skills/${skill.name}/`,
},
codex: {
cli: `npx skillhub install ${skillId} --platform codex`,
path: `~/.codex/skills/${skill.name}/`,
},
copilot: {
cli: `npx skillhub install ${skillId} --platform copilot`,
path: `.github/instructions/${skill.name}.instructions.md`,
},
cursor: {
cli: `npx skillhub install ${skillId} --platform cursor`,
path: `.cursor/rules/${skill.name}.mdc`,
},
windsurf: {
cli: `npx skillhub install ${skillId} --platform windsurf`,
path: `.windsurf/rules/${skill.name}.md`,
},
};
return (
<div className="min-h-screen flex flex-col bg-surface">
<Header />
<main className="flex-1">
{/* Hero Section */}
<div className="bg-gradient-subtle border-b border-border">
<div className="container-main py-6 lg:py-8">
{/* Breadcrumb */}
<nav className="flex items-center gap-2 text-sm text-text-muted mb-6">
<Link href={`/${locale}/browse`} className="hover:text-primary-600 transition-colors">
{isRTL ? 'مرور' : 'Browse'}
</Link>
<ChevronRight className="w-4 h-4" />
<span className="text-text-primary font-medium">{skill.name}</span>
</nav>
{/* Main Header */}
<div className="flex flex-col lg:flex-row lg:items-start lg:justify-between gap-6">
{/* Left: Title & Description */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3 mb-3 flex-wrap">
<h1 className="text-3xl lg:text-4xl font-bold text-text-primary">
{skill.name}
</h1>
{skill.isVerified && (
<span className="inline-flex items-center gap-1.5 px-3 py-1 bg-success/10 text-success text-sm font-medium rounded-full border border-success/20">
<CheckCircle className="w-4 h-4" />
{tCommon('verified')}
</span>
)}
<span className={`inline-flex items-center gap-1.5 px-3 py-1 ${securityConfig.bg} ${securityConfig.text} text-sm font-medium rounded-full border ${securityConfig.border}`}>
<Shield className="w-4 h-4" />
{securityConfig.label}
</span>
{sourceFormatBadge && (
<span className="inline-flex items-center gap-1.5 px-3 py-1 bg-primary-50 text-primary-700 text-sm font-medium rounded-full border border-primary-200 dark:bg-primary-900/20 dark:text-primary-400 dark:border-primary-800">
{sourceFormatBadge.platform}
</span>
)}
</div>
<p className="text-lg text-text-secondary mb-4 max-w-2xl" dir="auto">
{skill.description}
</p>
{/* Author, Version, License & Last Update */}
<div className="flex flex-wrap items-center gap-4 text-sm">
<a
href={`https://github.com/${skill.author}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-text-secondary hover:text-primary-600 transition-colors"
>
<div className="w-6 h-6 rounded-full bg-surface-subtle flex items-center justify-center">
<User className="w-4 h-4" />
</div>
<span className="font-medium">@{skill.author}</span>
</a>
{skill.version && (
<>
<span className="text-text-muted"></span>
<span className="flex items-center gap-1.5 text-text-muted">
<Tag className="w-4 h-4" />
<span className="ltr-nums">v{skill.version}</span>
</span>
</>
)}
<span className="text-text-muted"></span>
<span className="text-text-muted bg-surface-subtle px-2 py-0.5 rounded">
{skill.license}
</span>
<span className="text-text-muted"></span>
<span className="flex items-center gap-1.5 text-text-muted">
<Calendar className="w-4 h-4" />
<span className="ltr-nums">{skill.updatedAt}</span>
</span>
</div>
</div>
{/* Right: Actions */}
<div className="flex items-center gap-3">
<FavoriteButton skillId={skill.id} size="lg" showLabel={true} />
<ShareButton
title={skill.name}
path={`/${locale}/skill/${skill.id}`}
translations={{
share: t('share.button'),
copied: t('share.copied'),
copyLink: t('share.copyLink'),
}}
/>
<a
href={skill.repository}
target="_blank"
rel="noopener noreferrer"
className="p-3 rounded-xl bg-surface-elevated hover:bg-surface-subtle border border-border text-text-secondary hover:text-text-primary transition-colors"
title="GitHub"
>
<Github className="w-5 h-5" />
</a>
</div>
</div>
</div>
</div>
{/* Project Configuration Warning Banner (non-SKILL.md) */}
{sourceFormatBadge && (
<div className="bg-warning/10 border-b border-warning/20">
<div className="container-main py-3">
<div className="flex items-start gap-3 text-sm">
<span className="text-warning text-lg flex-shrink-0"></span>
<div>
<p className="text-warning-foreground dark:text-warning" dir="auto">
{isRTL
? `این یک فایل پیکربندی اختصاصی پروژه (${sourceFormatBadge.label}) است، نه یک مهارت عامل قابل استفاده مجدد. حاوی دستورالعمل‌هایی است که برای مخزن ${skill.repo} طراحی شده و ممکن است در پروژه‌های دیگر کاربردی نباشد.`
: `This is a project-specific configuration file (${sourceFormatBadge.label}), not a reusable Agent Skill. It contains instructions designed for the ${skill.repo} repository and may not be applicable to other projects.`}
</p>
<Link
href={`/${locale}/browse`}
className="inline-flex items-center gap-1 mt-1 text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300 font-medium"
>
{isRTL ? 'مرور مهارت‌های قابل استفاده مجدد' : 'Browse reusable skills'}
<ChevronRight className="w-3 h-3" />
</Link>
</div>
</div>
</div>
</div>
)}
{/* Stats Bar */}
<div className="bg-surface-elevated border-b border-border">
<div className="container-main">
<div className="flex items-center gap-6 lg:gap-10 py-4 overflow-x-auto">
<div className="flex items-center gap-2 min-w-fit">
<RatingStars
skillId={skill.id}
averageRating={skill.rating}
ratingCount={skill.ratingCount}
size="sm"
/>
</div>
<div className="h-6 w-px bg-border" />
<div className="flex items-center gap-2 min-w-fit">
<Star className="w-5 h-5 text-gold" />
<span className="font-semibold text-text-primary ltr-nums">
{formatCompactNumber(skill.stars, locale)}
</span>
<span className="text-text-muted text-sm">{tCommon('stars')}</span>
</div>
<div className="h-6 w-px bg-border" />
<div className="flex items-center gap-2 min-w-fit">
<Download className="w-5 h-5 text-primary-500" />
<span className="font-semibold text-text-primary ltr-nums">
{formatCompactNumber(skill.downloads, locale)}
</span>
<span className="text-text-muted text-sm">{tCommon('downloads')}</span>
</div>
<div className="h-6 w-px bg-border" />
<div className="flex items-center gap-2 min-w-fit">
<Eye className="w-5 h-5 text-text-muted" />
<span className="font-semibold text-text-primary ltr-nums">
{formatCompactNumber(skill.views, locale)}
</span>
<span className="text-text-muted text-sm">{tCommon('views')}</span>
</div>
</div>
</div>
</div>
{/* Main Content */}
<div className="container-main py-8">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Left: README Content */}
<div className="lg:col-span-2 space-y-6">
{/* Quick Install (Mobile) */}
<div className="lg:hidden">
<InstallSection
skillId={skill.id}
skillName={skill.name}
repositoryUrl={skill.repository}
sourceFormat={skill.sourceFormat}
installCommands={installCommands}
translations={{
title: t('install.title'),
cli: t('install.cli'),
cliGlobal: t('install.cliGlobal') || 'Install globally (user-level):',
cliProject: t('install.cliProject') || 'Install in current project:',
selectFolder: t('install.selectFolder'),
suggestedPath: t('install.suggestedPath'),
copied: t('install.copied'),
downloadZip: t('install.downloadZip') || 'Download ZIP',
copyCommand: t('install.copyCommand') || 'Copy command',
downloading: t('install.downloading') || 'Downloading...',
installing: t('install.installing') || 'Installing...',
installed: t('install.installed') || 'Installed!',
downloadFailed: t('install.downloadFailed') || 'Download failed',
browserNotSupported: t('install.browserNotSupported') || 'Browser not supported',
rateLimitError: t('install.rateLimitError') || 'Rate limit exceeded',
timeoutError: t('install.timeoutError') || 'Request timed out',
notFoundError: t('install.notFoundError') || 'Skill not found',
noFilesError: t('install.noFilesError') || 'No files found',
disclaimer: t('install.disclaimer'),
folderNotePrefix: t('install.folderNotePrefix') || 'A folder named "',
folderNoteSuffix: t('install.folderNoteSuffix') || '" will be created',
}}
/>
</div>
{/* README Section */}
<div className="bg-surface-elevated rounded-2xl border border-border overflow-hidden">
<div className="px-6 py-4 border-b border-border bg-surface-subtle/50">
<h2 className="font-semibold text-text-primary flex items-center gap-2">
<Copy className="w-4 h-4" />
{getContentTitle(skill.sourceFormat)}
</h2>
</div>
<div className="p-6">
<div className="prose prose-slate dark:prose-invert max-w-none" dir="auto">
<pre className="whitespace-pre-wrap text-sm leading-relaxed bg-surface-subtle rounded-xl p-4 overflow-x-auto text-start border border-border">
<code>{skill.longDescription}</code>
</pre>
</div>
</div>
</div>
{/* Source Links (Mobile) */}
<div className="lg:hidden bg-surface-elevated rounded-2xl border border-border p-6">
<h3 className="font-semibold text-text-primary mb-4">
{isRTL ? 'لینک‌ها' : 'Links'}
</h3>
<div className="space-y-3">
<a
href={skill.repository}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-between p-3 rounded-xl bg-surface-subtle hover:bg-surface-muted transition-colors group"
>
<div className="flex items-center gap-3">
<Github className="w-5 h-5 text-text-muted" />
<div>
<div className="font-medium text-text-primary">{t('meta.repository')}</div>
<div className="text-sm text-text-muted">{skill.author}/{skill.repo}</div>
</div>
</div>
<ExternalLink className="w-4 h-4 text-text-muted group-hover:text-primary-600 transition-colors" />
</a>
{skill.homepage && (
<a
href={skill.homepage}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-between p-3 rounded-xl bg-surface-subtle hover:bg-surface-muted transition-colors group"
>
<div className="flex items-center gap-3">
<ExternalLink className="w-5 h-5 text-text-muted" />
<div>
<div className="font-medium text-text-primary">{t('meta.homepage')}</div>
<div className="text-sm text-text-muted truncate max-w-[200px]">{skill.homepage}</div>
</div>
</div>
<ExternalLink className="w-4 h-4 text-text-muted group-hover:text-primary-600 transition-colors" />
</a>
)}
</div>
</div>
</div>
{/* Right: Sidebar (Desktop) */}
<div className="hidden lg:block space-y-6">
<div className="sticky top-24 max-h-[calc(100vh-7rem)] overflow-y-auto space-y-6 scrollbar-thin">
{/* Install Section */}
<InstallSection
skillId={skill.id}
skillName={skill.name}
repositoryUrl={skill.repository}
sourceFormat={skill.sourceFormat}
installCommands={installCommands}
translations={{
title: t('install.title'),
cli: t('install.cli'),
cliGlobal: t('install.cliGlobal') || 'Install globally (user-level):',
cliProject: t('install.cliProject') || 'Install in current project:',
selectFolder: t('install.selectFolder'),
suggestedPath: t('install.suggestedPath'),
copied: t('install.copied'),
downloadZip: t('install.downloadZip') || 'Download ZIP',
copyCommand: t('install.copyCommand') || 'Copy command',
downloading: t('install.downloading') || 'Downloading...',
installing: t('install.installing') || 'Installing...',
installed: t('install.installed') || 'Installed!',
downloadFailed: t('install.downloadFailed') || 'Download failed',
browserNotSupported: t('install.browserNotSupported') || 'Browser not supported',
rateLimitError: t('install.rateLimitError') || 'Rate limit exceeded',
timeoutError: t('install.timeoutError') || 'Request timed out',
notFoundError: t('install.notFoundError') || 'Skill not found',
noFilesError: t('install.noFilesError') || 'No files found',
disclaimer: t('install.disclaimer'),
folderNotePrefix: t('install.folderNotePrefix') || 'A folder named "',
folderNoteSuffix: t('install.folderNoteSuffix') || '" will be created',
}}
/>
{/* Links Card (Desktop) */}
{skill.homepage && (
<div className="bg-surface-elevated rounded-2xl border border-border p-6">
<h3 className="font-semibold text-text-primary mb-4">
{isRTL ? 'لینک‌ها' : 'Links'}
</h3>
<a
href={skill.homepage}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-sm text-text-secondary hover:text-primary-600 transition-colors py-2"
>
<ExternalLink className="w-4 h-4" />
<span className="flex-1">{t('meta.homepage')}</span>
<ExternalLink className="w-3 h-3" />
</a>
</div>
)}
</div>
</div>
</div>
</div>
</main>
<Footer />
</div>
);
}

View File

@@ -0,0 +1,117 @@
import { getTranslations, setRequestLocale } from 'next-intl/server';
import { Header } from '@/components/Header';
import { Footer } from '@/components/Footer';
import { Mail, Bitcoin, ExternalLink } from 'lucide-react';
export const dynamic = 'force-dynamic';
interface SupportPageProps {
params: Promise<{ locale: string }>;
}
export default async function SupportPage({ params }: SupportPageProps) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations('support');
const isRTL = locale === 'fa';
const supportOptions = [
{
icon: Mail,
title: t('email'),
description: t('emailDesc'),
href: 'mailto:hi.airano@gmail.com',
cta: t('sendEmail'),
color: 'bg-surface-subtle text-text-secondary',
},
{
icon: Bitcoin,
title: t('donate'),
description: t('donateDesc'),
href: 'https://nowpayments.io/donation/airano',
cta: t('donateNow'),
color: 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400',
},
];
return (
<div className="min-h-screen flex flex-col bg-surface">
<Header />
<main className="flex-1">
{/* Hero Section */}
<section className="section-header bg-gradient-subtle">
<div className="container-main text-center">
<h1 className="hero-title mb-4">{t('title')}</h1>
<p className="hero-subtitle max-w-2xl mx-auto">{t('subtitle')}</p>
</div>
</section>
{/* Support Options */}
<section className="section bg-surface">
<div className="container-main">
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 max-w-2xl mx-auto">
{supportOptions.map((option, index) => (
<a
key={index}
href={option.href}
target="_blank"
rel="noopener noreferrer"
className="card p-6 text-center hover:border-primary-500 border border-border transition-all group"
>
<div className={`inline-flex items-center justify-center w-14 h-14 rounded-xl ${option.color} mb-4`}>
<option.icon className="w-7 h-7" />
</div>
<h3 className="text-xl font-semibold text-text-primary mb-2">
{option.title}
</h3>
<p className="text-text-secondary mb-4" dir={isRTL ? 'rtl' : 'ltr'}>
{option.description}
</p>
<span className="inline-flex items-center gap-2 btn-primary text-sm py-2 px-4 group-hover:scale-105 transition-transform">
{option.cta}
<ExternalLink className="w-4 h-4" />
</span>
</a>
))}
</div>
{/* Why Crypto Only */}
<div className="mt-16 max-w-2xl mx-auto">
<div className="card p-6 bg-surface-subtle border border-border">
<h3 className="text-lg font-semibold text-text-primary mb-3">
{t('whyCryptoTitle')}
</h3>
<p className="text-text-secondary text-sm leading-relaxed" dir={isRTL ? 'rtl' : 'ltr'}>
{t('whyCryptoDesc')}
</p>
</div>
</div>
{/* Crypto Info */}
<div className="mt-8 text-center">
<h3 className="text-lg font-semibold text-text-primary mb-4">
{t('popularCoins')}
</h3>
<div className="flex flex-wrap justify-center gap-3">
{['Bitcoin', 'Ethereum', 'TON', 'Tron', 'Solana', 'Litecoin', 'Dogecoin'].map((crypto) => (
<span
key={crypto}
className="px-3 py-1.5 bg-surface-subtle text-text-secondary text-sm rounded-full"
>
{crypto}
</span>
))}
</div>
<p className="text-text-muted text-sm mt-4">
{t('andMoreCrypto')}
</p>
</div>
</div>
</section>
</main>
<Footer />
</div>
);
}

View File

@@ -0,0 +1,90 @@
import { getTranslations, setRequestLocale } from 'next-intl/server';
import Link from 'next/link';
import { Header } from '@/components/Header';
import { Footer } from '@/components/Footer';
import { FileText, UserCheck, AlertTriangle, Scale, Trash2, RefreshCw, ArrowRight, ArrowLeft } from 'lucide-react';
export const dynamic = 'force-dynamic';
const sectionIcons = {
service: FileText,
responsibilities: UserCheck,
disclaimer: AlertTriangle,
liability: Scale,
takedown: Trash2,
changes: RefreshCw,
};
export default async function TermsPage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations('terms');
const isRTL = locale === 'fa';
const ArrowIcon = isRTL ? ArrowLeft : ArrowRight;
const sections = [
'service',
'responsibilities',
'disclaimer',
'liability',
'takedown',
'changes',
] as const;
return (
<div className="min-h-screen flex flex-col">
<Header />
<main className="flex-1">
<section className="section-header bg-gradient-subtle">
<div className="container-main text-center">
<h1 className="hero-title mb-4">{t('title')}</h1>
<p className="hero-subtitle max-w-2xl mx-auto">{t('subtitle')}</p>
<p className="text-sm text-text-muted mt-4">{t('lastUpdated')}</p>
</div>
</section>
<section className="section bg-surface">
<div className="container-main max-w-4xl">
<div className="space-y-8">
{sections.map((sectionKey) => {
const Icon = sectionIcons[sectionKey];
const showClaimLink = sectionKey === 'takedown';
return (
<div key={sectionKey} className="card p-6">
<div className="flex items-start gap-4">
<div className="flex-shrink-0 w-10 h-10 rounded-lg bg-primary-50 dark:bg-primary-900/20 flex items-center justify-center">
<Icon className="w-5 h-5 text-primary-600 dark:text-primary-400" />
</div>
<div className="flex-1">
<h2 className="text-lg font-semibold text-text-primary mb-2">
{t(`sections.${sectionKey}.title`)}
</h2>
<p className="text-text-secondary" dir={locale === 'fa' ? 'rtl' : 'ltr'}>
{t(`sections.${sectionKey}.description`)}
</p>
{showClaimLink && (
<Link
href={`/${locale}/claim`}
className="inline-flex items-center gap-1 mt-3 text-primary-600 hover:text-primary-700 text-sm font-medium transition-colors"
>
{locale === 'fa' ? 'صفحه مدیریت مهارت‌ها' : 'Manage Skills Page'}
<ArrowIcon className="w-3 h-3" />
</Link>
)}
</div>
</div>
</div>
);
})}
</div>
</div>
</section>
</main>
<Footer />
</div>
);
}

View File

@@ -0,0 +1,205 @@
/**
* Test helpers for API route tests
*/
import { vi } from 'vitest';
import { NextRequest } from 'next/server';
/**
* Create a mock NextRequest for testing API routes
*/
export function createMockRequest(
url: string,
options?: {
method?: string;
body?: unknown;
headers?: Record<string, string>;
searchParams?: Record<string, string>;
}
): NextRequest {
const { method = 'GET', body, headers = {}, searchParams = {} } = options || {};
// Build URL with search params
const urlObj = new URL(url, 'http://localhost:3000');
Object.entries(searchParams).forEach(([key, value]) => {
urlObj.searchParams.set(key, value);
});
const requestInit: { method: string; headers: Headers; body?: string } = {
method,
headers: new Headers({
'Content-Type': 'application/json',
...headers,
}),
};
if (body && method !== 'GET') {
requestInit.body = JSON.stringify(body);
}
// Cast to any to avoid Next.js RequestInit vs global RequestInit type mismatch
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return new NextRequest(urlObj, requestInit as any);
}
/**
* Mock authenticated session
*/
export function mockAuthSession(user?: {
githubId: string;
username: string;
email?: string;
avatarUrl?: string;
}) {
const mockAuth = vi.fn().mockResolvedValue(
user
? {
user: {
...user,
name: user.username,
},
}
: null
);
vi.doMock('@/lib/auth', () => ({
auth: mockAuth,
}));
return mockAuth;
}
/**
* Create a mock user for testing
*/
export function createMockUser(overrides: Partial<{
id: string;
githubId: string;
username: string;
displayName: string;
email: string;
avatarUrl: string;
}> = {}) {
return {
id: 'user-123',
githubId: 'gh-12345',
username: 'testuser',
displayName: 'Test User',
email: 'test@example.com',
avatarUrl: 'https://example.com/avatar.png',
isAdmin: false,
createdAt: new Date(),
updatedAt: new Date(),
lastLoginAt: new Date(),
...overrides,
};
}
/**
* Create a mock skill for testing
*/
export function createMockSkill(overrides: Partial<{
id: string;
name: string;
description: string;
githubOwner: string;
githubRepo: string;
githubStars: number;
downloadCount: number;
securityScore: number;
isVerified: boolean;
isFeatured: boolean;
rating: number;
ratingCount: number;
compatibility: { platforms?: string[] };
}> = {}) {
return {
id: 'test-owner/test-repo/test-skill',
name: 'test-skill',
description: 'A test skill',
githubOwner: 'test-owner',
githubRepo: 'test-repo',
skillPath: 'skills/test-skill',
branch: 'main',
version: '1.0.0',
license: 'MIT',
author: 'Test Author',
githubStars: 100,
githubForks: 10,
downloadCount: 50,
viewCount: 200,
rating: 4,
ratingCount: 5,
ratingSum: 20,
securityScore: 85,
isVerified: false,
isFeatured: false,
compatibility: { platforms: ['claude', 'codex'] },
createdAt: new Date(),
updatedAt: new Date(),
indexedAt: new Date(),
...overrides,
};
}
/**
* Create a mock category for testing
*/
export function createMockCategory(overrides: Partial<{
id: string;
name: string;
slug: string;
description: string;
icon: string;
skillCount: number;
sortOrder: number;
}> = {}) {
return {
id: 'cat-1',
name: 'Test Category',
slug: 'test-category',
description: 'A test category',
icon: 'folder',
color: '#3B82F6',
skillCount: 10,
sortOrder: 0,
createdAt: new Date(),
...overrides,
};
}
/**
* Create a mock rating for testing
*/
export function createMockRating(overrides: Partial<{
id: string;
skillId: string;
userId: string;
rating: number;
review: string;
}> = {}) {
return {
id: 'rating-1',
skillId: 'test-owner/test-repo/test-skill',
userId: 'user-123',
rating: 4,
review: 'Great skill!',
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
/**
* Parse JSON response from NextResponse
*/
export async function parseResponse<T>(response: Response): Promise<{
status: number;
data: T;
}> {
const data = await response.json();
return {
status: response.status,
data,
};
}

View File

@@ -0,0 +1,62 @@
/**
* Test setup for API route tests
*
* This file is loaded before each test file via vitest setupFiles
*/
import { vi } from 'vitest';
// Mock environment variables
process.env.DATABASE_URL = 'postgresql://test:test@localhost:5432/test';
process.env.GITHUB_CLIENT_ID = 'test-client-id';
process.env.GITHUB_CLIENT_SECRET = 'test-client-secret';
process.env.AUTH_SECRET = 'test-auth-secret';
// Mock @skillhub/db module
vi.mock('@skillhub/db', () => ({
createDb: vi.fn(() => ({})),
skillQueries: {
getById: vi.fn(),
search: vi.fn(),
getFeatured: vi.fn(),
getTrending: vi.fn(),
getRecent: vi.fn(),
upsert: vi.fn(),
incrementDownloads: vi.fn(),
incrementViews: vi.fn(),
updateRating: vi.fn(),
},
categoryQueries: {
getAll: vi.fn(),
getBySlug: vi.fn(),
getSkills: vi.fn(),
updateSkillCount: vi.fn(),
},
userQueries: {
getByGithubId: vi.fn(),
upsertFromGithub: vi.fn(),
getFavorites: vi.fn(),
getById: vi.fn(),
},
ratingQueries: {
upsert: vi.fn(),
getForSkill: vi.fn(),
getUserRating: vi.fn(),
},
favoriteQueries: {
add: vi.fn(),
remove: vi.fn(),
isFavorited: vi.fn(),
getFavoritedIds: vi.fn(),
},
installationQueries: {
track: vi.fn(),
getStats: vi.fn(),
},
skills: {},
categories: {},
sql: vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => ({
sql: strings.join('?'),
values,
})),
}));

View File

@@ -0,0 +1,139 @@
import { type NextRequest, NextResponse } from 'next/server';
import { createDb, skills, discoveredRepos, awesomeLists, sql } from '@skillhub/db';
import { getCached, setCache, cacheTTL } from '@/lib/cache';
import { withRateLimit, createRateLimitResponse, createRateLimitHeaders } from '@/lib/rate-limit';
const db = createDb();
interface AttributionStats {
totalSkills: number;
totalContributors: number;
totalRepos: number;
awesomeLists: {
count: number;
totalRepos: number;
};
forkNetworks: number;
licenseDistribution: Array<{
license: string;
count: number;
percentage: number;
}>;
discoveryBySource: Array<{
source: string;
count: number;
withSkills: number;
}>;
lastUpdated: string;
}
export async function GET(request: NextRequest) {
// Rate limiting
const rateLimitResult = await withRateLimit(request, 'anonymous');
if (!rateLimitResult.allowed) {
return createRateLimitResponse(rateLimitResult);
}
try {
// Try to get from cache first (cache for 1 hour)
const cacheKey = 'attribution:stats';
const cached = await getCached<AttributionStats>(cacheKey);
if (cached) {
return NextResponse.json(cached, {
headers: {
'X-Cache': 'HIT',
'Cache-Control': 'public, max-age=3600, stale-while-revalidate=7200',
...createRateLimitHeaders(rateLimitResult),
},
});
}
// Get total skills and contributors
const skillStats = await db
.select({
totalSkills: sql<number>`count(*)::int`,
totalContributors: sql<number>`count(distinct ${skills.githubOwner})::int`,
})
.from(skills);
// Get license distribution
const licenseStats = await db
.select({
license: sql<string>`coalesce(${skills.license}, 'Unspecified')`,
count: sql<number>`count(*)::int`,
})
.from(skills)
.groupBy(sql`coalesce(${skills.license}, 'Unspecified')`)
.orderBy(sql`count(*) desc`)
.limit(10);
const totalForPercentage = skillStats[0]?.totalSkills ?? 1;
const licenseDistribution = licenseStats.map((l) => ({
license: l.license || 'Unspecified',
count: l.count,
percentage: Math.round((l.count / totalForPercentage) * 100),
}));
// Get discovered repos stats
const repoStats = await db
.select({ count: sql<number>`count(*)::int` })
.from(discoveredRepos);
// Get discovery by source
const bySource = await db
.select({
source: discoveredRepos.discoveredVia,
count: sql<number>`count(*)::int`,
withSkills: sql<number>`sum(case when has_skill_md then 1 else 0 end)::int`,
})
.from(discoveredRepos)
.groupBy(discoveredRepos.discoveredVia);
// Get fork networks count
const forkCount = bySource.find((s) => s.source === 'fork-network');
// Get awesome lists stats
const awesomeStats = await db
.select({
count: sql<number>`count(*)::int`,
totalRepos: sql<number>`coalesce(sum(${awesomeLists.repoCount}), 0)::int`,
})
.from(awesomeLists)
.where(sql`${awesomeLists.isActive} = true`);
const data: AttributionStats = {
totalSkills: skillStats[0]?.totalSkills ?? 0,
totalContributors: skillStats[0]?.totalContributors ?? 0,
totalRepos: repoStats[0]?.count ?? 0,
awesomeLists: {
count: awesomeStats[0]?.count ?? 0,
totalRepos: awesomeStats[0]?.totalRepos ?? 0,
},
forkNetworks: forkCount?.count ?? 0,
licenseDistribution,
discoveryBySource: bySource.map((s) => ({
source: s.source ?? 'unknown',
count: s.count,
withSkills: s.withSkills ?? 0,
})),
lastUpdated: new Date().toISOString(),
};
// Cache the result for 1 hour
await setCache(cacheKey, data, cacheTTL.stats);
return NextResponse.json(data, {
headers: {
'X-Cache': 'MISS',
'Cache-Control': 'public, max-age=3600, stale-while-revalidate=7200',
...createRateLimitHeaders(rateLimitResult),
},
});
} catch (error) {
console.error('Error fetching attribution stats:', error);
return NextResponse.json(
{ error: 'Failed to fetch attribution stats' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,3 @@
import { handlers } from '@/lib/auth';
export const { GET, POST } = handlers;

View File

@@ -0,0 +1,122 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { NextRequest } from 'next/server';
// Mock rate limiting - must be before route import
vi.mock('@/lib/rate-limit', () => ({
withRateLimit: vi.fn().mockResolvedValue({ allowed: true, remaining: 100, limit: 120, resetAt: Date.now() + 60000 }),
createRateLimitResponse: vi.fn(),
createRateLimitHeaders: vi.fn().mockReturnValue({}),
}));
// Mock cache - must be before route import
vi.mock('@/lib/cache', () => ({
getCached: vi.fn().mockResolvedValue(null),
setCache: vi.fn().mockResolvedValue(undefined),
cacheKeys: { categories: () => 'categories' },
cacheTTL: { categories: 43200 },
}));
// Create mock category helper
function createMockCategory(overrides: Partial<{
id: string;
name: string;
slug: string;
description: string | null;
icon: string | null;
skillCount: number | null;
sortOrder: number | null;
color: string | null;
parentId: string | null;
}> = {}) {
return {
id: 'cat-1',
name: 'Test Category',
slug: 'test-category',
description: 'A test category',
icon: 'folder',
color: '#3B82F6',
skillCount: 10,
sortOrder: 0,
parentId: null,
createdAt: new Date(),
...overrides,
};
}
// Mock the db module
vi.mock('@skillhub/db', () => ({
createDb: vi.fn(() => ({})),
categoryQueries: {
getLeafCategories: vi.fn(),
},
}));
import { GET } from './route';
// Import after mocking
import { categoryQueries } from '@skillhub/db';
// Helper to create mock request
function createMockRequest(url = 'http://localhost:3000/api/categories') {
return new NextRequest(url);
}
describe('GET /api/categories', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should return all categories', async () => {
const mockCategories = [
createMockCategory({ id: 'cat-1', name: 'Development', slug: 'development', skillCount: 20 }),
createMockCategory({ id: 'cat-2', name: 'Testing', slug: 'testing', skillCount: 15 }),
];
vi.mocked(categoryQueries.getLeafCategories).mockResolvedValue(mockCategories);
const request = createMockRequest();
const response = await GET(request);
const data = await response.json();
expect(response.status).toBe(200);
expect(data.categories).toBeDefined();
expect(Array.isArray(data.categories)).toBe(true);
expect(data.categories.length).toBe(2);
});
it('should include skillCount for each category', async () => {
const mockCategories = [
createMockCategory({ skillCount: 25 }),
];
vi.mocked(categoryQueries.getLeafCategories).mockResolvedValue(mockCategories);
const request = createMockRequest();
const response = await GET(request);
const data = await response.json();
expect(data.categories[0].skillCount).toBeDefined();
expect(typeof data.categories[0].skillCount).toBe('number');
});
it('should handle database errors gracefully', async () => {
vi.mocked(categoryQueries.getLeafCategories).mockRejectedValue(new Error('Database error'));
const request = createMockRequest();
const response = await GET(request);
const data = await response.json();
expect(response.status).toBe(500);
expect(data.error).toBeDefined();
});
it('should return empty array when no categories exist', async () => {
vi.mocked(categoryQueries.getLeafCategories).mockResolvedValue([]);
const request = createMockRequest();
const response = await GET(request);
const data = await response.json();
expect(response.status).toBe(200);
expect(data.categories).toBeDefined();
expect(Array.isArray(data.categories)).toBe(true);
expect(data.categories.length).toBe(0);
});
});

View File

@@ -0,0 +1,67 @@
import { type NextRequest, NextResponse } from 'next/server';
import { createDb, categoryQueries } from '@skillhub/db';
import { getCached, setCache, cacheKeys, cacheTTL } from '@/lib/cache';
import { withRateLimit, createRateLimitResponse, createRateLimitHeaders } from '@/lib/rate-limit';
const db = createDb();
interface CategoryData {
id: string;
name: string;
slug: string;
description: string | null;
icon: string | null;
skillCount: number;
sortOrder: number;
}
interface CategoriesResponse {
categories: CategoryData[];
}
export async function GET(request: NextRequest) {
// Rate limiting
const rateLimitResult = await withRateLimit(request, 'anonymous');
if (!rateLimitResult.allowed) {
return createRateLimitResponse(rateLimitResult);
}
try {
// Try to get from cache first
const cacheKey = cacheKeys.categories();
const cached = await getCached<CategoriesResponse>(cacheKey);
if (cached) {
return NextResponse.json(cached, {
headers: { 'X-Cache': 'HIT', ...createRateLimitHeaders(rateLimitResult) },
});
}
// Get only leaf categories (filter out parent categories)
const categories = await categoryQueries.getLeafCategories(db);
const data: CategoriesResponse = {
categories: categories.map((cat) => ({
id: cat.id,
name: cat.name,
slug: cat.slug,
description: cat.description,
icon: cat.icon,
skillCount: cat.skillCount ?? 0,
sortOrder: cat.sortOrder ?? 0,
})),
};
// Cache the result (12 hours - categories rarely change)
await setCache(cacheKey, data, cacheTTL.categories);
return NextResponse.json(data, {
headers: { 'X-Cache': 'MISS', ...createRateLimitHeaders(rateLimitResult) },
});
} catch (error) {
console.error('Error fetching categories:', error);
return NextResponse.json(
{ error: 'Failed to fetch categories' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,136 @@
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import { createDb, sql } from '@skillhub/db';
// Simple rate limiting
const rateLimitMap = new Map<string, { count: number; timestamp: number }>();
const RATE_LIMIT = 5; // 5 requests per minute
const RATE_LIMIT_WINDOW = 60 * 1000; // 1 minute
function checkRateLimit(ip: string): boolean {
const now = Date.now();
const record = rateLimitMap.get(ip);
if (!record || now - record.timestamp > RATE_LIMIT_WINDOW) {
rateLimitMap.set(ip, { count: 1, timestamp: now });
return true;
}
if (record.count >= RATE_LIMIT) {
return false;
}
record.count++;
return true;
}
export async function POST(request: NextRequest) {
try {
// Rate limiting
const ip = request.headers.get('x-forwarded-for') || 'unknown';
if (!checkRateLimit(ip)) {
return NextResponse.json(
{ error: 'Too many requests. Please try again later.' },
{ status: 429 }
);
}
const body = await request.json();
const { email, variant, locale, source } = body;
// Validate email
if (!email || typeof email !== 'string' || !email.includes('@')) {
return NextResponse.json(
{ error: 'Invalid email address' },
{ status: 400 }
);
}
// Sanitize email
const sanitizedEmail = email.toLowerCase().trim().slice(0, 255);
// Validate variant
const validVariant = variant === 'a' || variant === 'b' ? variant : 'a';
// Store in database
const db = createDb();
// Create table if not exists (for first-time setup)
await db.execute(sql`
CREATE TABLE IF NOT EXISTS early_access_signups (
id SERIAL PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
variant VARCHAR(1) NOT NULL DEFAULT 'a',
locale VARCHAR(10),
source VARCHAR(100),
ip_address VARCHAR(45),
user_agent TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
)
`);
// Insert or update (upsert)
await db.execute(sql`
INSERT INTO early_access_signups (email, variant, locale, source, ip_address, user_agent)
VALUES (
${sanitizedEmail},
${validVariant},
${locale || null},
${source || 'unknown'},
${ip},
${request.headers.get('user-agent') || null}
)
ON CONFLICT (email) DO UPDATE SET
variant = EXCLUDED.variant,
locale = EXCLUDED.locale,
source = EXCLUDED.source
`);
return NextResponse.json({ success: true });
} catch (error) {
console.error('Early access signup error:', error);
return NextResponse.json(
{ error: 'An error occurred. Please try again.' },
{ status: 500 }
);
}
}
// Get signup stats (for internal use)
export async function GET(request: NextRequest) {
try {
const authHeader = request.headers.get('authorization');
const expectedToken = process.env.ADMIN_API_TOKEN;
if (!expectedToken || authHeader !== `Bearer ${expectedToken}`) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const db = createDb();
// Get counts by variant
const stats = await db.execute(sql`
SELECT
variant,
COUNT(*) as count,
MAX(created_at) as last_signup
FROM early_access_signups
GROUP BY variant
`) as unknown as { rows: Array<{ variant: string; count: number; last_signup: Date }> };
const totalCount = await db.execute(sql`
SELECT COUNT(*) as total FROM early_access_signups
`) as unknown as { rows: Array<{ total: number }> };
return NextResponse.json({
total: totalCount.rows[0]?.total || 0,
byVariant: stats.rows,
});
} catch (error) {
console.error('Error fetching early access stats:', error);
return NextResponse.json(
{ error: 'Failed to fetch stats' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,48 @@
import { NextResponse, type NextRequest } from 'next/server';
import { createDb, userQueries, favoriteQueries } from '@skillhub/db';
import { auth } from '@/lib/auth';
import { withRateLimit, createRateLimitResponse, createRateLimitHeaders } from '@/lib/rate-limit';
const db = createDb();
export async function POST(request: NextRequest) {
// Rate limiting (authenticated)
const rateLimitResult = await withRateLimit(request, 'authenticated');
if (!rateLimitResult.allowed) {
return createRateLimitResponse(rateLimitResult);
}
try {
const session = await auth();
if (!session?.user?.githubId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json();
const { skillIds } = body;
if (!Array.isArray(skillIds)) {
return NextResponse.json({ error: 'skillIds must be an array' }, { status: 400 });
}
const dbUser = await userQueries.getByGithubId(db, session.user.githubId);
if (!dbUser) {
return NextResponse.json({ favorited: {} }, {
headers: createRateLimitHeaders(rateLimitResult),
});
}
const favoritedIds = await favoriteQueries.getFavoritedIds(db, dbUser.id, skillIds);
const favorited: Record<string, boolean> = {};
skillIds.forEach((id: string) => {
favorited[id] = favoritedIds.includes(id);
});
return NextResponse.json({ favorited }, {
headers: createRateLimitHeaders(rateLimitResult),
});
} catch (error) {
console.error('Error checking favorites:', error);
return NextResponse.json({ error: 'Failed to check favorites' }, { status: 500 });
}
}

View File

@@ -0,0 +1,227 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { NextRequest } from 'next/server';
// Mock rate limiting - must be before route import
vi.mock('@/lib/rate-limit', () => ({
withRateLimit: vi.fn().mockResolvedValue({ allowed: true, remaining: 100, limit: 120, resetAt: Date.now() + 60000 }),
createRateLimitResponse: vi.fn(),
createRateLimitHeaders: vi.fn().mockReturnValue({}),
}));
// Helper to create mock skill
function createMockSkill(overrides: Partial<{
id: string;
name: string;
description: string;
}> = {}) {
return {
id: 'test-owner/test-repo/test-skill',
name: 'test-skill',
description: 'A test skill',
githubOwner: 'test-owner',
githubRepo: 'test-repo',
githubStars: 100,
downloadCount: 50,
securityScore: 85,
isVerified: false,
compatibility: { platforms: ['claude'] },
rating: 4,
ratingCount: 5,
...overrides,
};
}
// Helper to create mock user
function createMockUser(overrides: Partial<{ id: string; githubId: string }> = {}) {
return {
id: 'user-123',
githubId: 'gh-12345',
username: 'testuser',
...overrides,
};
}
// Mock auth
const mockAuth = vi.fn();
vi.mock('@/lib/auth', () => ({
auth: () => mockAuth(),
}));
// Mock db
vi.mock('@skillhub/db', () => ({
createDb: vi.fn(() => ({})),
userQueries: {
getByGithubId: vi.fn(),
getFavorites: vi.fn(),
},
skillQueries: {
getById: vi.fn(),
},
favoriteQueries: {
add: vi.fn(),
remove: vi.fn(),
},
}));
import { GET, POST, DELETE } from './route';
import { userQueries, skillQueries, favoriteQueries } from '@skillhub/db';
// Helper to create mock GET request
function createMockGetRequest() {
return new NextRequest('http://localhost:3000/api/favorites');
}
describe('/api/favorites', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('GET', () => {
it('should return 401 when not authenticated', async () => {
mockAuth.mockResolvedValue(null);
const response = await GET(createMockGetRequest());
const data = await response.json();
expect(response.status).toBe(401);
expect(data.error).toBe('Unauthorized');
});
it('should return empty array for new user', async () => {
mockAuth.mockResolvedValue({ user: { githubId: 'gh-12345' } });
vi.mocked(userQueries.getByGithubId).mockResolvedValue(null as any);
const response = await GET(createMockGetRequest());
const data = await response.json();
expect(response.status).toBe(200);
expect(data.favorites).toEqual([]);
});
it('should return user favorites', async () => {
const mockUser = createMockUser();
const mockFavorites = [
{ skill: createMockSkill({ id: 'skill-1' }) },
{ skill: createMockSkill({ id: 'skill-2' }) },
];
mockAuth.mockResolvedValue({ user: { githubId: 'gh-12345' } });
vi.mocked(userQueries.getByGithubId).mockResolvedValue(mockUser as any);
vi.mocked(userQueries.getFavorites).mockResolvedValue(mockFavorites as any);
const response = await GET(createMockGetRequest());
const data = await response.json();
expect(response.status).toBe(200);
expect(data.favorites).toHaveLength(2);
});
});
describe('POST', () => {
const createRequest = (body: unknown) => {
return new NextRequest('http://localhost:3000/api/favorites', {
method: 'POST',
body: JSON.stringify(body),
headers: { 'Content-Type': 'application/json' },
});
};
it('should return 401 when not authenticated', async () => {
mockAuth.mockResolvedValue(null);
const request = createRequest({ skillId: 'test-skill' });
const response = await POST(request);
const data = await response.json();
expect(response.status).toBe(401);
expect(data.error).toBe('Unauthorized');
});
it('should return 400 when skillId missing', async () => {
mockAuth.mockResolvedValue({ user: { githubId: 'gh-12345' } });
const request = createRequest({});
const response = await POST(request);
const data = await response.json();
expect(response.status).toBe(400);
expect(data.error).toContain('skillId');
});
it('should return 404 when skill not found', async () => {
const mockUser = createMockUser();
mockAuth.mockResolvedValue({ user: { githubId: 'gh-12345' } });
vi.mocked(userQueries.getByGithubId).mockResolvedValue(mockUser as any);
vi.mocked(skillQueries.getById).mockResolvedValue(null as any);
const request = createRequest({ skillId: 'nonexistent' });
const response = await POST(request);
await response.json();
expect(response.status).toBe(404);
});
it('should add favorite successfully', async () => {
const mockUser = createMockUser();
const mockSkill = createMockSkill();
mockAuth.mockResolvedValue({ user: { githubId: 'gh-12345' } });
vi.mocked(userQueries.getByGithubId).mockResolvedValue(mockUser as any);
vi.mocked(skillQueries.getById).mockResolvedValue(mockSkill as any);
vi.mocked(favoriteQueries.add).mockResolvedValue(undefined);
const request = createRequest({ skillId: 'test-owner/test-repo/test-skill' });
const response = await POST(request);
const data = await response.json();
expect(response.status).toBe(200);
expect(data.success).toBe(true);
expect(data.favorited).toBe(true);
});
});
describe('DELETE', () => {
const createRequest = (body: unknown) => {
return new NextRequest('http://localhost:3000/api/favorites', {
method: 'DELETE',
body: JSON.stringify(body),
headers: { 'Content-Type': 'application/json' },
});
};
it('should return 401 when not authenticated', async () => {
mockAuth.mockResolvedValue(null);
const request = createRequest({ skillId: 'test-skill' });
const response = await DELETE(request);
const data = await response.json();
expect(response.status).toBe(401);
expect(data.error).toBe('Unauthorized');
});
it('should return 400 when skillId missing', async () => {
mockAuth.mockResolvedValue({ user: { githubId: 'gh-12345' } });
const request = createRequest({});
const response = await DELETE(request);
await response.json();
expect(response.status).toBe(400);
});
it('should remove favorite successfully', async () => {
const mockUser = createMockUser();
mockAuth.mockResolvedValue({ user: { githubId: 'gh-12345' } });
vi.mocked(userQueries.getByGithubId).mockResolvedValue(mockUser as any);
vi.mocked(favoriteQueries.remove).mockResolvedValue(undefined);
const request = createRequest({ skillId: 'test-owner/test-repo/test-skill' });
const response = await DELETE(request);
const data = await response.json();
expect(response.status).toBe(200);
expect(data.success).toBe(true);
expect(data.favorited).toBe(false);
});
});
});

View File

@@ -0,0 +1,127 @@
import { NextResponse, type NextRequest } from 'next/server';
import { createDb, userQueries, favoriteQueries, skillQueries } from '@skillhub/db';
import { auth } from '@/lib/auth';
import { withRateLimit, createRateLimitResponse, createRateLimitHeaders } from '@/lib/rate-limit';
const db = createDb();
export async function GET(request: NextRequest) {
// Rate limiting (authenticated)
const rateLimitResult = await withRateLimit(request, 'authenticated');
if (!rateLimitResult.allowed) {
return createRateLimitResponse(rateLimitResult);
}
try {
const session = await auth();
if (!session?.user?.githubId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const dbUser = await userQueries.getByGithubId(db, session.user.githubId);
if (!dbUser) {
return NextResponse.json({ favorites: [] }, {
headers: createRateLimitHeaders(rateLimitResult),
});
}
const favorites = await userQueries.getFavorites(db, dbUser.id);
return NextResponse.json({
favorites: favorites.map((f) => ({
id: f.skill.id,
name: f.skill.name,
description: f.skill.description,
githubOwner: f.skill.githubOwner,
githubRepo: f.skill.githubRepo,
githubStars: f.skill.githubStars,
downloadCount: f.skill.downloadCount,
securityStatus: f.skill.securityStatus,
isVerified: f.skill.isVerified,
compatibility: f.skill.compatibility,
rating: f.skill.rating,
ratingCount: f.skill.ratingCount,
})),
}, {
headers: createRateLimitHeaders(rateLimitResult),
});
} catch (error) {
console.error('Error fetching favorites:', error);
return NextResponse.json({ error: 'Failed to fetch favorites' }, { status: 500 });
}
}
export async function POST(request: NextRequest) {
// Rate limiting (authenticated)
const rateLimitResult = await withRateLimit(request, 'authenticated');
if (!rateLimitResult.allowed) {
return createRateLimitResponse(rateLimitResult);
}
try {
const session = await auth();
if (!session?.user?.githubId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json();
const { skillId } = body;
if (!skillId) {
return NextResponse.json({ error: 'skillId is required' }, { status: 400 });
}
const dbUser = await userQueries.getByGithubId(db, session.user.githubId);
if (!dbUser) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
// Verify skill exists
const skill = await skillQueries.getById(db, skillId);
if (!skill) {
return NextResponse.json({ error: 'Skill not found' }, { status: 404 });
}
await favoriteQueries.add(db, dbUser.id, skillId);
return NextResponse.json({ success: true, favorited: true }, {
headers: createRateLimitHeaders(rateLimitResult),
});
} catch (error) {
console.error('Error adding favorite:', error);
return NextResponse.json({ error: 'Failed to add favorite' }, { status: 500 });
}
}
export async function DELETE(request: NextRequest) {
// Rate limiting (authenticated)
const rateLimitResult = await withRateLimit(request, 'authenticated');
if (!rateLimitResult.allowed) {
return createRateLimitResponse(rateLimitResult);
}
try {
const session = await auth();
if (!session?.user?.githubId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json();
const { skillId } = body;
if (!skillId) {
return NextResponse.json({ error: 'skillId is required' }, { status: 400 });
}
const dbUser = await userQueries.getByGithubId(db, session.user.githubId);
if (!dbUser) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
await favoriteQueries.remove(db, dbUser.id, skillId);
return NextResponse.json({ success: true, favorited: false }, {
headers: createRateLimitHeaders(rateLimitResult),
});
} catch (error) {
console.error('Error removing favorite:', error);
return NextResponse.json({ error: 'Failed to remove favorite' }, { status: 500 });
}
}

View File

@@ -0,0 +1,49 @@
import { describe, it, expect, vi } from 'vitest';
import { GET } from './route';
// Mock the database module
vi.mock('@skillhub/db', () => ({
createDb: () => ({
execute: vi.fn().mockResolvedValue([{ '?column?': 1 }]),
}),
sql: (strings: TemplateStringsArray, ...values: unknown[]) => ({ strings, values }),
isMeilisearchHealthy: vi.fn().mockResolvedValue(true),
}));
describe('GET /api/health', () => {
it('should return status 200', async () => {
const response = await GET();
expect(response.status).toBe(200);
});
it('should return health status', async () => {
const response = await GET();
const data = await response.json();
expect(['healthy', 'degraded', 'unhealthy']).toContain(data.status);
});
it('should return current timestamp', async () => {
const before = new Date().toISOString();
const response = await GET();
const data = await response.json();
const after = new Date().toISOString();
expect(data.timestamp).toBeDefined();
expect(new Date(data.timestamp).getTime()).toBeGreaterThanOrEqual(new Date(before).getTime());
expect(new Date(data.timestamp).getTime()).toBeLessThanOrEqual(new Date(after).getTime());
});
it('should return version', async () => {
const response = await GET();
const data = await response.json();
expect(data.version).toBe('0.1.0');
});
it('should return services status', async () => {
const response = await GET();
const data = await response.json();
expect(data.services).toBeDefined();
expect(data.services.database).toBeDefined();
expect(data.services.meilisearch).toBeDefined();
});
});

View File

@@ -0,0 +1,190 @@
import { NextResponse } from 'next/server';
import { createDb, sql, isMeilisearchHealthy } from '@skillhub/db';
import { isCacheAvailable } from '@/lib/cache';
interface ServiceStatus {
status: 'healthy' | 'unhealthy' | 'degraded';
latency?: number;
error?: string;
}
interface ReplicationStatus extends ServiceStatus {
isReplica?: boolean;
lagSeconds?: number;
}
interface GitHubStatus extends ServiceStatus {
// GitHub accessibility status (for OAuth on mirror)
}
interface HealthResponse {
status: 'healthy' | 'unhealthy' | 'degraded';
timestamp: string;
version: string;
isPrimary: boolean;
services: {
database: ServiceStatus;
meilisearch: ServiceStatus;
redis: ServiceStatus;
replication?: ReplicationStatus;
github?: GitHubStatus;
};
}
export async function GET() {
const isPrimary = process.env.IS_PRIMARY_SERVER !== 'false';
const services: HealthResponse['services'] = {
database: { status: 'unhealthy' },
meilisearch: { status: 'unhealthy' },
redis: { status: 'unhealthy' },
};
// Check database connectivity
try {
const dbStart = Date.now();
const db = createDb();
await db.execute(sql`SELECT 1`);
services.database = {
status: 'healthy',
latency: Date.now() - dbStart,
};
} catch (error) {
// Don't expose DATABASE_URL or connection details in error messages
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
// Sanitize error message - remove any potential credentials
const sanitizedError = errorMessage
.replace(/postgresql:\/\/[^@]*@/gi, 'postgresql://***@')
.replace(/password[=:][^\s&]*/gi, 'password=***');
services.database = {
status: 'unhealthy',
error: process.env.DATABASE_URL ? sanitizedError : 'DATABASE_URL not configured',
};
}
// Check Meilisearch connectivity (optional service)
try {
const meiliStart = Date.now();
const meiliHealthy = await isMeilisearchHealthy();
if (meiliHealthy) {
services.meilisearch = {
status: 'healthy',
latency: Date.now() - meiliStart,
};
} else {
services.meilisearch = {
status: 'degraded',
error: 'Meilisearch not responding or not configured',
};
}
} catch (error) {
services.meilisearch = {
status: 'degraded',
error: error instanceof Error ? error.message : 'Unknown error',
};
}
// Check Redis connectivity (optional service for caching and rate limiting)
try {
const redisStart = Date.now();
const redisHealthy = await isCacheAvailable();
if (redisHealthy) {
services.redis = {
status: 'healthy',
latency: Date.now() - redisStart,
};
} else {
services.redis = {
status: 'degraded',
error: 'Redis not responding or not configured',
};
}
} catch (error) {
services.redis = {
status: 'degraded',
error: error instanceof Error ? error.message : 'Unknown error',
};
}
// Mirror server only: Check PostgreSQL replication status
if (!isPrimary) {
try {
const db = createDb();
// drizzle-orm/postgres-js returns results directly as an array
const lagResult = await db.execute<{ is_replica: boolean; lag_seconds: number | null }>(sql`
SELECT
pg_is_in_recovery() as is_replica,
EXTRACT(EPOCH FROM (now() - pg_last_xact_replay_timestamp()))::int as lag_seconds
`);
const row = lagResult[0];
const isReplica = row?.is_replica ?? false;
const lagSeconds = row?.lag_seconds ?? 0;
// Healthy if lag is under 5 minutes (300 seconds)
services.replication = {
status: isReplica && lagSeconds < 300 ? 'healthy' : 'degraded',
isReplica,
lagSeconds,
latency: undefined,
};
} catch (error) {
services.replication = {
status: 'degraded',
error: error instanceof Error ? error.message : 'Failed to check replication status',
isReplica: false,
lagSeconds: undefined,
};
}
// Mirror server only: Check GitHub accessibility (for OAuth)
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000);
const githubStart = Date.now();
const githubCheck = await fetch('https://github.com', {
method: 'HEAD',
signal: controller.signal,
});
clearTimeout(timeoutId);
services.github = {
status: githubCheck.ok ? 'healthy' : 'degraded',
latency: Date.now() - githubStart,
};
} catch {
// GitHub likely filtered/blocked in Iran
services.github = {
status: 'degraded',
error: 'GitHub unreachable (may be filtered)',
};
}
}
// Determine overall status
let overallStatus: 'healthy' | 'unhealthy' | 'degraded' = 'healthy';
// Database is critical - if it's down, the whole system is unhealthy
if (services.database.status === 'unhealthy') {
overallStatus = 'unhealthy';
}
// Meilisearch and Redis are optional - if either is down, system is degraded but still functional
else if (services.meilisearch.status !== 'healthy' || services.redis.status !== 'healthy') {
overallStatus = 'degraded';
}
const response: HealthResponse = {
status: overallStatus,
timestamp: new Date().toISOString(),
version: process.env.npm_package_version || '0.1.0',
isPrimary,
services,
};
// Return appropriate HTTP status code for load balancers/orchestrators
// 200 = healthy, 503 = unhealthy (critical service down)
const statusCode = overallStatus === 'unhealthy' ? 503 : 200;
return NextResponse.json(response, { status: statusCode });
}

View File

@@ -0,0 +1,114 @@
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import { createDb } from '@skillhub/db';
import { emailSubscriptionQueries } from '@skillhub/db';
import { withRateLimit, createRateLimitResponse } from '@/lib/rate-limit';
import { sendNewsletterWelcomeEmail } from '@/lib/email';
const PRIMARY_URL = process.env.PRIMARY_URL || process.env.NEXT_PUBLIC_APP_URL || 'https://skills.palebluedot.live';
// Email validation regex (RFC 5322 simplified)
const EMAIL_REGEX = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
/**
* GET handler for one-click newsletter subscription from email links
* Subscribes and redirects to homepage with confirmation
*/
export async function GET(request: NextRequest) {
try {
const email = request.nextUrl.searchParams.get('email');
const locale = request.nextUrl.searchParams.get('locale') || 'en';
if (!email) {
return NextResponse.redirect(new URL('/', PRIMARY_URL));
}
const sanitizedEmail = email.toLowerCase().trim().slice(0, 255);
if (!EMAIL_REGEX.test(sanitizedEmail)) {
return NextResponse.redirect(new URL('/', PRIMARY_URL));
}
const db = createDb();
await emailSubscriptionQueries.subscribe(db, {
email: sanitizedEmail,
source: 'newsletter',
marketingConsent: true,
});
const validLocales = ['en', 'fa'];
const sanitizedLocale = validLocales.includes(locale) ? locale : 'en';
sendNewsletterWelcomeEmail(sanitizedEmail, sanitizedLocale as 'en' | 'fa').catch((err) => {
console.error('[Newsletter] Failed to send newsletter welcome email:', err);
});
const redirectUrl = new URL('/', PRIMARY_URL);
redirectUrl.searchParams.set('subscribed', 'true');
return NextResponse.redirect(redirectUrl);
} catch (error) {
console.error('[Newsletter] Subscribe GET error:', error);
return NextResponse.redirect(new URL('/', PRIMARY_URL));
}
}
export async function POST(request: NextRequest) {
try {
// Rate limiting (reuse 'search' tier: 60/min)
const rateLimitResult = await withRateLimit(request, 'search');
if (!rateLimitResult.allowed) {
return createRateLimitResponse(rateLimitResult, 'search');
}
const body = await request.json();
const { email, source, marketingConsent, locale } = body;
// Validate email
if (!email || typeof email !== 'string') {
return NextResponse.json(
{ error: 'Email is required' },
{ status: 400 }
);
}
const sanitizedEmail = email.toLowerCase().trim().slice(0, 255);
if (!EMAIL_REGEX.test(sanitizedEmail)) {
return NextResponse.json(
{ error: 'Invalid email address' },
{ status: 400 }
);
}
// Validate source
const validSources = ['newsletter', 'oauth', 'claim', 'early-access'];
const sanitizedSource = validSources.includes(source) ? source : 'newsletter';
// Store in database
const db = createDb();
await emailSubscriptionQueries.subscribe(db, {
email: sanitizedEmail,
source: sanitizedSource,
marketingConsent: Boolean(marketingConsent),
});
// Validate locale
const validLocales = ['en', 'fa'];
const sanitizedLocale = validLocales.includes(locale) ? locale : 'en';
// Send newsletter welcome email (non-blocking, don't fail if email sending fails)
sendNewsletterWelcomeEmail(sanitizedEmail, sanitizedLocale).catch((err) => {
console.error('[Newsletter] Failed to send newsletter welcome email:', err);
});
return NextResponse.json({
success: true,
subscribed: true,
});
} catch (error) {
console.error('[Newsletter] Subscribe error:', error);
return NextResponse.json(
{ error: 'An error occurred. Please try again.' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,77 @@
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import { createDb } from '@skillhub/db';
import { emailSubscriptionQueries } from '@skillhub/db';
import { withRateLimit, createRateLimitResponse } from '@/lib/rate-limit';
const PRIMARY_URL = process.env.PRIMARY_URL || process.env.NEXT_PUBLIC_APP_URL || 'https://skills.palebluedot.live';
export async function POST(request: NextRequest) {
try {
// Rate limiting (reuse 'search' tier: 60/min)
const rateLimitResult = await withRateLimit(request, 'search');
if (!rateLimitResult.allowed) {
return createRateLimitResponse(rateLimitResult, 'search');
}
// POST is already blocked by middleware on mirror servers (503)
const body = await request.json();
const { email } = body;
if (!email || typeof email !== 'string') {
return NextResponse.json(
{ error: 'Email is required' },
{ status: 400 }
);
}
const db = createDb();
await emailSubscriptionQueries.unsubscribe(db, email);
// Always return success to prevent email enumeration
return NextResponse.json({
success: true,
unsubscribed: true,
});
} catch (error) {
console.error('[Newsletter] Unsubscribe error:', error);
return NextResponse.json(
{ error: 'An error occurred. Please try again.' },
{ status: 500 }
);
}
}
/**
* GET handler for one-click unsubscribe links in emails
* Redirects to homepage after unsubscribing
* On mirror servers, redirects to primary server for the unsubscribe action
*/
export async function GET(request: NextRequest) {
try {
const email = request.nextUrl.searchParams.get('email');
if (!email) {
return NextResponse.redirect(new URL('/', PRIMARY_URL));
}
// On mirror server, redirect to primary for write operation
const isPrimary = process.env.IS_PRIMARY_SERVER !== 'false';
if (!isPrimary) {
return NextResponse.redirect(
`${PRIMARY_URL}/api/newsletter/unsubscribe?email=${encodeURIComponent(email)}`
);
}
const db = createDb();
await emailSubscriptionQueries.unsubscribe(db, email);
// Redirect to homepage with unsubscribe confirmation
const redirectUrl = new URL('/', PRIMARY_URL);
redirectUrl.searchParams.set('unsubscribed', 'true');
return NextResponse.redirect(redirectUrl);
} catch (error) {
console.error('[Newsletter] Unsubscribe GET error:', error);
return NextResponse.redirect(new URL('/', request.url));
}
}

View File

@@ -0,0 +1,43 @@
import { NextResponse, type NextRequest } from 'next/server';
import { createDb, ratingQueries, userQueries } from '@skillhub/db';
import { auth } from '@/lib/auth';
import { withRateLimit, createRateLimitResponse, createRateLimitHeaders } from '@/lib/rate-limit';
const db = createDb();
export async function GET(request: NextRequest) {
// Rate limiting (authenticated)
const rateLimitResult = await withRateLimit(request, 'authenticated');
if (!rateLimitResult.allowed) {
return createRateLimitResponse(rateLimitResult);
}
try {
const session = await auth();
if (!session?.user?.githubId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const skillId = searchParams.get('skillId');
if (!skillId) {
return NextResponse.json({ error: 'skillId is required' }, { status: 400 });
}
const dbUser = await userQueries.getByGithubId(db, session.user.githubId);
if (!dbUser) {
return NextResponse.json({ rating: null }, {
headers: createRateLimitHeaders(rateLimitResult),
});
}
const rating = await ratingQueries.getUserRating(db, dbUser.id, skillId);
return NextResponse.json({ rating }, {
headers: createRateLimitHeaders(rateLimitResult),
});
} catch (error) {
console.error('Error fetching user rating:', error);
return NextResponse.json({ error: 'Failed to fetch rating' }, { status: 500 });
}
}

View File

@@ -0,0 +1,281 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { NextRequest } from 'next/server';
// Helper to create mock skill
function createMockSkill(overrides: Partial<{
id: string;
rating: number;
ratingCount: number;
}> = {}) {
return {
id: 'test-owner/test-repo/test-skill',
name: 'test-skill',
rating: 4,
ratingCount: 10,
...overrides,
};
}
// Helper to create mock user
function createMockUser(overrides: Partial<{ id: string; githubId: string; username: string }> = {}) {
return {
id: 'user-123',
githubId: 'gh-12345',
username: 'testuser',
avatarUrl: 'https://example.com/avatar.png',
...overrides,
};
}
// Helper to create mock rating
function createMockRating(overrides: Partial<{
id: string;
rating: number;
review: string;
}> = {}) {
return {
id: 'rating-1',
skillId: 'test-owner/test-repo/test-skill',
userId: 'user-123',
rating: 4,
review: 'Great skill!',
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
// Mock auth
const mockAuth = vi.fn();
vi.mock('@/lib/auth', () => ({
auth: () => mockAuth(),
}));
// Mock db
vi.mock('@skillhub/db', () => ({
createDb: vi.fn(() => ({})),
userQueries: {
getByGithubId: vi.fn(),
},
skillQueries: {
getById: vi.fn(),
},
ratingQueries: {
getForSkill: vi.fn(),
upsert: vi.fn(),
},
}));
import { GET, POST } from './route';
import { userQueries, skillQueries, ratingQueries } from '@skillhub/db';
describe('/api/ratings', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('GET', () => {
const createRequest = (searchParams: Record<string, string> = {}) => {
const url = new URL('http://localhost:3000/api/ratings');
Object.entries(searchParams).forEach(([key, value]) => {
url.searchParams.set(key, value);
});
return new NextRequest(url);
};
it('should return 400 when skillId missing', async () => {
const request = createRequest();
const response = await GET(request);
const data = await response.json();
expect(response.status).toBe(400);
expect(data.error).toContain('skillId');
});
it('should return ratings for skill', async () => {
const mockSkill = createMockSkill();
const mockRatings = [
{
rating: createMockRating({ id: 'rating-1' }),
user: createMockUser(),
},
];
vi.mocked(skillQueries.getById).mockResolvedValue(mockSkill as any);
vi.mocked(ratingQueries.getForSkill).mockResolvedValue(mockRatings as any);
const request = createRequest({ skillId: 'test-owner/test-repo/test-skill' });
const response = await GET(request);
const data = await response.json();
expect(response.status).toBe(200);
expect(data.ratings).toBeDefined();
expect(Array.isArray(data.ratings)).toBe(true);
});
it('should include user info', async () => {
const mockSkill = createMockSkill();
const mockRatings = [
{
rating: createMockRating(),
user: createMockUser({ username: 'testuser' }),
},
];
vi.mocked(skillQueries.getById).mockResolvedValue(mockSkill as any);
vi.mocked(ratingQueries.getForSkill).mockResolvedValue(mockRatings as any);
const request = createRequest({ skillId: 'test-owner/test-repo/test-skill' });
const response = await GET(request);
const data = await response.json();
expect(data.ratings[0].user).toBeDefined();
expect(data.ratings[0].user.username).toBe('testuser');
});
it('should include rating summary', async () => {
const mockSkill = createMockSkill({ rating: 4, ratingCount: 10 });
vi.mocked(skillQueries.getById).mockResolvedValue(mockSkill as any);
vi.mocked(ratingQueries.getForSkill).mockResolvedValue([]);
const request = createRequest({ skillId: 'test-owner/test-repo/test-skill' });
const response = await GET(request);
const data = await response.json();
expect(data.summary).toBeDefined();
expect(data.summary.average).toBe(4);
expect(data.summary.count).toBe(10);
});
it('should respect pagination', async () => {
const mockSkill = createMockSkill();
vi.mocked(skillQueries.getById).mockResolvedValue(mockSkill as any);
vi.mocked(ratingQueries.getForSkill).mockResolvedValue([]);
const request = createRequest({
skillId: 'test-owner/test-repo/test-skill',
limit: '5',
offset: '10',
});
const response = await GET(request);
expect(response.status).toBe(200);
expect(ratingQueries.getForSkill).toHaveBeenCalledWith(
expect.anything(),
'test-owner/test-repo/test-skill',
5,
10
);
});
});
describe('POST', () => {
const createRequest = (body: unknown) => {
return new NextRequest('http://localhost:3000/api/ratings', {
method: 'POST',
body: JSON.stringify(body),
headers: { 'Content-Type': 'application/json' },
});
};
it('should return 401 when not authenticated', async () => {
mockAuth.mockResolvedValue(null);
const request = createRequest({ skillId: 'test-skill', rating: 5 });
const response = await POST(request);
const data = await response.json();
expect(response.status).toBe(401);
expect(data.error).toBe('Unauthorized');
});
it('should return 400 when skillId missing', async () => {
mockAuth.mockResolvedValue({ user: { githubId: 'gh-12345' } });
const request = createRequest({ rating: 5 });
const response = await POST(request);
const data = await response.json();
expect(response.status).toBe(400);
expect(data.error).toContain('skillId');
});
it('should return 400 when rating invalid', async () => {
mockAuth.mockResolvedValue({ user: { githubId: 'gh-12345' } });
const request = createRequest({ skillId: 'test-skill', rating: 6 });
const response = await POST(request);
const data = await response.json();
expect(response.status).toBe(400);
expect(data.error).toContain('between 1 and 5');
});
it('should return 400 when rating is zero', async () => {
mockAuth.mockResolvedValue({ user: { githubId: 'gh-12345' } });
const request = createRequest({ skillId: 'test-skill', rating: 0 });
const response = await POST(request);
await response.json();
expect(response.status).toBe(400);
});
it('should return 404 when skill not found', async () => {
const mockUser = createMockUser();
mockAuth.mockResolvedValue({ user: { githubId: 'gh-12345' } });
vi.mocked(userQueries.getByGithubId).mockResolvedValue(mockUser as any);
vi.mocked(skillQueries.getById).mockResolvedValue(null as any);
const request = createRequest({ skillId: 'nonexistent', rating: 5 });
const response = await POST(request);
await response.json();
expect(response.status).toBe(404);
});
it('should create rating successfully', async () => {
const mockUser = createMockUser();
const mockSkill = createMockSkill();
const mockRating = createMockRating({ rating: 5 });
mockAuth.mockResolvedValue({ user: { githubId: 'gh-12345' } });
vi.mocked(userQueries.getByGithubId).mockResolvedValue(mockUser as any);
vi.mocked(skillQueries.getById).mockResolvedValue(mockSkill as any);
vi.mocked(ratingQueries.upsert).mockResolvedValue(mockRating as any);
const request = createRequest({
skillId: 'test-owner/test-repo/test-skill',
rating: 5,
review: 'Excellent!',
});
const response = await POST(request);
const data = await response.json();
expect(response.status).toBe(200);
expect(data.rating).toBeDefined();
expect(data.summary).toBeDefined();
});
it('should update existing rating', async () => {
const mockUser = createMockUser();
const mockSkill = createMockSkill({ rating: 4, ratingCount: 1 });
const mockRating = createMockRating({ rating: 4 });
mockAuth.mockResolvedValue({ user: { githubId: 'gh-12345' } });
vi.mocked(userQueries.getByGithubId).mockResolvedValue(mockUser as any);
vi.mocked(skillQueries.getById).mockResolvedValue(mockSkill as any);
vi.mocked(ratingQueries.upsert).mockResolvedValue(mockRating as any);
const request = createRequest({
skillId: 'test-owner/test-repo/test-skill',
rating: 4,
});
const response = await POST(request);
await response.json();
expect(response.status).toBe(200);
expect(ratingQueries.upsert).toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,122 @@
import { NextResponse, type NextRequest } from 'next/server';
import { createDb, ratingQueries, skillQueries, userQueries } from '@skillhub/db';
import { auth } from '@/lib/auth';
import { withRateLimit, createRateLimitResponse, createRateLimitHeaders } from '@/lib/rate-limit';
import { sanitizeReview } from '@/lib/sanitize';
const db = createDb();
export async function GET(request: NextRequest) {
// Rate limiting
const rateLimitResult = await withRateLimit(request, 'anonymous');
if (!rateLimitResult.allowed) {
return createRateLimitResponse(rateLimitResult);
}
try {
const { searchParams } = new URL(request.url);
const skillId = searchParams.get('skillId');
// Parse and validate pagination parameters
const limitRaw = parseInt(searchParams.get('limit') || '10');
const limit = isNaN(limitRaw) || limitRaw < 1 ? 10 : Math.min(limitRaw, 100);
const offsetRaw = parseInt(searchParams.get('offset') || '0');
const offset = isNaN(offsetRaw) || offsetRaw < 0 ? 0 : offsetRaw;
if (!skillId) {
return NextResponse.json({ error: 'skillId is required' }, { status: 400 });
}
const ratings = await ratingQueries.getForSkill(db, skillId, limit, offset);
const skill = await skillQueries.getById(db, skillId);
return NextResponse.json({
ratings: ratings.map((r) => ({
id: r.rating.id,
rating: r.rating.rating,
review: r.rating.review,
createdAt: r.rating.createdAt,
updatedAt: r.rating.updatedAt,
user: {
id: r.user.id,
username: r.user.username,
avatarUrl: r.user.avatarUrl,
},
})),
summary: {
average: skill?.rating || 0,
count: skill?.ratingCount || 0,
},
}, {
headers: createRateLimitHeaders(rateLimitResult),
});
} catch (error) {
console.error('Error fetching ratings:', error);
return NextResponse.json({ error: 'Failed to fetch ratings' }, { status: 500 });
}
}
export async function POST(request: NextRequest) {
// Rate limiting (authenticated tier for POST)
const rateLimitResult = await withRateLimit(request, 'authenticated');
if (!rateLimitResult.allowed) {
return createRateLimitResponse(rateLimitResult);
}
try {
const session = await auth();
if (!session?.user?.githubId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json();
const { skillId, rating, review } = body;
if (!skillId) {
return NextResponse.json({ error: 'skillId is required' }, { status: 400 });
}
// Validate rating
const ratingValue = parseInt(rating);
if (isNaN(ratingValue) || ratingValue < 1 || ratingValue > 5) {
return NextResponse.json({ error: 'Rating must be between 1 and 5' }, { status: 400 });
}
// Get database user ID
const dbUser = await userQueries.getByGithubId(db, session.user.githubId);
if (!dbUser) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
// Verify skill exists
const skill = await skillQueries.getById(db, skillId);
if (!skill) {
return NextResponse.json({ error: 'Skill not found' }, { status: 404 });
}
// Upsert rating with sanitized review
const result = await ratingQueries.upsert(db, {
skillId,
userId: dbUser.id,
rating: ratingValue,
review: sanitizeReview(review) ?? undefined,
});
// Get updated skill aggregates
const updatedSkill = await skillQueries.getById(db, skillId);
return NextResponse.json({
rating: result,
summary: {
average: updatedSkill?.rating || 0,
count: updatedSkill?.ratingCount || 0,
},
}, {
headers: createRateLimitHeaders(rateLimitResult),
});
} catch (error) {
console.error('Error creating rating:', error);
return NextResponse.json({ error: 'Failed to create rating' }, { status: 500 });
}
}

View File

@@ -0,0 +1,380 @@
import { NextResponse, type NextRequest } from 'next/server';
import { createDb, skillQueries } from '@skillhub/db';
import { withRateLimit, createRateLimitResponse, createRateLimitHeaders } from '@/lib/rate-limit';
import { getGitHubHeaders, updateTokenStats } from '@/lib/github-token-manager';
// Route configuration for longer timeout (skills with many files)
export const maxDuration = 60; // 60 seconds
// Maximum recursion depth to prevent infinite loops
const MAX_DEPTH = 5;
// Fetch timeout in milliseconds
const FETCH_TIMEOUT = 30000; // 30 seconds
// Create database connection
const db = createDb();
interface GitHubFile {
name: string;
path: string;
sha: string;
size: number;
type: 'file' | 'dir';
download_url: string | null;
}
interface SkillFile {
name: string;
path: string;
content: string;
size: number;
isBinary: boolean;
}
interface CachedFiles {
fetchedAt: string;
commitSha: string;
totalSize: number;
items: SkillFile[];
}
/**
* GET /api/skill-files?id=anthropics/skills/pdf
* Returns all files in a skill folder, using cache when available
*
* Flow:
* 1. Check if cached files exist in database
* 2. If cache is valid (commitSha matches), return from cache (FAST)
* 3. If cache is stale or missing, fetch from GitHub
* 4. Save to cache for future requests
* 5. Return files
*/
export async function GET(request: NextRequest) {
// Rate limiting (use search tier as this is an expensive operation)
const rateLimitResult = await withRateLimit(request, 'search');
if (!rateLimitResult.allowed) {
return createRateLimitResponse(rateLimitResult);
}
try {
const skillId = request.nextUrl.searchParams.get('id');
if (!skillId) {
return NextResponse.json(
{ error: 'Missing skill ID parameter', code: 'MISSING_ID' },
{ status: 400 }
);
}
// Get skill from database
const skill = await skillQueries.getById(db, skillId);
if (!skill) {
return NextResponse.json(
{ error: 'Skill not found', code: 'NOT_FOUND' },
{ status: 404 }
);
}
const { githubOwner, githubRepo, skillPath, branch, commitSha, sourceFormat } = skill;
// === CACHE CHECK ===
// Try to get cached files first (this is the fast path)
const cachedFiles = await skillQueries.getCachedFiles(db, skillId);
if (cachedFiles) {
// eslint-disable-next-line no-console
console.log(`[skill-files] Cache HIT for ${skillId}`);
// Note: Download count is NOT incremented here.
// It should only be incremented in /api/skills/install after successful download.
// This endpoint is just for fetching files - the download may still fail
// (e.g., if JSZip fails to load from CDN).
return NextResponse.json({
skillId,
githubOwner,
githubRepo,
skillPath,
branch: branch || 'main',
sourceFormat: sourceFormat || 'skill.md',
files: cachedFiles.items.map(item => ({
name: item.name,
path: item.path,
type: 'file' as const,
size: item.size,
content: item.isBinary ? undefined : item.content,
downloadUrl: item.isBinary ? `https://raw.githubusercontent.com/${githubOwner}/${githubRepo}/${branch || 'main'}/${skillPath}/${item.path}` : undefined,
})),
fromCache: true,
cachedAt: cachedFiles.fetchedAt,
}, {
headers: createRateLimitHeaders(rateLimitResult),
});
}
// === CACHE MISS - Fetch from GitHub ===
// eslint-disable-next-line no-console
console.log(`[skill-files] Cache MISS for ${skillId}, fetching from GitHub...`);
// Get GitHub headers with token rotation
const { headers: githubHeaders, token } = await getGitHubHeaders();
// Warn if no GitHub token (limited to 60 req/hr)
if (!token) {
console.warn('No GITHUB_TOKEN configured - limited to 60 requests/hour');
}
// Fetch skill folder contents from GitHub
const files = await fetchSkillFiles(
githubOwner,
githubRepo,
skillPath,
branch || 'main',
0, // Start at depth 0
githubHeaders,
token
);
// === SAVE TO CACHE ===
// Prepare cached files structure
const filesToCache: CachedFiles = {
fetchedAt: new Date().toISOString(),
commitSha: commitSha || 'unknown',
totalSize: files.reduce((sum, f) => sum + f.size, 0),
items: files,
};
// Save to database (async, don't block response)
skillQueries.updateCachedFiles(db, skillId, filesToCache).catch((err) => {
console.error(`[skill-files] Failed to cache files for ${skillId}:`, err);
});
// Note: Download count is NOT incremented here.
// It should only be incremented in /api/skills/install after successful download.
// Return response with files
return NextResponse.json({
skillId,
githubOwner,
githubRepo,
skillPath,
branch: branch || 'main',
sourceFormat: sourceFormat || 'skill.md',
files: files.map(item => ({
name: item.name,
path: item.path,
type: 'file' as const,
size: item.size,
content: item.isBinary ? undefined : item.content,
downloadUrl: item.isBinary ? `https://raw.githubusercontent.com/${githubOwner}/${githubRepo}/${branch || 'main'}/${skillPath}/${item.path}` : undefined,
})),
fromCache: false,
}, {
headers: createRateLimitHeaders(rateLimitResult),
});
} catch (error) {
console.error('Error fetching skill files:', error);
// Return specific error codes for different failure types
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
if (errorMessage.includes('rate limit') || errorMessage.includes('403')) {
return NextResponse.json(
{ error: 'GitHub API rate limit exceeded. Please try again later.', code: 'RATE_LIMIT' },
{ status: 429 }
);
}
if (errorMessage.includes('timeout') || errorMessage.includes('AbortError')) {
return NextResponse.json(
{ error: 'Request timed out. The skill may have too many files.', code: 'TIMEOUT' },
{ status: 504 }
);
}
if (errorMessage.includes('404')) {
return NextResponse.json(
{ error: 'Skill files not found on GitHub', code: 'GITHUB_NOT_FOUND' },
{ status: 404 }
);
}
return NextResponse.json(
{ error: 'Failed to fetch skill files', code: 'FETCH_ERROR' },
{ status: 500 }
);
}
}
/**
* Recursively fetch all files in a skill folder from GitHub
* @param depth - Current recursion depth (max MAX_DEPTH levels)
* @param headers - GitHub API headers (with token rotation)
* @param token - Current token for stats tracking
*/
async function fetchSkillFiles(
owner: string,
repo: string,
path: string,
ref: string,
depth: number = 0,
headers: Record<string, string>,
token: string | null
): Promise<SkillFile[]> {
// Prevent infinite recursion
if (depth > MAX_DEPTH) {
console.warn(`Max depth (${MAX_DEPTH}) reached at: ${path}`);
return [];
}
const files: SkillFile[] = [];
// Fetch directory contents with timeout
const apiUrl = `https://api.github.com/repos/${owner}/${repo}/contents/${path}?ref=${ref}`;
const response = await fetch(apiUrl, {
headers,
signal: AbortSignal.timeout(FETCH_TIMEOUT),
});
// Update token stats for rotation
if (token) {
await updateTokenStats(token, response.headers);
}
if (!response.ok) {
// Check for rate limiting
if (response.status === 403) {
const rateLimitRemaining = response.headers.get('x-ratelimit-remaining');
if (rateLimitRemaining === '0') {
throw new Error('GitHub API rate limit exceeded');
}
}
throw new Error(`GitHub API error: ${response.status}`);
}
const contents: GitHubFile[] = await response.json();
// Process each item
for (const item of contents) {
if (item.type === 'file') {
// Determine if file is binary
const isBinary = !isTextFile(item.name);
if (!isBinary && item.size < 1024 * 1024) {
// For text files (< 1MB), fetch content
try {
const content = await fetchFileContent(owner, repo, item.path, ref, headers, token);
files.push({
name: item.name,
path: item.path.replace(`${path}/`, '').replace(path, '') || item.name,
content,
size: item.size,
isBinary: false,
});
} catch {
// If content fetch fails, mark as binary (will use download URL)
files.push({
name: item.name,
path: item.path.replace(`${path}/`, '').replace(path, '') || item.name,
content: '',
size: item.size,
isBinary: true,
});
}
} else {
// For binary or large files, don't store content (use download URL)
files.push({
name: item.name,
path: item.path.replace(`${path}/`, '').replace(path, '') || item.name,
content: '',
size: item.size,
isBinary: true,
});
}
} else if (item.type === 'dir') {
// Recursively fetch subdirectory (with depth limit)
const subPath = `${path}/${item.name}`;
const subFiles = await fetchSkillFiles(owner, repo, subPath, ref, depth + 1, headers, token);
// Add subdirectory files with proper paths
for (const subFile of subFiles) {
files.push({
...subFile,
path: `${item.name}/${subFile.path}`,
});
}
}
}
return files;
}
/**
* Fetch file content from GitHub with timeout
*/
async function fetchFileContent(
owner: string,
repo: string,
path: string,
ref: string,
headers: Record<string, string>,
token: string | null
): Promise<string> {
const apiUrl = `https://api.github.com/repos/${owner}/${repo}/contents/${path}?ref=${ref}`;
const response = await fetch(apiUrl, {
headers,
signal: AbortSignal.timeout(FETCH_TIMEOUT),
});
// Update token stats for rotation
if (token) {
await updateTokenStats(token, response.headers);
}
if (!response.ok) {
throw new Error(`Failed to fetch file: ${path} (${response.status})`);
}
const data = await response.json();
if (data.content && data.encoding === 'base64') {
return Buffer.from(data.content, 'base64').toString('utf-8');
}
throw new Error(`Invalid file content for: ${path}`);
}
/**
* Check if file is a text file based on extension
* Note: .env files are excluded for security (could contain secrets)
*/
/**
* Known instruction file names that are always plain text
*/
const KNOWN_TEXT_FILENAMES = ['SKILL.md', 'AGENTS.md', '.cursorrules', '.windsurfrules', 'copilot-instructions.md'];
function isTextFile(filename: string): boolean {
// Exclude potentially sensitive files
const sensitivePatterns = ['.env', '.secret', '.key', '.pem', '.credential'];
const lowerFilename = filename.toLowerCase();
if (sensitivePatterns.some((p) => lowerFilename.includes(p))) {
return false;
}
// Known instruction dotfiles that are plain text
if (KNOWN_TEXT_FILENAMES.includes(filename)) {
return true;
}
const textExtensions = [
'.md', '.txt', '.json', '.yaml', '.yml', '.xml', '.html', '.css',
'.js', '.ts', '.jsx', '.tsx', '.py', '.sh', '.bash', '.ps1',
'.rb', '.go', '.rs', '.java', '.c', '.cpp', '.h', '.hpp',
'.toml', '.ini', '.cfg', '.conf', '.gitignore', '.editorconfig',
];
const ext = filename.substring(filename.lastIndexOf('.')).toLowerCase();
return textExtensions.includes(ext) || !filename.includes('.');
}

View File

@@ -0,0 +1,438 @@
import { type NextRequest } from 'next/server';
import { createDb, skillQueries } from '@skillhub/db';
import { withRateLimit, createRateLimitHeaders } from '@/lib/rate-limit';
import { getGitHubHeaders, updateTokenStats } from '@/lib/github-token-manager';
import { INSTRUCTION_FILE_PATTERNS } from 'skillhub-core';
import archiver from 'archiver';
import { Readable } from 'stream';
// Route configuration for longer timeout
export const maxDuration = 60;
// Create database connection
const db = createDb();
// Fetch timeout in milliseconds
const FETCH_TIMEOUT = 30000;
// Maximum recursion depth
const MAX_DEPTH = 5;
type Platform = 'claude' | 'codex' | 'copilot' | 'cursor' | 'windsurf';
const VALID_PLATFORMS: Platform[] = ['claude', 'codex', 'copilot', 'cursor', 'windsurf'];
// All known main instruction file names
const MAIN_FILE_NAMES = INSTRUCTION_FILE_PATTERNS.map(p => p.filename);
interface GitHubFile {
name: string;
path: string;
sha: string;
size: number;
type: 'file' | 'dir';
download_url: string | null;
}
interface SkillFile {
name: string;
path: string;
content: string;
size: number;
isBinary: boolean;
}
interface CachedFiles {
fetchedAt: string;
commitSha: string;
totalSize: number;
items: SkillFile[];
}
// --- Platform transformation (mirrors InstallSection.tsx logic) ---
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();
const descMatch = yaml.match(/^description:\s*(.+)$/m);
const description = descMatch ? descMatch[1].trim() : undefined;
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 transformContent(platform: Platform, content: string, skillName: string): string {
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';
}
function isMainInstructionFile(fileName: string): boolean {
return MAIN_FILE_NAMES.includes(fileName);
}
/**
* GET /api/skill-files/zip?id=owner/repo/skill-name&platform=cursor
* Returns a ZIP file containing all skill files, transformed for the target platform
*/
export async function GET(request: NextRequest) {
// Rate limiting
const rateLimitResult = await withRateLimit(request, 'search');
if (!rateLimitResult.allowed) {
return new Response(
JSON.stringify({ error: 'Rate limit exceeded', code: 'RATE_LIMIT' }),
{
status: 429,
headers: {
'Content-Type': 'application/json',
...createRateLimitHeaders(rateLimitResult),
},
}
);
}
try {
const skillId = request.nextUrl.searchParams.get('id');
const platformParam = request.nextUrl.searchParams.get('platform');
const platform: Platform = (platformParam && VALID_PLATFORMS.includes(platformParam as Platform))
? platformParam as Platform
: 'claude';
if (!skillId) {
return new Response(
JSON.stringify({ error: 'Missing skill ID parameter', code: 'MISSING_ID' }),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
);
}
// Get skill from database
const skill = await skillQueries.getById(db, skillId);
if (!skill) {
return new Response(
JSON.stringify({ error: 'Skill not found', code: 'NOT_FOUND' }),
{ status: 404, headers: { 'Content-Type': 'application/json' } }
);
}
const { githubOwner, githubRepo, skillPath, branch, name: skillName } = skill;
// Try to get cached files first
let files: SkillFile[];
const cachedFiles = await skillQueries.getCachedFiles(db, skillId);
if (cachedFiles) {
// eslint-disable-next-line no-console
console.log(`[skill-files/zip] Cache HIT for ${skillId}`);
files = cachedFiles.items;
} else {
// Fetch from GitHub
// eslint-disable-next-line no-console
console.log(`[skill-files/zip] Cache MISS for ${skillId}, fetching from GitHub...`);
const { headers: githubHeaders, token } = await getGitHubHeaders();
files = await fetchSkillFiles(
githubOwner,
githubRepo,
skillPath,
branch || 'main',
0,
githubHeaders,
token
);
// Save to cache (async, don't block)
const filesToCache: CachedFiles = {
fetchedAt: new Date().toISOString(),
commitSha: skill.commitSha || 'unknown',
totalSize: files.reduce((sum, f) => sum + f.size, 0),
items: files,
};
skillQueries.updateCachedFiles(db, skillId, filesToCache).catch((err) => {
console.error(`[skill-files/zip] Failed to cache files for ${skillId}:`, err);
});
}
if (files.length === 0) {
return new Response(
JSON.stringify({ error: 'No files found in skill', code: 'NO_FILES' }),
{ status: 404, headers: { 'Content-Type': 'application/json' } }
);
}
// Determine if this is a flat-file platform
const isFlatPlatform = ['cursor', 'windsurf', 'copilot'].includes(platform);
// Create ZIP archive using streaming
const archive = archiver('zip', { zlib: { level: 6 } });
// Add files to archive with platform-specific transformation
for (const file of files) {
if (file.content && !file.isBinary) {
const isMainFile = isMainInstructionFile(file.name) && file.path === file.name;
if (isMainFile) {
const platformFileName = getPlatformFileName(platform, skillName);
const transformed = transformContent(platform, file.content, skillName);
if (isFlatPlatform) {
archive.append(transformed, { name: platformFileName });
} else {
archive.append(transformed, { name: `${skillName}/${platformFileName}` });
}
} else {
// Supporting files always go in subfolder
archive.append(file.content, { name: `${skillName}/${file.path}` });
}
} else if (file.isBinary) {
// For binary files, we need to fetch them
const downloadUrl = `https://raw.githubusercontent.com/${githubOwner}/${githubRepo}/${branch || 'main'}/${skillPath}/${file.path}`;
try {
const response = await fetch(downloadUrl, {
signal: AbortSignal.timeout(FETCH_TIMEOUT),
});
if (response.ok) {
const buffer = await response.arrayBuffer();
archive.append(Buffer.from(buffer), { name: `${skillName}/${file.path}` });
}
} catch {
console.warn(`[skill-files/zip] Failed to fetch binary file: ${file.path}`);
}
}
}
// Finalize archive
archive.finalize();
// Convert Node.js stream to Web ReadableStream
const webStream = Readable.toWeb(archive) as ReadableStream<Uint8Array>;
// Sanitize filename for Content-Disposition header
const safeFileName = skillName.replace(/[^a-zA-Z0-9_-]/g, '_');
return new Response(webStream, {
status: 200,
headers: {
'Content-Type': 'application/zip',
'Content-Disposition': `attachment; filename="${safeFileName}.zip"`,
...createRateLimitHeaders(rateLimitResult),
},
});
} catch (error) {
console.error('[skill-files/zip] Error:', error);
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
if (errorMessage.includes('rate limit') || errorMessage.includes('403')) {
return new Response(
JSON.stringify({ error: 'GitHub API rate limit exceeded', code: 'RATE_LIMIT' }),
{ status: 429, headers: { 'Content-Type': 'application/json' } }
);
}
if (errorMessage.includes('timeout') || errorMessage.includes('AbortError')) {
return new Response(
JSON.stringify({ error: 'Request timed out', code: 'TIMEOUT' }),
{ status: 504, headers: { 'Content-Type': 'application/json' } }
);
}
return new Response(
JSON.stringify({ error: 'Failed to generate ZIP', code: 'INTERNAL_ERROR' }),
{ status: 500, headers: { 'Content-Type': 'application/json' } }
);
}
}
/**
* Recursively fetch all files in a skill folder from GitHub
*/
async function fetchSkillFiles(
owner: string,
repo: string,
path: string,
ref: string,
depth: number = 0,
headers: Record<string, string>,
token: string | null
): Promise<SkillFile[]> {
if (depth > MAX_DEPTH) {
console.warn(`Max depth (${MAX_DEPTH}) reached at: ${path}`);
return [];
}
const files: SkillFile[] = [];
const apiUrl = `https://api.github.com/repos/${owner}/${repo}/contents/${path}?ref=${ref}`;
const response = await fetch(apiUrl, {
headers,
signal: AbortSignal.timeout(FETCH_TIMEOUT),
});
if (token) {
await updateTokenStats(token, response.headers);
}
if (!response.ok) {
if (response.status === 403) {
const rateLimitRemaining = response.headers.get('x-ratelimit-remaining');
if (rateLimitRemaining === '0') {
throw new Error('GitHub API rate limit exceeded');
}
}
throw new Error(`GitHub API error: ${response.status}`);
}
const contents: GitHubFile[] = await response.json();
for (const item of contents) {
if (item.type === 'file') {
const isBinary = !isTextFile(item.name);
if (!isBinary && item.size < 1024 * 1024) {
try {
const content = await fetchFileContent(owner, repo, item.path, ref, headers, token);
files.push({
name: item.name,
path: item.path.replace(`${path}/`, '').replace(path, '') || item.name,
content,
size: item.size,
isBinary: false,
});
} catch {
files.push({
name: item.name,
path: item.path.replace(`${path}/`, '').replace(path, '') || item.name,
content: '',
size: item.size,
isBinary: true,
});
}
} else {
files.push({
name: item.name,
path: item.path.replace(`${path}/`, '').replace(path, '') || item.name,
content: '',
size: item.size,
isBinary: true,
});
}
} else if (item.type === 'dir') {
const subPath = `${path}/${item.name}`;
const subFiles = await fetchSkillFiles(owner, repo, subPath, ref, depth + 1, headers, token);
for (const subFile of subFiles) {
files.push({
...subFile,
path: `${item.name}/${subFile.path}`,
});
}
}
}
return files;
}
/**
* Fetch file content from GitHub
*/
async function fetchFileContent(
owner: string,
repo: string,
path: string,
ref: string,
headers: Record<string, string>,
token: string | null
): Promise<string> {
const apiUrl = `https://api.github.com/repos/${owner}/${repo}/contents/${path}?ref=${ref}`;
const response = await fetch(apiUrl, {
headers,
signal: AbortSignal.timeout(FETCH_TIMEOUT),
});
if (token) {
await updateTokenStats(token, response.headers);
}
if (!response.ok) {
throw new Error(`Failed to fetch file: ${path} (${response.status})`);
}
const data = await response.json();
if (data.content && data.encoding === 'base64') {
return Buffer.from(data.content, 'base64').toString('utf-8');
}
throw new Error(`Invalid file content for: ${path}`);
}
/**
* Check if file is a text file based on extension
*/
function isTextFile(filename: string): boolean {
const sensitivePatterns = ['.env', '.secret', '.key', '.pem', '.credential'];
const lowerFilename = filename.toLowerCase();
if (sensitivePatterns.some((p) => lowerFilename.includes(p))) {
return false;
}
// Known instruction dotfiles that are plain text
if (MAIN_FILE_NAMES.includes(filename)) {
return true;
}
const textExtensions = [
'.md', '.txt', '.json', '.yaml', '.yml', '.xml', '.html', '.css',
'.js', '.ts', '.jsx', '.tsx', '.py', '.sh', '.bash', '.ps1',
'.rb', '.go', '.rs', '.java', '.c', '.cpp', '.h', '.hpp',
'.toml', '.ini', '.cfg', '.conf', '.gitignore', '.editorconfig',
];
const ext = filename.substring(filename.lastIndexOf('.')).toLowerCase();
return textExtensions.includes(ext) || !filename.includes('.');
}

View File

@@ -0,0 +1,93 @@
import { NextResponse, type NextRequest } from 'next/server';
import { createDb, skillQueries } from '@skillhub/db';
import { shouldCountView } from '@/lib/cache';
// Create database connection
const db = createDb();
/**
* Get client IP from request headers
* Handles various proxy headers (Cloudflare, nginx, etc.)
*/
function getClientIp(request: NextRequest): string {
// Try various headers in order of preference
const cfConnectingIp = request.headers.get('cf-connecting-ip');
if (cfConnectingIp) return cfConnectingIp;
const xRealIp = request.headers.get('x-real-ip');
if (xRealIp) return xRealIp;
const xForwardedFor = request.headers.get('x-forwarded-for');
if (xForwardedFor) {
// x-forwarded-for can contain multiple IPs, take the first one
return xForwardedFor.split(',')[0].trim();
}
// Fallback to a default (shouldn't happen in production)
return 'unknown';
}
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string[] }> }
) {
try {
const { id } = await params;
const skillId = id.join('/');
// Get skill from database
const skill = await skillQueries.getById(db, skillId);
if (!skill) {
return NextResponse.json(
{ error: 'Skill not found' },
{ status: 404 }
);
}
// Increment view count only on primary server (mirror DB is read-only)
const isPrimary = process.env.IS_PRIMARY_SERVER !== 'false';
if (isPrimary) {
const clientIp = getClientIp(request);
const shouldCount = await shouldCountView(skillId, clientIp);
if (shouldCount) {
await skillQueries.incrementViews(db, skillId);
}
}
return NextResponse.json({
id: skill.id,
name: skill.name,
description: skill.description,
githubOwner: skill.githubOwner,
githubRepo: skill.githubRepo,
skillPath: skill.skillPath,
branch: skill.branch,
version: skill.version,
license: skill.license,
author: skill.author,
homepage: skill.homepage,
githubStars: skill.githubStars,
githubForks: skill.githubForks,
downloadCount: skill.downloadCount,
viewCount: skill.viewCount,
securityScore: skill.securityScore,
isVerified: skill.isVerified,
isFeatured: skill.isFeatured,
compatibility: skill.compatibility,
triggers: skill.triggers,
rawContent: skill.rawContent,
sourceFormat: skill.sourceFormat || 'skill.md',
createdAt: skill.createdAt,
updatedAt: skill.updatedAt,
indexedAt: skill.indexedAt,
});
} catch (error) {
console.error('Error fetching skill:', error);
return NextResponse.json(
{ error: 'Failed to fetch skill' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,429 @@
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import { auth } from '@/lib/auth';
import { createDb, addRequestQueries, userQueries } from '@skillhub/db';
import { sanitizeReason } from '@/lib/sanitize';
import { sendClaimSubmittedEmail } from '@/lib/email';
export const dynamic = 'force-dynamic';
const db = createDb();
// Parse GitHub URL to extract owner and repo
function parseGitHubUrl(url: string): { owner: string; repo: string; path?: string } | null {
try {
const urlObj = new URL(url);
if (!urlObj.hostname.includes('github.com')) {
return null;
}
// Handle various GitHub URL formats:
// https://github.com/owner/repo
// https://github.com/owner/repo/tree/main/path/to/skill
// https://github.com/owner/repo/blob/main/SKILL.md
const pathParts = urlObj.pathname.split('/').filter(Boolean);
if (pathParts.length < 2) {
return null;
}
const owner = pathParts[0];
const repo = pathParts[1];
// Extract path if present (after tree/branch or blob/branch)
let skillPath: string | undefined;
if (pathParts.length > 3 && (pathParts[2] === 'tree' || pathParts[2] === 'blob')) {
// Skip 'tree' or 'blob' and branch name
const remainingPath = pathParts.slice(4).join('/');
if (remainingPath && !remainingPath.endsWith('.md')) {
skillPath = remainingPath;
}
}
return { owner, repo, path: skillPath };
} catch {
return null;
}
}
// Find all SKILL.md files in a repository using GitHub Tree API
async function findSkillMdFiles(
owner: string,
repo: string,
defaultBranch: string
): Promise<string[]> {
try {
// Get the full repository tree recursively
const treeResponse = await fetch(
`https://api.github.com/repos/${owner}/${repo}/git/trees/${defaultBranch}?recursive=1`,
{
headers: {
Accept: 'application/vnd.github.v3+json',
'User-Agent': 'SkillHub',
...(process.env.GITHUB_TOKEN && {
Authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
}),
},
signal: AbortSignal.timeout(30000),
}
);
if (!treeResponse.ok) {
console.error(`Failed to fetch repository tree (HTTP ${treeResponse.status}):`, treeResponse.statusText);
return [];
}
const treeData = await treeResponse.json() as {
tree: Array<{ path: string; type: string }>;
truncated?: boolean;
};
if (treeData.truncated) {
console.warn(`Repository tree for ${owner}/${repo} is truncated - some SKILL.md files may be missed`);
}
// Find all SKILL.md files (case-sensitive)
const skillMdPaths = treeData.tree
.filter((item) => item.type === 'blob' && item.path.endsWith('/SKILL.md'))
.map((item) => {
// Extract the directory path (remove /SKILL.md)
const parts = item.path.split('/');
parts.pop(); // Remove SKILL.md
return parts.join('/');
});
// Also check for SKILL.md at root level
const hasRootSkillMd = treeData.tree.some(
(item) => item.type === 'blob' && item.path === 'SKILL.md'
);
if (hasRootSkillMd) {
skillMdPaths.unshift(''); // Empty string means root
}
return skillMdPaths;
} catch (error) {
console.error('Error finding SKILL.md files:', error);
return [];
}
}
// Validate GitHub repository exists and check for SKILL.md
async function validateGitHubRepo(
owner: string,
repo: string,
skillPath?: string
): Promise<{
valid: boolean;
hasSkillMd: boolean;
skillPaths: string[];
defaultBranch: string;
error?: string;
}> {
try {
// Check if repository exists and get default branch
const repoResponse = await fetch(
`https://api.github.com/repos/${owner}/${repo}`,
{
headers: {
Accept: 'application/vnd.github.v3+json',
'User-Agent': 'SkillHub',
...(process.env.GITHUB_TOKEN && {
Authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
}),
},
signal: AbortSignal.timeout(10000),
}
);
if (!repoResponse.ok) {
if (repoResponse.status === 404) {
return {
valid: false,
hasSkillMd: false,
skillPaths: [],
defaultBranch: 'main',
error: 'Repository not found. Please check the URL and ensure the repository exists.'
};
}
if (repoResponse.status === 403) {
// Check if it's rate limit or forbidden access
const rateLimitRemaining = repoResponse.headers.get('x-ratelimit-remaining');
if (rateLimitRemaining === '0') {
return {
valid: false,
hasSkillMd: false,
skillPaths: [],
defaultBranch: 'main',
error: 'GitHub API rate limit exceeded. Please try again later.'
};
}
return {
valid: false,
hasSkillMd: false,
skillPaths: [],
defaultBranch: 'main',
error: 'Repository is private or you do not have access. Please ensure the repository is public.'
};
}
return {
valid: false,
hasSkillMd: false,
skillPaths: [],
defaultBranch: 'main',
error: `Failed to access repository (HTTP ${repoResponse.status})`
};
}
const repoData = await repoResponse.json() as { default_branch: string; private?: boolean };
const defaultBranch = repoData.default_branch || 'main';
// Additional check for private repos (in case 200 but private)
if (repoData.private) {
return {
valid: false,
hasSkillMd: false,
skillPaths: [],
defaultBranch,
error: 'Repository is private. SkillHub only indexes public repositories.'
};
}
// If a specific path is provided, only check that path
if (skillPath) {
const skillMdPath = `${skillPath}/SKILL.md`;
const fileResponse = await fetch(
`https://api.github.com/repos/${owner}/${repo}/contents/${skillMdPath}`,
{
headers: {
Accept: 'application/vnd.github.v3+json',
'User-Agent': 'SkillHub',
...(process.env.GITHUB_TOKEN && {
Authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
}),
},
signal: AbortSignal.timeout(10000),
}
);
const hasSkillMd = fileResponse.ok;
return {
valid: true,
hasSkillMd,
skillPaths: hasSkillMd ? [skillPath] : [],
defaultBranch,
};
}
// No specific path - do a deep scan for all SKILL.md files
const skillPaths = await findSkillMdFiles(owner, repo, defaultBranch);
return {
valid: true,
hasSkillMd: skillPaths.length > 0,
skillPaths,
defaultBranch,
};
} catch (error) {
console.error('GitHub API error:', error);
// Distinguish timeout errors
if (error instanceof Error && error.name === 'TimeoutError') {
return {
valid: false,
hasSkillMd: false,
skillPaths: [],
defaultBranch: 'main',
error: 'Request timed out while checking repository. Please try again.'
};
}
return {
valid: false,
hasSkillMd: false,
skillPaths: [],
defaultBranch: 'main',
error: 'Network error while verifying repository. Please check your connection and try again.'
};
}
}
/**
* GET /api/skills/add-request - Get user's add requests
*/
export async function GET() {
try {
const session = await auth();
if (!session?.user?.githubId) {
return NextResponse.json(
{ error: 'Authentication required' },
{ status: 401 }
);
}
// Get user from database
const dbUser = await userQueries.getByGithubId(db, session.user.githubId);
if (!dbUser) {
return NextResponse.json({ requests: [] });
}
const requests = await addRequestQueries.getByUser(db, dbUser.id);
return NextResponse.json({ requests });
} catch (error) {
console.error('Error fetching add requests:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
/**
* POST /api/skills/add-request - Submit an add request
*/
export async function POST(request: NextRequest) {
try {
const session = await auth();
if (!session?.user?.githubId || !session.user.username) {
return NextResponse.json(
{ error: 'Authentication required', code: 'AUTH_REQUIRED' },
{ status: 401 }
);
}
const body = await request.json();
const { repositoryUrl, reason } = body;
if (!repositoryUrl) {
return NextResponse.json(
{ error: 'repositoryUrl is required', code: 'INVALID_INPUT' },
{ status: 400 }
);
}
// Parse GitHub URL
const parsed = parseGitHubUrl(repositoryUrl);
if (!parsed) {
return NextResponse.json(
{ error: 'Invalid GitHub URL', code: 'INVALID_URL' },
{ status: 400 }
);
}
// Normalize the repository URL
const normalizedUrl = `https://github.com/${parsed.owner}/${parsed.repo}`;
const finalSkillPath = undefined;
// Get or create user in database
let dbUser = await userQueries.getByGithubId(db, session.user.githubId);
if (!dbUser) {
dbUser = await userQueries.upsertFromGithub(db, {
githubId: session.user.githubId,
username: session.user.username,
displayName: session.user.name || undefined,
email: session.user.email || undefined,
avatarUrl: session.user.image || undefined,
});
}
if (!dbUser) {
return NextResponse.json(
{ error: 'Failed to create user record', code: 'USER_CREATE_FAILED' },
{ status: 500 }
);
}
// Check if user already has a pending request for this repository + path combination
const hasPending = await addRequestQueries.hasPendingRequest(
db,
dbUser.id,
normalizedUrl,
finalSkillPath || null
);
if (hasPending) {
return NextResponse.json(
{ error: 'You already have a pending request for this skill path', code: 'ALREADY_PENDING' },
{ status: 409 }
);
}
// Validate the GitHub repository
const validation = await validateGitHubRepo(parsed.owner, parsed.repo, finalSkillPath);
if (!validation.valid) {
let errorCode = 'INVALID_REPO';
// Map specific error messages to error codes
if (validation.error?.includes('rate limit')) {
errorCode = 'RATE_LIMIT_EXCEEDED';
} else if (validation.error?.includes('not found')) {
errorCode = 'INVALID_REPO';
} else if (validation.error?.includes('timeout') || validation.error?.includes('timed out')) {
errorCode = 'NETWORK_TIMEOUT';
}
return NextResponse.json(
{ error: validation.error || 'Invalid repository', code: errorCode },
{ status: 400 }
);
}
// Create the add request with found skill paths
const skillPathsJson = validation.skillPaths.length > 0
? validation.skillPaths.join(',')
: undefined;
// Sanitize user-provided reason
const sanitizedReason = reason ? sanitizeReason(reason) : 'No reason provided';
const requestId = await addRequestQueries.create(db, {
userId: dbUser.id,
repositoryUrl: normalizedUrl,
skillPath: skillPathsJson,
reason: sanitizedReason,
validRepo: validation.valid,
hasSkillMd: validation.hasSkillMd,
});
// Build appropriate response message
let message: string;
if (validation.skillPaths.length > 1) {
message = `Request submitted. Found ${validation.skillPaths.length} skills in the repository.`;
} else if (validation.skillPaths.length === 1) {
const pathInfo = validation.skillPaths[0] === '' ? 'at root' : `in ${validation.skillPaths[0]}`;
message = `Request submitted. SKILL.md found ${pathInfo}.`;
} else {
message = 'Request submitted. No SKILL.md found - repository will be reviewed.';
}
// Send confirmation email (non-blocking) - ONLY if skills were found
if (dbUser.email && validation.skillPaths.length > 0) {
const locale = (dbUser.preferredLocale === 'fa' ? 'fa' : 'en') as 'en' | 'fa';
sendClaimSubmittedEmail(dbUser.email, locale, 'add', {
repositoryUrl: normalizedUrl,
skillCount: validation.skillPaths.length,
}).catch((err) => {
console.error('[Claim] Failed to send add confirmation email:', err);
});
}
return NextResponse.json({
success: true,
requestId,
hasSkillMd: validation.hasSkillMd,
skillCount: validation.skillPaths.length,
skillPaths: validation.skillPaths,
message,
});
} catch (error) {
console.error('Error creating add request:', error);
return NextResponse.json(
{ error: 'Internal server error', code: 'SERVER_ERROR' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,102 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { NextRequest } from 'next/server';
// Mock rate limiting - must be before route import
vi.mock('@/lib/rate-limit', () => ({
withRateLimit: vi.fn().mockResolvedValue({ allowed: true, remaining: 100, limit: 120, resetAt: Date.now() + 60000 }),
createRateLimitResponse: vi.fn(),
createRateLimitHeaders: vi.fn().mockReturnValue({}),
}));
// Helper to create mock skill
function createMockSkill(overrides: Partial<{
id: string;
name: string;
isFeatured: boolean;
githubStars: number;
}> = {}) {
return {
id: 'test-owner/test-repo/test-skill',
name: 'test-skill',
description: 'A test skill',
githubOwner: 'test-owner',
githubRepo: 'test-repo',
githubStars: 100,
downloadCount: 50,
securityScore: 85,
isVerified: false,
isFeatured: true,
compatibility: { platforms: ['claude'] },
...overrides,
};
}
// Mock db
vi.mock('@skillhub/db', () => ({
createDb: vi.fn(() => ({})),
skillQueries: {
getFeatured: vi.fn(),
getByPopularity: vi.fn(),
getFeaturedWithDiversity: vi.fn(),
},
skills: {},
}));
import { GET } from './route';
import { skillQueries } from '@skillhub/db';
describe('GET /api/skills/featured', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should return featured skills', async () => {
const mockSkills = [
createMockSkill({ id: 'skill-1', isFeatured: true }),
createMockSkill({ id: 'skill-2', isFeatured: true }),
];
vi.mocked(skillQueries.getFeatured).mockResolvedValue(mockSkills as any);
const request = new NextRequest('http://localhost:3000/api/skills/featured');
const response = await GET(request);
const data = await response.json();
expect(response.status).toBe(200);
expect(data.skills).toBeDefined();
expect(Array.isArray(data.skills)).toBe(true);
expect(data.skills.length).toBe(2);
});
it('should respect limit parameter', async () => {
const mockSkills = [createMockSkill()];
vi.mocked(skillQueries.getFeatured).mockResolvedValue(mockSkills as any);
const request = new NextRequest('http://localhost:3000/api/skills/featured?limit=5');
await GET(request);
expect(skillQueries.getFeatured).toHaveBeenCalledWith(expect.anything(), 5);
});
it('should fallback to diversity-based popularity when no featured', async () => {
vi.mocked(skillQueries.getFeatured).mockResolvedValue([]);
vi.mocked(skillQueries.getFeaturedWithDiversity).mockResolvedValue([createMockSkill()] as any);
const request = new NextRequest('http://localhost:3000/api/skills/featured');
const response = await GET(request);
const data = await response.json();
expect(skillQueries.getFeaturedWithDiversity).toHaveBeenCalled();
expect(data.skills.length).toBeGreaterThan(0);
});
it('should handle database errors gracefully', async () => {
vi.mocked(skillQueries.getFeatured).mockRejectedValue(new Error('Database error'));
const request = new NextRequest('http://localhost:3000/api/skills/featured');
const response = await GET(request);
const data = await response.json();
expect(response.status).toBe(500);
expect(data.error).toBeDefined();
});
});

View File

@@ -0,0 +1,88 @@
import { NextResponse, type NextRequest } from 'next/server';
import { createDb, skillQueries, type skills } from '@skillhub/db';
import { getCached, setCache, cacheKeys, cacheTTL } from '@/lib/cache';
import { withRateLimit, createRateLimitResponse, createRateLimitHeaders } from '@/lib/rate-limit';
const db = createDb();
type Skill = typeof skills.$inferSelect;
interface SkillData {
id: string;
name: string;
description: string | null;
githubOwner: string;
githubRepo: string;
githubStars: number | null;
downloadCount: number | null;
securityStatus: string | null;
isVerified: boolean | null;
compatibility: unknown;
}
interface FeaturedResponse {
skills: SkillData[];
}
export async function GET(request: NextRequest) {
// Rate limiting
const rateLimitResult = await withRateLimit(request, 'anonymous');
if (!rateLimitResult.allowed) {
return createRateLimitResponse(rateLimitResult);
}
try {
const searchParams = request.nextUrl.searchParams;
const limit = parseInt(searchParams.get('limit') || '6');
// Try to get from cache first (only for default limit)
const cacheKey = cacheKeys.featuredSkills();
if (limit === 6) {
const cached = await getCached<FeaturedResponse>(cacheKey);
if (cached) {
return NextResponse.json(cached, {
headers: { 'X-Cache': 'HIT', ...createRateLimitHeaders(rateLimitResult) },
});
}
}
// Get featured skills, fallback to popularity-based ranking
// Uses adaptive algorithm: quality + freshness + engagement
let featuredSkills = await skillQueries.getFeatured(db, limit);
// If no manually featured skills, use adaptive popularity with owner/repo diversity
if (featuredSkills.length === 0) {
featuredSkills = await skillQueries.getFeaturedWithDiversity(db, limit, 2, 3);
}
const data: FeaturedResponse = {
skills: featuredSkills.map((skill: Skill) => ({
id: skill.id,
name: skill.name,
description: skill.description,
githubOwner: skill.githubOwner,
githubRepo: skill.githubRepo,
githubStars: skill.githubStars,
downloadCount: skill.downloadCount,
securityStatus: skill.securityStatus,
isVerified: skill.isVerified,
compatibility: skill.compatibility,
})),
};
// Cache the result (2 hours)
if (limit === 6) {
await setCache(cacheKey, data, cacheTTL.featured);
}
return NextResponse.json(data, {
headers: { 'X-Cache': 'MISS', ...createRateLimitHeaders(rateLimitResult) },
});
} catch (error) {
console.error('Error fetching featured skills:', error);
return NextResponse.json(
{ error: 'Failed to fetch featured skills' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,93 @@
import { NextResponse, type NextRequest } from 'next/server';
import { createDb, skillQueries } from '@skillhub/db';
import { invalidateCache, cacheKeys, shouldCountDownload } from '@/lib/cache';
// Create database connection
const db = createDb();
/**
* Get client IP from request headers
*/
function getClientIp(request: NextRequest): string {
const cfConnectingIp = request.headers.get('cf-connecting-ip');
if (cfConnectingIp) return cfConnectingIp;
const xRealIp = request.headers.get('x-real-ip');
if (xRealIp) return xRealIp;
const xForwardedFor = request.headers.get('x-forwarded-for');
if (xForwardedFor) {
return xForwardedFor.split(',')[0].trim();
}
return 'unknown';
}
/**
* POST /api/skills/install
* Track a skill installation from CLI or other sources
*
* Body: { skillId: string, platform?: string, method?: string }
*/
export async function POST(request: NextRequest) {
try {
// Parse request body
let body: { skillId?: string; platform?: string; method?: string };
try {
body = await request.json();
} catch {
return NextResponse.json(
{ error: 'Invalid request body' },
{ status: 400 }
);
}
const { skillId, platform = 'unknown', method = 'unknown' } = body;
if (!skillId) {
return NextResponse.json(
{ error: 'skillId is required' },
{ status: 400 }
);
}
// Verify skill exists
const skill = await skillQueries.getById(db, skillId);
if (!skill) {
return NextResponse.json(
{ error: 'Skill not found' },
{ status: 404 }
);
}
// Rate limit: same IP can only count 1 download per skill per 5 minutes
const clientIp = getClientIp(request);
const shouldCount = await shouldCountDownload(skillId, clientIp);
// Only increment if this is a new download from this IP
if (shouldCount) {
await skillQueries.incrementDownloads(db, skillId);
}
// Invalidate relevant caches so featured/recent lists reflect the new download
await Promise.all([
invalidateCache(cacheKeys.featuredSkills()),
invalidateCache(cacheKeys.recentSkills()),
invalidateCache(cacheKeys.stats()),
invalidateCache(cacheKeys.skill(skillId)),
]);
return NextResponse.json({
success: true,
skillId,
platform,
method,
});
} catch (error) {
console.error('Error tracking install:', error);
return NextResponse.json(
{ error: 'Failed to track install' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,94 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { NextRequest } from 'next/server';
// Helper to create mock skill
function createMockSkill(overrides: Partial<{
id: string;
name: string;
updatedAt: Date;
}> = {}) {
return {
id: 'test-owner/test-repo/test-skill',
name: 'test-skill',
description: 'A test skill',
githubOwner: 'test-owner',
githubRepo: 'test-repo',
githubStars: 100,
downloadCount: 50,
securityScore: 85,
isVerified: false,
compatibility: { platforms: ['claude'] },
updatedAt: new Date(),
createdAt: new Date(),
...overrides,
};
}
// Mock db
vi.mock('@skillhub/db', () => ({
createDb: vi.fn(() => ({})),
skillQueries: {
getRecent: vi.fn(),
},
skills: {},
}));
import { GET } from './route';
import { skillQueries } from '@skillhub/db';
describe('GET /api/skills/recent', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should return recent skills', async () => {
const mockSkills = [
createMockSkill({ id: 'skill-1', updatedAt: new Date('2024-01-02') }),
createMockSkill({ id: 'skill-2', updatedAt: new Date('2024-01-01') }),
];
vi.mocked(skillQueries.getRecent).mockResolvedValue(mockSkills as any);
const request = new NextRequest('http://localhost:3000/api/skills/recent');
const response = await GET(request);
const data = await response.json();
expect(response.status).toBe(200);
expect(data.skills).toBeDefined();
expect(Array.isArray(data.skills)).toBe(true);
expect(data.skills.length).toBe(2);
});
it('should order by updatedAt descending', async () => {
const mockSkills = [
createMockSkill({ id: 'skill-1', updatedAt: new Date('2024-01-02') }),
createMockSkill({ id: 'skill-2', updatedAt: new Date('2024-01-01') }),
];
vi.mocked(skillQueries.getRecent).mockResolvedValue(mockSkills as any);
const request = new NextRequest('http://localhost:3000/api/skills/recent');
const response = await GET(request);
const data = await response.json();
expect(data.skills[0].updatedAt).toBeDefined();
});
it('should respect limit parameter', async () => {
vi.mocked(skillQueries.getRecent).mockResolvedValue([]);
const request = new NextRequest('http://localhost:3000/api/skills/recent?limit=5');
await GET(request);
expect(skillQueries.getRecent).toHaveBeenCalledWith(expect.anything(), 5);
});
it('should handle database errors gracefully', async () => {
vi.mocked(skillQueries.getRecent).mockRejectedValue(new Error('Database error'));
const request = new NextRequest('http://localhost:3000/api/skills/recent');
const response = await GET(request);
const data = await response.json();
expect(response.status).toBe(500);
expect(data.error).toBeDefined();
});
});

View File

@@ -0,0 +1,85 @@
import { NextResponse, type NextRequest } from 'next/server';
import { createDb, skillQueries, type skills } from '@skillhub/db';
import { getCached, setCache, cacheKeys, cacheTTL } from '@/lib/cache';
import { withRateLimit, createRateLimitResponse, createRateLimitHeaders } from '@/lib/rate-limit';
const db = createDb();
type Skill = typeof skills.$inferSelect;
interface SkillData {
id: string;
name: string;
description: string | null;
githubOwner: string;
githubRepo: string;
githubStars: number | null;
downloadCount: number | null;
securityStatus: string | null;
isVerified: boolean | null;
compatibility: unknown;
updatedAt: Date | null;
createdAt: Date | null;
}
interface RecentResponse {
skills: SkillData[];
}
export async function GET(request: NextRequest) {
// Rate limiting
const rateLimitResult = await withRateLimit(request, 'anonymous');
if (!rateLimitResult.allowed) {
return createRateLimitResponse(rateLimitResult);
}
try {
const searchParams = request.nextUrl.searchParams;
const limit = parseInt(searchParams.get('limit') || '10');
// Try to get from cache first (only for default limit)
const cacheKey = cacheKeys.recentSkills();
if (limit === 10) {
const cached = await getCached<RecentResponse>(cacheKey);
if (cached) {
return NextResponse.json(cached, {
headers: { 'X-Cache': 'HIT', ...createRateLimitHeaders(rateLimitResult) },
});
}
}
const recentSkills = await skillQueries.getRecent(db, limit);
const data: RecentResponse = {
skills: recentSkills.map((skill: Skill) => ({
id: skill.id,
name: skill.name,
description: skill.description,
githubOwner: skill.githubOwner,
githubRepo: skill.githubRepo,
githubStars: skill.githubStars,
downloadCount: skill.downloadCount,
securityStatus: skill.securityStatus,
isVerified: skill.isVerified,
compatibility: skill.compatibility,
updatedAt: skill.updatedAt,
createdAt: skill.createdAt,
})),
};
// Cache the result (1 hour)
if (limit === 10) {
await setCache(cacheKey, data, cacheTTL.recent);
}
return NextResponse.json(data, {
headers: { 'X-Cache': 'MISS', ...createRateLimitHeaders(rateLimitResult) },
});
} catch (error) {
console.error('Error fetching recent skills:', error);
return NextResponse.json(
{ error: 'Failed to fetch recent skills' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,210 @@
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import { auth } from '@/lib/auth';
import { createDb, skillQueries, removalRequestQueries, userQueries } from '@skillhub/db';
import { sanitizeReason } from '@/lib/sanitize';
import { sendClaimSubmittedEmail } from '@/lib/email';
export const dynamic = 'force-dynamic';
const db = createDb();
/**
* GET /api/skills/removal-request - Get user's removal requests
*/
export async function GET() {
try {
const session = await auth();
if (!session?.user?.githubId) {
return NextResponse.json(
{ error: 'Authentication required' },
{ status: 401 }
);
}
// Get user from database
const dbUser = await userQueries.getByGithubId(db, session.user.githubId);
if (!dbUser) {
return NextResponse.json({ requests: [] });
}
const requests = await removalRequestQueries.getByUser(db, dbUser.id);
return NextResponse.json({ requests });
} catch (error) {
console.error('Error fetching removal requests:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
/**
* POST /api/skills/removal-request - Submit a removal request
*/
export async function POST(request: NextRequest) {
try {
const session = await auth();
if (!session?.user?.githubId || !session.user.username) {
return NextResponse.json(
{ error: 'Authentication required', code: 'AUTH_REQUIRED' },
{ status: 401 }
);
}
const body = await request.json();
const { skillId, reason } = body;
if (!skillId) {
return NextResponse.json(
{ error: 'skillId is required', code: 'INVALID_INPUT' },
{ status: 400 }
);
}
// Check if skill exists
const skill = await skillQueries.getById(db, skillId);
if (!skill) {
return NextResponse.json(
{ error: 'Skill not found', code: 'SKILL_NOT_FOUND' },
{ status: 404 }
);
}
// Get or create user in database
let dbUser = await userQueries.getByGithubId(db, session.user.githubId);
// Auto-create user if not in database (first API request after OAuth)
if (!dbUser) {
dbUser = await userQueries.upsertFromGithub(db, {
githubId: session.user.githubId,
username: session.user.username,
displayName: session.user.name || undefined,
email: session.user.email || undefined,
avatarUrl: session.user.image || undefined,
});
}
if (!dbUser) {
return NextResponse.json(
{ error: 'Failed to create user record', code: 'USER_CREATE_FAILED' },
{ status: 500 }
);
}
// Check if user already has a pending request for this skill
const hasPending = await removalRequestQueries.hasPendingRequest(
db,
dbUser.id,
skillId
);
if (hasPending) {
return NextResponse.json(
{ error: 'You already have a pending request for this skill', code: 'ALREADY_PENDING' },
{ status: 409 }
);
}
// Verify ownership via GitHub API
const owner = skill.githubOwner;
const repo = skill.githubRepo;
const username = session.user.username;
// Check if skill has required GitHub info
if (!owner || !repo) {
console.error('Skill missing GitHub info:', { skillId, owner, repo });
return NextResponse.json(
{ error: 'Skill does not have valid GitHub repository information', code: 'INVALID_SKILL' },
{ status: 400 }
);
}
// Check repository ownership using public API (no token needed for public repos)
let isOwner = false;
let githubError: string | null = null;
try {
const repoResponse = await fetch(
`https://api.github.com/repos/${owner}/${repo}`,
{
headers: {
Accept: 'application/vnd.github.v3+json',
'User-Agent': 'SkillHub',
},
signal: AbortSignal.timeout(10000), // 10 second timeout
}
);
if (repoResponse.ok) {
const repoData = await repoResponse.json();
if (repoData.owner?.login?.toLowerCase() === username.toLowerCase()) {
isOwner = true;
}
} else if (repoResponse.status === 404) {
githubError = 'Repository not found on GitHub';
} else if (repoResponse.status === 403) {
githubError = 'GitHub API rate limit exceeded';
}
} catch (fetchError) {
console.error('GitHub API fetch error:', fetchError);
githubError = 'Failed to verify repository ownership';
}
if (githubError) {
return NextResponse.json(
{ error: githubError, code: 'GITHUB_ERROR' },
{ status: 502 }
);
}
if (!isOwner) {
return NextResponse.json(
{ error: 'You are not the owner of this repository', code: 'NOT_OWNER' },
{ status: 403 }
);
}
// Create the removal request (auto-approved since owner is verified)
const sanitizedReason = reason ? sanitizeReason(reason) : 'No reason provided';
const requestId = await removalRequestQueries.create(db, {
userId: dbUser.id,
skillId,
reason: sanitizedReason,
verifiedOwner: true,
});
// Auto-approve: Block the skill from being re-indexed
await skillQueries.block(db, skillId);
// Update the request status to approved
await removalRequestQueries.resolve(db, requestId, {
status: 'approved',
resolvedBy: dbUser.id,
resolutionNote: 'Auto-approved: Owner verified via GitHub API',
});
// Send confirmation email (non-blocking)
if (dbUser.email) {
const locale = (dbUser.preferredLocale === 'fa' ? 'fa' : 'en') as 'en' | 'fa';
sendClaimSubmittedEmail(dbUser.email, locale, 'remove', { skillId }).catch((err) => {
console.error('[Claim] Failed to send removal confirmation email:', err);
});
}
return NextResponse.json({
success: true,
requestId,
message: 'Skill has been blocked from indexing',
blocked: true,
});
} catch (error) {
console.error('Error creating removal request:', error);
return NextResponse.json(
{ error: 'Internal server error', code: 'SERVER_ERROR' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,252 @@
import { NextResponse, type NextRequest } from 'next/server';
import { createDb, skillQueries, type skills, isMeilisearchHealthy, searchSkills as meilisearchSearch } from '@skillhub/db';
import { withRateLimit, createRateLimitResponse, createRateLimitHeaders } from '@/lib/rate-limit';
import { getCached, setCache, hashSearchParams, cacheKeys } from '@/lib/cache';
import { captureException, log } from '@/lib/sentry';
// Create database connection
const db = createDb();
type Skill = typeof skills.$inferSelect;
/**
* Restore skill ID from Meilisearch format
* Converts sanitized IDs back to original format:
* "anthropics__skills__pdf" -> "anthropics/skills/pdf"
* "bdmorin___dot_claude__git" -> "bdmorin/.claude/git"
* "user__repo_dot_name__skill" -> "user/repo.name/skill"
*/
function restoreIdFromMeili(meiliId: string): string {
return meiliId
.replace(/_dot_/g, '.') // _dot_ -> dot (do this FIRST)
.replace(/__/g, '/'); // double underscore -> slash
}
export async function GET(request: NextRequest) {
// Apply rate limiting (search is more expensive, use lower limit)
const rateLimitResult = await withRateLimit(request, 'search');
if (!rateLimitResult.allowed) {
return createRateLimitResponse(rateLimitResult);
}
try {
const searchParams = request.nextUrl.searchParams;
const query = searchParams.get('q') || undefined;
const platform = searchParams.get('platform') || undefined;
const category = searchParams.get('category') || undefined;
const format = searchParams.get('format') || 'skill.md';
const verified = searchParams.get('verified') === 'true';
// Parse and validate numeric parameters
const minStarsRaw = parseInt(searchParams.get('minStars') || '0');
const minStars = isNaN(minStarsRaw) || minStarsRaw < 0 ? 0 : minStarsRaw;
const sort = searchParams.get('sort') || 'stars';
const pageRaw = parseInt(searchParams.get('page') || '1');
const page = isNaN(pageRaw) || pageRaw < 1 ? 1 : pageRaw;
const limitRaw = parseInt(searchParams.get('limit') || '20');
const limit = isNaN(limitRaw) || limitRaw < 1 ? 20 : Math.min(limitRaw, 100); // Max 100 per page
const offset = (page - 1) * limit;
// Create cache key from search parameters
const searchHash = hashSearchParams({
q: query,
category,
platform,
format,
verified: verified ? 'true' : undefined,
sort,
page,
limit,
minStars: minStars > 0 ? minStars : undefined,
});
const cacheKey = cacheKeys.searchSkills(searchHash);
// Check cache first
const cached = await getCached<{
skills: Skill[];
total: number;
searchEngine: string;
}>(cacheKey);
if (cached) {
return NextResponse.json(
{
skills: cached.skills,
pagination: {
page,
limit,
total: cached.total,
totalPages: Math.ceil(cached.total / limit),
},
searchEngine: 'cache',
cachedFrom: cached.searchEngine,
},
{
headers: createRateLimitHeaders(rateLimitResult),
}
);
}
// Try Meilisearch for text search queries
if (query) {
const useMeilisearch = await isMeilisearchHealthy();
if (useMeilisearch) {
// Use Meilisearch for full-text search with relevance ranking
// Note: category filter not supported in Meilisearch, handled in PostgreSQL fallback
const meiliResult = await meilisearchSearch({
query,
filters: {
platforms: platform && platform !== 'all' ? [platform] : undefined,
minStars: minStars > 0 ? minStars : undefined,
verified: verified ? true : undefined,
},
sort: sort as 'stars' | 'downloads' | 'rating' | 'recent',
limit,
offset,
});
if (meiliResult) {
const skills = meiliResult.hits.map((hit) => ({
id: restoreIdFromMeili(hit.id),
name: hit.name,
description: hit.description,
githubOwner: hit.githubOwner,
githubRepo: hit.githubRepo,
githubStars: hit.githubStars,
downloadCount: hit.downloadCount,
securityScore: hit.securityScore,
securityStatus: null, // Not available in Meilisearch yet
rating: hit.rating,
ratingCount: null, // Not available in Meilisearch yet
isVerified: hit.isVerified,
compatibility: { platforms: hit.platforms },
}));
// Cache the result (5 minutes TTL)
await setCache(
cacheKey,
{
skills,
total: meiliResult.estimatedTotalHits,
searchEngine: 'meilisearch',
},
5 * 60
);
return NextResponse.json({
skills,
pagination: {
page,
limit,
total: meiliResult.estimatedTotalHits,
totalPages: Math.ceil(meiliResult.estimatedTotalHits / limit),
},
searchEngine: 'meilisearch',
processingTimeMs: meiliResult.processingTimeMs,
}, {
headers: createRateLimitHeaders(rateLimitResult),
});
}
// If Meilisearch search failed, fall through to PostgreSQL
}
}
// Fall back to PostgreSQL search
// Map sort parameter to database column
const sortByMap: Record<string, 'stars' | 'downloads' | 'rating' | 'updated' | 'lastDownloaded'> = {
stars: 'stars',
downloads: 'downloads',
rating: 'rating',
recent: 'updated',
lastDownloaded: 'lastDownloaded',
security: 'stars', // Use stars as fallback
};
const sortBy = sortByMap[sort] || 'downloads';
// Build filter options for database query
const filterOptions = {
query,
category: category || undefined,
platform: platform && platform !== 'all' ? platform : undefined,
sourceFormat: format,
minStars,
verified: verified || undefined,
};
// Get paginated results directly from database (no in-memory filtering)
const paginatedResults = await skillQueries.search(db, {
...filterOptions,
limit,
offset,
sortBy,
sortOrder: 'desc',
});
// Get total count for pagination
const total = await skillQueries.count(db, filterOptions);
const skills = paginatedResults.map((skill: Skill) => ({
id: skill.id,
name: skill.name,
description: skill.description,
githubOwner: skill.githubOwner,
githubRepo: skill.githubRepo,
skillPath: skill.skillPath,
version: skill.version,
license: skill.license,
githubStars: skill.githubStars,
downloadCount: skill.downloadCount,
securityScore: skill.securityScore,
securityStatus: skill.securityStatus,
rating: skill.rating,
ratingCount: skill.ratingCount,
isVerified: skill.isVerified,
compatibility: skill.compatibility,
updatedAt: skill.updatedAt,
}));
// Cache the result (5 minutes TTL)
await setCache(
cacheKey,
{
skills,
total,
searchEngine: 'postgresql',
},
5 * 60
);
return NextResponse.json({
skills,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
},
searchEngine: 'postgresql',
}, {
headers: createRateLimitHeaders(rateLimitResult),
});
} catch (error) {
// Log and report error to Sentry
log.error('Error fetching skills', {
error: error instanceof Error ? error.message : String(error),
});
captureException(error, {
tags: { route: '/api/skills' },
extra: { searchParams: Object.fromEntries(request.nextUrl.searchParams) },
});
return NextResponse.json(
{ error: 'Failed to fetch skills' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,78 @@
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import { auth } from '@/lib/auth';
export const dynamic = 'force-dynamic';
/**
* Verify if the current user is the owner of a GitHub repository
* GET /api/skills/verify-ownership?owner=...&repo=...
*/
export async function GET(request: NextRequest) {
try {
const session = await auth();
if (!session?.user?.username) {
return NextResponse.json(
{ error: 'Authentication required', isOwner: false },
{ status: 401 }
);
}
const { searchParams } = new URL(request.url);
const owner = searchParams.get('owner');
const repo = searchParams.get('repo');
if (!owner || !repo) {
return NextResponse.json(
{ error: 'owner and repo parameters are required', isOwner: false },
{ status: 400 }
);
}
// Get the GitHub username from session
const username = session.user.username;
// Check if user is the repo owner using public API
const repoResponse = await fetch(
`https://api.github.com/repos/${owner}/${repo}`,
{
headers: {
Accept: 'application/vnd.github.v3+json',
'User-Agent': 'SkillHub',
},
}
);
if (!repoResponse.ok) {
if (repoResponse.status === 404) {
return NextResponse.json(
{ error: 'Repository not found', isOwner: false },
{ status: 404 }
);
}
return NextResponse.json(
{ error: 'Failed to verify repository', isOwner: false },
{ status: 500 }
);
}
const repoData = await repoResponse.json();
// Check if the user is the owner
const isOwner = repoData.owner?.login?.toLowerCase() === username.toLowerCase();
return NextResponse.json({
isOwner,
permission: isOwner ? 'owner' : 'none',
username,
repoOwner: repoData.owner?.login,
});
} catch (error) {
console.error('Error verifying ownership:', error);
return NextResponse.json(
{ error: 'Internal server error', isOwner: false },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,91 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { NextRequest } from 'next/server';
// Mock rate limiting - must be before route import
vi.mock('@/lib/rate-limit', () => ({
withRateLimit: vi.fn().mockResolvedValue({ allowed: true, remaining: 100, limit: 120, resetAt: Date.now() + 60000 }),
createRateLimitResponse: vi.fn(),
createRateLimitHeaders: vi.fn().mockReturnValue({}),
}));
// Mock cache - must be before route import
vi.mock('@/lib/cache', () => ({
getCached: vi.fn().mockResolvedValue(null),
setCache: vi.fn().mockResolvedValue(undefined),
cacheKeys: { stats: () => 'stats' },
cacheTTL: { stats: 3600 },
}));
// Mock the db module - must be before imports that use it
vi.mock('@skillhub/db', () => {
return {
createDb: vi.fn(() => ({
select: vi.fn().mockReturnValue({
from: vi.fn().mockResolvedValue([
{ totalSkills: 100, totalDownloads: 5000, totalContributors: 50 },
]),
}),
})),
skills: { downloadCount: 'download_count', githubOwner: 'github_owner' },
categories: {},
sql: vi.fn(() => 'mock-sql'),
};
});
import { GET } from './route';
// Helper to create mock request
function createMockRequest(url = 'http://localhost:3000/api/stats') {
return new NextRequest(url);
}
describe('GET /api/stats', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should return totalSkills count', async () => {
const request = createMockRequest();
const response = await GET(request);
const data = await response.json();
expect(response.status).toBe(200);
expect(data.totalSkills).toBeDefined();
expect(typeof data.totalSkills).toBe('number');
});
it('should return totalDownloads sum', async () => {
const request = createMockRequest();
const response = await GET(request);
const data = await response.json();
expect(data.totalDownloads).toBeDefined();
expect(typeof data.totalDownloads).toBe('number');
});
it('should return totalCategories count', async () => {
const request = createMockRequest();
const response = await GET(request);
const data = await response.json();
expect(data.totalCategories).toBeDefined();
expect(typeof data.totalCategories).toBe('number');
});
it('should return totalContributors count', async () => {
const request = createMockRequest();
const response = await GET(request);
const data = await response.json();
expect(data.totalContributors).toBeDefined();
expect(typeof data.totalContributors).toBe('number');
});
it('should return platforms count', async () => {
const request = createMockRequest();
const response = await GET(request);
const data = await response.json();
expect(data.platforms).toBe(5);
});
});

View File

@@ -0,0 +1,79 @@
import { type NextRequest, NextResponse } from 'next/server';
import { createDb, skills, categories, sql } from '@skillhub/db';
import { getCached, setCache, cacheKeys, cacheTTL } from '@/lib/cache';
import { withRateLimit, createRateLimitResponse, createRateLimitHeaders } from '@/lib/rate-limit';
const db = createDb();
interface StatsData {
totalSkills: number;
totalDownloads: number;
totalCategories: number;
totalContributors: number;
platforms: number;
}
export async function GET(request: NextRequest) {
// Rate limiting
const rateLimitResult = await withRateLimit(request, 'anonymous');
if (!rateLimitResult.allowed) {
return createRateLimitResponse(rateLimitResult);
}
try {
// Try to get from cache first
const cacheKey = cacheKeys.stats();
const cached = await getCached<StatsData>(cacheKey);
if (cached) {
return NextResponse.json(cached, {
headers: {
'X-Cache': 'HIT',
'Cache-Control': 'public, max-age=3600, stale-while-revalidate=7200',
...createRateLimitHeaders(rateLimitResult),
},
});
}
// Consolidate all skill stats into a single query for better performance
const statsResult = await db
.select({
totalSkills: sql<number>`count(*)::int`,
totalDownloads: sql<number>`coalesce(sum(${skills.downloadCount}), 0)::int`,
totalContributors: sql<number>`count(distinct ${skills.githubOwner})::int`,
})
.from(skills);
// Get category count in separate query (different table)
const categoryResult = await db
.select({ count: sql<number>`count(*)::int` })
.from(categories);
const stats = statsResult[0];
const totalCategories = categoryResult[0]?.count ?? 0;
const data: StatsData = {
totalSkills: stats?.totalSkills ?? 0,
totalDownloads: stats?.totalDownloads ?? 0,
totalCategories,
totalContributors: stats?.totalContributors ?? 0,
platforms: 5, // Claude, Codex, Copilot, Cursor, Windsurf
};
// Cache the result
await setCache(cacheKey, data, cacheTTL.stats);
return NextResponse.json(data, {
headers: {
'X-Cache': 'MISS',
'Cache-Control': 'public, max-age=3600, stale-while-revalidate=7200',
...createRateLimitHeaders(rateLimitResult),
},
});
} catch (error) {
console.error('Error fetching stats:', error);
return NextResponse.json(
{ error: 'Failed to fetch stats' },
{ status: 500 }
);
}
}

399
apps/web/app/globals.css Normal file
View File

@@ -0,0 +1,399 @@
/* Font import must be at the very top */
@import url('https://cdn.jsdelivr.net/gh/rastikerdar/vazirmatn@v33.003/Vazirmatn-font-face.css');
@tailwind base;
@tailwind components;
@tailwind utilities;
/* ============================================
CSS Variables - Design Tokens
============================================ */
:root {
/* Primary Colors - Sky Blue */
--color-primary-50: #f0f9ff;
--color-primary-100: #e0f2fe;
--color-primary-200: #bae6fd;
--color-primary-300: #7dd3fc;
--color-primary-400: #38bdf8;
--color-primary-500: #66b3e6;
--color-primary-600: #0284c7;
--color-primary-700: #0369a1;
--color-primary-800: #075985;
--color-primary-900: #0c4a6e;
/* Accent - Gold */
--color-accent-gold: #f7c150;
--color-accent-gold-light: #fbd87a;
--color-accent-gold-dark: #d4a43d;
/* Gradients */
--gradient-primary: linear-gradient(135deg, #66b3e6 0%, #3b82f6 100%);
--gradient-hero: linear-gradient(180deg, rgba(102, 179, 230, 0.08) 0%, transparent 60%);
--gradient-gold: linear-gradient(135deg, #f7c150 0%, #f59e0b 100%);
/* Text Colors */
--color-text-primary: #0f172a;
--color-text-secondary: #475569;
--color-text-muted: #94a3b8;
/* Surface Colors */
--color-surface: #ffffff;
--color-surface-elevated: #ffffff;
--color-surface-muted: #f8fafc;
--color-surface-subtle: #f1f5f9;
/* Borders */
--color-border: #e2e8f0;
--color-border-light: #f1f5f9;
/* States */
--color-success: #22c55e;
--color-success-bg: #f0fdf4;
--color-warning: #f59e0b;
--color-warning-bg: #fffbeb;
--color-error: #ef4444;
--color-error-bg: #fef2f2;
/* Shadows */
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
/* Animation */
--duration-fast: 150ms;
--duration-normal: 200ms;
--duration-slow: 300ms;
--ease-out: cubic-bezier(0, 0, 0.2, 1);
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
/* Spacing */
--space-xs: 0.25rem;
--space-sm: 0.5rem;
--space-md: 1rem;
--space-lg: 1.5rem;
--space-xl: 2rem;
--space-2xl: 3rem;
--space-3xl: 4rem;
/* Border Radius */
--radius-sm: 0.375rem;
--radius-md: 0.5rem;
--radius-lg: 0.75rem;
--radius-xl: 1rem;
--radius-2xl: 1.5rem;
--radius-full: 9999px;
}
/* ============================================
Dark Mode Colors
============================================ */
.dark {
/* Primary Colors - Adjusted for dark mode */
--color-primary-50: #0c3d5a;
--color-primary-100: #0f4c6e;
--color-primary-200: #125e87;
--color-primary-300: #1c79a8;
--color-primary-400: #3b9ac9;
--color-primary-500: #66b3e6;
--color-primary-600: #8cc7ed;
--color-primary-700: #b3dbf5;
--color-primary-800: #d9effa;
--color-primary-900: #ecf7fd;
/* Accent - Gold */
--color-accent-gold: #f7c150;
--color-accent-gold-light: #fbd87a;
--color-accent-gold-dark: #c48f30;
/* Gradients */
--gradient-primary: linear-gradient(135deg, #66b3e6 0%, #3b82f6 100%);
--gradient-hero: linear-gradient(180deg, rgba(102, 179, 230, 0.15) 0%, transparent 60%);
/* Text Colors */
--color-text-primary: #f1f5f9;
--color-text-secondary: #94a3b8;
--color-text-muted: #64748b;
/* Surface Colors */
--color-surface: #0f172a;
--color-surface-elevated: #1e293b;
--color-surface-muted: #1e293b;
--color-surface-subtle: #334155;
/* Borders */
--color-border: #334155;
--color-border-light: #1e293b;
/* States */
--color-success: #4ade80;
--color-success-bg: #14532d;
--color-warning: #fbbf24;
--color-warning-bg: #78350f;
--color-error: #f87171;
--color-error-bg: #7f1d1d;
/* Shadows - Softer for dark mode */
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4), 0 2px 4px -1px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.4), 0 4px 6px -2px rgba(0, 0, 0, 0.3);
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.4), 0 10px 10px -5px rgba(0, 0, 0, 0.3);
}
/* ============================================
Font Faces - Vazirmatn (Open Source Persian Font)
https://github.com/rastikerdar/vazirmatn
Note: @import is at the top of this file
============================================ */
/* ============================================
Base Styles
============================================ */
@layer base {
html {
@apply antialiased;
scroll-behavior: smooth;
}
/* RTL Typography - Persian optimized with Vazirmatn */
html[dir="rtl"] {
font-family: 'Vazirmatn', system-ui, sans-serif;
font-size: 16px;
line-height: 1.8;
}
/* LTR Typography - English */
html[dir="ltr"] {
font-family: system-ui, 'Vazirmatn', sans-serif;
font-size: 16px;
line-height: 1.6;
}
body {
@apply bg-surface text-text-primary;
}
/* Persian headings - slightly tighter line-height */
html[dir="rtl"] h1,
html[dir="rtl"] h2,
html[dir="rtl"] h3 {
line-height: 1.4;
}
/* Avoid bold in Persian - use medium weight instead */
html[dir="rtl"] strong,
html[dir="rtl"] b {
font-weight: 500;
}
/* Focus visible for accessibility */
:focus-visible {
outline: 2px solid var(--color-primary-500);
outline-offset: 2px;
}
:focus:not(:focus-visible) {
outline: none;
}
}
/* ============================================
Component Styles
============================================ */
@layer components {
/* Container */
.container-main {
@apply container mx-auto px-4;
max-width: 1200px;
}
/* Primary Button */
.btn-primary {
@apply inline-flex items-center justify-center;
@apply px-6 py-3 rounded-xl;
@apply font-medium text-white;
@apply transition-all duration-200;
background: var(--gradient-primary);
box-shadow: 0 4px 15px rgba(102, 179, 230, 0.4);
min-height: 44px;
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(102, 179, 230, 0.5);
}
.btn-primary:active {
transform: translateY(0);
}
.btn-primary:disabled {
@apply opacity-50 cursor-not-allowed;
transform: none;
}
/* Secondary Button */
.btn-secondary {
@apply inline-flex items-center justify-center;
@apply px-6 py-3 rounded-xl;
@apply font-medium;
@apply border-2 border-border;
@apply bg-surface text-text-primary;
@apply transition-all duration-200;
min-height: 44px;
}
.btn-secondary:hover {
@apply border-primary-500 text-primary-600 bg-primary-50;
}
/* Ghost Button */
.btn-ghost {
@apply inline-flex items-center justify-center;
@apply px-6 py-3 rounded-xl;
@apply font-medium text-text-secondary;
@apply transition-all duration-200;
@apply bg-transparent;
min-height: 44px;
}
.btn-ghost:hover {
@apply bg-surface-subtle text-text-primary;
}
/* Card */
.card {
@apply bg-surface-elevated rounded-2xl;
@apply transition-all duration-300;
box-shadow: var(--shadow-md);
}
.card:hover {
transform: translateY(-4px);
box-shadow: var(--shadow-xl);
}
/* Glass Card */
.glass-card {
background: rgba(255, 255, 255, 0.8);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: var(--radius-2xl);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
}
.dark .glass-card {
background: rgba(30, 41, 59, 0.8);
border: 1px solid rgba(51, 65, 85, 0.3);
}
/* Input Field */
.input-field {
@apply w-full px-4 py-3 rounded-xl;
@apply border-2 border-border;
@apply bg-surface text-text-primary;
@apply transition-all duration-200;
@apply placeholder-text-muted;
min-height: 44px;
}
.input-field:focus {
@apply border-primary-500;
@apply outline-none;
box-shadow: 0 0 0 3px rgba(102, 179, 230, 0.15);
}
/* Hero Typography */
.hero-title {
font-size: clamp(2.5rem, 8vw, 4rem);
font-weight: 700;
letter-spacing: -0.02em;
line-height: 1.1;
color: var(--color-text-primary);
}
.hero-tagline {
font-size: clamp(1rem, 2.5vw, 1.25rem);
color: var(--color-primary-500);
font-weight: 500;
}
.hero-subtitle {
font-size: clamp(1.125rem, 3vw, 1.25rem);
color: var(--color-text-secondary);
line-height: 1.6;
}
/* Section */
.section {
@apply py-16 lg:py-24;
}
/* Section following a header - no top padding to avoid double spacing */
.section-header + .section,
.section.no-top-padding {
@apply pt-0;
}
/* Section header - reduced padding for hero/header sections */
.section-header {
@apply py-12 lg:py-16;
}
.section-title {
@apply text-3xl lg:text-4xl font-bold text-center mb-4;
color: var(--color-text-primary);
}
.section-subtitle {
@apply text-lg text-center max-w-2xl mx-auto;
color: var(--color-text-secondary);
}
}
/* ============================================
Utility Classes
============================================ */
@layer utilities {
/* Keep numbers LTR in RTL context */
.ltr-nums {
direction: ltr;
unicode-bidi: embed;
}
/* Gradient text */
.text-gradient {
background: var(--gradient-primary);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* Subtle gradient background */
.bg-gradient-subtle {
background: var(--gradient-hero);
}
/* Animation delay utilities */
.animation-delay-100 { animation-delay: 0.1s; }
.animation-delay-200 { animation-delay: 0.2s; }
.animation-delay-300 { animation-delay: 0.3s; }
.animation-delay-400 { animation-delay: 0.4s; }
.animation-delay-500 { animation-delay: 0.5s; }
/* Line clamp */
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.line-clamp-3 {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
}

View File

@@ -0,0 +1,21 @@
'use client';
import { SessionProvider } from 'next-auth/react';
import { ThemeProvider } from 'next-themes';
import { CsrfProvider } from '@/components/CsrfProvider';
interface ProvidersProps {
children: React.ReactNode;
}
export function Providers({ children }: ProvidersProps) {
return (
<SessionProvider>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<CsrfProvider>
{children}
</CsrfProvider>
</ThemeProvider>
</SessionProvider>
);
}

37
apps/web/app/robots.ts Normal file
View File

@@ -0,0 +1,37 @@
import type { MetadataRoute } from 'next';
/**
* Dynamic robots.txt for mirror server support
*
* Primary server (IS_PRIMARY_SERVER=true): Allow all crawlers
* Mirror server (IS_PRIMARY_SERVER=false): Block all crawlers to prevent duplicate content
*/
export default function robots(): MetadataRoute.Robots {
const isPrimary = process.env.IS_PRIMARY_SERVER !== 'false';
const primaryDomain = process.env.NEXT_PUBLIC_APP_URL || 'https://skills.palebluedot.live';
if (isPrimary) {
return {
rules: [
{
userAgent: '*',
allow: '/',
disallow: ['/api/', '/monitoring/'],
},
],
sitemap: `${primaryDomain}/sitemap.xml`,
};
}
// Mirror server: Block all crawlers to prevent duplicate content indexing
return {
rules: [
{
userAgent: '*',
disallow: '/',
},
],
// Point to primary sitemap even on mirror
sitemap: `${primaryDomain}/sitemap.xml`,
};
}