Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
0cf9210
feat: add quiz types and localStorage helpers
dionbiancha Jun 5, 2026
f52e0be
fix: use crypto.randomUUID for quiz id, add answer bounds guard
dionbiancha Jun 5, 2026
8fe2f35
feat: add quiz i18n keys
dionbiancha Jun 5, 2026
75611d3
feat: add server action for quiz question fetching
dionbiancha Jun 5, 2026
76d6c03
feat: add QuizSetupPanel component
dionbiancha Jun 5, 2026
4268d75
feat: add QuizSetupPanel to question list page
dionbiancha Jun 5, 2026
cf7fe64
feat: add quiz entry point to category hub
dionbiancha Jun 5, 2026
25ed650
feat: add quiz page route
dionbiancha Jun 5, 2026
53caad3
feat: add QuizQuestion component
dionbiancha Jun 5, 2026
e4b8e23
feat: add QuizReport component
dionbiancha Jun 5, 2026
afe6fbe
feat: add QuizPageClient with timer and answer flow
dionbiancha Jun 5, 2026
5b2fd12
feat: add quiz history page
dionbiancha Jun 5, 2026
10749ff
refactor: convert QuizSetupPanel inline panel to modal
dionbiancha Jun 5, 2026
ea8d92b
refactor: move QuizSetupPanel to page headers
dionbiancha Jun 5, 2026
51147ee
refactor: move quiz button to toolbar beside random question button
dionbiancha Jun 5, 2026
094b0a9
fix: opaque modal backdrop, remove answer icons, rename to Acertei/Er…
dionbiancha Jun 5, 2026
19316ee
refactor: move history link to quiz modal footer, remove from nav
dionbiancha Jun 5, 2026
5f4e8e2
style: highlight random and create test buttons with solid colors
dionbiancha Jun 5, 2026
8a22a72
style: soften button highlight to colored border and text
dionbiancha Jun 5, 2026
382af52
style: remove icons from random and create test buttons
dionbiancha Jun 5, 2026
a646066
docs: add custom quiz to changelog v1.7.0
dionbiancha Jun 5, 2026
124bab8
fix: rename filter label from Tags to Senioridade/Seniority
dionbiancha Jun 5, 2026
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
10 changes: 9 additions & 1 deletion src/app/[locale]/questions/[category]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ export default async function CategoryPage({ params }: Props) {

const questions = getAllQuestionMeta(locale, category)

const categoryCounts = Object.fromEntries(
CATEGORIES.map(c => [c.slug, getAllQuestionMeta(locale, c.slug).length])
)

return (
<div className="max-w-4xl mx-auto px-4 py-12">
<div className="flex items-center gap-3 mb-8">
Expand Down Expand Up @@ -54,7 +58,11 @@ export default async function CategoryPage({ params }: Props) {
</Link>
</div>
) : (
<QuestionListClient questions={questions} />
<QuestionListClient
questions={questions}
categoryCounts={categoryCounts}
currentCategory={category}
/>
)}
</div>
)
Expand Down
21 changes: 16 additions & 5 deletions src/app/[locale]/questions/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { getTranslations, getLocale } from 'next-intl/server'
import { CATEGORIES } from '@/lib/categories'
import { getAllQuestionMeta } from '@/lib/content'
import { CategoryCard } from '@/components/questions/CategoryCard'
import { QuizSetupPanel } from '@/components/quiz/QuizSetupPanel'

