sync: Redis caching for browse/skill API, progress bar on search, default sort by stars

- Add Redis caching to browse search results and skill detail API
- Show progress bar on homepage search
- Default sort to stars

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
airano
2026-02-22 16:32:07 +03:30
parent d19113795e
commit 328520bb70
6 changed files with 73 additions and 41 deletions

View File

@@ -6,7 +6,7 @@ import { BrowseFilters, SearchBar, Pagination, ActiveFilters, EmptyState } from
import { SkillCard } from '@/components/SkillCard'; import { SkillCard } from '@/components/SkillCard';
import { toPersianNumber } from '@/lib/format-number'; import { toPersianNumber } from '@/lib/format-number';
import { getPageAlternates } from '@/lib/seo'; import { getPageAlternates } from '@/lib/seo';
import { getOrSetCache, cacheKeys, cacheTTL } from '@/lib/cache'; import { getOrSetCache, cacheKeys, cacheTTL, hashSearchParams } from '@/lib/cache';
// Force dynamic rendering to fetch fresh data from database // Force dynamic rendering to fetch fresh data from database
@@ -25,6 +25,7 @@ interface BrowsePageProps {
} }
// Get skills directly from database with filters - all filtering at database level // Get skills directly from database with filters - all filtering at database level
// Results are cached in Redis for 30 minutes, keyed by a hash of all filter parameters
async function getSkills(params: { async function getSkills(params: {
q?: string; q?: string;
platform?: string; platform?: string;
@@ -34,9 +35,23 @@ async function getSkills(params: {
category?: string; category?: string;
}) { }) {
try { try {
const db = createDb();
const limit = 20; const limit = 20;
const page = parseInt(params.page || '1'); const page = parseInt(params.page || '1');
const hash = hashSearchParams({
q: params.q,
category: params.category,
platform: params.platform && params.platform !== 'all' ? params.platform : undefined,
format: params.format || 'skill.md',
sort: params.sort || 'lastDownloaded',
page,
});
return await getOrSetCache(
cacheKeys.searchSkills(hash),
cacheTTL.search,
async () => {
const db = createDb();
const offset = (page - 1) * limit; const offset = (page - 1) * limit;
const sortMap: Record<string, 'stars' | 'downloads' | 'rating' | 'updated' | 'lastDownloaded'> = { const sortMap: Record<string, 'stars' | 'downloads' | 'rating' | 'updated' | 'lastDownloaded'> = {
@@ -47,7 +62,6 @@ async function getSkills(params: {
'lastDownloaded': 'lastDownloaded', 'lastDownloaded': 'lastDownloaded',
}; };
// Build filter options - push ALL filters to database level
const filterOptions = { const filterOptions = {
query: params.q, query: params.q,
category: params.category, category: params.category,
@@ -59,16 +73,15 @@ async function getSkills(params: {
offset, offset,
}; };
// Fetch paginated results directly from database const [skills, total] = await Promise.all([
const skills = await skillQueries.search(db, filterOptions); skillQueries.search(db, filterOptions),
skillQueries.count(db, {
// Get accurate total count for pagination
const total = await skillQueries.count(db, {
query: params.q, query: params.q,
category: params.category, category: params.category,
platform: params.platform && params.platform !== 'all' ? params.platform : undefined, platform: params.platform && params.platform !== 'all' ? params.platform : undefined,
sourceFormat: params.format || 'skill.md', sourceFormat: params.format || 'skill.md',
}); }),
]);
const totalPages = Math.ceil(total / limit); const totalPages = Math.ceil(total / limit);
@@ -76,6 +89,8 @@ async function getSkills(params: {
skills, skills,
pagination: { total, page, totalPages }, pagination: { total, page, totalPages },
}; };
}
);
} catch (error) { } catch (error) {
console.error('Error fetching skills:', error); console.error('Error fetching skills:', error);
return { skills: [], pagination: { total: 0, page: 1, totalPages: 1 } }; return { skills: [], pagination: { total: 0, page: 1, totalPages: 1 } };

View File

@@ -1,6 +1,6 @@
import { NextResponse, type NextRequest } from 'next/server'; import { NextResponse, type NextRequest } from 'next/server';
import { createDb, skillQueries } from '@skillhub/db'; import { createDb, skillQueries } from '@skillhub/db';
import { shouldCountView } from '@/lib/cache'; import { shouldCountView, getOrSetCache, cacheKeys, cacheTTL } from '@/lib/cache';
// Create database connection // Create database connection
const db = createDb(); const db = createDb();
@@ -35,8 +35,12 @@ export async function GET(
const { id } = await params; const { id } = await params;
const skillId = id.join('/'); const skillId = id.join('/');
// Get skill from database // Get skill from database (cached 1h)
const skill = await skillQueries.getById(db, skillId); const skill = await getOrSetCache(
cacheKeys.skill(skillId),
cacheTTL.skill,
() => skillQueries.getById(db, skillId)
);
if (!skill) { if (!skill) {
return NextResponse.json( return NextResponse.json(

View File

@@ -1,6 +1,6 @@
import { NextResponse, type NextRequest } from 'next/server'; import { NextResponse, type NextRequest } from 'next/server';
import { createDb, skillQueries } from '@skillhub/db'; import { createDb, skillQueries } from '@skillhub/db';
import { invalidateCache, cacheKeys, shouldCountDownload } from '@/lib/cache'; import { invalidateCache, invalidateCachePattern, cacheKeys, shouldCountDownload } from '@/lib/cache';
// Create database connection // Create database connection
const db = createDb(); const db = createDb();
@@ -69,12 +69,13 @@ export async function POST(request: NextRequest) {
await skillQueries.incrementDownloads(db, skillId); await skillQueries.incrementDownloads(db, skillId);
} }
// Invalidate relevant caches so featured/recent lists reflect the new download // Invalidate relevant caches so featured/recent/browse lists reflect the new download
await Promise.all([ await Promise.all([
invalidateCache(cacheKeys.featuredSkills()), invalidateCache(cacheKeys.featuredSkills()),
invalidateCache(cacheKeys.recentSkills()), invalidateCache(cacheKeys.recentSkills()),
invalidateCache(cacheKeys.stats()), invalidateCache(cacheKeys.stats()),
invalidateCache(cacheKeys.skill(skillId)), invalidateCache(cacheKeys.skill(skillId)),
invalidateCachePattern('skills:search:*'),
]); ]);
return NextResponse.json({ return NextResponse.json({

View File

@@ -272,6 +272,10 @@ export function SearchBar({ placeholder, defaultValue = '' }: SearchBarProps) {
if (searchQuery) { if (searchQuery) {
params.set('q', searchQuery); params.set('q', searchQuery);
// Default to sorting by stars when searching
if (!params.get('sort')) {
params.set('sort', 'stars');
}
} else { } else {
params.delete('q'); params.delete('q');
} }

View File

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

View File

@@ -121,12 +121,19 @@ export function ProgressBar() {
const handlePopState = () => onUrlChange(); const handlePopState = () => onUrlChange();
window.addEventListener('popstate', handlePopState); window.addEventListener('popstate', handlePopState);
// 4. Custom event for programmatic navigation (e.g. form submits with router.push)
const handleManualStart = () => {
if (!isRunningRef.current) start();
};
window.addEventListener('progressbar:start', handleManualStart);
return () => { return () => {
clearTimers(); clearTimers();
document.removeEventListener('click', handleClick, true); document.removeEventListener('click', handleClick, true);
history.pushState = origPushState; history.pushState = origPushState;
history.replaceState = origReplaceState; history.replaceState = origReplaceState;
window.removeEventListener('popstate', handlePopState); window.removeEventListener('popstate', handlePopState);
window.removeEventListener('progressbar:start', handleManualStart);
}; };
}, []); }, []);