Skip to content

Commit be76fca

Browse files
Merge pull request #272 from DevLoversTeam/sl/feat/quiz
(SP: 1) [Frontend] Improve Lighthouse Performance and SEO scores for quiz pages
2 parents 839ac57 + 56f76e5 commit be76fca

11 files changed

Lines changed: 126 additions & 65 deletions

File tree

frontend/.browserslistrc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
defaults and fully supports es6-module

frontend/app/[locale]/quiz/[slug]/page.tsx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { Metadata } from 'next';
12
import { notFound, redirect } from 'next/navigation';
23
import { getTranslations } from 'next-intl/server';
34

@@ -6,6 +7,23 @@ import { stripCorrectAnswers } from '@/db/queries/quiz';
67
import { getQuizBySlug, getQuizQuestionsRandomized } from '@/db/queries/quiz';
78
import { getCurrentUser } from '@/lib/auth';
89

10+
type MetadataProps = { params: Promise<{ locale: string; slug: string }> };
11+
12+
export async function generateMetadata({ params }: MetadataProps): Promise<Metadata> {
13+
const { locale, slug } = await params;
14+
const t = await getTranslations({ locale, namespace: 'quiz.page' });
15+
const quiz = await getQuizBySlug(slug, locale);
16+
17+
if (!quiz) {
18+
return { title: t('notFoundTitle') };
19+
}
20+
21+
return {
22+
title: `${quiz.title} | ${t('metaSuffix')}`,
23+
description: quiz.description ?? t('metaDescriptionFallback', { title: quiz.title ?? '' }),
24+
};
25+
}
26+
927
interface QuizPageProps {
1028
params: Promise<{ locale: string; slug: string }>;
1129
searchParams: Promise<{ seed?: string }>;

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { Metadata } from 'next';
12
import { getTranslations } from 'next-intl/server';
23

34
import QuizzesSection from '@/components/quiz/QuizzesSection';
@@ -7,6 +8,16 @@ import { getCurrentUser } from '@/lib/auth';
78

89
type PageProps = { params: Promise<{ locale: string }> };
910

11+
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
12+
const { locale } = await params;
13+
const t = await getTranslations({ locale, namespace: 'quiz.list' });
14+
15+
return {
16+
title: t('metaTitle'),
17+
description: t('metaDescription'),
18+
};
19+
}
20+
1021
export const dynamic = 'force-dynamic';
1122

