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:
@@ -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>
|
||||
|
||||
@@ -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` },
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
|
||||
70
apps/web/components/ReviewedSortSelector.tsx
Normal file
70
apps/web/components/ReviewedSortSelector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user