Skip to content

Commit 3a59b4e

Browse files
authored
(SP: 2) [Frontend] Quizzes page ISR + client-side progress + GitHub stars cache (#371)
* perf(quiz-flow): move quiz progress to client-side fetch, enable ISR for quizzes page - Move user progress fetch from SSR to client-side API (/api/quiz/progress) - Remove force-dynamic and getCurrentUser() from quizzes page - Add revalidate=300 for ISR caching - Use window.history.replaceState for tab URL sync (avoid Next.js navigation) - Add forceMount to TabsContent to prevent layout shift on tab switch - Fix nested <main> — use <section> inside DynamicGridBackground - Cache GitHub stars count in sessionStorage to avoid refetch + re-animation * perf: replace useRef with useState lazy initializer in GitHubStarButton Fixes React 19 react-hooks/refs ESLint error — useRef.current cannot be read during render. Uses useState(getStoredStars) to capture the sessionStorage value once on mount instead. * fix: stop star icon trembling on hover in GitHubStarButton
1 parent 9130df1 commit 3a59b4e

4 files changed

Lines changed: 88 additions & 26 deletions

File tree

frontend/app/[locale]/quizzes/page.tsx

Lines changed: 4 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,7 @@ import QuizzesSection from '@/components/quiz/QuizzesSection';
55
import { DynamicGridBackground } from '@/components/shared/DynamicGridBackground';
66
import {
77
getActiveQuizzes,
8-
getUserQuizzesProgress,
98
} from '@/db/queries/quizzes/quiz';
10-
import { getCurrentUser } from '@/lib/auth';
119

1210
type PageProps = { params: Promise<{ locale: string }> };
1311

@@ -23,22 +21,14 @@ export async function generateMetadata({
2321
};
2422
}
2523

26-
export const dynamic = 'force-dynamic';
24+
export const revalidate = 300
2725

2826
export default async function QuizzesPage({ params }: PageProps) {
2927
const { locale } = await params;
3028
const t = await getTranslations({ locale, namespace: 'quiz.list' });
31-
const session = await getCurrentUser();
3229

3330
const quizzes = await getActiveQuizzes(locale);
3431

35-
let userProgressMap: Record<string, any> = {};
36-
37-
if (session?.id) {
38-
const progressMapData = await getUserQuizzesProgress(session.id);
39-
userProgressMap = Object.fromEntries(progressMapData);
40-
}
41-
4232
if (!quizzes.length) {
4333
return (
4434
<div className="mx-auto max-w-7xl px-4 py-12 sm:px-6 lg:px-8">
@@ -50,7 +40,7 @@ export default async function QuizzesPage({ params }: PageProps) {
5040

5141
return (
5242
<DynamicGridBackground className="min-h-screen bg-gray-50 py-10 transition-colors duration-300 dark:bg-transparent">
53-
<main className="relative z-10 mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
43+
<section className="relative z-10 mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
5444
<div className="mb-8">
5545
<p className="text-sm font-semibold text-(--accent-primary)">
5646
{t('practice')}
@@ -59,8 +49,8 @@ export default async function QuizzesPage({ params }: PageProps) {
5949
<p className="text-gray-600 dark:text-gray-400">{t('subtitle')}</p>
6050
</div>
6151

62-
<QuizzesSection quizzes={quizzes} userProgressMap={userProgressMap} />
63-
</main>
52+
<QuizzesSection quizzes={quizzes} userProgressMap={{}} />
53+
</section>
6454
</DynamicGridBackground>
6555
);
6656
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { NextResponse } from 'next/server';
2+
3+
import { getUserQuizzesProgress } from '@/db/queries/quizzes/quiz';
4+
import { getCurrentUser } from '@/lib/auth';
5+
6+
export const runtime = 'nodejs';
7+
8+
export async function GET() {
9+
const user = await getCurrentUser();
10+
11+
if (!user?.id) {
12+
return NextResponse.json({}, {
13+
headers: { 'Cache-Control': 'no-store' },
14+
});
15+
}
16+
17+
const rawProgress = await getUserQuizzesProgress(user.id);
18+
const progressMap: Record<string, { bestScore: number; totalQuestions: number; attemptsCount: number }> = {};
19+
20+
for (const [quizId, progress] of rawProgress) {
21+
progressMap[quizId] = {
22+
bestScore: progress.bestScore,
23+
totalQuestions: progress.totalQuestions,
24+
attemptsCount: progress.attemptsCount,
25+
};
26+
}
27+
28+
return NextResponse.json(progressMap, {
29+
headers: { 'Cache-Control': 'no-store' },
30+
});
31+
}

frontend/components/quiz/QuizzesSection.tsx

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import { useParams, useRouter, useSearchParams } from 'next/navigation';
44
import { useTranslations } from 'next-intl';
5+
import { useEffect, useState } from 'react';
56

67
import { CategoryTabButton } from '@/components/shared/CategoryTabButton';
78
import { Tabs, TabsContent, TabsList } from '@/components/ui/tabs';
@@ -47,16 +48,37 @@ export default function QuizzesSection({
4748
? (locale as 'uk' | 'en' | 'pl')
4849
: 'en';
4950

51+
const [progressMap, setProgressMap] =
52+
useState<Record<string, UserProgress>>(userProgressMap);
53+
const [progressLoaded, setProgressLoaded] = useState(
54+
Object.keys(userProgressMap).length > 0
55+
);
56+
57+
useEffect(() => {
58+
fetch('/api/quiz/progress')
59+
.then(res => res.ok ? res.json() : {})
60+
.then(data => {
61+
setProgressMap(data);
62+
setProgressLoaded(true);
63+
})
64+
.catch(() => {
65+
setProgressLoaded(true);
66+
});
67+
}, []);
68+
5069
const DEFAULT_CATEGORY = categoryData[0]?.slug || 'git';
5170

5271
const categoryFromUrl = searchParams.get('category');
5372
const validCategory = categoryData.some(c => c.slug === categoryFromUrl);
54-
const activeCategory = validCategory ? categoryFromUrl! : DEFAULT_CATEGORY;
73+
const [activeCategory, setActiveCategory] = useState(
74+
validCategory ? categoryFromUrl! : DEFAULT_CATEGORY
75+
);
5576

5677
const handleCategoryChange = (category: string) => {
78+
setActiveCategory(category);
5779
const params = new URLSearchParams(searchParams.toString());
5880
params.set('category', category);
59-
router.replace(`?${params.toString()}`, { scroll: false });
81+
window.history.replaceState(null, '', `?${params.toString()}`);
6082
};
6183

6284
return (
@@ -85,7 +107,12 @@ export default function QuizzesSection({
85107
quiz => quiz.categorySlug === category.slug
86108
);
87109
return (
88-
<TabsContent key={category.slug} value={category.slug}>
110+
<TabsContent
111+
key={category.slug}
112+
value={category.slug}
113+
forceMount
114+
className={activeCategory !== category.slug ? 'hidden' : ''}
115+
>
89116
{categoryQuizzes.length > 0 ? (
90117
<div className="max-w-5xl">
91118
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
@@ -102,7 +129,7 @@ export default function QuizzesSection({
102129
categoryName: quiz.categoryName ?? category.slug,
103130
categorySlug: quiz.categorySlug ?? category.slug,
104131
}}
105-
userProgress={userProgressMap[quiz.id] || null}
132+
userProgress={progressLoaded ? (progressMap[quiz.id] || null) : null}
106133
/>
107134
))}
108135
</div>

frontend/components/shared/GitHubStarButton.tsx

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,32 @@
22

33
import { Star } from 'lucide-react';
44
import { useTranslations } from 'next-intl';
5-
import { useEffect, useRef, useState } from 'react';
5+
import { useEffect, useState } from 'react';
6+
7+
const STORAGE_KEY = 'github-stars';
8+
9+
function getStoredStars(): number | null {
10+
if (typeof sessionStorage === 'undefined') return null;
11+
const stored = sessionStorage.getItem(STORAGE_KEY);
12+
if (!stored) return null;
13+
const parsed = parseInt(stored, 10);
14+
return Number.isNaN(parsed) ? null : parsed;
15+
}
616

717
interface GitHubStarButtonProps {
818
className?: string;
919
}
1020

1121
export function GitHubStarButton({ className = '' }: GitHubStarButtonProps) {
1222
const t = useTranslations('aria');
13-
const [displayCount, setDisplayCount] = useState(0);
14-
const [finalCount, setFinalCount] = useState<number | null>(null);
23+
const [storedStars] = useState(getStoredStars);
24+
const [displayCount, setDisplayCount] = useState(storedStars ?? 0);
25+
const [finalCount, setFinalCount] = useState<number | null>(storedStars);
1526
const githubUrl = 'https://github.com/DevLoversTeam/devlovers.net';
16-
const hasAnimated = useRef(false);
1727

1828
useEffect(() => {
29+
if (storedStars !== null) return;
30+
1931
const fetchStars = async () => {
2032
try {
2133
const response = await fetch('/api/stats');
@@ -46,9 +58,8 @@ export function GitHubStarButton({ className = '' }: GitHubStarButtonProps) {
4658
}, []);
4759

4860
useEffect(() => {
49-
if (finalCount === null || hasAnimated.current) return;
61+
if (finalCount === null || storedStars !== null) return;
5062

51-
hasAnimated.current = true;
5263
const duration = 2000;
5364
const steps = 60;
5465
const increment = finalCount / steps;
@@ -59,13 +70,16 @@ export function GitHubStarButton({ className = '' }: GitHubStarButtonProps) {
5970
if (current >= finalCount) {
6071
setDisplayCount(finalCount);
6172
clearInterval(timer);
73+
try {
74+
sessionStorage.setItem(STORAGE_KEY, String(finalCount));
75+
} catch {}
6276
} else {
6377
setDisplayCount(Math.floor(current));
6478
}
6579
}, duration / steps);
6680

6781
return () => clearInterval(timer);
68-
}, [finalCount]);
82+
}, [finalCount, storedStars]);
6983

7084
const formatStarCount = (count: number): string => {
7185
return count.toLocaleString();
@@ -93,7 +107,7 @@ export function GitHubStarButton({ className = '' }: GitHubStarButtonProps) {
93107
{formatStarCount(displayCount)}
94108
</span>
95109
<Star
96-
className="h-3.5 w-3.5 shrink-0 text-muted-foreground transition-all duration-300 group-hover:rotate-12 group-hover:text-yellow-400 group-hover:drop-shadow-[0_0_6px_rgba(250,204,21,0.5)] group-active:rotate-12 group-active:text-yellow-400 group-active:drop-shadow-[0_0_6px_rgba(250,204,21,0.5)]"
110+
className="h-3.5 w-3.5 shrink-0 text-muted-foreground transition-[transform,color] duration-300 group-hover:rotate-12 group-hover:text-yellow-400 group-hover:drop-shadow-[0_0_6px_rgba(250,204,21,0.5)] group-active:rotate-12 group-active:text-yellow-400 group-active:drop-shadow-[0_0_6px_rgba(250,204,21,0.5)]"
97111
fill="currentColor"
98112
aria-hidden="true"
99113
/>

0 commit comments

Comments
 (0)