sync: Redis caching, progress bar rewrite, sentry filtering, UI improvements

- Add Redis caching to all pages and uncached API routes
- Rewrite progress bar with direct DOM manipulation for search/filters/navigation
- Filter generic network errors from Sentry client reporting
- Fix non-null assertion for repo.owner in popular-repos strategy
- Remove next-nprogress-bar dependency

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
airano
2026-02-22 12:58:16 +03:30
parent 0713607693
commit d19113795e
16 changed files with 353 additions and 151 deletions

View File

@@ -6,6 +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';
// Force dynamic rendering to fetch fresh data from database // 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') }, { 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'); const tCategories = await getTranslations('categories');
type HierarchicalCategory = { type HierarchicalCategory = {
id: string; id: string;
@@ -119,8 +120,14 @@ export default async function BrowsePage({ params, searchParams }: BrowsePagePro
}; };
let categories: HierarchicalCategory[] = []; let categories: HierarchicalCategory[] = [];
try { try {
const rawCategories = await getOrSetCache(
cacheKeys.categoriesHierarchical(),
cacheTTL.categories,
async () => {
const db = createDb(); const db = createDb();
const rawCategories = await categoryQueries.getHierarchical(db); return await categoryQueries.getHierarchical(db);
}
);
categories = rawCategories.map(parent => ({ categories = rawCategories.map(parent => ({
id: parent.id, id: parent.id,
name: tCategories(`parents.${parent.slug}`) || parent.name, name: tCategories(`parents.${parent.slug}`) || parent.name,

View File

@@ -32,6 +32,7 @@ import {
import { createDb, categoryQueries } from '@skillhub/db'; import { createDb, categoryQueries } from '@skillhub/db';
import { formatNumber } from '@/lib/format-number'; import { formatNumber } from '@/lib/format-number';
import { getPageAlternates } from '@/lib/seo'; import { getPageAlternates } from '@/lib/seo';
import { getOrSetCache, cacheKeys, cacheTTL } from '@/lib/cache';
// Force dynamic rendering to fetch fresh data from database // Force dynamic rendering to fetch fresh data from database
@@ -87,11 +88,13 @@ const parentColorMap: Record<string, string> = {
'specialized': 'bg-gray-50 text-gray-600 dark:bg-gray-800 dark:text-gray-400', '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() { async function getHierarchicalCategories() {
try { try {
return await getOrSetCache(cacheKeys.categoriesHierarchical(), cacheTTL.categories, async () => {
const db = createDb(); const db = createDb();
return await categoryQueries.getHierarchical(db); return await categoryQueries.getHierarchical(db);
});
} catch (error) { } catch (error) {
console.error('Error fetching categories:', error); console.error('Error fetching categories:', error);
return []; return [];

View File

@@ -18,6 +18,7 @@ import { Footer } from '@/components/Footer';
import { EarlyAccessForm } from '@/components/EarlyAccessForm'; import { EarlyAccessForm } from '@/components/EarlyAccessForm';
import { createDb, skills, sql } from '@skillhub/db'; import { createDb, skills, sql } from '@skillhub/db';
import { formatCompactNumber } from '@/lib/format-number'; import { formatCompactNumber } from '@/lib/format-number';
import { getOrSetCache, cacheKeys, cacheTTL } from '@/lib/cache';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
@@ -42,11 +43,13 @@ export async function generateMetadata({
async function getStats() { async function getStats() {
try { try {
return await getOrSetCache(cacheKeys.pageCount('claude-plugin'), cacheTTL.pageCount, async () => {
const db = createDb(); const db = createDb();
const skillsResult = await db const skillsResult = await db
.select({ count: sql<number>`count(*)::int` }) .select({ count: sql<number>`count(*)::int` })
.from(skills); .from(skills);
return skillsResult[0]?.count ?? 0; return skillsResult[0]?.count ?? 0;
});
} catch { } catch {
return 119000; return 119000;
} }

View File

@@ -7,18 +7,21 @@ import { ArrowLeft, ArrowRight } from 'lucide-react';
import { createDb, skills, sql } from '@skillhub/db'; import { createDb, skills, sql } from '@skillhub/db';
import { formatPromptSkillCount } from '@/lib/format-number'; import { formatPromptSkillCount } from '@/lib/format-number';
import { getPageAlternates } from '@/lib/seo'; import { getPageAlternates } from '@/lib/seo';
import { getOrSetCache, cacheKeys, cacheTTL } from '@/lib/cache';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
async function getSkillCount(): Promise<string> { async function getSkillCount(): Promise<string> {
try { try {
return await getOrSetCache(cacheKeys.pageCount('getting-started'), cacheTTL.pageCount, async () => {
const db = createDb(); const db = createDb();
const result = await db const result = await db
.select({ count: sql<number>`count(*)::int` }) .select({ count: sql<number>`count(*)::int` })
.from(skills) .from(skills)
.where(sql`${skills.isDuplicate} = false`); .where(sql`${skills.isDuplicate} = false`);
return formatPromptSkillCount(result[0]?.count ?? 16000); return formatPromptSkillCount(result[0]?.count ?? 16000);
});
} catch { } catch {
return '16,000+'; return '16,000+';
} }

View File

@@ -6,6 +6,7 @@ import { toPersianNumber } from '@/lib/format-number';
import { Pagination } from '@/components/BrowseFilters'; import { Pagination } from '@/components/BrowseFilters';
import { SkillCard } from '@/components/SkillCard'; import { SkillCard } from '@/components/SkillCard';
import { getPageAlternates } from '@/lib/seo'; import { getPageAlternates } from '@/lib/seo';
import { getOrSetCache, cacheKeys, cacheTTL } from '@/lib/cache';
// Force dynamic rendering to fetch fresh data from database // Force dynamic rendering to fetch fresh data from database
@@ -16,9 +17,10 @@ interface FeaturedPageProps {
searchParams: Promise<{ page?: string }>; 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) { async function getFeaturedSkills(page: number, limit: number) {
try { try {
return await getOrSetCache(cacheKeys.featuredPage(page), cacheTTL.featured, async () => {
const db = createDb(); const db = createDb();
const offset = (page - 1) * limit; const offset = (page - 1) * limit;
@@ -33,6 +35,7 @@ async function getFeaturedSkills(page: number, limit: number) {
} }
return { skills: featuredSkills, total }; return { skills: featuredSkills, total };
});
} catch (error) { } catch (error) {
console.error('Error fetching featured skills:', error); console.error('Error fetching featured skills:', error);
return { skills: [], total: 0 }; return { skills: [], total: 0 };

View File

@@ -8,6 +8,7 @@ import { createDb, skillQueries } from '@skillhub/db';
import { toPersianNumber } from '@/lib/format-number'; import { toPersianNumber } from '@/lib/format-number';
import { Pagination } from '@/components/BrowseFilters'; import { Pagination } from '@/components/BrowseFilters';
import { getPageAlternates } from '@/lib/seo'; import { getPageAlternates } from '@/lib/seo';
import { getOrSetCache, cacheKeys, cacheTTL } from '@/lib/cache';
// Force dynamic rendering to fetch fresh data from database // Force dynamic rendering to fetch fresh data from database
@@ -41,9 +42,10 @@ function formatTimeAgo(date: Date | null, locale: string): string {
return 'Just now'; 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) { async function getSkillsForTab(tab: 'new' | 'updated', page: number, limit: number) {
try { try {
return await getOrSetCache(cacheKeys.newSkills(tab, page), cacheTTL.newSkills, async () => {
const db = createDb(); const db = createDb();
const offset = (page - 1) * limit; const offset = (page - 1) * limit;
@@ -56,21 +58,24 @@ async function getSkillsForTab(tab: 'new' | 'updated', page: number, limit: numb
const total = await skillQueries.countUpdatedSkills(db); const total = await skillQueries.countUpdatedSkills(db);
return { skills, total }; return { skills, total };
} }
});
} catch (error) { } catch (error) {
console.error('Error fetching skills:', error); console.error('Error fetching skills:', error);
return { skills: [], total: 0 }; return { skills: [], total: 0 };
} }
} }
// Get counts for both tabs // Get counts for both tabs with Redis caching (30 min TTL)
async function getTabCounts() { async function getTabCounts() {
try { try {
return await getOrSetCache(cacheKeys.newSkillsCounts(), cacheTTL.newSkills, async () => {
const db = createDb(); const db = createDb();
const [newCount, updatedCount] = await Promise.all([ const [newCount, updatedCount] = await Promise.all([
skillQueries.countNewSkills(db), skillQueries.countNewSkills(db),
skillQueries.countUpdatedSkills(db), skillQueries.countUpdatedSkills(db),
]); ]);
return { newCount, updatedCount }; return { newCount, updatedCount };
});
} catch (error) { } catch (error) {
console.error('Error fetching counts:', error); console.error('Error fetching counts:', error);
return { newCount: 0, updatedCount: 0 }; return { newCount: 0, updatedCount: 0 };

View File

@@ -9,6 +9,7 @@ import { ExternalLink, Download, Package, Eye, GitFork, ArrowUpDown, FolderGit2
import Link from 'next/link'; import Link from 'next/link';
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import { getPageAlternates } from '@/lib/seo'; import { getPageAlternates } from '@/lib/seo';
import { getOrSetCache, cacheKeys, cacheTTL } from '@/lib/cache';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
@@ -49,11 +50,15 @@ export default async function OwnerPage({ params, searchParams }: OwnerPageProps
const db = createDb(); 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([ 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.countByOwner(db, username, activeRepo || undefined),
skillQueries.getOwnerRepos(db, username), getOrSetCache(cacheKeys.ownerRepos(username), cacheTTL.owner, () =>
skillQueries.getOwnerRepos(db, username)
),
]); ]);
if (stats.totalSkills === 0) { if (stats.totalSkills === 0) {

View File

@@ -8,67 +8,61 @@ import { SkillCard } from '@/components/SkillCard';
import { createDb, categoryQueries, skillQueries, skills, sql } from '@skillhub/db'; import { createDb, categoryQueries, skillQueries, skills, sql } from '@skillhub/db';
import { formatCompactNumber } from '@/lib/format-number'; import { formatCompactNumber } from '@/lib/format-number';
import { getPageAlternates } from '@/lib/seo'; import { getPageAlternates } from '@/lib/seo';
import { getOrSetCache, cacheKeys, cacheTTL } from '@/lib/cache';
// Force dynamic rendering to fetch fresh data from database // Force dynamic rendering to fetch fresh data from database
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
// Get stats directly from database // Get stats with Redis caching (1 hour TTL)
async function getStats() { async function getStats() {
try { try {
return await getOrSetCache(cacheKeys.homeStats(), cacheTTL.stats, async () => {
const db = createDb(); const db = createDb();
// Browse-ready filter: exclude duplicates (matches browseReadyFilter in queries.ts) // Browse-ready filter: exclude duplicates (matches browseReadyFilter in queries.ts)
const browseReady = sql`${skills.isDuplicate} = false`; const browseReady = sql`${skills.isDuplicate} = false`;
// 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) // Get total skills count (browse-ready, SKILL.md only)
const skillsResult = await db db.select({ count: sql<number>`count(*)::int` })
.select({ count: sql<number>`count(*)::int` })
.from(skills) .from(skills)
.where(sql`${skills.sourceFormat} = 'skill.md' AND ${skills.isBlocked} = false AND ${browseReady}`); .where(sql`${skills.sourceFormat} = 'skill.md' AND ${skills.isBlocked} = false AND ${browseReady}`),
const totalSkills = skillsResult[0]?.count ?? 0;
// Get total downloads (ALL skills — downloads are real user actions) // Get total downloads (ALL skills — downloads are real user actions)
const downloadsResult = await db db.select({ sum: sql<number>`coalesce(sum(${skills.downloadCount}), 0)::int` })
.select({ sum: sql<number>`coalesce(sum(${skills.downloadCount}), 0)::int` }) .from(skills),
.from(skills);
const totalDownloads = downloadsResult[0]?.sum ?? 0;
// Get total categories // Get total categories
const categories = await categoryQueries.getAll(db); categoryQueries.getAll(db),
const totalCategories = categories.length;
// Get unique contributors (browse-ready skills only) // Get unique contributors (browse-ready skills only)
const contributorsResult = await db db.select({ count: sql<number>`count(distinct ${skills.githubOwner})::int` })
.select({ count: sql<number>`count(distinct ${skills.githubOwner})::int` })
.from(skills) .from(skills)
.where(browseReady); .where(browseReady),
const totalContributors = contributorsResult[0]?.count ?? 0;
// Get total indexed skills (all, before curation) for curation note // Get total indexed skills (all, before curation) for curation note
const totalIndexedResult = await db db.select({ count: sql<number>`count(*)::int` })
.select({ count: sql<number>`count(*)::int` })
.from(skills) .from(skills)
.where(sql`${skills.isBlocked} = false`); .where(sql`${skills.isBlocked} = false`),
const totalIndexed = totalIndexedResult[0]?.count ?? 0; ]);
return { return {
totalSkills, totalSkills: skillsResult[0]?.count ?? 0,
totalDownloads, totalDownloads: downloadsResult[0]?.sum ?? 0,
totalCategories, totalCategories: categories.length,
totalContributors, totalContributors: contributorsResult[0]?.count ?? 0,
totalIndexed, totalIndexed: totalIndexedResult[0]?.count ?? 0,
platforms: 5, platforms: 5,
}; };
});
} catch (error) { } catch (error) {
console.error('Error fetching stats:', error); console.error('Error fetching stats:', error);
return null; return null;
} }
} }
// Get featured skills directly from database // Get featured skills with Redis caching (2 hour TTL)
async function getFeaturedSkills() { async function getFeaturedSkills() {
try { try {
return await getOrSetCache(cacheKeys.homeFeatured(), cacheTTL.featured, async () => {
const db = createDb(); const db = createDb();
// Get featured skills, or top skills by popularity if none are featured // Get featured skills, or top skills by popularity if none are featured
let featuredSkills = await skillQueries.getFeatured(db, 6); let featuredSkills = await skillQueries.getFeatured(db, 6);
@@ -77,6 +71,7 @@ async function getFeaturedSkills() {
featuredSkills = await skillQueries.getFeaturedWithDiversity(db, 6, 2, 3); featuredSkills = await skillQueries.getFeaturedWithDiversity(db, 6, 2, 3);
} }
return featuredSkills; return featuredSkills;
});
} catch (error) { } catch (error) {
console.error('Error fetching featured skills:', error); console.error('Error fetching featured skills:', error);
return []; return [];

View File

@@ -15,7 +15,7 @@ import { ShareButton } from '@/components/ShareButton';
import { createDb, skillQueries } from '@skillhub/db'; import { createDb, skillQueries } from '@skillhub/db';
import { FORMAT_LABELS } from 'skillhub-core'; import { FORMAT_LABELS } from 'skillhub-core';
import { formatCompactNumber } from '@/lib/format-number'; 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 type { Metadata } from 'next';
import { getPageAlternates } from '@/lib/seo'; import { getPageAlternates } from '@/lib/seo';
@@ -46,11 +46,13 @@ interface SkillPageProps {
params: Promise<{ locale: string; id: string[] }>; params: Promise<{ locale: string; id: string[] }>;
} }
// Get skill directly from database // Get skill with Redis caching (1 hour TTL)
async function getSkill(skillId: string) { async function getSkill(skillId: string) {
try { try {
return await getOrSetCache(cacheKeys.skillDetail(skillId), cacheTTL.skill, async () => {
const db = createDb(); const db = createDb();
return await skillQueries.getById(db, skillId); return await skillQueries.getById(db, skillId);
});
} catch (error) { } catch (error) {
console.error('Error fetching skill:', error); console.error('Error fetching skill:', error);
return null; return null;

View File

@@ -3,6 +3,7 @@ import { createDb, ratingQueries, skillQueries, userQueries } from '@skillhub/db
import { auth } from '@/lib/auth'; import { auth } from '@/lib/auth';
import { withRateLimit, createRateLimitResponse, createRateLimitHeaders } from '@/lib/rate-limit'; import { withRateLimit, createRateLimitResponse, createRateLimitHeaders } from '@/lib/rate-limit';
import { sanitizeReview } from '@/lib/sanitize'; import { sanitizeReview } from '@/lib/sanitize';
import { getOrSetCache, invalidateCache, cacheKeys, cacheTTL } from '@/lib/cache';
const db = createDb(); const db = createDb();
@@ -28,10 +29,13 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: 'skillId is required' }, { status: 400 }); return NextResponse.json({ error: 'skillId is required' }, { status: 400 });
} }
const data = await getOrSetCache(
cacheKeys.skillRatings(skillId, limit, offset),
cacheTTL.ratings,
async () => {
const ratings = await ratingQueries.getForSkill(db, skillId, limit, offset); const ratings = await ratingQueries.getForSkill(db, skillId, limit, offset);
const skill = await skillQueries.getById(db, skillId); const skill = await skillQueries.getById(db, skillId);
return {
return NextResponse.json({
ratings: ratings.map((r) => ({ ratings: ratings.map((r) => ({
id: r.rating.id, id: r.rating.id,
rating: r.rating.rating, rating: r.rating.rating,
@@ -48,7 +52,11 @@ export async function GET(request: NextRequest) {
average: skill?.rating || 0, average: skill?.rating || 0,
count: skill?.ratingCount || 0, count: skill?.ratingCount || 0,
}, },
}, { };
}
);
return NextResponse.json(data, {
headers: createRateLimitHeaders(rateLimitResult), headers: createRateLimitHeaders(rateLimitResult),
}); });
} catch (error) { } catch (error) {
@@ -103,6 +111,12 @@ export async function POST(request: NextRequest) {
review: sanitizeReview(review) ?? undefined, 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 // Get updated skill aggregates
const updatedSkill = await skillQueries.getById(db, skillId); const updatedSkill = await skillQueries.getById(db, skillId);

View File

@@ -1,14 +1,157 @@
'use client'; 'use client';
import { AppProgressBar } from 'next-nprogress-bar'; import { useEffect, useRef } from 'react';
export function ProgressBar() { export function ProgressBar() {
const barRef = useRef<HTMLDivElement>(null);
const timerRef = useRef<ReturnType<typeof setTimeout> | 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 <a> 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 ( return (
<AppProgressBar <div
height="3px" style={{
color="#0284c7" position: 'fixed',
options={{ showSpinner: false }} top: 0,
shallowRouting left: 0,
right: 0,
zIndex: 99999,
pointerEvents: 'none',
height: '3px',
}}
>
<div
ref={barRef}
style={{
height: '100%',
background: 'linear-gradient(90deg, #0284c7, #38bdf8)',
width: '0%',
opacity: '0',
boxShadow: '0 0 8px rgba(2, 132, 199, 0.4)',
}}
/> />
</div>
); );
} }

View File

@@ -129,12 +129,41 @@ export async function isCacheAvailable(): Promise<boolean> {
} }
} }
/**
* Get cached data or fetch and cache it.
* If Redis is unavailable, falls back to fetcher directly (graceful degradation).
*/
export async function getOrSetCache<T>(
key: string,
ttlSeconds: number,
fetcher: () => Promise<T>
): Promise<T> {
const cached = await getCached<T>(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 // Cache key builders for consistency
export const cacheKeys = { export const cacheKeys = {
stats: () => 'stats:global', stats: () => 'stats:global',
homeStats: () => 'page:home:stats',
homeFeatured: () => 'page:home:featured',
categories: () => 'categories:all', categories: () => 'categories:all',
categoriesHierarchical: () => 'categories:hierarchical',
featuredSkills: () => 'skills:featured', featuredSkills: () => 'skills:featured',
featuredPage: (page: number) => `page:featured:${page}`,
recentSkills: () => 'skills:recent', 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}`, searchSkills: (hash: string) => `skills:search:${hash}`,
skill: (id: string) => `skill:${id.replace(/\//g, ':')}`, skill: (id: string) => `skill:${id.replace(/\//g, ':')}`,
skillView: (skillId: string, ip: string) => `view:${skillId.replace(/\//g, ':')}:${ip}`, skillView: (skillId: string, ip: string) => `view:${skillId.replace(/\//g, ':')}:${ip}`,
@@ -149,6 +178,10 @@ export const cacheTTL = {
recent: 60 * 60, // 1 hour recent: 60 * 60, // 1 hour
search: 30 * 60, // 30 minutes search: 30 * 60, // 30 minutes
skill: 60 * 60, // 1 hour 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 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 download: 5 * 60, // 5 minutes - same IP can only count as 1 download per 5 min
}; };

View File

@@ -27,8 +27,7 @@
"next": "^15.0.0", "next": "^15.0.0",
"next-auth": "5.0.0-beta.30", "next-auth": "5.0.0-beta.30",
"next-intl": "^3.4.0", "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": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"resend": "^6.9.1", "resend": "^6.9.1",

View File

@@ -37,6 +37,8 @@ Sentry.init({
"Error in input stream", "Error in input stream",
// Network fetch failures (Safari / mobile) // Network fetch failures (Safari / mobile)
"Load failed", "Load failed",
// Generic network errors (client connectivity issues)
"network error",
// Browser extension noise (translation/accessibility extensions) // Browser extension noise (translation/accessibility extensions)
/Object Not Found Matching Id/, /Object Not Found Matching Id/,
// ResizeObserver loop limit — browser quirk, not actionable // ResizeObserver loop limit — browser quirk, not actionable

15
pnpm-lock.yaml generated
View File

@@ -187,9 +187,6 @@ importers:
next-intl: next-intl:
specifier: ^3.4.0 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) 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: next-themes:
specifier: ^0.4.4 specifier: ^0.4.4
version: 0.4.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1) 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 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 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: next-themes@0.4.6:
resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==} resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==}
peerDependencies: peerDependencies:
@@ -4377,9 +4371,6 @@ packages:
resolution: {integrity: sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==} resolution: {integrity: sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} 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: oauth4webapi@3.8.3:
resolution: {integrity: sha512-pQ5BsX3QRTgnt5HxgHwgunIRaDXBdkT23tf8dfzmtTIL2LTpdmxgbpbBm0VgFWAIDlezQvQCTgnVIUmHupXHxw==} resolution: {integrity: sha512-pQ5BsX3QRTgnt5HxgHwgunIRaDXBdkT23tf8dfzmtTIL2LTpdmxgbpbBm0VgFWAIDlezQvQCTgnVIUmHupXHxw==}
@@ -9616,10 +9607,6 @@ snapshots:
react: 18.3.1 react: 18.3.1
use-intl: 3.26.5(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): next-themes@0.4.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies: dependencies:
react: 18.3.1 react: 18.3.1
@@ -9673,8 +9660,6 @@ snapshots:
dependencies: dependencies:
path-key: 4.0.0 path-key: 4.0.0
nprogress-v2@1.1.10: {}
oauth4webapi@3.8.3: {} oauth4webapi@3.8.3: {}
object-assign@4.1.1: {} object-assign@4.1.1: {}

View File

@@ -111,7 +111,7 @@ export class PopularReposCrawler {
for (const repo of response.data.items) { for (const repo of response.data.items) {
if (!repo.archived) { if (!repo.archived) {
results.push({ results.push({
owner: repo.owner.login, owner: repo.owner!.login,
repo: repo.name, repo: repo.name,
stars: repo.stargazers_count, stars: repo.stargazers_count,
forks: repo.forks_count, forks: repo.forks_count,