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
14 changes: 5 additions & 9 deletions src/app/[locale]/questions/page.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,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 { CategoryListClient } from '@/components/questions/CategoryListClient'
import { QuizSetupPanel } from '@/components/quiz/QuizSetupPanel'

export async function generateStaticParams() {
Expand All @@ -16,14 +16,14 @@ export default async function QuestionsPage() {
CATEGORIES.map(cat => [cat.slug, getAllQuestionMeta(locale, cat.slug).length])
)

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

return (
<div className="max-w-5xl mx-auto px-4 py-12">
<div className="max-w-4xl mx-auto px-4 py-12">
<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')}
Expand All @@ -33,14 +33,10 @@ export default async function QuestionsPage() {
categoryCounts={categoryCounts}
/>
</div>
<p className="text-dark-muted mb-10">
<p className="text-dark-muted mb-8">
{t('categories.subtitle')}
</p>
<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} />
))}
</div>
<CategoryListClient categories={categories} />
</div>
)
}
18 changes: 18 additions & 0 deletions src/app/[locale]/updates/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,24 @@ type ChangelogEntry = {
}

const CHANGELOG: ChangelogEntry[] = [
{
date: '2026-06-05',
version: '1.8.0',
title: 'Category page redesign — filterable list with progress',
titlePt: 'Redesign da página de categorias — lista filtrável com progresso',
points: [
'Replaced the card grid with a full-width vertical list — cleaner and more readable',
'Each category now shows a progress bar and a "X/total completed" label based on your local study history',
'Added a filter bar with text search and quick chips: all / with progress / not started',
'Renamed "Create Test" to "Quiz" throughout the app for consistency',
],
pointsPt: [
'Grid de cards substituído por lista vertical full-width — mais limpa e legível',
'Cada categoria agora exibe uma barra de progresso e o contador "X/total concluídas" baseado no histórico de estudo local',
'Adicionada barra de filtro com busca por texto e chips rápidos: todas / com progresso / não iniciadas',
'Renomeado "Criar Teste" para "Quiz" em todo o app para maior consistência',
],
},
{
date: '2026-06-05',
version: '1.7.0',
Expand Down
37 changes: 27 additions & 10 deletions src/components/questions/CategoryCard.tsx
Original file line number Diff line number Diff line change
@@ -1,32 +1,49 @@
'use client'

import Link from 'next/link'
import { useLocale } from 'next-intl'
import { ProgressBadge } from './ProgressBadge'
import { useLocale, useTranslations } from 'next-intl'
import type { Category } from '@/lib/categories'

type Props = {
category: Category
label: string
count: number
known: number
}

export function CategoryCard({ category, label, count }: Props) {
export function CategoryCard({ category, label, count, known }: Props) {
const locale = useLocale()
const t = useTranslations('categories')
const tProgress = useTranslations('progress')
const pct = count > 0 ? Math.round((known / count) * 100) : 0

return (
<Link
href={`/${locale}/questions/${category.slug}`}
className={`block bg-gray-50 dark:bg-dark-surface border ${category.borderColor} rounded-lg p-6 hover:opacity-80 transition-opacity group`}
className="block bg-dark-surface border border-dark-border rounded-lg p-4 hover:bg-[#1c2128] transition-colors"
style={{ borderLeftColor: category.accentColor, borderLeftWidth: '3px' }}
>
<div className="flex items-start justify-between mb-4">
<span className="text-3xl">{category.icon}</span>
<ProgressBadge category={category.slug} total={count} />
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<span className="text-base">{category.icon}</span>
<span className="font-mono font-bold text-sm text-dark-heading">{label}</span>
</div>
<span className="text-xs text-dark-muted font-mono">{count} {t('questionsTotal')}</span>
</div>
<div className={`font-mono font-bold text-lg ${category.color} mb-1`}>
{label}
<div className="h-[3px] bg-dark-border rounded-full mb-1">
<div
data-testid="progress-fill"
className="h-full rounded-full"
style={{ width: `${pct}%`, backgroundColor: category.accentColor }}
/>
</div>
<div className="text-sm text-gray-400 dark:text-dark-muted">{count} questions</div>
{known > 0 ? (
<p className="text-[10px] font-mono" style={{ color: category.accentColor }}>
{known}/{count} {tProgress('studied')}
</p>
) : (
<p className="text-[10px] font-mono text-dark-muted">{t('notStarted')}</p>
)}
</Link>
)
}
105 changes: 105 additions & 0 deletions src/components/questions/CategoryListClient.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
'use client'

import { useState, useEffect, useMemo } from 'react'
import { useTranslations } from 'next-intl'
import { CategoryCard } from './CategoryCard'
import { countProgress } from '@/lib/progress'
import type { Category } from '@/lib/categories'

type CategoryItem = {
cat: Category
label: string
count: number
}

type StatusFilter = 'all' | 'in-progress' | 'not-started'

type Props = {
categories: CategoryItem[]
}

export function CategoryListClient({ categories }: Props) {
const t = useTranslations('categories')
const [query, setQuery] = useState('')
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all')
const [progressMap, setProgressMap] = useState<Record<string, number>>({})
const [isHydrated, setIsHydrated] = useState(false)

useEffect(() => {
const map: Record<string, number> = {}
for (const { cat } of categories) {
const { known } = countProgress(cat.slug)
map[cat.slug] = known
}
setProgressMap(map)
setIsHydrated(true)
}, [categories])

const chips: { value: StatusFilter; label: string }[] = [
{ value: 'all', label: t('filterAll') },
{ value: 'in-progress', label: t('filterInProgress') },
{ value: 'not-started', label: t('filterNotStarted') },
]

const filtered = useMemo(() =>
categories.filter(({ cat, label }) => {
const q = query.toLowerCase()
const matchesQuery = label.toLowerCase().includes(q) || cat.slug.toLowerCase().includes(q)
const known = progressMap[cat.slug] ?? 0
if (statusFilter === 'in-progress') return matchesQuery && known > 0
if (statusFilter === 'not-started') return matchesQuery && known === 0
return matchesQuery
}),
[categories, query, statusFilter, progressMap]
)

return (
<div>
<div className="flex flex-col gap-2 mb-6">
<div className="flex items-center gap-2 bg-dark-surface border border-dark-border rounded-md px-3 py-2">
<span className="text-dark-muted text-sm">🔍</span>
<input
type="text"
value={query}
onChange={e => setQuery(e.target.value)}
placeholder={t('filterPlaceholder')}
aria-label={t('filterPlaceholder')}
className="flex-1 bg-transparent text-sm text-dark-text placeholder-dark-muted outline-none font-mono"
/>
</div>
<div className="flex gap-2 flex-wrap">
{chips.map(chip => (
<button
key={chip.value}
onClick={() => setStatusFilter(chip.value)}
aria-pressed={statusFilter === chip.value}
className={`text-xs font-mono px-3 py-1 rounded-full border transition-colors ${
statusFilter === chip.value
? 'bg-blue-500/15 border-blue-500/50 text-blue-400'
: 'bg-dark-surface border-dark-border text-dark-muted hover:text-dark-heading'
}`}
>
{chip.label}
</button>
))}
</div>
</div>

<div className="flex flex-col gap-2">
{filtered.length === 0 ? (
<p className="text-sm text-dark-muted font-mono">{t('noCategoriesFound')}</p>
) : (
filtered.map(({ cat, label, count }) => (
<CategoryCard
key={cat.slug}
category={cat}
label={label}
count={count}
known={isHydrated ? (progressMap[cat.slug] ?? 0) : 0}
/>
))
)}
</div>
</div>
)
}
26 changes: 0 additions & 26 deletions src/components/questions/ProgressBadge.tsx

This file was deleted.

68 changes: 68 additions & 0 deletions src/components/questions/__tests__/CategoryCard.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import { CategoryCard } from '../CategoryCard'
import type { Category } from '@/lib/categories'

vi.mock('next/link', () => ({
default: ({ href, children, className, style }: { href: string; children: React.ReactNode; className?: string; style?: React.CSSProperties }) =>
<a href={href} className={className} style={style}>{children}</a>,
}))

vi.mock('next-intl', () => ({
useLocale: () => 'pt',
useTranslations: (ns: string) => (key: string) => {
const map: Record<string, Record<string, string>> = {
categories: {
questionsTotal: 'perguntas',
notStarted: 'não iniciado',
},
progress: {
studied: 'concluídas',
},
}
return map[ns]?.[key] ?? key
},
}))

const mockCategory: Category = {
slug: 'frontend',
icon: '⚛️',
color: 'text-blue-400',
borderColor: 'border-blue-500/30',
accentColor: '#58a6ff',
}

describe('CategoryCard', () => {
it('renders category label and count', () => {
render(<CategoryCard category={mockCategory} label="Frontend" count={48} known={0} />)
expect(screen.getByText('Frontend')).toBeInTheDocument()
expect(screen.getByText('48 perguntas')).toBeInTheDocument()
})

it('renders "não iniciado" when known is 0', () => {
render(<CategoryCard category={mockCategory} label="Frontend" count={48} known={0} />)
expect(screen.getByText('não iniciado')).toBeInTheDocument()
})

it('renders progress label when known > 0', () => {
render(<CategoryCard category={mockCategory} label="Frontend" count={48} known={17} />)
expect(screen.getByText('17/48 concluídas')).toBeInTheDocument()
})

it('links to the correct category URL', () => {
render(<CategoryCard category={mockCategory} label="Frontend" count={48} known={0} />)
expect(screen.getByRole('link')).toHaveAttribute('href', '/pt/questions/frontend')
})

it('renders the category icon', () => {
render(<CategoryCard category={mockCategory} label="Frontend" count={48} known={0} />)
expect(screen.getByText('⚛️')).toBeInTheDocument()
})

it('renders a progress bar with correct width', () => {
const { container } = render(<CategoryCard category={mockCategory} label="Frontend" count={48} known={17} />)
const fill = container.querySelector('[data-testid="progress-fill"]')
expect(fill).toBeInTheDocument()
expect(fill).toHaveStyle({ width: '35%' })
})
})
Loading
Loading