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 { 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,
|
||||||
|
|||||||
@@ -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 [];
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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+';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 [];
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
15
pnpm-lock.yaml
generated
@@ -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: {}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user