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:
@@ -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,
|
||||
|
||||
@@ -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<string, string> = {
|
||||
'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 [];
|
||||
|
||||
@@ -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<number>`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<number>`count(*)::int` })
|
||||
.from(skills);
|
||||
return skillsResult[0]?.count ?? 0;
|
||||
});
|
||||
} catch {
|
||||
return 119000;
|
||||
}
|
||||
|
||||
@@ -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<string> {
|
||||
try {
|
||||
const db = createDb();
|
||||
const result = await db
|
||||
.select({ count: sql<number>`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<number>`count(*)::int` })
|
||||
.from(skills)
|
||||
.where(sql`${skills.isDuplicate} = false`);
|
||||
return formatPromptSkillCount(result[0]?.count ?? 16000);
|
||||
});
|
||||
} catch {
|
||||
return '16,000+';
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<number>`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<number>`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<number>`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<number>`count(distinct ${skills.githubOwner})::int` })
|
||||
.from(skills)
|
||||
.where(browseReady),
|
||||
// Get total indexed skills (all, before curation) for curation note
|
||||
db.select({ count: sql<number>`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<number>`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<number>`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<number>`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 [];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -1,14 +1,157 @@
|
||||
'use client';
|
||||
|
||||
import { AppProgressBar } from 'next-nprogress-bar';
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
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 (
|
||||
<AppProgressBar
|
||||
height="3px"
|
||||
color="#0284c7"
|
||||
options={{ showSpinner: false }}
|
||||
shallowRouting
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
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
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
15
pnpm-lock.yaml
generated
15
pnpm-lock.yaml
generated
@@ -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: {}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user