1223
export default async function QuizzesPage({ params }: PageProps) {

frontend/app/globals.css

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
@import url(https://fonts.googleapis.com/css?family=Lato:100,300,400,700);
2-
@import url(https://raw.github.com/FortAwesome/Font-Awesome/master/docs/assets/css/font-awesome.min.css);
32
@import 'tailwindcss';
43

54
@custom-variant dark (&:is(.dark *));
@@ -146,11 +145,13 @@
146145
border-color: var(--qa-accent);
147146
}
148147

149-
.no-select {
150-
-webkit-user-select: none; /* Safari */
151-
-moz-user-select: none; /* Firefox */
152-
-ms-user-select: none; /* IE10+/Edge */
153-
user-select: none; /* Standard */
148+
.no-select,
149+
.no-select * {
150+
-webkit-touch-callout: none;
151+
-webkit-user-select: none;
152+
-moz-user-select: none;
153+
-ms-user-select: none;
154+
user-select: none;
154155
}
155156

156157
@keyframes progress {

frontend/components/quiz/CountdownTimer.tsx

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use client';
22

3-
import { TriangleAlert } from 'lucide-react';
3+
import { Clock, TriangleAlert } from 'lucide-react';
44
import { useTranslations } from 'next-intl';
55
import { useEffect, useState } from 'react';
66

@@ -21,19 +21,33 @@ export function CountdownTimer({
2121
}: CountdownTimerProps) {
2222
const t = useTranslations('quiz.timer');
2323
const endTime = startedAt.getTime() + timeLimitSeconds * 1000;
24-
const [remainingSeconds, setRemainingSeconds] = useState(() =>
25-
Math.max(0, Math.floor((endTime - Date.now()) / 1000))
26-
);
24+
const [remainingSeconds, setRemainingSeconds] = useState(timeLimitSeconds);
25+
const [isSynced, setIsSynced] = useState(false);
26+
const [prevEndTime, setPrevEndTime] = useState(endTime);
27+
28+
if (endTime !== prevEndTime) {
29+
setPrevEndTime(endTime);
30+
setIsSynced(false);
31+
setRemainingSeconds(timeLimitSeconds);
32+
}
33+
2734

2835
useEffect(() => {
2936
if (!isActive) return;
3037

38+
let synced = false;
39+
3140
const interval = setInterval(() => {
3241
const now = Date.now();
3342
const remaining = Math.max(0, Math.floor((endTime - now) / 1000));
3443

3544
setRemainingSeconds(remaining);
3645

46+
if (!synced) {
47+
synced = true;
48+
setIsSynced(true);
49+
}
50+
3751
if (remaining === 0) {
3852
clearInterval(interval);
3953
queueMicrotask(onTimeUp);
@@ -43,11 +57,13 @@ export function CountdownTimer({
4357
return () => clearInterval(interval);
4458
}, [isActive, onTimeUp, endTime]);
4559

60+
4661
useEffect(() => {
4762
if (!isActive) return;
4863

4964
const handleVisibilityChange = () => {
5065
if (!document.hidden) {
66+
setIsSynced(false);
5167
const remaining = Math.max(
5268
0,
5369
Math.floor((endTime - Date.now()) / 1000)
@@ -100,10 +116,12 @@ export function CountdownTimer({
100116

101117
<div className="h-2 overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
102118
<div
103-
className={cn(
104-
'h-full transition-all duration-1000 ease-linear',
105-
getProgressBarColor()
106-
)}
119+
className={cn(
120+
'h-full',
121+
isSynced && 'transition-all duration-1000 ease-linear',
122+
getProgressBarColor()
123+
)}
124+
107125
style={{ width: `${percentage}%` }}
108126
/>
109127
</div>
@@ -120,7 +138,7 @@ export function CountdownTimer({
120138
</>
121139
) : (
122140
<>
123-
<span aria-hidden="true"></span> {t('hurryUp')}
141+
<Clock className="inline h-4 w-4" aria-hidden="true" /> {t('hurryUp')}
124142
</>
125143
)}
126144
</p>

frontend/db/seed-quiz-javascript.ts

Lines changed: 29 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import 'dotenv/config';
2+
13
import { eq } from 'drizzle-orm';
24
import { readFileSync } from 'fs';
35
import { join } from 'path';
@@ -75,7 +77,7 @@ async function loadQuestions(partNumber: number): Promise<QuestionData[]> {
7577
return partData.questions;
7678
}
7779

78-
async function ensureQuizExists(): Promise<string> {
80+
async function ensureQuizExists(forceDelete: boolean): Promise<string> {
7981
console.log('Ensuring quiz exists...');
8082

8183
const [category] = await db
@@ -93,14 +95,24 @@ async function ensureQuizExists(): Promise<string> {
9395
const existing = await db.query.quizzes.findFirst({
9496
where: eq(quizzes.slug, QUIZ_METADATA.slug),
9597
});
98+
9699
if (existing) {
97-
const existingAttempt = await db.query.quizAttempts.findFirst({
98-
where: eq(quizAttempts.quizId, existing.id),
99-
});
100-
if (existingAttempt) {
101-
throw new Error(
102-
`Quiz ${QUIZ_METADATA.slug} has existing attempts. Aborting to avoid data loss.`
103-
);
100+
const existingAttempts = await db
101+
.select()
102+
.from(quizAttempts)
103+
.where(eq(quizAttempts.quizId, existing.id))
104+
.limit(1);
105+
106+
if (existingAttempts.length > 0) {
107+
if (!forceDelete) {
108+
throw new Error(
109+
`Quiz ${QUIZ_METADATA.slug} has existing attempts. Use --force to delete them.`
110+
);
111+
}
112+
113+
console.log('Deleting existing attempts (--force flag used)...');
114+
await db.delete(quizAttempts).where(eq(quizAttempts.quizId, existing.id));
115+
console.log('Attempts deleted.');
104116
}
105117

106118
await db.delete(quizQuestions).where(eq(quizQuestions.quizId, existing.id));
@@ -211,22 +223,24 @@ async function seedQuestions(
211223

212224
async function seedQuizFromJson() {
213225
const args = process.argv.slice(2);
214-
const partArg = args[0];
226+
const forceDelete = args.includes('--force');
227+
const partArg = args.find(arg => arg !== '--force');
215228

216229
if (!partArg) {
217230
console.error('Error: Please specify which part to upload');
218-
console.log(
219-
'Usage: npx tsx db/seeds/seed-quiz-javascript.ts <part-number>'
220-
);
221-
console.log('Example: npx tsx db/seeds/seed-quiz-javascript.ts 1');
222-
console.log('Or upload all: npx tsx db/seeds/seed-quiz-javascript.ts all');
231+
console.log('Usage: npx tsx db/seed-quiz-javascript.ts <part-number> [--force]');
232+
console.log('Example: npx tsx db/seed-quiz-javascript.ts 1');
233+
console.log(' npx tsx db/seed-quiz-javascript.ts all --force');
223234
process.exit(1);
224235
}
225236

226237
console.log('Starting JavaScript quiz seed...\n');
238+
if (forceDelete) {
239+
console.log('WARNING: --force flag used. Existing attempts will be deleted.\n');
240+
}
227241

228242
try {
229-
const quizId = await ensureQuizExists();
243+
const quizId = await ensureQuizExists(forceDelete);
230244

231245
if (partArg.toLowerCase() === 'all') {
232246
console.log('Uploading all parts...\n');

frontend/hooks/useAntiCheat.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export function useAntiCheat(isActive: boolean = true) {
2222
const [isTabActive, setIsTabActive] = useState(true);
2323
const [showWarning, setShowWarning] = useState(false);
2424
const warningTimeoutRef = useRef<NodeJS.Timeout | null>(null);
25+
const lastInteractionWasTouch = useRef(false);
2526

2627
const addViolation = useCallback(
2728
(type: AntiCheatViolation['type']) => {
@@ -64,7 +65,10 @@ export function useAntiCheat(isActive: boolean = true) {
6465

6566
const handleContextMenu = (e: MouseEvent) => {
6667
e.preventDefault();
67-
addViolation('context-menu');
68+
69+
if (!lastInteractionWasTouch.current) {
70+
addViolation('context-menu');
71+
}
6872
};
6973

7074
const handleVisibilityChange = () => {
@@ -76,16 +80,29 @@ export function useAntiCheat(isActive: boolean = true) {
7680
}
7781
};
7882

83+
const handleTouchStart = () => {
84+
lastInteractionWasTouch.current = true;
85+
};
86+
87+
const handleMouseDown = () => {
88+
lastInteractionWasTouch.current = false;
89+
};
90+
7991
document.addEventListener('copy', handleCopy);
8092
document.addEventListener('paste', handlePaste);
8193
document.addEventListener('contextmenu', handleContextMenu);
8294
document.addEventListener('visibilitychange', handleVisibilityChange);
95+
document.addEventListener('touchstart', handleTouchStart, { passive: true });
96+
document.addEventListener('mousedown', handleMouseDown);
97+
8398

8499
return () => {
85100
document.removeEventListener('copy', handleCopy);
86101
document.removeEventListener('paste', handlePaste);
87102
document.removeEventListener('contextmenu', handleContextMenu);
88103
document.removeEventListener('visibilitychange', handleVisibilityChange);
104+
document.removeEventListener('touchstart', handleTouchStart);
105+
document.removeEventListener('mousedown', handleMouseDown);
89106

90107
if (warningTimeoutRef.current) {
91108
clearTimeout(warningTimeoutRef.current);

frontend/messages/en.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,12 +85,17 @@
8585
"metaTitle": "Quizzes | DevLovers",
8686
"metaDescription": "Test your knowledge with interactive quizzes",
8787
"page": {
88+
"notFoundTitle": "Quiz Not Found",
89+
"metaSuffix": "DevLovers Quiz",
90+
"metaDescriptionFallback": "Take the {title} quiz and test your knowledge.",
8891
"noQuestions": "No questions available for this quiz",
8992
"questionsLabel": "Questions",
9093
"timeLabel": "Time",
9194
"minutes": "min"
9295
},
9396
"list": {
97+
"metaTitle": "Quizzes | DevLovers",
98+
"metaDescription": "Test your knowledge with interactive quizzes on React, Angular, Vue, Node.js and more.",
9499
"title": "Quizzes",
95100
"practice": "Practice",
96101
"subtitle": "Choose a quiz to test your knowledge",

frontend/messages/pl.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,12 +85,17 @@
8585
"metaTitle": "Quizy | DevLovers",
8686
"metaDescription": "Sprawdź swoją wiedzę dzięki interaktywnym quizom",
8787
"page": {
88+
"notFoundTitle": "Quiz nie znaleziony",
89+
"metaSuffix": "Quiz DevLovers",
90+
"metaDescriptionFallback": "Rozwiąż quiz {title} i sprawdź swoją wiedzę.",
8891
"noQuestions": "Brak dostępnych pytań dla tego quizu",
8992
"questionsLabel": "Pytania",
9093
"timeLabel": "Czas",
9194
"minutes": "min"
9295
},
9396
"list": {
97+
"metaTitle": "Quizy | DevLovers",
98+
"metaDescription": "Sprawdź swoją wiedzę z React, Angular, Vue, Node.js i innych technologii.",
9499
"title": "Quizy",
95100
"practice": "Ćwicz",
96101
"subtitle": "Wybierz quiz, aby sprawdzić swoją wiedzę",

frontend/messages/uk.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,12 +85,17 @@
8585
"metaTitle": "Квізи | DevLovers",
8686
"metaDescription": "Перевірте свої знання з допомогою інтерактивних квізів",
8787
"page": {
88+
"notFoundTitle": "Квіз не знайдено",
89+
"metaSuffix": "Квіз DevLovers",
90+
"metaDescriptionFallback": "Пройдіть квіз {title} та перевірте свої знання.",
8891
"noQuestions": "Немає питань для цього квізу",
8992
"questionsLabel": "Питань",
9093
"timeLabel": "Час",
9194
"minutes": "хв"
9295
},
9396
"list": {
97+
"metaTitle": "Квізи | DevLovers",
98+
"metaDescription": "Перевірте свої знання з React, Angular, Vue, Node.js та інших технологій.",
9499
"title": "Квізи",
95100
"practice": "Практика",
96101
"subtitle": "Оберіть квіз, щоб перевірити свої знання",

0 commit comments

Comments
 (0)