sync: AI review scores, reviewed page, search improvements, score filter

- Surface AI review scores across skill pages, browse, and search
- Add dedicated /reviewed page with score filtering
- Add recommended sort using Meilisearch relevance + AI scores
- Fix search sort defaulting to stars instead of recommended
- Fix CLI TypeScript null check for aiScore in search command
- Adjust cache TTL for reviewed content

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
airano
2026-03-14 05:05:37 +03:30
parent 4212b5ba47
commit 3e22d2e238
34 changed files with 929 additions and 194 deletions

View File

@@ -58,13 +58,15 @@ export function BrowseFilters({
// Get current values from URL
const currentCategory = searchParams.get('category') || '';
const currentSort = searchParams.get('sort') || 'lastDownloaded';
const hasQuery = !!searchParams.get('q');
const defaultSort = hasQuery ? 'recommended' : 'lastDownloaded';
const currentSort = searchParams.get('sort') || defaultSort;
const currentFormat = searchParams.get('format') || '';
// Count active filters for mobile badge
const activeFilterCount = [
currentCategory ? 1 : 0,
currentSort !== 'lastDownloaded' ? 1 : 0,
currentSort !== defaultSort ? 1 : 0,
currentFormat ? 1 : 0,
].reduce((a, b) => a + b, 0);
@@ -98,7 +100,7 @@ export function BrowseFilters({
// Handle sort change
const handleSortChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
updateParams({ sort: e.target.value === 'lastDownloaded' ? null : e.target.value });
updateParams({ sort: e.target.value === defaultSort ? null : e.target.value });
};
// Handle format change
@@ -272,9 +274,9 @@ export function SearchBar({ placeholder, defaultValue = '' }: SearchBarProps) {
if (searchQuery) {
params.set('q', searchQuery);
// Default to sorting by stars when searching
if (!params.get('sort')) {
params.set('sort', 'stars');
// Remove sort param so server-side defaults to 'recommended' for searches
if (!params.get('sort') || params.get('sort') === 'lastDownloaded') {
params.delete('sort');
}
} else {
params.delete('q');
@@ -361,7 +363,8 @@ export function ActiveFilters({
const searchParams = useSearchParams();
const [isPending, startTransition] = useTransition();
const hasFilters = query || categoryId || (sortBy && sortBy !== 'lastDownloaded');
const defaultSort = query ? 'recommended' : 'lastDownloaded';
const hasFilters = query || categoryId || (sortBy && sortBy !== defaultSort);
if (!hasFilters) return null;
@@ -410,7 +413,7 @@ export function ActiveFilters({
</span>
)}
{sortBy && sortBy !== 'lastDownloaded' && sortName && (
{sortBy && sortBy !== defaultSort && sortName && (
<span className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-surface-subtle text-text-secondary text-sm rounded-full">
<SlidersHorizontal className="w-3 h-3" />
<span>{sortName}</span>

View File

@@ -16,6 +16,7 @@ export function Footer() {
{ name: t('links.categories'), href: `/${locale}/categories` },
{ name: t('links.featured'), href: `/${locale}/featured` },
{ name: t('links.newSkills'), href: `/${locale}/new` },
{ name: t('links.reviewedSkills'), href: `/${locale}/reviewed` },
],
resources: [
{ name: t('links.documentation'), href: `/${locale}/docs` },

View File

@@ -17,7 +17,7 @@ export function HeroSearch({ placeholder, locale }: HeroSearchProps) {
e.preventDefault();
window.dispatchEvent(new Event('progressbar:start'));
if (query.trim()) {
router.push(`/${locale}/browse?q=${encodeURIComponent(query.trim())}&sort=stars`);
router.push(`/${locale}/browse?q=${encodeURIComponent(query.trim())}`);
} else {
router.push(`/${locale}/browse`);
}

View File

@@ -0,0 +1,70 @@
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import { useTransition } from 'react';
interface ReviewedSortSelectorProps {
locale: string;
translations: {
sortBy: string;
reviewDate: string;
aiScore: string;
scoreFilter: string;
scoreAbove50: string;
scoreAll: string;
};
}
export function ReviewedSortSelector({ locale, translations }: ReviewedSortSelectorProps) {
const router = useRouter();
const searchParams = useSearchParams();
const [isPending, startTransition] = useTransition();
const currentSort = searchParams.get('sort') || 'reviewDate';
const currentScore = searchParams.get('score') || '50';
const navigate = (updates: Record<string, string>) => {
const params = new URLSearchParams(searchParams.toString());
for (const [key, value] of Object.entries(updates)) {
params.set(key, value);
}
params.delete('page');
startTransition(() => {
router.push(`/${locale}/reviewed?${params.toString()}`);
});
};
return (
<div className="flex items-center gap-4 flex-wrap">
<div className="flex items-center gap-2">
<label htmlFor="sort-select" className="text-sm text-text-secondary whitespace-nowrap">
{translations.sortBy}
</label>
<select
id="sort-select"
value={currentSort}
onChange={(e) => navigate({ sort: e.target.value })}
disabled={isPending}
className="text-sm border border-border rounded-lg px-3 py-1.5 bg-surface text-text-primary focus:outline-none focus:ring-2 focus:ring-primary-500 disabled:opacity-50"
>
<option value="reviewDate">{translations.reviewDate}</option>
<option value="aiScore">{translations.aiScore}</option>
</select>
</div>
<div className="flex items-center gap-2">
<label htmlFor="score-filter" className="text-sm text-text-secondary whitespace-nowrap">
{translations.scoreFilter}
</label>
<select
id="score-filter"
value={currentScore}
onChange={(e) => navigate({ score: e.target.value })}
disabled={isPending}
className="text-sm border border-border rounded-lg px-3 py-1.5 bg-surface text-text-primary focus:outline-none focus:ring-2 focus:ring-primary-500 disabled:opacity-50"
>
<option value="50">{translations.scoreAbove50}</option>
<option value="0">{translations.scoreAll}</option>
</select>
</div>
</div>
);
}

View File

@@ -1,5 +1,5 @@
import Link from 'next/link';
import { Star, Download, Shield, CheckCircle, Clock, RefreshCw } from 'lucide-react';
import { Star, Download, Shield, CheckCircle, Clock, RefreshCw, Sparkles } from 'lucide-react';
import { formatCompactNumber } from '@/lib/format-number';
const FORMAT_BADGE_LABELS: Record<string, string> = {
@@ -21,6 +21,9 @@ interface SkillCardProps {
ratingCount: number | null;
securityStatus: string | null;
isVerified: boolean | null;
reviewStatus?: string | null;
aiScore?: number | null;
latestAiScore?: number | null;
sourceFormat?: string | null;
createdAt?: Date | string | null;
updatedAt?: Date | string | null;
@@ -53,6 +56,19 @@ export function SkillCard({ skill, locale, showTimeBadge, formatTimeAgo }: Skill
const showRating = (skill.ratingCount ?? 0) >= 3;
// AI review score badge (only for reviewed skills with score >= 50)
const getAiScoreBadge = () => {
const rs = skill.reviewStatus;
if (!rs || rs === 'unreviewed' || rs === 'auto-scored') return null;
const score = skill.aiScore ?? skill.latestAiScore;
if (!score || score < 50) return null;
const color = score >= 75
? 'text-success bg-success/10 border-success/20'
: 'text-gold bg-gold/10 border-gold/20';
return { score, color };
};
const aiScoreBadge = getAiScoreBadge();
return (
<Link
href={`/${locale}/skill/${skill.id}`}
@@ -101,8 +117,14 @@ export function SkillCard({ skill, locale, showTimeBadge, formatTimeAgo }: Skill
{skill.description}
</p>
{/* Metadata Row: Stars + Downloads + Rating */}
{/* Metadata Row: AI Score + Stars + Downloads + Rating */}
<div className="flex flex-wrap items-center gap-4 text-sm text-text-muted mb-2">
{aiScoreBadge && (
<span className={`flex items-center gap-1 px-1.5 py-0.5 text-xs font-medium rounded border ${aiScoreBadge.color}`}>
<Sparkles className="w-3 h-3" />
<span className="ltr-nums">{aiScoreBadge.score}</span>
</span>
)}
<span className="flex items-center gap-1">
<Star className="w-4 h-4" />
<span className="ltr-nums">{formatCompactNumber(skill.githubStars || 0, locale)}</span>