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:
@@ -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,48 +35,62 @@ 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 offset = (page - 1) * limit;
|
|
||||||
|
|
||||||
const sortMap: Record<string, 'stars' | 'downloads' | 'rating' | 'updated' | 'lastDownloaded'> = {
|
const hash = hashSearchParams({
|
||||||
'stars': 'stars',
|
q: params.q,
|
||||||
'downloads': 'downloads',
|
|
||||||
'recent': 'updated',
|
|
||||||
'rating': 'rating',
|
|
||||||
'lastDownloaded': 'lastDownloaded',
|
|
||||||
};
|
|
||||||
|
|
||||||
// Build filter options - push ALL filters to database level
|
|
||||||
const filterOptions = {
|
|
||||||
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',
|
format: params.format || 'skill.md',
|
||||||
sortBy: sortMap[params.sort || 'lastDownloaded'] || 'lastDownloaded',
|
sort: params.sort || 'lastDownloaded',
|
||||||
sortOrder: 'desc' as const,
|
page,
|
||||||
limit,
|
|
||||||
offset,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Fetch paginated results directly from database
|
|
||||||
const skills = await skillQueries.search(db, filterOptions);
|
|
||||||
|
|
||||||
// Get accurate total count for pagination
|
|
||||||
const total = await skillQueries.count(db, {
|
|
||||||
query: params.q,
|
|
||||||
category: params.category,
|
|
||||||
platform: params.platform && params.platform !== 'all' ? params.platform : undefined,
|
|
||||||
sourceFormat: params.format || 'skill.md',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const totalPages = Math.ceil(total / limit);
|
return await getOrSetCache(
|
||||||
|
cacheKeys.searchSkills(hash),
|
||||||
|
cacheTTL.search,
|
||||||
|
async () => {
|
||||||
|
const db = createDb();
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
return {
|
const sortMap: Record<string, 'stars' | 'downloads' | 'rating' | 'updated' | 'lastDownloaded'> = {
|
||||||
skills,
|
'stars': 'stars',
|
||||||
pagination: { total, page, totalPages },
|
'downloads': 'downloads',
|
||||||
};
|
'recent': 'updated',
|
||||||
|
'rating': 'rating',
|
||||||
|
'lastDownloaded': 'lastDownloaded',
|
||||||
|
};
|
||||||
|
|
||||||
|
const filterOptions = {
|
||||||
|
query: params.q,
|
||||||
|
category: params.category,
|
||||||
|
platform: params.platform && params.platform !== 'all' ? params.platform : undefined,
|
||||||
|
sourceFormat: params.format || 'skill.md',
|
||||||
|
sortBy: sortMap[params.sort || 'lastDownloaded'] || 'lastDownloaded',
|
||||||
|
sortOrder: 'desc' as const,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
};
|
||||||
|
|
||||||
|
const [skills, total] = await Promise.all([
|
||||||
|
skillQueries.search(db, filterOptions),
|
||||||
|
skillQueries.count(db, {
|
||||||
|
query: params.q,
|
||||||
|
category: params.category,
|
||||||
|
platform: params.platform && params.platform !== 'all' ? params.platform : undefined,
|
||||||
|
sourceFormat: params.format || 'skill.md',
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(total / limit);
|
||||||
|
|
||||||
|
return {
|
||||||
|
skills,
|
||||||
|
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 } };
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user