diff --git a/CHANGELOG.md b/CHANGELOG.md index 5bb78ed6..9bc90cd6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -747,3 +747,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/). - Lower Vercel Function Invocations and CPU usage - Reduced origin data transfer for blog content - Improved overall runtime efficiency + +## [1.0.5] - 2026-02-26 + +### Added + +- Auth improvements: + - Cross-tab authentication sync via BroadcastChannel + - Client-side auth handling in Header for faster UI updates + +### Changed + +- Quizzes performance: + - Quizzes page now uses ISR (revalidate: 300) + - User progress moved from SSR to client-side API (`/api/quiz/progress`) + - URL tab sync via `history.replaceState` without navigation + - GitHub stars cached in `sessionStorage` to prevent refetch and re-animation +- Rendering optimization: + - Removed `force-dynamic` from locale layout + - Reduced authentication overhead and dynamic rendering + - Replaced nested `
` structure with semantic `
` + +### Fixed + +- Fixed quiz timer flash when switching language +- Fixed layout shift on quizzes page (skeleton grid during progress loading) +- Fixed GitHub star button hover trembling +- Fixed React 19 `useRef` render warning (lazy `useState` initializer) +- Prevented stale auth state across tabs +- Eliminated layout shift when switching quiz tabs + +### Performance + +- Reduced server load by moving auth and progress logic to client +- Improved ISR caching efficiency for quizzes page +- Faster navigation and more stable UI during locale and tab changes diff --git a/frontend/app/[locale]/layout.tsx b/frontend/app/[locale]/layout.tsx index 08f97593..ef202148 100644 --- a/frontend/app/[locale]/layout.tsx +++ b/frontend/app/[locale]/layout.tsx @@ -13,10 +13,8 @@ import { CookieBanner } from '@/components/shared/CookieBanner'; import Footer from '@/components/shared/Footer'; import { ScrollWatcher } from '@/components/shared/ScrollWatcher'; import { ThemeProvider } from '@/components/theme/ThemeProvider'; +import { AuthProvider } from '@/hooks/useAuth'; import { locales } from '@/i18n/config'; -import { getCurrentUser } from '@/lib/auth'; - -export const dynamic = 'force-dynamic'; const getCachedBlogCategories = unstable_cache( async () => @@ -41,11 +39,11 @@ export default async function LocaleLayout({ if (!locales.includes(locale as any)) notFound(); - const messages = await getMessages({ locale }); - const user = await getCurrentUser(); - const blogCategories = await getCachedBlogCategories(); + const [messages, blogCategories] = await Promise.all([ + getMessages({ locale }), + getCachedBlogCategories(), + ]); - const userExists = Boolean(user); const enableAdmin = ( process.env.ENABLE_ADMIN_API ?? @@ -53,10 +51,6 @@ export default async function LocaleLayout({ '' ).toLowerCase() === 'true'; - const isAdmin = user?.role === 'admin'; - const showAdminNavLink = Boolean(user) && isAdmin && enableAdmin; - const userId = user?.id ?? null; - return ( - - + - {children} - - + + {children} + + +
diff --git a/frontend/app/[locale]/quiz/[slug]/loading.tsx b/frontend/app/[locale]/quiz/[slug]/loading.tsx deleted file mode 100644 index 69203c53..00000000 --- a/frontend/app/[locale]/quiz/[slug]/loading.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import { Loader } from '@/components/shared/Loader'; - -export default function QuizLoading() { - return ( -
- -
- ); -} diff --git a/frontend/app/[locale]/quizzes/page.tsx b/frontend/app/[locale]/quizzes/page.tsx index a9fb5f08..7706683a 100644 --- a/frontend/app/[locale]/quizzes/page.tsx +++ b/frontend/app/[locale]/quizzes/page.tsx @@ -5,9 +5,7 @@ import QuizzesSection from '@/components/quiz/QuizzesSection'; import { DynamicGridBackground } from '@/components/shared/DynamicGridBackground'; import { getActiveQuizzes, - getUserQuizzesProgress, } from '@/db/queries/quizzes/quiz'; -import { getCurrentUser } from '@/lib/auth'; type PageProps = { params: Promise<{ locale: string }> }; @@ -23,22 +21,14 @@ export async function generateMetadata({ }; } -export const dynamic = 'force-dynamic'; +export const revalidate = 300 export default async function QuizzesPage({ params }: PageProps) { const { locale } = await params; const t = await getTranslations({ locale, namespace: 'quiz.list' }); - const session = await getCurrentUser(); const quizzes = await getActiveQuizzes(locale); - let userProgressMap: Record = {}; - - if (session?.id) { - const progressMapData = await getUserQuizzesProgress(session.id); - userProgressMap = Object.fromEntries(progressMapData); - } - if (!quizzes.length) { return (
@@ -50,7 +40,7 @@ export default async function QuizzesPage({ params }: PageProps) { return ( -
+

{t('practice')} @@ -59,8 +49,8 @@ export default async function QuizzesPage({ params }: PageProps) {

{t('subtitle')}

- -
+ +
); } diff --git a/frontend/app/api/auth/me/route.ts b/frontend/app/api/auth/me/route.ts index 5b755c69..3723fbb1 100644 --- a/frontend/app/api/auth/me/route.ts +++ b/frontend/app/api/auth/me/route.ts @@ -2,9 +2,16 @@ import 'server-only'; import { NextResponse } from 'next/server'; -import { getCurrentUser } from '@/lib/auth'; +import { getAuthSession } from '@/lib/auth'; export async function GET() { - const user = await getCurrentUser(); - return NextResponse.json({ user }, { status: 200 }); + const session = await getAuthSession(); + const payload = session ? { id: session.id, role: session.role } : null; + + return NextResponse.json(payload, { + status: 200, + headers: { + 'Cache-Control': 'no-store', + }, + }); } diff --git a/frontend/app/api/quiz/progress/route.ts b/frontend/app/api/quiz/progress/route.ts new file mode 100644 index 00000000..69819f4f --- /dev/null +++ b/frontend/app/api/quiz/progress/route.ts @@ -0,0 +1,31 @@ +import { NextResponse } from 'next/server'; + +import { getUserQuizzesProgress } from '@/db/queries/quizzes/quiz'; +import { getCurrentUser } from '@/lib/auth'; + +export const runtime = 'nodejs'; + +export async function GET() { + const user = await getCurrentUser(); + + if (!user?.id) { + return NextResponse.json({}, { + headers: { 'Cache-Control': 'no-store' }, + }); + } + + const rawProgress = await getUserQuizzesProgress(user.id); + const progressMap: Record = {}; + + for (const [quizId, progress] of rawProgress) { + progressMap[quizId] = { + bestScore: progress.bestScore, + totalQuestions: progress.totalQuestions, + attemptsCount: progress.attemptsCount, + }; + } + + return NextResponse.json(progressMap, { + headers: { 'Cache-Control': 'no-store' }, + }); +} diff --git a/frontend/components/auth/LoginForm.tsx b/frontend/components/auth/LoginForm.tsx index cda0b0f7..e999aa0f 100644 --- a/frontend/components/auth/LoginForm.tsx +++ b/frontend/components/auth/LoginForm.tsx @@ -11,6 +11,7 @@ import { EmailField } from '@/components/auth/fields/EmailField'; import { PasswordField } from '@/components/auth/fields/PasswordField'; import { Button } from '@/components/ui/button'; import { Link } from '@/i18n/routing'; +import { broadcastAuthUpdated } from '@/lib/auth-sync'; type LoginFormProps = { locale: string; @@ -59,6 +60,7 @@ export function LoginForm({ locale, returnTo }: LoginFormProps) { return; } + broadcastAuthUpdated(); window.location.href = returnTo || `/${locale}/dashboard`; } catch (err) { console.error('Login request failed:', err); diff --git a/frontend/components/auth/SignupForm.tsx b/frontend/components/auth/SignupForm.tsx index 8920f3b3..078c528f 100644 --- a/frontend/components/auth/SignupForm.tsx +++ b/frontend/components/auth/SignupForm.tsx @@ -22,6 +22,7 @@ import { PASSWORD_MIN_LEN, PASSWORD_POLICY_REGEX, } from '@/lib/auth/signup-constraints'; +import { broadcastAuthUpdated } from '@/lib/auth-sync'; type SignupFormProps = { locale: string; @@ -173,6 +174,7 @@ export function SignupForm({ locale, returnTo }: SignupFormProps) { return; } + broadcastAuthUpdated(); window.location.href = returnTo || `/${locale}/dashboard`; } catch { setError(t('errors.networkError')); diff --git a/frontend/components/header/AppChrome.tsx b/frontend/components/header/AppChrome.tsx index fd439509..1c67ebe2 100644 --- a/frontend/components/header/AppChrome.tsx +++ b/frontend/components/header/AppChrome.tsx @@ -5,25 +5,24 @@ import React from 'react'; import { UnifiedHeader } from '@/components/header/UnifiedHeader'; import { CartProvider } from '@/components/shop/CartProvider'; +import { useAuth } from '@/hooks/useAuth'; type AppChromeProps = { - userExists: boolean; - userId?: string | null; - showAdminLink?: boolean; + enableAdminFeature?: boolean; blogCategories?: Array<{ _id: string; title: string }>; children: React.ReactNode; }; export function AppChrome({ - userExists, - userId = null, - showAdminLink = false, + enableAdminFeature = false, blogCategories = [], children, }: AppChromeProps) { + const { userExists, userId, isAdmin } = useAuth(); const segments = useSelectedLayoutSegments(); const isShop = segments.includes('shop'); const isBlog = segments.includes('blog'); + const showAdminLink = userExists && isAdmin && enableAdminFeature; if (isShop) { return ( diff --git a/frontend/components/header/MainSwitcher.tsx b/frontend/components/header/MainSwitcher.tsx index fe0904ec..4833e5d8 100644 --- a/frontend/components/header/MainSwitcher.tsx +++ b/frontend/components/header/MainSwitcher.tsx @@ -4,6 +4,7 @@ import { usePathname } from 'next/navigation'; import type { ReactNode } from 'react'; import { UnifiedHeader } from '@/components/header/UnifiedHeader'; +import { useAuth } from '@/hooks/useAuth'; import { locales } from '@/i18n/config'; function isShopPath(pathname: string): boolean { @@ -52,20 +53,20 @@ function isLeaderboardPath(pathname: string): boolean { type MainSwitcherProps = { children: ReactNode; - userExists: boolean; - showAdminLink?: boolean; + enableAdminFeature?: boolean; blogCategories?: Array<{ _id: string; title: string }>; }; export function MainSwitcher({ children, - userExists, - showAdminLink = false, + enableAdminFeature = false, blogCategories = [], }: MainSwitcherProps) { + const { userExists, isAdmin } = useAuth(); const pathname = usePathname(); const isQa = isQaPath(pathname); const isHome = isHomePath(pathname); + const showAdminLink = userExists && isAdmin && enableAdminFeature; if (isShopPath(pathname)) return <>{children}; diff --git a/frontend/components/q&a/AIWordHelper.tsx b/frontend/components/q&a/AIWordHelper.tsx index 8f300cb6..bc556941 100644 --- a/frontend/components/q&a/AIWordHelper.tsx +++ b/frontend/components/q&a/AIWordHelper.tsx @@ -19,6 +19,7 @@ import { useParams } from 'next/navigation'; import { useTranslations } from 'next-intl'; import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { useAuth } from '@/hooks/useAuth'; import { Link } from '@/i18n/routing'; import { getCachedExplanation, @@ -160,8 +161,8 @@ export default function AIWordHelper({ ); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); - const [isAuthenticated, setIsAuthenticated] = useState(null); - const [isCheckingAuth, setIsCheckingAuth] = useState(true); + const { userExists, loading: isCheckingAuth } = useAuth(); + const isAuthenticated = userExists; const [rateLimitState, setRateLimitState] = useState({ isRateLimited: false, resetIn: 0, @@ -186,25 +187,6 @@ export default function AIWordHelper({ const modalRef = useRef(null); - useEffect(() => { - if (!isOpen) return; - - const checkAuth = async () => { - setIsCheckingAuth(true); - try { - const response = await fetch('/api/auth/me'); - const data = await response.json(); - setIsAuthenticated(Boolean(data.user)); - } catch { - setIsAuthenticated(false); - } finally { - setIsCheckingAuth(false); - } - }; - - checkAuth(); - }, [isOpen]); - useEffect(() => { if (isOpen) { setPosition({ x: 0, y: 0 }); diff --git a/frontend/components/quiz/CountdownTimer.tsx b/frontend/components/quiz/CountdownTimer.tsx index ea13bede..42deaed9 100644 --- a/frontend/components/quiz/CountdownTimer.tsx +++ b/frontend/components/quiz/CountdownTimer.tsx @@ -21,22 +21,20 @@ export function CountdownTimer({ }: CountdownTimerProps) { const t = useTranslations('quiz.timer'); const endTime = startedAt.getTime() + timeLimitSeconds * 1000; - const [remainingSeconds, setRemainingSeconds] = useState(timeLimitSeconds); + // Timer needs current time on mount to show correct remaining (e.g. after language switch restore). + const [remainingSeconds, setRemainingSeconds] = useState(() => + Math.max(0, Math.floor((endTime - Date.now()) / 1000)) + ); + const [isSynced, setIsSynced] = useState(false); - const [prevEndTime, setPrevEndTime] = useState(endTime); - if (endTime !== prevEndTime) { - setPrevEndTime(endTime); - setIsSynced(false); - setRemainingSeconds(timeLimitSeconds); - } useEffect(() => { if (!isActive) return; let synced = false; - const interval = setInterval(() => { + const tick = () => { const now = Date.now(); const remaining = Math.max(0, Math.floor((endTime - now) / 1000)); @@ -48,12 +46,16 @@ export function CountdownTimer({ } if (remaining === 0) { - clearInterval(interval); + clearInterval(intervalId); queueMicrotask(onTimeUp); } - }, 100); + }; - return () => clearInterval(interval); + const intervalId = setInterval(tick, 1000); + + return () => { + clearInterval(intervalId); + }; }, [isActive, onTimeUp, endTime]); useEffect(() => { diff --git a/frontend/components/quiz/QuizContainer.tsx b/frontend/components/quiz/QuizContainer.tsx index c2e233c4..96722ae1 100644 --- a/frontend/components/quiz/QuizContainer.tsx +++ b/frontend/components/quiz/QuizContainer.tsx @@ -3,7 +3,6 @@ import { Ban, FileText, TriangleAlert, UserRound } from 'lucide-react'; import { useRouter, useSearchParams } from 'next/navigation'; import { useLocale, useTranslations } from 'next-intl'; import { - useCallback, useEffect, useReducer, useState, @@ -24,8 +23,13 @@ import { Link } from '@/i18n/routing'; import { savePendingQuizResult } from '@/lib/quiz/guest-quiz'; import { clearQuizSession, + loadQuizSession, type QuizSessionData, } from '@/lib/quiz/quiz-session'; +import { + getQuizReloadKey, + QUIZ_ALLOW_RESTORE_KEY, +} from '@/lib/quiz/quiz-storage-keys'; import { CountdownTimer } from './CountdownTimer'; import { QuizProgress } from './QuizProgress'; @@ -180,16 +184,44 @@ export function QuizContainer({ : '#3B82F6'; const [isStarting, setIsStarting] = useState(false); const [isPending, startTransition] = useTransition(); - const [state, dispatch] = useReducer(quizReducer, { - status: 'rules', - currentIndex: 0, - answers: [], - questionStatus: 'answering', - selectedAnswerId: null, - startedAt: null, - pointsAwarded: null, - attemptId: null, - isIncomplete: false, + const [state, dispatch] = useReducer(quizReducer, quizId, (id) => { + const defaultState: QuizState = { + status: 'rules', + currentIndex: 0, + answers: [], + questionStatus: 'answering', + selectedAnswerId: null, + startedAt: null, + pointsAwarded: null, + attemptId: null, + isIncomplete: false, + }; + + if (typeof window === 'undefined') return defaultState; + + const allowRestore = sessionStorage.getItem(QUIZ_ALLOW_RESTORE_KEY); + const reloadKey = getQuizReloadKey(id); + const isReload = sessionStorage.getItem(reloadKey); + + if (!allowRestore && !isReload) return defaultState; + + const saved = loadQuizSession(id); + if (!saved) return defaultState; + + return { + status: saved.status, + currentIndex: saved.currentIndex, + answers: saved.answers.map(a => ({ + ...a, + answeredAt: new Date(a.answeredAt), + })), + questionStatus: saved.questionStatus, + selectedAnswerId: saved.selectedAnswerId, + startedAt: saved.startedAt ? new Date(saved.startedAt) : null, + pointsAwarded: saved.pointsAwarded ?? null, + attemptId: saved.attemptId ?? null, + isIncomplete: saved.isIncomplete ?? false, + }; }); const [showExitModal, setShowExitModal] = useState(false); const [isVerifyingAnswer, setIsVerifyingAnswer] = useState(false); @@ -205,14 +237,9 @@ export function QuizContainer({ const currentQuestion = questions[state.currentIndex]; const totalQuestions = questions.length; - const handleRestoreSession = useCallback((data: QuizSessionData) => { - dispatch({ type: 'RESTORE_SESSION', payload: data }); - }, []); - useQuizSession({ quizId, state, - onRestore: handleRestoreSession, }); const { markQuitting } = useQuizGuards({ diff --git a/frontend/components/quiz/QuizzesSection.tsx b/frontend/components/quiz/QuizzesSection.tsx index a7287104..77981fc1 100644 --- a/frontend/components/quiz/QuizzesSection.tsx +++ b/frontend/components/quiz/QuizzesSection.tsx @@ -2,6 +2,7 @@ import { useParams, useRouter, useSearchParams } from 'next/navigation'; import { useTranslations } from 'next-intl'; +import { useEffect, useState } from 'react'; import { CategoryTabButton } from '@/components/shared/CategoryTabButton'; import { Tabs, TabsContent, TabsList } from '@/components/ui/tabs'; @@ -47,16 +48,37 @@ export default function QuizzesSection({ ? (locale as 'uk' | 'en' | 'pl') : 'en'; + const [progressMap, setProgressMap] = + useState>(userProgressMap); + const [progressLoaded, setProgressLoaded] = useState( + Object.keys(userProgressMap).length > 0 + ); + + useEffect(() => { + fetch('/api/quiz/progress') + .then(res => res.ok ? res.json() : {}) + .then(data => { + setProgressMap(data); + setProgressLoaded(true); + }) + .catch(() => { + setProgressLoaded(true); + }); + }, []); + const DEFAULT_CATEGORY = categoryData[0]?.slug || 'git'; const categoryFromUrl = searchParams.get('category'); const validCategory = categoryData.some(c => c.slug === categoryFromUrl); - const activeCategory = validCategory ? categoryFromUrl! : DEFAULT_CATEGORY; + const [activeCategory, setActiveCategory] = useState( + validCategory ? categoryFromUrl! : DEFAULT_CATEGORY + ); const handleCategoryChange = (category: string) => { + setActiveCategory(category); const params = new URLSearchParams(searchParams.toString()); params.set('category', category); - router.replace(`?${params.toString()}`, { scroll: false }); + window.history.replaceState(null, '', `?${params.toString()}`); }; return ( @@ -85,26 +107,51 @@ export default function QuizzesSection({ quiz => quiz.categorySlug === category.slug ); return ( - + {categoryQuizzes.length > 0 ? (
- {categoryQuizzes.map(quiz => ( - - ))} + {!progressLoaded + ? categoryQuizzes.map(quiz => ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )) + : categoryQuizzes.map(quiz => ( + + ))}
) : ( diff --git a/frontend/components/shared/GitHubStarButton.tsx b/frontend/components/shared/GitHubStarButton.tsx index 847a1fb4..64ffc4ff 100644 --- a/frontend/components/shared/GitHubStarButton.tsx +++ b/frontend/components/shared/GitHubStarButton.tsx @@ -2,7 +2,17 @@ import { Star } from 'lucide-react'; import { useTranslations } from 'next-intl'; -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useState } from 'react'; + +const STORAGE_KEY = 'github-stars'; + +function getStoredStars(): number | null { + if (typeof sessionStorage === 'undefined') return null; + const stored = sessionStorage.getItem(STORAGE_KEY); + if (!stored) return null; + const parsed = parseInt(stored, 10); + return Number.isNaN(parsed) ? null : parsed; +} interface GitHubStarButtonProps { className?: string; @@ -10,12 +20,14 @@ interface GitHubStarButtonProps { export function GitHubStarButton({ className = '' }: GitHubStarButtonProps) { const t = useTranslations('aria'); - const [displayCount, setDisplayCount] = useState(0); - const [finalCount, setFinalCount] = useState(null); + const [storedStars] = useState(getStoredStars); + const [displayCount, setDisplayCount] = useState(storedStars ?? 0); + const [finalCount, setFinalCount] = useState(storedStars); const githubUrl = 'https://github.com/DevLoversTeam/devlovers.net'; - const hasAnimated = useRef(false); useEffect(() => { + if (storedStars !== null) return; + const fetchStars = async () => { try { const response = await fetch('/api/stats'); @@ -46,9 +58,8 @@ export function GitHubStarButton({ className = '' }: GitHubStarButtonProps) { }, []); useEffect(() => { - if (finalCount === null || hasAnimated.current) return; + if (finalCount === null || storedStars !== null) return; - hasAnimated.current = true; const duration = 2000; const steps = 60; const increment = finalCount / steps; @@ -59,13 +70,16 @@ export function GitHubStarButton({ className = '' }: GitHubStarButtonProps) { if (current >= finalCount) { setDisplayCount(finalCount); clearInterval(timer); + try { + sessionStorage.setItem(STORAGE_KEY, String(finalCount)); + } catch {} } else { setDisplayCount(Math.floor(current)); } }, duration / steps); return () => clearInterval(timer); - }, [finalCount]); + }, [finalCount, storedStars]); const formatStarCount = (count: number): string => { return count.toLocaleString(); @@ -93,7 +107,7 @@ export function GitHubStarButton({ className = '' }: GitHubStarButtonProps) { {formatStarCount(displayCount)}