export async function generateStaticParams() {
return [{ locale: 'en' }, { locale: 'pt' }]
Expand All @@ -11,21 +12,31 @@ export default async function QuestionsPage() {
const t = await getTranslations()
const locale = await getLocale()

const categoryCounts = Object.fromEntries(
CATEGORIES.map(cat => [cat.slug, getAllQuestionMeta(locale, cat.slug).length])
)

const categoriesWithCount = CATEGORIES.map(cat => ({
cat,
label: t(`categories.${cat.slug}`),
count: getAllQuestionMeta(locale, cat.slug).length,
count: categoryCounts[cat.slug],
}))

return (
<div className="max-w-5xl mx-auto px-4 py-12">
<h1 className="font-mono text-3xl font-bold text-dark-heading mb-2">
{t('categories.title')}
</h1>
<div className="flex items-start justify-between gap-4 mb-2">
<h1 className="font-mono text-3xl font-bold text-dark-heading">
{t('categories.title')}
</h1>
<QuizSetupPanel
preselectedCategories={[]}
categoryCounts={categoryCounts}
/>
</div>
<p className="text-dark-muted mb-10">
{t('categories.subtitle')}
</p>
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-4 mb-8">
{categoriesWithCount.map(({ cat, label, count }) => (
<CategoryCard key={cat.slug} category={cat} label={label} count={count} />
))}
Expand Down
14 changes: 14 additions & 0 deletions src/app/[locale]/quiz/[id]/loading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
export default function Loading() {
return (
<div className="max-w-2xl mx-auto px-4 py-12 animate-pulse">
<div className="h-3 bg-dark-border rounded w-1/4 mb-8" />
<div className="flex gap-1 mb-8">
{Array.from({ length: 10 }).map((_, i) => (
<div key={i} className="h-1.5 flex-1 bg-dark-border rounded-full" />
))}
</div>
<div className="h-6 bg-dark-border rounded w-3/4 mb-4" />
<div className="h-4 bg-dark-border rounded w-1/2" />
</div>
)
}
8 changes: 8 additions & 0 deletions src/app/[locale]/quiz/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { QuizPageClient } from '@/components/quiz/QuizPageClient'

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

export default async function QuizPage({ params }: Props) {
const { id } = await params
return <QuizPageClient id={id} />
}
14 changes: 14 additions & 0 deletions src/app/[locale]/quiz/history/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { getTranslations } from 'next-intl/server'
import { QuizHistoryList } from '@/components/quiz/QuizHistoryList'

export default async function QuizHistoryPage() {
const t = await getTranslations('quiz')
return (
<div className="max-w-2xl mx-auto px-4 py-12">
<h1 className="font-mono text-3xl font-bold text-dark-heading mb-8">
{t('history')}
</h1>
<QuizHistoryList />
</div>
)
}
20 changes: 20 additions & 0 deletions src/app/[locale]/updates/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,26 @@ type ChangelogEntry = {
}

const CHANGELOG: ChangelogEntry[] = [
{
date: '2026-06-05',
version: '1.7.0',
title: 'Custom quiz mode',
titlePt: 'Modo de teste customizado',
points: [
'Create a custom quiz from the question list or category hub — select categories, number of questions, seniority level, tags, and an optional timer',
'Answer each question with three options: Got it / Sort of / Wrong',
'Final report with overall score, breakdown by category, and a link to each question',
'Test history saved locally in the browser — no login required',
'Access past tests from the quiz setup modal',
],
pointsPt: [
'Crie um teste customizado pela lista de perguntas ou pelo hub de categorias — escolha categorias, número de perguntas, senioridade, tags e um tempo opcional',
'Responda cada pergunta com três opções: Acertei / Mais ou menos / Errei',
'Relatório final com aproveitamento geral, breakdown por categoria e link para cada pergunta',
'Histórico de testes salvo localmente no navegador — sem cadastro',
'Acesse testes anteriores pelo modal de criar teste',
],
},
{
date: '2026-06-03',
version: '1.6.2',
Expand Down
34 changes: 34 additions & 0 deletions src/app/quiz-actions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
'use server'

import { getAllQuestionMeta } from '@/lib/content'
import type { QuizConfig } from '@/lib/quiz'
import type { QuestionMeta } from '@/lib/content'

function shuffle<T>(arr: T[]): T[] {
const a = [...arr]
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[a[i], a[j]] = [a[j], a[i]]
}
return a
}

export async function getQuestionsForQuiz(
locale: string,
config: QuizConfig
): Promise<QuestionMeta[]> {
const all: QuestionMeta[] = []
for (const category of config.categories) {
all.push(...getAllQuestionMeta(locale, category))
}

const filtered = all.filter(q => {
const diffMatch =
config.difficulties.length === 0 || config.difficulties.includes(q.difficulty)
const tagMatch =
config.tags.length === 0 || config.tags.some(tag => q.tags.includes(tag))
return diffMatch && tagMatch
})

return shuffle(filtered).slice(0, config.count)
}
26 changes: 17 additions & 9 deletions src/components/questions/QuestionListClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ import { useTranslations } from 'next-intl'
import { useEffect } from 'react'
import { Badge } from '@/components/ui/Badge'
import { FocusMode } from './FocusMode'
import { QuizSetupPanel } from '@/components/quiz/QuizSetupPanel'
import type { QuestionMeta } from '@/lib/content'
import { QuestionCard } from './QuestionCard'

const LEVELS = ['junior', 'pleno', 'senior', 'especialista'] as const

const difficultyVariant: Record<string, 'blue' | 'green' | 'orange'> = {
Expand All @@ -19,9 +19,11 @@ const difficultyVariant: Record<string, 'blue' | 'green' | 'orange'> = {

type Props = {
questions: QuestionMeta[]
categoryCounts: Record<string, number>
currentCategory: string
}

export function QuestionListClient({ questions }: Props) {
export function QuestionListClient({ questions, categoryCounts, currentCategory }: Props) {
const t = useTranslations('categories')
const tQuestion = useTranslations('question')

Expand Down Expand Up @@ -240,13 +242,19 @@ export function QuestionListClient({ questions }: Props) {
)}
</span>

<button
onClick={pickRandom}
disabled={filtered.length === 0}
className="inline-flex items-center gap-2 border border-dark-border hover:border-blue-500/50 text-dark-muted hover:text-dark-heading text-sm px-4 py-2 rounded-lg transition-colors disabled:opacity-40 disabled:pointer-events-none flex-shrink-0"
>
🎲 {t('randomQuestion')}
</button>
<div className="flex items-center gap-2 flex-shrink-0">
<button
onClick={pickRandom}
disabled={filtered.length === 0}
className="inline-flex items-center gap-2 border border-indigo-500/40 text-indigo-400 hover:border-indigo-500/70 hover:text-indigo-300 text-sm px-4 py-2 rounded-lg transition-colors disabled:opacity-40 disabled:pointer-events-none"
>
{t('randomQuestion')}
</button>
<QuizSetupPanel
preselectedCategories={[currentCategory]}
categoryCounts={categoryCounts}
/>
</div>
</div>

{/* Focus mode overlay */}
Expand Down
77 changes: 77 additions & 0 deletions src/components/quiz/QuizHistoryList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
'use client'

import { useState, useEffect } from 'react'
import Link from 'next/link'
import { useLocale, useTranslations } from 'next-intl'
import type { CompletedQuiz } from '@/lib/quiz'
import { getQuizStore, clearQuizHistory, computeScore } from '@/lib/quiz'

export function QuizHistoryList() {
const t = useTranslations('quiz')
const locale = useLocale()
const [history, setHistory] = useState<CompletedQuiz[]>([])

useEffect(() => {
setHistory(getQuizStore().history)
}, [])

const handleClear = () => {
if (!confirm(t('clearHistoryConfirm'))) return
clearQuizHistory()
setHistory([])
}

if (history.length === 0) {
return (
<div className="text-center py-20 text-dark-muted">
<p className="mb-4">{t('noHistory')}</p>
<Link href={`/${locale}/questions`} className="text-blue-400 hover:underline text-sm">
{t('newTest')}
</Link>
</div>
)
}

return (
<div>
<div className="flex flex-col gap-3">
{history.map(quiz => {
const { known, partial, unknown, score } = computeScore(quiz)
const date = new Date(quiz.completedAt).toLocaleString(locale === 'pt' ? 'pt-BR' : 'en-US', {
day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit',
})
return (
<Link
key={quiz.id}
href={`/${locale}/quiz/${quiz.id}`}
className="block bg-dark-surface border border-dark-border hover:border-blue-500/30 rounded-lg px-4 py-3 transition-colors"
>
<div className="flex justify-between items-center mb-1">
<span className="text-sm text-dark-text font-mono">
{quiz.config.categories.join(' · ')}
</span>
<span className="text-xs text-dark-muted">{date}</span>
</div>
<div className="flex items-center gap-4 text-xs">
<span className="text-green-400">{known} acertei</span>
<span className="text-yellow-400">{partial} mais ou menos</span>
<span className="text-red-400">{unknown} errei</span>
<span className="ml-auto text-dark-muted font-mono">{score}%</span>
</div>
</Link>
)
})}
</div>

<div className="mt-10 text-center">
<button
onClick={handleClear}
className="text-sm text-dark-muted hover:text-red-400 transition-colors"
>
{t('clearHistory')}
</button>
</div>
</div>
)
}
Loading
Loading