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:
@@ -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 "code review" --sort stars --limit 5
|
||||
<span className="text-text-muted">$</span> npx skillhub search "code review" --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
|
||||
|
||||
@@ -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>
|
||||
|
||||
149
apps/web/app/[locale]/malware/page.tsx
Normal file
149
apps/web/app/[locale]/malware/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
224
apps/web/app/[locale]/reviewed/stats/page.tsx
Normal file
224
apps/web/app/[locale]/reviewed/stats/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user