Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ next-env.d.ts
# Documentation (development only)
.claude/
CLAUDE.md
frontend/docs/
frontend/_dev-notes/
frontend/.env.bak


Expand Down
6 changes: 1 addition & 5 deletions frontend/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,5 @@ next-env.d.ts

# Documentation (only for development)
CLAUDE.md
docs/
_dev-notes/
.claude

!docs/
!docs/security/
!docs/security/origin-posture.md
11 changes: 7 additions & 4 deletions frontend/app/[locale]/quizzes/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { getActiveQuizzes, getUserQuizzesProgress } from '@/db/queries/quiz';
import { getCurrentUser } from '@/lib/auth';
import { getTranslations } from 'next-intl/server';
import QuizzesSection from '@/components/quiz/QuizzesSection';
import { DynamicGridBackground } from '@/components/shared/DynamicGridBackground';

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

Expand All @@ -23,7 +24,7 @@ export default async function QuizzesPage({ params }: PageProps) {

if (!quizzes.length) {
return (
<div className="mx-auto max-w-5xl py-12">
<div className="mx-auto max-w-7xl py-12 px-4 sm:px-6 lg:px-8">
<h1 className="text-3xl font-bold mb-4">{t('title')}</h1>
<p className="text-gray-600 dark:text-gray-400">
{t('noQuizzes')}
Expand All @@ -33,9 +34,10 @@ export default async function QuizzesPage({ params }: PageProps) {
}

return (
<div className="mx-auto max-w-5xl py-12">
<DynamicGridBackground className="min-h-screen bg-gray-50 transition-colors duration-300 dark:bg-transparent py-10">
<main className="relative z-10 mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div className="mb-8">
<p className="text-sm text-blue-600 dark:text-blue-400 font-semibold">
<p className="text-sm text-[var(--accent-primary)] font-semibold">
{t('practice')}
</p>
<h1 className="text-3xl font-bold">{t('title')}</h1>
Expand All @@ -45,6 +47,7 @@ export default async function QuizzesPage({ params }: PageProps) {
</div>

<QuizzesSection quizzes={quizzes} userProgressMap={userProgressMap} />
</div>
</main>
</DynamicGridBackground>
);
}
4 changes: 2 additions & 2 deletions frontend/components/q&a/AccordionList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
AccordionTrigger,
AccordionContent,
} from '@/components/ui/accordion';
import { qaTabStyles } from '@/data/qaTabs';
import { categoryTabStyles } from '@/data/categoryStyles';

import CodeBlock from '@/components/q&a/CodeBlock';
import type {
Expand Down Expand Up @@ -241,7 +241,7 @@ export default function AccordionList({ items }: { items: QuestionEntry[] }) {
{items.map((q, idx) => {
const key = q.id ?? idx;
const accent =
qaTabStyles[q.category as keyof typeof qaTabStyles]?.accent;
categoryTabStyles[q.category as keyof typeof categoryTabStyles]?.accent;
return (
<AccordionItem
key={key}
Expand Down
12 changes: 6 additions & 6 deletions frontend/components/q&a/QaSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ import { Pagination } from '@/components/q&a/Pagination';
import { Tabs, TabsList, TabsContent } from '@/components/ui/tabs';
import { categoryData } from '@/data/category';
import { useQaTabs } from '@/components/q&a/useQaTabs';
import { QaTabButton } from '@/components/q&a/QaTabButton';
import { qaTabStyles } from '@/data/qaTabs';
import { CategoryTabButton } from '@/components/shared/CategoryTabButton';
import { categoryTabStyles } from '@/data/categoryStyles';
import { cn } from '@/lib/utils';
import type { CategorySlug } from '@/components/q&a/types';

Expand All @@ -29,18 +29,18 @@ export default function TabsSection() {
<Tabs value={active} onValueChange={handleCategoryChange}>
<TabsList className="!bg-transparent !p-0 !h-auto !w-full flex flex-wrap items-stretch justify-start gap-3 mb-6">
{categoryData.map(category => {
const slug = category.slug as keyof typeof qaTabStyles;
const slug = category.slug as keyof typeof categoryTabStyles;
const value = slug as CategorySlug;
return (
<QaTabButton
<CategoryTabButton
key={slug}
value={value}
label={
category.translations[localeKey] ??
category.translations.en ??
value
}
style={qaTabStyles[slug]}
style={categoryTabStyles[slug]}
isActive={active === value}
/>
);
Expand Down Expand Up @@ -78,7 +78,7 @@ export default function TabsSection() {
currentPage={currentPage}
totalPages={totalPages}
onPageChange={handlePageChange}
accentColor={qaTabStyles[active as keyof typeof qaTabStyles].accent}
accentColor={categoryTabStyles[active as keyof typeof categoryTabStyles].accent}
/>
)}
</div>
Expand Down
4 changes: 2 additions & 2 deletions frontend/components/quiz/CountdownTimer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,13 +68,13 @@ export function CountdownTimer({
if (percentage <= 30) {
return 'text-yellow-600 dark:text-yellow-400 bg-yellow-50 dark:bg-yellow-950/30 border-yellow-200 dark:border-yellow-800';
}
return 'text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-950/30 border-blue-200 dark:border-blue-800';
return 'text-green-600 dark:text-green-400 bg-green-50 dark:bg-green-950/30 border-green-200 dark:border-green-800';
};

const getProgressBarColor = () => {
if (percentage <= 10) return 'bg-red-600';
if (percentage <= 30) return 'bg-yellow-600';
return 'bg-blue-600';
return 'bg-green-600';
};

if (!isActive) return null;
Expand Down
42 changes: 35 additions & 7 deletions frontend/components/quiz/QuizCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useTranslations } from 'next-intl';
import { Link } from '@/i18n/routing';
import { Badge } from '@/components/ui/badge';
import { FileText, Clock } from 'lucide-react';
import { categoryTabStyles } from '@/data/categoryStyles';

interface QuizCardProps {
quiz: {
Expand All @@ -14,6 +15,7 @@ interface QuizCardProps {
questionsCount: number;
timeLimitSeconds: number | null;
categoryName: string | null;
categorySlug: string | null;
};
userProgress?: {
bestScore: number;
Expand All @@ -24,16 +26,33 @@ interface QuizCardProps {

export function QuizCard({ quiz, userProgress }: QuizCardProps) {
const t = useTranslations('quiz.card');
const slug = quiz.categorySlug as keyof typeof categoryTabStyles | null;
const style = slug && categoryTabStyles[slug] ? categoryTabStyles[slug] : null;
const accentColor = style?.accent ?? '#3B82F6'; // fallback blue

const percentage =
userProgress && userProgress.totalQuestions > 0
? Math.round((userProgress.bestScore / userProgress.totalQuestions) * 100)
: 0;

return (
<div className="flex flex-col rounded-xl border border-gray-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-5 shadow-sm hover:shadow-md transition-shadow">
<div
className="group/card relative flex flex-col rounded-xl border border-black/10 dark:border-white/10 hover:!border-[var(--accent)] bg-white dark:bg-neutral-900 p-5 shadow-sm overflow-hidden transition-all duration-300 hover:-translate-y-1 hover:shadow-xl"
style={{ '--accent': `${accentColor}60` } as React.CSSProperties}
>
<span
className="pointer-events-none absolute -top-6 -right-6 w-24 h-24 rounded-full blur-[40px] opacity-0 group-hover/card:opacity-20 transition-opacity duration-500"
style={{ backgroundColor: accentColor }}
/>
<div className="flex-grow">
<div className="flex gap-2 mb-3">
<Badge variant="blue">
<Badge
variant="default"
style={{
backgroundColor: `${accentColor}20`,
color: accentColor,
}}
>
{quiz.categoryName ?? t('uncategorized')}
</Badge>
{userProgress && <Badge variant="success">{t('completed')}</Badge>}
Expand All @@ -48,11 +67,11 @@ export function QuizCard({ quiz, userProgress }: QuizCardProps) {
)}
<div className="flex gap-3 text-xs text-gray-500 mb-3">
<span className="flex items-center gap-1">
<FileText className="w-3.5 h-3.5 text-blue-500 dark:text-blue-400" />
<FileText className="w-3.5 h-3.5" style={{ color: accentColor }} />
{quiz.questionsCount} {t('questions')}
</span>
<span className="flex items-center gap-1">
<Clock className="w-3.5 h-3.5 text-blue-500 dark:text-blue-400" />
<Clock className="w-3.5 h-3.5" style={{ color: accentColor }} />
{Math.floor(
(quiz.timeLimitSeconds ?? quiz.questionsCount * 30) / 60
)}{' '}
Expand All @@ -76,17 +95,26 @@ export function QuizCard({ quiz, userProgress }: QuizCardProps) {
</div>
<div className="h-1.5 bg-gray-200 dark:bg-neutral-800 rounded-full overflow-hidden">
<div
className="h-full bg-blue-600 rounded-full transition-all"
style={{ width: `${percentage}%` }}
className="h-full rounded-full transition-all"
style={{ width: `${percentage}%`, backgroundColor: accentColor }}
/>
</div>
</div>
)}
<Link
href={`/quiz/${quiz.slug}`}
className="btn block w-full text-center rounded-lg text-white px-4 py-2.5 text-sm font-medium transition-colors"
className="group relative block w-full overflow-hidden text-center rounded-xl border px-4 py-2.5 text-sm font-semibold transition-all duration-300"
style={{
borderColor: `${accentColor}50`,
backgroundColor: `${accentColor}15`,
color: accentColor,
}}
>
{userProgress ? t('retake') : t('start')}
<span
className="pointer-events-none absolute left-1/2 top-1/2 h-[150%] w-[80%] -translate-x-1/2 -translate-y-1/2 rounded-full blur-[20px] opacity-0 transition-opacity duration-300 group-hover:opacity-30"
style={{ backgroundColor: accentColor }}
/>
</Link>
</div>
);
Expand Down
26 changes: 20 additions & 6 deletions frontend/components/quiz/QuizContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import type { QuizQuestionClient } from '@/db/queries/quiz';
import { ConfirmModal } from '@/components/ui/confirm-modal';
import { Button } from '@/components/ui/button';
import { FileText, Ban, AlertTriangle, Clock } from 'lucide-react';
import { categoryTabStyles } from '@/data/categoryStyles';

interface Answer {
questionId: string;
Expand Down Expand Up @@ -149,6 +150,8 @@ export function QuizContainer({
const tRules = useTranslations('quiz.rules');
const tExit = useTranslations('quiz.exitModal');
const tQuestion = useTranslations('quiz.question');
const categoryStyle = categorySlug ? categoryTabStyles[categorySlug as keyof typeof categoryTabStyles] : null;
const accentColor = categoryStyle?.accent ?? '#3B82F6';
const [isPending, startTransition] = useTransition();
const [state, dispatch] = useReducer(quizReducer, {
status: 'rules',
Expand Down Expand Up @@ -195,7 +198,6 @@ const { markQuitting } = useQuizGuards({
resetViolations,
});


// Sync seed to URL for language switch persistence
useEffect(() => {
if (!searchParams.has('seed')) {
Expand Down Expand Up @@ -369,7 +371,7 @@ const confirmQuit = () => {
if (onBackToTopics) {
onBackToTopics();
} else {
window.location.href = `/${locale}/`;
window.location.href = `/${locale}/q&a`;
}
};

Expand Down Expand Up @@ -423,10 +425,21 @@ const confirmQuit = () => {
</div>
</div>
</div>

<Button onClick={handleStart} className="w-full" size="md">
{tRules('startButton')}
</Button>
<button
onClick={handleStart}
className="group relative w-full overflow-hidden text-center rounded-xl border px-6 py-3 text-base font-semibold transition-all duration-300"
style={{
borderColor: `${accentColor}50`,
backgroundColor: `${accentColor}15`,
color: accentColor,
}}
>
{tRules('startButton')}
<span
className="pointer-events-none absolute left-1/2 top-1/2 h-[150%] w-[80%] -translate-x-1/2 -translate-y-1/2 rounded-full blur-[20px] opacity-0 transition-opacity duration-300 group-hover:opacity-30"
style={{ backgroundColor: accentColor }}
/>
</button>
</div>
);
}
Expand Down Expand Up @@ -493,6 +506,7 @@ const confirmQuit = () => {
onAnswer={handleAnswer}
onNext={handleNext}
isLoading={isPending}
accentColor={accentColor}
/>
<ConfirmModal
isOpen={showExitModal}
Expand Down
19 changes: 13 additions & 6 deletions frontend/components/quiz/QuizProgress.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,11 +82,11 @@ export function QuizProgress({ current, total, answers }: QuizProgressProps) {
<div
key={index}
className={cn(
'relative flex items-center justify-center w-9 h-9 rounded-full transition-all border-2 text-sm font-medium',
isCurrent && !isAnswered && 'border-blue-500 bg-blue-50 dark:bg-blue-950',
isAnswered && isCorrect && 'border-green-500 bg-green-500',
isAnswered && !isCorrect && 'border-red-500 bg-red-500',
!isAnswered && !isCurrent && 'border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900'
'relative flex items-center justify-center w-9 h-9 rounded-full transition-all border text-sm font-medium',
isCurrent && !isAnswered && 'border-blue-500 bg-blue-500/20 dark:bg-blue-500/20',
isAnswered && isCorrect && 'border-green-500 bg-green-500/20 dark:bg-green-500/20',
isAnswered && !isCorrect && 'border-red-500 bg-red-500/20 dark:bg-red-500/20',
!isAnswered && !isCurrent && 'border-gray-300 dark:border-gray-600 bg-white/90 dark:bg-neutral-900/80'
)}
>
{isAnswered ? (
Expand All @@ -103,7 +103,14 @@ export function QuizProgress({ current, total, answers }: QuizProgressProps) {
</span>
)}
{isCurrent && (
<div className="absolute inset-0 rounded-full border-2 border-blue-500 animate-pulse" />
<div
className={cn(
"absolute inset-0 rounded-full border-1 animate-pulse",
!isAnswered && "border-blue-500",
isAnswered && isCorrect && "border-green-500",
isAnswered && !isCorrect && "border-red-500"
)}
/>
)}
</div>
);
Expand Down
Loading