diff --git a/apps/web/app/[locale]/browse/page.tsx b/apps/web/app/[locale]/browse/page.tsx index 379c644..3f5ce87 100644 --- a/apps/web/app/[locale]/browse/page.tsx +++ b/apps/web/app/[locale]/browse/page.tsx @@ -6,7 +6,7 @@ import { BrowseFilters, SearchBar, Pagination, ActiveFilters, EmptyState } from import { SkillCard } from '@/components/SkillCard'; import { toPersianNumber } from '@/lib/format-number'; 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 @@ -25,6 +25,7 @@ interface BrowsePageProps { } // 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: { q?: string; platform?: string; @@ -34,48 +35,62 @@ async function getSkills(params: { category?: string; }) { try { - const db = createDb(); const limit = 20; const page = parseInt(params.page || '1'); - const offset = (page - 1) * limit; - const sortMap: Record = { - 'stars': 'stars', - 'downloads': 'downloads', - 'recent': 'updated', - 'rating': 'rating', - 'lastDownloaded': 'lastDownloaded', - }; - - // Build filter options - push ALL filters to database level - const filterOptions = { - query: params.q, + const hash = hashSearchParams({ + q: 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, - }; - - // 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', + format: params.format || 'skill.md', + sort: params.sort || 'lastDownloaded', + page, }); - const totalPages = Math.ceil(total / limit); + return await getOrSetCache( + cacheKeys.searchSkills(hash), + cacheTTL.search, + async () => { + const db = createDb(); + const offset = (page - 1) * limit; - return { - skills, - pagination: { total, page, totalPages }, - }; + const sortMap: Record = { + 'stars': 'stars', + '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) { console.error('Error fetching skills:', error); return { skills: [], pagination: { total: 0, page: 1, totalPages: 1 } }; diff --git a/apps/web/app/api/skills/[...id]/route.ts b/apps/web/app/api/skills/[...id]/route.ts index d721f53..c6f0ffd 100644 --- a/apps/web/app/api/skills/[...id]/route.ts +++ b/apps/web/app/api/skills/[...id]/route.ts @@ -1,6 +1,6 @@ import { NextResponse, type NextRequest } from 'next/server'; import { createDb, skillQueries } from '@skillhub/db'; -import { shouldCountView } from '@/lib/cache'; +import { shouldCountView, getOrSetCache, cacheKeys, cacheTTL } from '@/lib/cache'; // Create database connection const db = createDb(); @@ -35,8 +35,12 @@ export async function GET( const { id } = await params; const skillId = id.join('/'); - // Get skill from database - const skill = await skillQueries.getById(db, skillId); + // Get skill from database (cached 1h) + const skill = await getOrSetCache( + cacheKeys.skill(skillId), + cacheTTL.skill, + () => skillQueries.getById(db, skillId) + ); if (!skill) { return NextResponse.json( diff --git a/apps/web/app/api/skills/install/route.ts b/apps/web/app/api/skills/install/route.ts index 593be5d..ca3b73e 100644 --- a/apps/web/app/api/skills/install/route.ts +++ b/apps/web/app/api/skills/install/route.ts @@ -1,6 +1,6 @@ import { NextResponse, type NextRequest } from 'next/server'; import { createDb, skillQueries } from '@skillhub/db'; -import { invalidateCache, cacheKeys, shouldCountDownload } from '@/lib/cache'; +import { invalidateCache, invalidateCachePattern, cacheKeys, shouldCountDownload } from '@/lib/cache'; // Create database connection const db = createDb(); @@ -69,12 +69,13 @@ export async function POST(request: NextRequest) { 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([ invalidateCache(cacheKeys.featuredSkills()), invalidateCache(cacheKeys.recentSkills()), invalidateCache(cacheKeys.stats()), invalidateCache(cacheKeys.skill(skillId)), + invalidateCachePattern('skills:search:*'), ]); return NextResponse.json({ diff --git a/apps/web/components/BrowseFilters.tsx b/apps/web/components/BrowseFilters.tsx index 46d8cb0..f99c511 100644 --- a/apps/web/components/BrowseFilters.tsx +++ b/apps/web/components/BrowseFilters.tsx @@ -272,6 +272,10 @@ 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'); + } } else { params.delete('q'); } diff --git a/apps/web/components/HeroSearch.tsx b/apps/web/components/HeroSearch.tsx index 99925fd..d23b2ca 100644 --- a/apps/web/components/HeroSearch.tsx +++ b/apps/web/components/HeroSearch.tsx @@ -15,8 +15,9 @@ export function HeroSearch({ placeholder, locale }: HeroSearchProps) { const handleSubmit = (e: FormEvent) => { e.preventDefault(); + window.dispatchEvent(new Event('progressbar:start')); if (query.trim()) { - router.push(`/${locale}/browse?q=${encodeURIComponent(query.trim())}`); + router.push(`/${locale}/browse?q=${encodeURIComponent(query.trim())}&sort=stars`); } else { router.push(`/${locale}/browse`); } diff --git a/apps/web/components/ProgressBar.tsx b/apps/web/components/ProgressBar.tsx index 532b31e..2480a66 100644 --- a/apps/web/components/ProgressBar.tsx +++ b/apps/web/components/ProgressBar.tsx @@ -121,12 +121,19 @@ export function ProgressBar() { const handlePopState = () => onUrlChange(); 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 () => { clearTimers(); document.removeEventListener('click', handleClick, true); history.pushState = origPushState; history.replaceState = origReplaceState; window.removeEventListener('popstate', handlePopState); + window.removeEventListener('progressbar:start', handleManualStart); }; }, []);