diff --git a/apps/web/app/[locale]/browse/page.tsx b/apps/web/app/[locale]/browse/page.tsx index e9f2372..379c644 100644 --- a/apps/web/app/[locale]/browse/page.tsx +++ b/apps/web/app/[locale]/browse/page.tsx @@ -6,6 +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'; // Force dynamic rendering to fetch fresh data from database @@ -108,7 +109,7 @@ export default async function BrowsePage({ params, searchParams }: BrowsePagePro { id: 'rating', name: t('filters.sortOptions.rating') }, ]; - // Fetch categories hierarchically for filter dropdown with translations + // Fetch categories hierarchically for filter dropdown with translations (cached 12h) const tCategories = await getTranslations('categories'); type HierarchicalCategory = { id: string; @@ -119,8 +120,14 @@ export default async function BrowsePage({ params, searchParams }: BrowsePagePro }; let categories: HierarchicalCategory[] = []; try { - const db = createDb(); - const rawCategories = await categoryQueries.getHierarchical(db); + const rawCategories = await getOrSetCache( + cacheKeys.categoriesHierarchical(), + cacheTTL.categories, + async () => { + const db = createDb(); + return await categoryQueries.getHierarchical(db); + } + ); categories = rawCategories.map(parent => ({ id: parent.id, name: tCategories(`parents.${parent.slug}`) || parent.name, diff --git a/apps/web/app/[locale]/categories/page.tsx b/apps/web/app/[locale]/categories/page.tsx index fcc1ff2..7c15d28 100644 --- a/apps/web/app/[locale]/categories/page.tsx +++ b/apps/web/app/[locale]/categories/page.tsx @@ -32,6 +32,7 @@ import { import { createDb, categoryQueries } from '@skillhub/db'; import { formatNumber } from '@/lib/format-number'; import { getPageAlternates } from '@/lib/seo'; +import { getOrSetCache, cacheKeys, cacheTTL } from '@/lib/cache'; // Force dynamic rendering to fetch fresh data from database @@ -87,11 +88,13 @@ const parentColorMap: Record = { 'specialized': 'bg-gray-50 text-gray-600 dark:bg-gray-800 dark:text-gray-400', }; -// Get categories hierarchically from database +// Get categories hierarchically with Redis caching (12 hour TTL) async function getHierarchicalCategories() { try { - const db = createDb(); - return await categoryQueries.getHierarchical(db); + return await getOrSetCache(cacheKeys.categoriesHierarchical(), cacheTTL.categories, async () => { + const db = createDb(); + return await categoryQueries.getHierarchical(db); + }); } catch (error) { console.error('Error fetching categories:', error); return []; diff --git a/apps/web/app/[locale]/claude-plugin/page.tsx b/apps/web/app/[locale]/claude-plugin/page.tsx index ff02f38..736b099 100644 --- a/apps/web/app/[locale]/claude-plugin/page.tsx +++ b/apps/web/app/[locale]/claude-plugin/page.tsx @@ -18,6 +18,7 @@ import { Footer } from '@/components/Footer'; import { EarlyAccessForm } from '@/components/EarlyAccessForm'; import { createDb, skills, sql } from '@skillhub/db'; import { formatCompactNumber } from '@/lib/format-number'; +import { getOrSetCache, cacheKeys, cacheTTL } from '@/lib/cache'; export const dynamic = 'force-dynamic'; @@ -42,11 +43,13 @@ export async function generateMetadata({ async function getStats() { try { - const db = createDb(); - const skillsResult = await db - .select({ count: sql`count(*)::int` }) - .from(skills); - return skillsResult[0]?.count ?? 0; + return await getOrSetCache(cacheKeys.pageCount('claude-plugin'), cacheTTL.pageCount, async () => { + const db = createDb(); + const skillsResult = await db + .select({ count: sql`count(*)::int` }) + .from(skills); + return skillsResult[0]?.count ?? 0; + }); } catch { return 119000; } diff --git a/apps/web/app/[locale]/docs/getting-started/page.tsx b/apps/web/app/[locale]/docs/getting-started/page.tsx index 0753efc..1a42fcd 100644 --- a/apps/web/app/[locale]/docs/getting-started/page.tsx +++ b/apps/web/app/[locale]/docs/getting-started/page.tsx @@ -7,18 +7,21 @@ import { ArrowLeft, ArrowRight } from 'lucide-react'; import { createDb, skills, sql } from '@skillhub/db'; import { formatPromptSkillCount } from '@/lib/format-number'; import { getPageAlternates } from '@/lib/seo'; +import { getOrSetCache, cacheKeys, cacheTTL } from '@/lib/cache'; export const dynamic = 'force-dynamic'; async function getSkillCount(): Promise { try { - const db = createDb(); - const result = await db - .select({ count: sql`count(*)::int` }) - .from(skills) - .where(sql`${skills.isDuplicate} = false`); - return formatPromptSkillCount(result[0]?.count ?? 16000); + return await getOrSetCache(cacheKeys.pageCount('getting-started'), cacheTTL.pageCount, async () => { + const db = createDb(); + const result = await db + .select({ count: sql`count(*)::int` }) + .from(skills) + .where(sql`${skills.isDuplicate} = false`); + return formatPromptSkillCount(result[0]?.count ?? 16000); + }); } catch { return '16,000+'; } diff --git a/apps/web/app/[locale]/featured/page.tsx b/apps/web/app/[locale]/featured/page.tsx index e14ccd7..da0b0cd 100644 --- a/apps/web/app/[locale]/featured/page.tsx +++ b/apps/web/app/[locale]/featured/page.tsx @@ -6,6 +6,7 @@ import { toPersianNumber } from '@/lib/format-number'; import { Pagination } from '@/components/BrowseFilters'; import { SkillCard } from '@/components/SkillCard'; import { getPageAlternates } from '@/lib/seo'; +import { getOrSetCache, cacheKeys, cacheTTL } from '@/lib/cache'; // Force dynamic rendering to fetch fresh data from database @@ -16,23 +17,25 @@ interface FeaturedPageProps { searchParams: Promise<{ page?: string }>; } -// Get featured skills with pagination +// Get featured skills with pagination and Redis caching (2 hour TTL) async function getFeaturedSkills(page: number, limit: number) { try { - const db = createDb(); - const offset = (page - 1) * limit; + return await getOrSetCache(cacheKeys.featuredPage(page), cacheTTL.featured, async () => { + const db = createDb(); + const offset = (page - 1) * limit; - // Try featured first, fall back to combined popularity score - let featuredSkills = await skillQueries.getFeatured(db, limit, offset); - let total = await skillQueries.countFeatured(db); + // Try featured first, fall back to combined popularity score + let featuredSkills = await skillQueries.getFeatured(db, limit, offset); + let total = await skillQueries.countFeatured(db); - // If no featured skills, use adaptive popularity with owner/repo diversity - if (total === 0) { - featuredSkills = await skillQueries.getFeaturedWithDiversity(db, limit, 2, 3); - total = await skillQueries.countAll(db); - } + // If no featured skills, use adaptive popularity with owner/repo diversity + if (total === 0) { + featuredSkills = await skillQueries.getFeaturedWithDiversity(db, limit, 2, 3); + total = await skillQueries.countAll(db); + } - return { skills: featuredSkills, total }; + return { skills: featuredSkills, total }; + }); } catch (error) { console.error('Error fetching featured skills:', error); return { skills: [], total: 0 }; diff --git a/apps/web/app/[locale]/new/page.tsx b/apps/web/app/[locale]/new/page.tsx index 7c46d43..d794bcb 100644 --- a/apps/web/app/[locale]/new/page.tsx +++ b/apps/web/app/[locale]/new/page.tsx @@ -8,6 +8,7 @@ import { createDb, skillQueries } from '@skillhub/db'; import { toPersianNumber } from '@/lib/format-number'; import { Pagination } from '@/components/BrowseFilters'; import { getPageAlternates } from '@/lib/seo'; +import { getOrSetCache, cacheKeys, cacheTTL } from '@/lib/cache'; // Force dynamic rendering to fetch fresh data from database @@ -41,36 +42,40 @@ function formatTimeAgo(date: Date | null, locale: string): string { return 'Just now'; } -// Get skills based on tab with pagination +// Get skills based on tab with pagination and Redis caching (30 min TTL) async function getSkillsForTab(tab: 'new' | 'updated', page: number, limit: number) { try { - const db = createDb(); - const offset = (page - 1) * limit; + return await getOrSetCache(cacheKeys.newSkills(tab, page), cacheTTL.newSkills, async () => { + const db = createDb(); + const offset = (page - 1) * limit; - if (tab === 'new') { - const skills = await skillQueries.getNewSkills(db, limit, offset); - const total = await skillQueries.countNewSkills(db); - return { skills, total }; - } else { - const skills = await skillQueries.getUpdatedSkills(db, limit, offset); - const total = await skillQueries.countUpdatedSkills(db); - return { skills, total }; - } + if (tab === 'new') { + const skills = await skillQueries.getNewSkills(db, limit, offset); + const total = await skillQueries.countNewSkills(db); + return { skills, total }; + } else { + const skills = await skillQueries.getUpdatedSkills(db, limit, offset); + const total = await skillQueries.countUpdatedSkills(db); + return { skills, total }; + } + }); } catch (error) { console.error('Error fetching skills:', error); return { skills: [], total: 0 }; } } -// Get counts for both tabs +// Get counts for both tabs with Redis caching (30 min TTL) async function getTabCounts() { try { - const db = createDb(); - const [newCount, updatedCount] = await Promise.all([ - skillQueries.countNewSkills(db), - skillQueries.countUpdatedSkills(db), - ]); - return { newCount, updatedCount }; + return await getOrSetCache(cacheKeys.newSkillsCounts(), cacheTTL.newSkills, async () => { + const db = createDb(); + const [newCount, updatedCount] = await Promise.all([ + skillQueries.countNewSkills(db), + skillQueries.countUpdatedSkills(db), + ]); + return { newCount, updatedCount }; + }); } catch (error) { console.error('Error fetching counts:', error); return { newCount: 0, updatedCount: 0 }; diff --git a/apps/web/app/[locale]/owner/[username]/page.tsx b/apps/web/app/[locale]/owner/[username]/page.tsx index bb1b1e3..362cd93 100644 --- a/apps/web/app/[locale]/owner/[username]/page.tsx +++ b/apps/web/app/[locale]/owner/[username]/page.tsx @@ -9,6 +9,7 @@ import { ExternalLink, Download, Package, Eye, GitFork, ArrowUpDown, FolderGit2 import Link from 'next/link'; import type { Metadata } from 'next'; import { getPageAlternates } from '@/lib/seo'; +import { getOrSetCache, cacheKeys, cacheTTL } from '@/lib/cache'; export const dynamic = 'force-dynamic'; @@ -49,11 +50,15 @@ export default async function OwnerPage({ params, searchParams }: OwnerPageProps const db = createDb(); - // Fetch stats, count, and repo list in parallel + // Fetch stats and repo list with caching (30 min TTL), count is dynamic per filter const [stats, totalSkills, ownerRepos] = await Promise.all([ - skillQueries.getOwnerStats(db, username), + getOrSetCache(cacheKeys.ownerStats(username), cacheTTL.owner, () => + skillQueries.getOwnerStats(db, username) + ), skillQueries.countByOwner(db, username, activeRepo || undefined), - skillQueries.getOwnerRepos(db, username), + getOrSetCache(cacheKeys.ownerRepos(username), cacheTTL.owner, () => + skillQueries.getOwnerRepos(db, username) + ), ]); if (stats.totalSkills === 0) { diff --git a/apps/web/app/[locale]/page.tsx b/apps/web/app/[locale]/page.tsx index f395eb6..a1d8f68 100644 --- a/apps/web/app/[locale]/page.tsx +++ b/apps/web/app/[locale]/page.tsx @@ -8,75 +8,70 @@ import { SkillCard } from '@/components/SkillCard'; import { createDb, categoryQueries, skillQueries, skills, sql } from '@skillhub/db'; import { formatCompactNumber } from '@/lib/format-number'; import { getPageAlternates } from '@/lib/seo'; +import { getOrSetCache, cacheKeys, cacheTTL } from '@/lib/cache'; // Force dynamic rendering to fetch fresh data from database export const dynamic = 'force-dynamic'; -// Get stats directly from database +// Get stats with Redis caching (1 hour TTL) async function getStats() { try { - const db = createDb(); + return await getOrSetCache(cacheKeys.homeStats(), cacheTTL.stats, async () => { + const db = createDb(); - // Browse-ready filter: exclude duplicates (matches browseReadyFilter in queries.ts) - const browseReady = sql`${skills.isDuplicate} = false`; + // Browse-ready filter: exclude duplicates (matches browseReadyFilter in queries.ts) + const browseReady = sql`${skills.isDuplicate} = false`; - // Get total skills count (browse-ready, SKILL.md only) - const skillsResult = await db - .select({ count: sql`count(*)::int` }) - .from(skills) - .where(sql`${skills.sourceFormat} = 'skill.md' AND ${skills.isBlocked} = false AND ${browseReady}`); - const totalSkills = skillsResult[0]?.count ?? 0; + // Run all independent count queries in parallel + const [skillsResult, downloadsResult, categories, contributorsResult, totalIndexedResult] = await Promise.all([ + // Get total skills count (browse-ready, SKILL.md only) + db.select({ count: sql`count(*)::int` }) + .from(skills) + .where(sql`${skills.sourceFormat} = 'skill.md' AND ${skills.isBlocked} = false AND ${browseReady}`), + // Get total downloads (ALL skills — downloads are real user actions) + db.select({ sum: sql`coalesce(sum(${skills.downloadCount}), 0)::int` }) + .from(skills), + // Get total categories + categoryQueries.getAll(db), + // Get unique contributors (browse-ready skills only) + db.select({ count: sql`count(distinct ${skills.githubOwner})::int` }) + .from(skills) + .where(browseReady), + // Get total indexed skills (all, before curation) for curation note + db.select({ count: sql`count(*)::int` }) + .from(skills) + .where(sql`${skills.isBlocked} = false`), + ]); - // Get total downloads (ALL skills — downloads are real user actions) - const downloadsResult = await db - .select({ sum: sql`coalesce(sum(${skills.downloadCount}), 0)::int` }) - .from(skills); - const totalDownloads = downloadsResult[0]?.sum ?? 0; - - // Get total categories - const categories = await categoryQueries.getAll(db); - const totalCategories = categories.length; - - // Get unique contributors (browse-ready skills only) - const contributorsResult = await db - .select({ count: sql`count(distinct ${skills.githubOwner})::int` }) - .from(skills) - .where(browseReady); - const totalContributors = contributorsResult[0]?.count ?? 0; - - // Get total indexed skills (all, before curation) for curation note - const totalIndexedResult = await db - .select({ count: sql`count(*)::int` }) - .from(skills) - .where(sql`${skills.isBlocked} = false`); - const totalIndexed = totalIndexedResult[0]?.count ?? 0; - - return { - totalSkills, - totalDownloads, - totalCategories, - totalContributors, - totalIndexed, - platforms: 5, - }; + return { + totalSkills: skillsResult[0]?.count ?? 0, + totalDownloads: downloadsResult[0]?.sum ?? 0, + totalCategories: categories.length, + totalContributors: contributorsResult[0]?.count ?? 0, + totalIndexed: totalIndexedResult[0]?.count ?? 0, + platforms: 5, + }; + }); } catch (error) { console.error('Error fetching stats:', error); return null; } } -// Get featured skills directly from database +// Get featured skills with Redis caching (2 hour TTL) async function getFeaturedSkills() { try { - const db = createDb(); - // Get featured skills, or top skills by popularity if none are featured - let featuredSkills = await skillQueries.getFeatured(db, 6); - if (featuredSkills.length === 0) { - // Fallback to adaptive popularity with owner/repo diversity - featuredSkills = await skillQueries.getFeaturedWithDiversity(db, 6, 2, 3); - } - return featuredSkills; + return await getOrSetCache(cacheKeys.homeFeatured(), cacheTTL.featured, async () => { + const db = createDb(); + // Get featured skills, or top skills by popularity if none are featured + let featuredSkills = await skillQueries.getFeatured(db, 6); + if (featuredSkills.length === 0) { + // Fallback to adaptive popularity with owner/repo diversity + featuredSkills = await skillQueries.getFeaturedWithDiversity(db, 6, 2, 3); + } + return featuredSkills; + }); } catch (error) { console.error('Error fetching featured skills:', error); return []; diff --git a/apps/web/app/[locale]/skill/[...id]/page.tsx b/apps/web/app/[locale]/skill/[...id]/page.tsx index afefd7f..3d0cdca 100644 --- a/apps/web/app/[locale]/skill/[...id]/page.tsx +++ b/apps/web/app/[locale]/skill/[...id]/page.tsx @@ -15,7 +15,7 @@ import { ShareButton } from '@/components/ShareButton'; import { createDb, skillQueries } from '@skillhub/db'; import { FORMAT_LABELS } from 'skillhub-core'; import { formatCompactNumber } from '@/lib/format-number'; -import { shouldCountView } from '@/lib/cache'; +import { shouldCountView, getOrSetCache, cacheKeys, cacheTTL } from '@/lib/cache'; import type { Metadata } from 'next'; import { getPageAlternates } from '@/lib/seo'; @@ -46,11 +46,13 @@ interface SkillPageProps { params: Promise<{ locale: string; id: string[] }>; } -// Get skill directly from database +// Get skill with Redis caching (1 hour TTL) async function getSkill(skillId: string) { try { - const db = createDb(); - return await skillQueries.getById(db, skillId); + return await getOrSetCache(cacheKeys.skillDetail(skillId), cacheTTL.skill, async () => { + const db = createDb(); + return await skillQueries.getById(db, skillId); + }); } catch (error) { console.error('Error fetching skill:', error); return null; diff --git a/apps/web/app/api/ratings/route.ts b/apps/web/app/api/ratings/route.ts index a035082..5a6bdea 100644 --- a/apps/web/app/api/ratings/route.ts +++ b/apps/web/app/api/ratings/route.ts @@ -3,6 +3,7 @@ import { createDb, ratingQueries, skillQueries, userQueries } from '@skillhub/db import { auth } from '@/lib/auth'; import { withRateLimit, createRateLimitResponse, createRateLimitHeaders } from '@/lib/rate-limit'; import { sanitizeReview } from '@/lib/sanitize'; +import { getOrSetCache, invalidateCache, cacheKeys, cacheTTL } from '@/lib/cache'; const db = createDb(); @@ -28,27 +29,34 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: 'skillId is required' }, { status: 400 }); } - const ratings = await ratingQueries.getForSkill(db, skillId, limit, offset); - const skill = await skillQueries.getById(db, skillId); + const data = await getOrSetCache( + cacheKeys.skillRatings(skillId, limit, offset), + cacheTTL.ratings, + async () => { + const ratings = await ratingQueries.getForSkill(db, skillId, limit, offset); + const skill = await skillQueries.getById(db, skillId); + return { + ratings: ratings.map((r) => ({ + id: r.rating.id, + rating: r.rating.rating, + review: r.rating.review, + createdAt: r.rating.createdAt, + updatedAt: r.rating.updatedAt, + user: { + id: r.user.id, + username: r.user.username, + avatarUrl: r.user.avatarUrl, + }, + })), + summary: { + average: skill?.rating || 0, + count: skill?.ratingCount || 0, + }, + }; + } + ); - return NextResponse.json({ - ratings: ratings.map((r) => ({ - id: r.rating.id, - rating: r.rating.rating, - review: r.rating.review, - createdAt: r.rating.createdAt, - updatedAt: r.rating.updatedAt, - user: { - id: r.user.id, - username: r.user.username, - avatarUrl: r.user.avatarUrl, - }, - })), - summary: { - average: skill?.rating || 0, - count: skill?.ratingCount || 0, - }, - }, { + return NextResponse.json(data, { headers: createRateLimitHeaders(rateLimitResult), }); } catch (error) { @@ -103,6 +111,12 @@ export async function POST(request: NextRequest) { review: sanitizeReview(review) ?? undefined, }); + // Invalidate ratings and skill detail caches + await Promise.all([ + invalidateCache(cacheKeys.skillRatings(skillId, 10, 0)), + invalidateCache(cacheKeys.skillDetail(skillId)), + ]); + // Get updated skill aggregates const updatedSkill = await skillQueries.getById(db, skillId); diff --git a/apps/web/components/ProgressBar.tsx b/apps/web/components/ProgressBar.tsx index fe8de19..532b31e 100644 --- a/apps/web/components/ProgressBar.tsx +++ b/apps/web/components/ProgressBar.tsx @@ -1,14 +1,157 @@ 'use client'; -import { AppProgressBar } from 'next-nprogress-bar'; +import { useEffect, useRef } from 'react'; export function ProgressBar() { + const barRef = useRef(null); + const timerRef = useRef | null>(null); + const isRunningRef = useRef(false); + const currentUrlRef = useRef(''); + + useEffect(() => { + const bar = barRef.current; + if (!bar) return; + + currentUrlRef.current = location.pathname + location.search; + + function clearTimers() { + if (timerRef.current) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + } + + function start() { + clearTimers(); + isRunningRef.current = true; + + bar!.style.transition = 'none'; + bar!.style.width = '0%'; + bar!.style.opacity = '1'; + + // Force reflow so browser registers 0% before animating + void bar!.offsetWidth; + + bar!.style.transition = 'width 2s cubic-bezier(0.1, 0.5, 0.3, 1)'; + bar!.style.width = '80%'; + + // Safety: auto-complete after 10s + timerRef.current = setTimeout(done, 10000); + } + + function done() { + clearTimers(); + isRunningRef.current = false; + + bar!.style.transition = 'width 200ms ease-out'; + bar!.style.width = '100%'; + + timerRef.current = setTimeout(() => { + if (!barRef.current) return; + barRef.current.style.transition = 'opacity 300ms ease-out'; + barRef.current.style.opacity = '0'; + }, 250); + } + + // Called when URL has changed (navigation completed) + function onUrlChange() { + const newUrl = location.pathname + location.search; + if (newUrl === currentUrlRef.current) return; + currentUrlRef.current = newUrl; + + if (isRunningRef.current) { + // Bar was started by click handler — complete it + done(); + } else { + // Programmatic navigation (search, filters, router.push/replace) + // Show a quick fill animation as feedback + start(); + requestAnimationFrame(() => requestAnimationFrame(() => done())); + } + } + + // 1. Intercept tag clicks (for Link components) + const handleClick = (e: MouseEvent) => { + const anchor = (e.target as HTMLElement).closest('a'); + if (!anchor) return; + + const href = anchor.getAttribute('href'); + if (!href) return; + + if ( + href.startsWith('http') || + href.startsWith('#') || + href.startsWith('mailto:') || + anchor.target === '_blank' || + e.ctrlKey || + e.metaKey || + e.shiftKey + ) { + return; + } + + if (href === currentUrlRef.current) return; + start(); + }; + document.addEventListener('click', handleClick, true); + + // 2. Monkey-patch pushState/replaceState for programmatic navigation + const origPushState = history.pushState; + const origReplaceState = history.replaceState; + + history.pushState = function ( + data: unknown, + unused: string, + url?: string | URL | null, + ) { + origPushState.call(this, data, unused, url); + onUrlChange(); + }; + + history.replaceState = function ( + data: unknown, + unused: string, + url?: string | URL | null, + ) { + origReplaceState.call(this, data, unused, url); + onUrlChange(); + }; + + // 3. Back/forward navigation + const handlePopState = () => onUrlChange(); + window.addEventListener('popstate', handlePopState); + + return () => { + clearTimers(); + document.removeEventListener('click', handleClick, true); + history.pushState = origPushState; + history.replaceState = origReplaceState; + window.removeEventListener('popstate', handlePopState); + }; + }, []); + return ( - +
+
+
); } diff --git a/apps/web/lib/cache.ts b/apps/web/lib/cache.ts index 72ea697..bbbed01 100644 --- a/apps/web/lib/cache.ts +++ b/apps/web/lib/cache.ts @@ -129,12 +129,41 @@ export async function isCacheAvailable(): Promise { } } +/** + * Get cached data or fetch and cache it. + * If Redis is unavailable, falls back to fetcher directly (graceful degradation). + */ +export async function getOrSetCache( + key: string, + ttlSeconds: number, + fetcher: () => Promise +): Promise { + const cached = await getCached(key); + if (cached !== null) return cached; + + const data = await fetcher(); + // Fire-and-forget cache write + setCache(key, data, ttlSeconds).catch(() => {}); + return data; +} + // Cache key builders for consistency export const cacheKeys = { stats: () => 'stats:global', + homeStats: () => 'page:home:stats', + homeFeatured: () => 'page:home:featured', categories: () => 'categories:all', + categoriesHierarchical: () => 'categories:hierarchical', featuredSkills: () => 'skills:featured', + featuredPage: (page: number) => `page:featured:${page}`, recentSkills: () => 'skills:recent', + newSkills: (tab: string, page: number) => `page:new:${tab}:${page}`, + newSkillsCounts: () => 'page:new:counts', + ownerStats: (username: string) => `page:owner:${username}:stats`, + ownerRepos: (username: string) => `page:owner:${username}:repos`, + skillDetail: (id: string) => `page:skill:${id.replace(/\//g, ':')}`, + skillRatings: (id: string, limit: number, offset: number) => `ratings:${id.replace(/\//g, ':')}:${limit}:${offset}`, + pageCount: (page: string) => `page:${page}:count`, searchSkills: (hash: string) => `skills:search:${hash}`, skill: (id: string) => `skill:${id.replace(/\//g, ':')}`, skillView: (skillId: string, ip: string) => `view:${skillId.replace(/\//g, ':')}:${ip}`, @@ -149,6 +178,10 @@ export const cacheTTL = { recent: 60 * 60, // 1 hour search: 30 * 60, // 30 minutes skill: 60 * 60, // 1 hour + newSkills: 30 * 60, // 30 minutes + owner: 30 * 60, // 30 minutes + ratings: 15 * 60, // 15 minutes + pageCount: 60 * 60, // 1 hour view: 60 * 60, // 1 hour - same IP can only count as 1 view per hour download: 5 * 60, // 5 minutes - same IP can only count as 1 download per 5 min }; diff --git a/apps/web/package.json b/apps/web/package.json index 661a784..589cafd 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -27,8 +27,7 @@ "next": "^15.0.0", "next-auth": "5.0.0-beta.30", "next-intl": "^3.4.0", - "next-nprogress-bar": "^2.4.7", - "next-themes": "^0.4.4", +"next-themes": "^0.4.4", "react": "^18.2.0", "react-dom": "^18.2.0", "resend": "^6.9.1", diff --git a/apps/web/sentry.client.config.ts b/apps/web/sentry.client.config.ts index b31edb3..ca7eb19 100644 --- a/apps/web/sentry.client.config.ts +++ b/apps/web/sentry.client.config.ts @@ -37,6 +37,8 @@ Sentry.init({ "Error in input stream", // Network fetch failures (Safari / mobile) "Load failed", + // Generic network errors (client connectivity issues) + "network error", // Browser extension noise (translation/accessibility extensions) /Object Not Found Matching Id/, // ResizeObserver loop limit — browser quirk, not actionable diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 88c4916..95c87f5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -187,9 +187,6 @@ importers: next-intl: specifier: ^3.4.0 version: 3.26.5(next@15.5.9(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) - next-nprogress-bar: - specifier: ^2.4.7 - version: 2.4.7 next-themes: specifier: ^0.4.4 version: 0.4.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -4313,9 +4310,6 @@ packages: next: ^10.0.0 || ^11.0.0 || ^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 react: ^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0 - next-nprogress-bar@2.4.7: - resolution: {integrity: sha512-OeveNQYFBhQhZ+RgrDnvHNUEQfHCmipymmD4AfAVE9pFV4jeWi7/nNK5f0lIk7ODRrtjyyr/n2YpkRbs5kUoMg==} - next-themes@0.4.6: resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==} peerDependencies: @@ -4377,9 +4371,6 @@ packages: resolution: {integrity: sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - nprogress-v2@1.1.10: - resolution: {integrity: sha512-MypWLNIPIM07SS0bAc/oac0vhVFz9vAHm7d1sj//Pnf3J03LQ3CuWrlDteIu6exq0fIvkDJ6tUDRWLaifsIt5w==} - oauth4webapi@3.8.3: resolution: {integrity: sha512-pQ5BsX3QRTgnt5HxgHwgunIRaDXBdkT23tf8dfzmtTIL2LTpdmxgbpbBm0VgFWAIDlezQvQCTgnVIUmHupXHxw==} @@ -9616,10 +9607,6 @@ snapshots: react: 18.3.1 use-intl: 3.26.5(react@18.3.1) - next-nprogress-bar@2.4.7: - dependencies: - nprogress-v2: 1.1.10 - next-themes@0.4.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: react: 18.3.1 @@ -9673,8 +9660,6 @@ snapshots: dependencies: path-key: 4.0.0 - nprogress-v2@1.1.10: {} - oauth4webapi@3.8.3: {} object-assign@4.1.1: {} diff --git a/services/indexer/src/strategies/popular-repos.ts b/services/indexer/src/strategies/popular-repos.ts index 4e28dae..f8996cf 100644 --- a/services/indexer/src/strategies/popular-repos.ts +++ b/services/indexer/src/strategies/popular-repos.ts @@ -111,7 +111,7 @@ export class PopularReposCrawler { for (const repo of response.data.items) { if (!repo.archived) { results.push({ - owner: repo.owner.login, + owner: repo.owner!.login, repo: repo.name, stars: repo.stargazers_count, forks: repo.forks_count,