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, Sparkles } 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, skillReviewQueries } from '@skillhub/db'; import { FORMAT_LABELS, parseReviewNotes } from 'skillhub-core'; import { formatCompactNumber } from '@/lib/format-number'; import { shouldCountView, getOrSetCache, cacheKeys, cacheTTL } from '@/lib/cache'; import type { Metadata } from 'next'; import { getPageAlternates } from '@/lib/seo'; // Force dynamic rendering to fetch fresh data from database export const dynamic = 'force-dynamic'; export async function generateMetadata({ params, }: { params: Promise<{ locale: string; id: string[] }>; }): Promise { const { locale, id } = await params; const skillId = id.join('/'); // Optionally fetch skill name for title const dbSkill = await getSkill(skillId); const title = dbSkill ? `${dbSkill.name} | SkillHub` : 'SkillHub'; const description = dbSkill ? dbSkill.description : undefined; return { title, description, alternates: getPageAlternates(locale, `/skill/${skillId}`), }; } interface SkillPageProps { params: Promise<{ locale: string; id: string[] }>; } // Get skill with Redis caching (1 hour TTL) async function getSkill(skillId: string) { try { return await getOrSetCache(cacheKeys.skillDetail(skillId), cacheTTL.skill, async () => { const db = createDb(); return await skillQueries.getById(db, skillId); }); } catch (error) { console.error('Error fetching skill:', error); return null; } } // Get skill review with Redis caching (1 hour TTL) async function getSkillReview(skillId: string) { try { return await getOrSetCache(cacheKeys.skillReview(skillId), cacheTTL.skill, async () => { const db = createDb(); return await skillReviewQueries.getLatestBySkillId(db, skillId); }); } catch { return null; } } // Score bar component for the review section function ScoreBar({ label, score }: { label: string; score: number | null | undefined }) { if (score === null || score === undefined) return null; const percentage = Math.min(score, 100); const color = score >= 75 ? 'bg-success' : score >= 50 ? 'bg-gold' : 'bg-text-muted'; return (
{label} {score}
); } 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 and review data from database (in parallel) const [dbSkill, review] = await Promise.all([ getSkill(skillId), getSkillReview(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 ? new Date(dbSkill.updatedAt).toLocaleDateString(locale === 'fa' ? 'fa-IR' : 'en-US') : 'N/A', rating: dbSkill.rating || 0, ratingCount: dbSkill.ratingCount || 0, sourceFormat: dbSkill.sourceFormat || 'skill.md', }; // Parse review notes for structured display const parsedNotes = review?.reviewNotes ? parseReviewNotes(review.reviewNotes) : null; const hasReview = review && review.aiScore && (dbSkill.reviewStatus === 'ai-reviewed' || dbSkill.reviewStatus === 'verified'); // 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 = { '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 (
{/* Hero Section */}
{/* Breadcrumb */} {/* Main Header */}
{/* Left: Title & Description */}

{skill.name}

{skill.isVerified && ( {tCommon('verified')} )} {securityConfig.label} {sourceFormatBadge && ( {sourceFormatBadge.platform} )}

{skill.description}

{/* Author, Version, License & Last Update */}
@{skill.author}
{skill.version && ( v{skill.version} )} {skill.license} {skill.updatedAt}
{/* Right: Actions + AI Score Summary */}
{skill.homepage && ( )}
{/* Compact AI Review Score */} {hasReview && (
= 75 ? 'text-success' : 'text-gold'}`} /> = 75 ? 'text-success' : review.aiScore! >= 50 ? 'text-gold' : 'text-text-primary'}`}> {review.aiScore} {t('review.outOf100')}
)}
{/* Project Configuration Warning Banner (non-SKILL.md) */} {sourceFormatBadge && (

{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.`}

{isRTL ? 'مرور مهارت‌های قابل استفاده مجدد' : 'Browse reusable skills'}
)} {/* Stale Skill Warning Banner */} {dbSkill.isStale && (

{isRTL ? 'این مهارت ممکن است از مخزن GitHub اصلی حذف یا جابجا شده باشد. فایل‌ها از حافظه پنهان SkillHub ارائه می‌شوند و ممکن است قدیمی باشند.' : 'This skill may have been removed or moved from its GitHub repository. Files are served from the SkillHub cache and may be outdated.'}

{isRTL ? 'بررسی مخزن GitHub' : 'Check GitHub repository'}
)} {/* Stats Bar */}
{formatCompactNumber(skill.stars, locale)} {tCommon('stars')}
{formatCompactNumber(skill.downloads, locale)} {tCommon('downloads')}
{formatCompactNumber(skill.views, locale)} {tCommon('views')}
{/* Main Content */}
{/* Left: README Content */}
{/* Quick Install (Mobile) */}
{/* AI Review Card (Mobile) */} {hasReview && (

{t('review.title')}

{parsedNotes?.rationale && (

{parsedNotes.rationale}

)}
)} {/* README Section */}

{getContentTitle(skill.sourceFormat)}

                      {skill.longDescription}
                    
{/* Right: Sidebar (Desktop) */}
{/* AI Review Card (Desktop) — above Install for visibility */} {hasReview && (

{t('review.title')}

{/* Overall Score */}
= 75 ? 'text-success' : review.aiScore! >= 50 ? 'text-gold' : 'text-text-primary'}`}> {review.aiScore}
{t('review.outOf100')}
{/* 4-axis score bars */}
{/* Rationale */} {parsedNotes?.rationale && (

{parsedNotes.rationale}

)} {/* Tags: Audience, Maturity, Complexity, Use Cases */} {(parsedNotes?.maturity || parsedNotes?.complexity || (parsedNotes?.audience && parsedNotes.audience.length > 0) || (parsedNotes?.useCases && parsedNotes.useCases.length > 0)) && (
{parsedNotes?.maturity && ( {parsedNotes.maturity} )} {parsedNotes?.complexity && ( {parsedNotes.complexity} )} {parsedNotes?.audience?.map((a: string) => ( {a.trim()} ))} {parsedNotes?.useCases?.map((uc: string) => ( {uc.trim()} ))}
)} {/* Reviewer info */}
{t('review.reviewedBy', { reviewer: review.reviewer || 'AI' })} {review.reviewedAt && ( <> {t('review.reviewedOn', { date: new Date(review.reviewedAt).toLocaleDateString(locale === 'fa' ? 'fa-IR' : 'en-US') })} )}
)} {/* Install Section */}
); }