sync: malware page, security alerts, review diagnostics, skill detail improvements

- Add malware security advisory page and SecurityAlertBanner component
- Add review diagnostics API and reviewed stats page
- Improve skill detail page with better scoring display and metadata
- Update review API endpoints with enhanced filtering and stats
- Add skill file serving improvements and cache enhancements
- Remove legacy curation scripts (moved to internal tooling)
- Update CLI search with score filtering support

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-31 16:08:12 +02:00
parent 3e22d2e238
commit 16ee9e3beb
33 changed files with 1231 additions and 3404 deletions

View File

@@ -120,7 +120,7 @@ export default async function CliDocsPage({
<div>
<p className="text-sm text-text-muted mb-1">{tContent('exSearchSort')}</p>
<code className="block text-sm font-mono">
<span className="text-text-muted">$</span> npx skillhub search &quot;code review&quot; --sort stars --limit 5
<span className="text-text-muted">$</span> npx skillhub search &quot;code review&quot; --sort aiScore --limit 5
</code>
<code className="block text-sm font-mono text-text-muted mt-1">
# Sort options: recommended (default), aiScore, downloads, stars, rating, recent

View File

@@ -5,6 +5,7 @@ import { notFound } from 'next/navigation';
import { locales, localeDirection, type Locale } from '@/i18n';
import { Providers } from '../providers';
import { Suspense } from 'react';
import { SecurityAlertBanner } from '@/components/SecurityAlertBanner';
import { QueryNotification } from '@/components/QueryNotification';
import { ProgressBar } from '@/components/ProgressBar';
import '../globals.css';
@@ -68,6 +69,7 @@ export default async function LocaleLayout({
<body className="min-h-screen bg-surface">
<Providers>
<NextIntlClientProvider messages={messages}>
<SecurityAlertBanner />
<Suspense fallback={null}>
<QueryNotification />
</Suspense>

View File

@@ -0,0 +1,149 @@
import { getTranslations, setRequestLocale } from 'next-intl/server';
import Link from 'next/link';
import { Header } from '@/components/Header';
import { Footer } from '@/components/Footer';
import { createDb, skillReviewQueries } from '@skillhub/db';
import { getPageAlternates } from '@/lib/seo';
import { getOrSetCache, cacheKeys, cacheTTL } from '@/lib/cache';
import { ShieldAlert, Ban, ExternalLink } from 'lucide-react';
export const dynamic = 'force-dynamic';
async function getMalwareData(page: number, limit: number) {
try {
return await getOrSetCache(cacheKeys.malwarePage(page), cacheTTL.malware, async () => {
const db = createDb();
const offset = (page - 1) * limit;
const [skills, total] = await Promise.all([
skillReviewQueries.getMaliciousSkills(db, limit, offset),
skillReviewQueries.countMaliciousSkills(db),
]);
return { skills, total };
});
} catch (error) {
console.error('Error fetching malware skills:', error);
return { skills: [], total: 0 };
}
}
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
return {
alternates: getPageAlternates(locale, '/malware'),
};
}
export default async function MalwarePage({
params,
searchParams,
}: {
params: Promise<{ locale: string }>;
searchParams: Promise<{ page?: string }>;
}) {
const { locale } = await params;
const searchParamsResolved = await searchParams;
setRequestLocale(locale);
const t = await getTranslations('malwarePage');
const tMalicious = await getTranslations('malicious');
const limit = 20;
const page = parseInt(searchParamsResolved.page || '1');
const { skills: malwareSkills } = await getMalwareData(page, limit);
return (
<div className="min-h-screen flex flex-col">
<Header />
<main className="flex-1">
{/* Header */}
<section className="section-header bg-gradient-subtle">
<div className="container-main text-center">
<div className="inline-flex items-center justify-center w-14 h-14 rounded-2xl bg-error/10 text-error mb-4">
<ShieldAlert className="w-7 h-7" />
</div>
<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 max-w-4xl">
{malwareSkills.length === 0 ? (
<p className="text-center text-text-muted py-12">{t('noResults')}</p>
) : (
<div className="space-y-4">
{malwareSkills.map((skill) => (
<div
key={skill.id}
className="card p-5 border-error/20 bg-error/[0.02]"
>
<div className="flex flex-col sm:flex-row sm:items-start gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2 flex-wrap">
<Link
href={`/${locale}/skill/${skill.id}`}
className="text-lg font-semibold text-text-primary hover:text-primary-600 transition-colors truncate"
>
{skill.name || skill.id}
</Link>
<span className="flex items-center gap-1 px-1.5 py-0.5 text-xs font-bold rounded border text-error bg-error/10 border-error/20 shrink-0">
<ShieldAlert className="w-3 h-3" />
{tMalicious('badge')}
</span>
</div>
<p className="text-sm text-text-muted mb-2 font-mono">
{skill.githubOwner}/{skill.githubRepo}
</p>
{skill.description && (
<p className="text-sm text-text-secondary line-clamp-2 mb-3">
{skill.description}
</p>
)}
<div className="flex items-center gap-4 text-xs text-text-muted flex-wrap">
<span className="flex items-center gap-1">
<Ban className="w-3.5 h-3.5 text-error" />
{t('downloadBlocked')}
</span>
<span className="flex items-center gap-1">
<Ban className="w-3.5 h-3.5 text-error" />
{t('installBlocked')}
</span>
</div>
</div>
<a
href={`https://github.com/${skill.githubOwner}/${skill.githubRepo}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-sm text-text-muted hover:text-text-primary transition-colors shrink-0"
>
GitHub <ExternalLink className="w-3.5 h-3.5" />
</a>
</div>
</div>
))}
</div>
)}
{/* Back to stats link */}
<div className="text-center mt-8">
<Link
href={`/${locale}/reviewed/stats`}
className="btn-secondary"
>
{locale === 'fa' ? 'بازگشت به آمار بررسی‌ها' : 'Back to Review Statistics'}
</Link>
</div>
</div>
</section>
</main>
<Footer />
</div>
);
}

View File

@@ -8,7 +8,8 @@ import { SkillCard } from '@/components/SkillCard';
import { ReviewedSortSelector } from '@/components/ReviewedSortSelector';
import { getPageAlternates } from '@/lib/seo';
import { getOrSetCache, cacheKeys, cacheTTL } from '@/lib/cache';
import { Sparkles } from 'lucide-react';
import { Sparkles, BarChart3 } from 'lucide-react';
import Link from 'next/link';
// Force dynamic rendering to fetch fresh data from database
@@ -95,9 +96,16 @@ export default async function ReviewedPage({
</div>
<h1 className="hero-title mb-4">{t('title')}</h1>
<p className="hero-subtitle max-w-2xl mx-auto mb-6">{t('subtitle')}</p>
<p className="text-text-secondary text-sm max-w-3xl mx-auto leading-relaxed">
<p className="text-text-secondary text-sm max-w-3xl mx-auto leading-relaxed mb-4">
{t('explanation')}
</p>
<Link
href={`/${locale}/reviewed/stats`}
className="inline-flex items-center gap-1.5 text-sm text-primary-600 hover:text-primary-700 font-medium"
>
<BarChart3 className="w-4 h-4" />
{locale === 'fa' ? 'مشاهده آمار بررسی‌ها' : 'View Review Statistics'}
</Link>
</div>
</section>

View File

@@ -0,0 +1,224 @@
import { getTranslations, setRequestLocale } from 'next-intl/server';
import Link from 'next/link';
import { Header } from '@/components/Header';
import { Footer } from '@/components/Footer';
import { createDb, skillReviewQueries } from '@skillhub/db';
import { getPageAlternates } from '@/lib/seo';
import { getOrSetCache, cacheKeys, cacheTTL } from '@/lib/cache';
import { BarChart3, ShieldAlert, AlertTriangle, CheckCircle } from 'lucide-react';
export const dynamic = 'force-dynamic';
interface StatsData {
pipeline: Record<string, number>;
totalReviews: number;
scoreDistribution: Record<string, number>;
securityStats: Record<string, number>;
malwareCount: number;
}
async function getPublicReviewStats(): Promise<StatsData | null> {
try {
return await getOrSetCache(cacheKeys.reviewStatsPublic(), cacheTTL.reviewStatsPublic, async () => {
const db = createDb();
const [pipeline, totalReviews, scoreDistribution, securityStats, malwareCount] = await Promise.all([
skillReviewQueries.getPublicPipelineStats(db),
skillReviewQueries.countTotalReviews(db),
skillReviewQueries.getScoreDistribution(db),
skillReviewQueries.getSecurityStats(db),
skillReviewQueries.countMaliciousSkills(db),
]);
return { pipeline, totalReviews, scoreDistribution, securityStats, malwareCount };
});
} catch (error) {
console.error('Error fetching review stats:', error);
return null;
}
}
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
return {
alternates: getPageAlternates(locale, '/reviewed/stats'),
};
}
function StatCard({
icon: Icon,
label,
value,
color,
href,
}: {
icon: React.ElementType;
label: string;
value: number;
color: string;
href?: string;
}) {
const content = (
<div className={`flex items-center gap-3 p-4 rounded-xl border border-border bg-surface ${href ? 'hover:border-primary-300 transition-colors' : ''}`}>
<div className={`flex items-center justify-center w-10 h-10 rounded-lg ${color}`}>
<Icon className="w-5 h-5" />
</div>
<div>
<p className="text-2xl font-bold text-text-primary ltr-nums">{value.toLocaleString()}</p>
<p className="text-sm text-text-secondary">{label}</p>
</div>
</div>
);
if (href) {
return <Link href={href}>{content}</Link>;
}
return content;
}
function BarRow({
label,
value,
total,
color,
}: {
label: string;
value: number;
total: number;
color: string;
}) {
const pct = total > 0 ? (value / total) * 100 : 0;
return (
<div className="space-y-1">
<div className="flex items-center justify-between text-sm">
<span className="text-text-secondary">{label}</span>
<span className="font-medium text-text-primary ltr-nums">
{value.toLocaleString()} <span className="text-text-muted">({pct.toFixed(1)}%)</span>
</span>
</div>
<div className="h-3 rounded-full bg-surface-subtle overflow-hidden">
<div
className={`h-full rounded-full ${color} transition-all duration-500`}
style={{ width: `${Math.max(pct, 0.5)}%` }}
/>
</div>
</div>
);
}
export default async function ReviewStatsPage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations('reviewStats');
const data = await getPublicReviewStats();
if (!data) {
return (
<div className="min-h-screen flex flex-col">
<Header />
<main className="flex-1 flex items-center justify-center">
<p className="text-text-muted">Failed to load stats.</p>
</main>
<Footer />
</div>
);
}
// Total = all browse-ready SKILL.md skills (for meaningful percentages)
const pipelineTotal = Object.values(data.pipeline).reduce((sum, n) => sum + n, 0);
const scoreTotal =
data.scoreDistribution.high +
data.scoreDistribution.mid +
data.scoreDistribution.low;
return (
<div className="min-h-screen flex flex-col">
<Header />
<main className="flex-1">
{/* Header Section */}
<section className="section-header bg-gradient-subtle">
<div className="container-main text-center">
<div className="inline-flex items-center justify-center w-14 h-14 rounded-2xl bg-primary-50 text-primary-600 mb-4">
<BarChart3 className="w-7 h-7" />
</div>
<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 max-w-4xl">
{/* Review Pipeline */}
<div className="card p-6 mb-8">
<h2 className="text-xl font-semibold text-text-primary mb-1">{t('pipelineTitle')}</h2>
<p className="text-sm text-text-muted mb-6">{t('pipelineDescription')}</p>
<div className="space-y-4">
<BarRow label={t('aiReviewed')} value={data.pipeline['ai-reviewed'] ?? 0} total={pipelineTotal} color="bg-primary-500" />
<BarRow label={t('needsReReview')} value={data.pipeline['needs-re-review'] ?? 0} total={pipelineTotal} color="bg-warning" />
</div>
<div className="mt-6 pt-4 border-t border-border flex items-center justify-between text-sm">
<span className="text-text-secondary">{t('totalReviews')}</span>
<span className="font-semibold text-text-primary ltr-nums">{data.totalReviews.toLocaleString()}</span>
</div>
</div>
{/* Score Distribution */}
<div className="card p-6 mb-8">
<h2 className="text-xl font-semibold text-text-primary mb-1">{t('scoreTitle')}</h2>
<p className="text-sm text-text-muted mb-6">{t('scoreDescription')}</p>
<div className="space-y-4">
<BarRow label={t('scoreHigh')} value={data.scoreDistribution.high} total={scoreTotal} color="bg-success" />
<BarRow label={t('scoreMid')} value={data.scoreDistribution.mid} total={scoreTotal} color="bg-gold" />
<BarRow label={t('scoreLow')} value={data.scoreDistribution.low} total={scoreTotal} color="bg-error" />
</div>
</div>
{/* Security & Malware */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
{/* Security Scan */}
<div className="card p-6">
<h2 className="text-xl font-semibold text-text-primary mb-1">{t('securityTitle')}</h2>
<p className="text-sm text-text-muted mb-6">{t('securityDescription')}</p>
<div className="space-y-3">
<StatCard icon={CheckCircle} label={t('securityPass')} value={data.securityStats.pass} color="bg-success/10 text-success" />
<StatCard icon={AlertTriangle} label={t('securityWarning')} value={data.securityStats.warning} color="bg-warning/10 text-warning" />
<StatCard icon={ShieldAlert} label={t('securityFail')} value={data.securityStats.fail} color="bg-error/10 text-error" />
</div>
</div>
{/* Malware Detection */}
<div className="card p-6">
<h2 className="text-xl font-semibold text-text-primary mb-1">{t('malwareTitle')}</h2>
<p className="text-sm text-text-muted mb-6">{t('malwareDescription')}</p>
<StatCard
icon={ShieldAlert}
label={t('malwareFlagged')}
value={data.malwareCount}
color="bg-error/10 text-error"
href={`/${locale}/malware`}
/>
{data.malwareCount > 0 && (
<Link
href={`/${locale}/malware`}
className="inline-flex items-center gap-1.5 mt-4 text-sm text-primary-600 hover:text-primary-700 font-medium"
>
{t('malwareViewAll')}
</Link>
)}
</div>
</div>
</div>
</section>
</main>
<Footer />
</div>
);
}

View File

@@ -4,7 +4,7 @@ import { notFound } from 'next/navigation';
import { headers } from 'next/headers';
import {
Star, Download, Shield, CheckCircle, Copy,
ExternalLink, Github, Calendar, User, Tag, ChevronRight, Eye, Sparkles
ExternalLink, Github, Calendar, User, Tag, ChevronRight, Eye, Sparkles, XCircle, ShieldAlert
} from 'lucide-react';
import { Header } from '@/components/Header';
import { Footer } from '@/components/Footer';
@@ -113,6 +113,8 @@ export default async function SkillPage({ params }: SkillPageProps) {
notFound();
}
const isMalicious = dbSkill.isMalicious ?? false;
// 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();
@@ -159,7 +161,13 @@ export default async function SkillPage({ params }: SkillPageProps) {
// 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');
const hasReview = review && review.aiScore != null && (dbSkill.reviewStatus === 'ai-reviewed' || dbSkill.reviewStatus === 'verified');
const isRejected = hasReview && review.aiScore === 0;
// Review is outdated only if both hashes are non-empty and differ
const reviewOutdated = hasReview
&& review.contentHashAtReview && review.contentHashAtReview.length > 1
&& dbSkill.contentHash && dbSkill.contentHash.length > 1
&& dbSkill.contentHash !== review.contentHashAtReview;
// Content section title based on source format (uses FORMAT_LABELS from skillhub-core)
const getContentTitle = (format: string) => {
@@ -358,12 +366,23 @@ export default async function SkillPage({ params }: SkillPageProps) {
{/* Compact AI Review Score */}
{hasReview && (
<div className="flex items-center gap-3 px-4 py-3 bg-surface-elevated rounded-xl border border-border">
<Sparkles className={`w-5 h-5 ${review.aiScore! >= 75 ? 'text-success' : 'text-gold'}`} />
<span className={`text-2xl font-bold ltr-nums ${review.aiScore! >= 75 ? 'text-success' : review.aiScore! >= 50 ? 'text-gold' : 'text-text-primary'}`}>
{review.aiScore}
</span>
<span className="text-sm text-text-muted">{t('review.outOf100')}</span>
<div className={`flex items-center gap-3 px-4 py-3 rounded-xl border ${isRejected ? 'bg-error/5 border-error/20' : 'bg-surface-elevated border-border'}`}>
{isRejected ? (
<>
<XCircle className="w-5 h-5 text-error" />
<span className="text-lg font-bold text-error">
{isRTL ? 'رد شده' : 'Rejected'}
</span>
</>
) : (
<>
<Sparkles className={`w-5 h-5 ${review.aiScore! >= 75 ? 'text-success' : review.aiScore! >= 50 ? 'text-gold' : 'text-text-muted'}`} />
<span className={`text-2xl font-bold ltr-nums ${review.aiScore! >= 75 ? 'text-success' : review.aiScore! >= 50 ? 'text-gold' : 'text-text-primary'}`}>
{review.aiScore}
</span>
<span className="text-sm text-text-muted">{t('review.outOf100')}</span>
</>
)}
</div>
)}
</div>
@@ -396,6 +415,25 @@ export default async function SkillPage({ params }: SkillPageProps) {
</div>
)}
{/* Malicious Skill Warning Banner */}
{isMalicious && (
<div className="bg-error/10 border-y-2 border-error px-4 py-6">
<div className="container-main flex items-start gap-4">
<ShieldAlert className="w-8 h-8 text-error flex-shrink-0 mt-0.5" />
<div>
<h2 className="text-lg font-bold text-error mb-1" dir="auto">
{isRTL ? 'بدافزار شناسایی شد' : 'Malware Detected'}
</h2>
<p className="text-text-secondary text-sm" dir="auto">
{isRTL
? 'این مهارت به عنوان مخرب شناسایی شده است. شامل کد مبهم‌سازی شده برای دانلود و اجرای بارهای مضر است. دانلود فایل و نصب مسدود شده است.'
: 'This skill has been flagged as malicious. It contains obfuscated code designed to download and execute harmful payloads. File downloads and installation are blocked.'}
</p>
</div>
</div>
</div>
)}
{/* Stale Skill Warning Banner */}
{dbSkill.isStale && (
<div className="bg-warning/10 border-b border-warning/20">
@@ -470,56 +508,87 @@ export default async function SkillPage({ params }: SkillPageProps) {
<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',
}}
/>
{isMalicious ? (
<div className="bg-error/5 border border-error/20 rounded-lg p-4 text-center">
<ShieldAlert className="w-6 h-6 text-error mx-auto mb-2" />
<p className="text-sm text-error font-medium" dir="auto">
{isRTL ? 'نصب مسدود شده — این مهارت حاوی بدافزار است' : 'Installation blocked — this skill contains malware'}
</p>
</div>
) : (
<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>
{/* AI Review Card (Mobile) */}
{hasReview && (
<div className="lg:hidden bg-surface-elevated rounded-2xl border border-border p-6">
<div className={`lg:hidden rounded-2xl border p-6 ${isRejected ? 'bg-error/5 border-error/20' : 'bg-surface-elevated border-border'}`}>
<h3 className="font-semibold text-text-primary mb-4 flex items-center gap-2">
<Sparkles className="w-4 h-4 text-primary-500" />
{isRejected ? <XCircle className="w-4 h-4 text-error" /> : <Sparkles className="w-4 h-4 text-primary-500" />}
{t('review.title')}
</h3>
<div className="space-y-4">
<div className="space-y-3">
<ScoreBar label={t('review.instructionQuality')} score={review.instructionQuality} />
<ScoreBar label={t('review.descriptionPrecision')} score={review.descriptionPrecision} />
<ScoreBar label={t('review.usefulness')} score={review.usefulness} />
<ScoreBar label={t('review.technicalSoundness')} score={review.technicalSoundness} />
{isRejected ? (
<div className="space-y-4">
<div className="text-center">
<div className="text-xl font-bold text-error">
{isRTL ? 'رد شده' : 'Rejected'}
</div>
<div className="text-sm text-text-muted">
{isRTL ? 'این مهارت معیارهای کیفیت را ندارد' : 'Does not meet quality standards'}
</div>
</div>
{parsedNotes?.rationale && (
<p className="text-sm text-text-secondary pt-3 border-t border-error/20" dir="auto">{parsedNotes.rationale}</p>
)}
</div>
{parsedNotes?.rationale && (
<p className="text-sm text-text-secondary pt-3 border-t border-border" dir="auto">{parsedNotes.rationale}</p>
)}
</div>
) : (
<div className="space-y-4">
<div className="space-y-3">
<ScoreBar label={t('review.instructionQuality')} score={review.instructionQuality} />
<ScoreBar label={t('review.descriptionPrecision')} score={review.descriptionPrecision} />
<ScoreBar label={t('review.usefulness')} score={review.usefulness} />
<ScoreBar label={t('review.technicalSoundness')} score={review.technicalSoundness} />
</div>
{parsedNotes?.rationale && (
<p className="text-sm text-text-secondary pt-3 border-t border-border" dir="auto">{parsedNotes.rationale}</p>
)}
{reviewOutdated && (
<p className="text-xs text-warning flex items-center gap-1.5 pt-3 border-t border-border">
<span></span>
{isRTL ? 'بررسی بر اساس نسخه قبلی' : 'Review based on previous version'}
</p>
)}
</div>
)}
</div>
)}
@@ -547,61 +616,84 @@ export default async function SkillPage({ params }: SkillPageProps) {
<div className="sticky top-24 max-h-[calc(100vh-7rem)] overflow-y-auto space-y-6 scrollbar-thin">
{/* AI Review Card (Desktop) — above Install for visibility */}
{hasReview && (
<div className="bg-surface-elevated rounded-2xl border border-border p-6">
<div className={`rounded-2xl border p-6 ${isRejected ? 'bg-error/5 border-error/20' : 'bg-surface-elevated border-border'}`}>
<h3 className="font-semibold text-text-primary mb-4 flex items-center gap-2">
<Sparkles className="w-4 h-4 text-primary-500" />
{isRejected ? <XCircle className="w-4 h-4 text-error" /> : <Sparkles className="w-4 h-4 text-primary-500" />}
{t('review.title')}
</h3>
{/* Overall Score */}
<div className="text-center mb-4">
<div className={`text-4xl font-bold ${review.aiScore! >= 75 ? 'text-success' : review.aiScore! >= 50 ? 'text-gold' : 'text-text-primary'}`}>
{review.aiScore}
</div>
<div className="text-sm text-text-muted">{t('review.outOf100')}</div>
</div>
{/* 4-axis score bars */}
<div className="space-y-3">
<ScoreBar label={t('review.instructionQuality')} score={review.instructionQuality} />
<ScoreBar label={t('review.descriptionPrecision')} score={review.descriptionPrecision} />
<ScoreBar label={t('review.usefulness')} score={review.usefulness} />
<ScoreBar label={t('review.technicalSoundness')} score={review.technicalSoundness} />
</div>
{/* Rationale */}
{parsedNotes?.rationale && (
<div className="mt-4 pt-4 border-t border-border">
<p className="text-sm text-text-secondary" dir="auto">{parsedNotes.rationale}</p>
</div>
)}
{/* Tags: Audience, Maturity, Complexity, Use Cases */}
{(parsedNotes?.maturity || parsedNotes?.complexity || (parsedNotes?.audience && parsedNotes.audience.length > 0) || (parsedNotes?.useCases && parsedNotes.useCases.length > 0)) && (
<div className="mt-4 pt-4 border-t border-border">
<div className="flex flex-wrap gap-1.5">
{parsedNotes?.maturity && (
<span className="px-2 py-0.5 text-xs rounded bg-surface-subtle text-text-muted border border-border">
{parsedNotes.maturity}
</span>
)}
{parsedNotes?.complexity && (
<span className="px-2 py-0.5 text-xs rounded bg-surface-subtle text-text-muted border border-border">
{parsedNotes.complexity}
</span>
)}
{parsedNotes?.audience?.map((a: string) => (
<span key={a} className="px-2 py-0.5 text-xs rounded bg-primary-50 text-primary-700 dark:bg-primary-900/20 dark:text-primary-400">
{a.trim()}
</span>
))}
{parsedNotes?.useCases?.map((uc: string) => (
<span key={uc} className="px-2 py-0.5 text-xs rounded bg-success/10 text-success">
{uc.trim()}
</span>
))}
{isRejected ? (
<>
{/* Rejected status */}
<div className="text-center mb-4">
<div className="text-2xl font-bold text-error">
{isRTL ? 'رد شده' : 'Rejected'}
</div>
<div className="text-sm text-text-muted">
{isRTL ? 'این مهارت معیارهای کیفیت را ندارد' : 'Does not meet quality standards'}
</div>
</div>
</div>
{/* Rationale for rejection */}
{parsedNotes?.rationale && (
<div className="pt-4 border-t border-error/20">
<p className="text-sm text-text-secondary" dir="auto">{parsedNotes.rationale}</p>
</div>
)}
</>
) : (
<>
{/* Overall Score */}
<div className="text-center mb-4">
<div className={`text-4xl font-bold ${review.aiScore! >= 75 ? 'text-success' : review.aiScore! >= 50 ? 'text-gold' : 'text-text-primary'}`}>
{review.aiScore}
</div>
<div className="text-sm text-text-muted">{t('review.outOf100')}</div>
</div>
{/* 4-axis score bars */}
<div className="space-y-3">
<ScoreBar label={t('review.instructionQuality')} score={review.instructionQuality} />
<ScoreBar label={t('review.descriptionPrecision')} score={review.descriptionPrecision} />
<ScoreBar label={t('review.usefulness')} score={review.usefulness} />
<ScoreBar label={t('review.technicalSoundness')} score={review.technicalSoundness} />
</div>
{/* Rationale */}
{parsedNotes?.rationale && (
<div className="mt-4 pt-4 border-t border-border">
<p className="text-sm text-text-secondary" dir="auto">{parsedNotes.rationale}</p>
</div>
)}
{/* Tags: Audience, Maturity, Complexity, Use Cases */}
{(parsedNotes?.maturity || parsedNotes?.complexity || (parsedNotes?.audience && parsedNotes.audience.length > 0) || (parsedNotes?.useCases && parsedNotes.useCases.length > 0)) && (
<div className="mt-4 pt-4 border-t border-border">
<div className="flex flex-wrap gap-1.5">
{parsedNotes?.maturity && (
<span className="px-2 py-0.5 text-xs rounded bg-surface-subtle text-text-muted border border-border">
{parsedNotes.maturity}
</span>
)}
{parsedNotes?.complexity && (
<span className="px-2 py-0.5 text-xs rounded bg-surface-subtle text-text-muted border border-border">
{parsedNotes.complexity}
</span>
)}
{parsedNotes?.audience?.map((a: string) => (
<span key={a} className="px-2 py-0.5 text-xs rounded bg-primary-50 text-primary-700 dark:bg-primary-900/20 dark:text-primary-400">
{a.trim()}
</span>
))}
{parsedNotes?.useCases?.map((uc: string) => (
<span key={uc} className="px-2 py-0.5 text-xs rounded bg-success/10 text-success">
{uc.trim()}
</span>
))}
</div>
</div>
)}
</>
)}
{/* Reviewer info */}
@@ -611,40 +703,59 @@ export default async function SkillPage({ params }: SkillPageProps) {
<> {t('review.reviewedOn', { date: new Date(review.reviewedAt).toLocaleDateString(locale === 'fa' ? 'fa-IR' : 'en-US') })}</>
)}
</div>
{/* Outdated review warning */}
{!isRejected && reviewOutdated && (
<div className="mt-3 pt-3 border-t border-border">
<p className="text-xs text-warning flex items-center gap-1.5">
<span></span>
{isRTL ? 'بررسی بر اساس نسخه قبلی' : 'Review based on previous version'}
</p>
</div>
)}
</div>
)}
{/* 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',
}}
/>
{isMalicious ? (
<div className="bg-error/5 border border-error/20 rounded-lg p-4 text-center">
<ShieldAlert className="w-6 h-6 text-error mx-auto mb-2" />
<p className="text-sm text-error font-medium" dir="auto">
{isRTL ? 'نصب مسدود شده — این مهارت حاوی بدافزار است' : 'Installation blocked — this skill contains malware'}
</p>
</div>
) : (
<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>
</div>
</div>