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:
69
apps/web/app/[locale]/about/page.tsx
Normal file
69
apps/web/app/[locale]/about/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
311
apps/web/app/[locale]/attribution/page.tsx
Normal file
311
apps/web/app/[locale]/attribution/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
286
apps/web/app/[locale]/browse/page.tsx
Normal file
286
apps/web/app/[locale]/browse/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
176
apps/web/app/[locale]/categories/page.tsx
Normal file
176
apps/web/app/[locale]/categories/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
190
apps/web/app/[locale]/claim/claim.test.ts
Normal file
190
apps/web/app/[locale]/claim/claim.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
115
apps/web/app/[locale]/claim/page.tsx
Normal file
115
apps/web/app/[locale]/claim/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
285
apps/web/app/[locale]/claude-plugin/page.tsx
Normal file
285
apps/web/app/[locale]/claude-plugin/page.tsx
Normal 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">"pdf"</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>
|
||||
);
|
||||
}
|
||||
10
apps/web/app/[locale]/contact/page.tsx
Normal file
10
apps/web/app/[locale]/contact/page.tsx
Normal 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`);
|
||||
}
|
||||
556
apps/web/app/[locale]/docs/api/page.tsx
Normal file
556
apps/web/app/[locale]/docs/api/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
152
apps/web/app/[locale]/docs/cli/page.tsx
Normal file
152
apps/web/app/[locale]/docs/cli/page.tsx
Normal 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 "code review" --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>
|
||||
);
|
||||
}
|
||||
206
apps/web/app/[locale]/docs/getting-started/page.tsx
Normal file
206
apps/web/app/[locale]/docs/getting-started/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
71
apps/web/app/[locale]/docs/page.tsx
Normal file
71
apps/web/app/[locale]/docs/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
76
apps/web/app/[locale]/favorites/page.tsx
Normal file
76
apps/web/app/[locale]/favorites/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
112
apps/web/app/[locale]/featured/page.tsx
Normal file
112
apps/web/app/[locale]/featured/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
79
apps/web/app/[locale]/layout.tsx
Normal file
79
apps/web/app/[locale]/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
193
apps/web/app/[locale]/new/page.tsx
Normal file
193
apps/web/app/[locale]/new/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
307
apps/web/app/[locale]/owner/[username]/page.tsx
Normal file
307
apps/web/app/[locale]/owner/[username]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
274
apps/web/app/[locale]/page.tsx
Normal file
274
apps/web/app/[locale]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
90
apps/web/app/[locale]/privacy/page.tsx
Normal file
90
apps/web/app/[locale]/privacy/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
518
apps/web/app/[locale]/skill/[...id]/page.tsx
Normal file
518
apps/web/app/[locale]/skill/[...id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
117
apps/web/app/[locale]/support/page.tsx
Normal file
117
apps/web/app/[locale]/support/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
90
apps/web/app/[locale]/terms/page.tsx
Normal file
90
apps/web/app/[locale]/terms/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user