diff --git a/src/components/questions/FocusMode.tsx b/src/components/questions/FocusMode.tsx new file mode 100644 index 0000000..ab6575f --- /dev/null +++ b/src/components/questions/FocusMode.tsx @@ -0,0 +1,135 @@ +'use client' + +import { useEffect, useState } from 'react' +import { createPortal } from 'react-dom' +import { useLocale, useTranslations } from 'next-intl' +import { getQuestionStatus } from '@/lib/progress' +import { Badge } from '@/components/ui/Badge' +import type { QuestionMeta } from '@/lib/content' + +const difficultyVariant: Record = { + junior: 'green', + pleno: 'blue', + senior: 'orange', + especialista: 'orange', +} + +type Props = { + question: QuestionMeta + onNext: () => void + onPrev: () => void + hasPrev: boolean + onClose: () => void +} + +export function FocusMode({ question, onNext, onPrev, hasPrev, onClose }: Props) { + const locale = useLocale() + const t = useTranslations('categories') + const tQuestion = useTranslations('question') + const [status, setStatus] = useState<'known' | 'review' | null>(null) + const [revealed, setRevealed] = useState(false) + + useEffect(() => { + setStatus(getQuestionStatus(question.path)) + setRevealed(false) + }, [question.path]) + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose() + } + document.addEventListener('keydown', handleKeyDown) + return () => document.removeEventListener('keydown', handleKeyDown) + }, [onClose]) + + return createPortal( +
+ {/* Modal card */} +
e.stopPropagation()} + > + {/* Header */} +
+ + 🎲 {t('randomTitle')} + + +
+ + {/* Scrollable content */} +
+
+ + {tQuestion(`difficulty.${question.difficulty}`)} + + {question.tags.slice(0, 4).map(tag => ( + {tag} + ))} + {status === 'known' && } + {status === 'review' && 🔄} +
+ +

+ {question.title} +

+ + {!revealed ? ( + + ) : ( +
+
+

+ {t('quickAnswerLabel')} +

+

+ {question.quickAnswer || '—'} +

+
+ + {t('openQuestion')} → + +
+ )} +
+ + {/* Footer */} +
+ + +
+
+
, + document.body + ) +} diff --git a/src/components/questions/QuestionListClient.tsx b/src/components/questions/QuestionListClient.tsx index a5859aa..ac0dfa7 100644 --- a/src/components/questions/QuestionListClient.tsx +++ b/src/components/questions/QuestionListClient.tsx @@ -1,13 +1,12 @@ 'use client' import { useState, useMemo, useCallback, useRef } from 'react' -import Link from 'next/link' -import { useLocale, useTranslations } from 'next-intl' +import { useTranslations } from 'next-intl' import { useEffect } from 'react' -import { getQuestionStatus } from '@/lib/progress' import { Badge } from '@/components/ui/Badge' -import { QuestionCard } from './QuestionCard' +import { FocusMode } from './FocusMode' import type { QuestionMeta } from '@/lib/content' +import { QuestionCard } from './QuestionCard' const LEVELS = ['junior', 'pleno', 'senior', 'especialista'] as const @@ -18,82 +17,6 @@ const difficultyVariant: Record = { especialista: 'orange', } -function RandomCard({ question, onClose }: { - question: QuestionMeta - onClose: () => void -}) { - const locale = useLocale() - const t = useTranslations('categories') - const tQuestion = useTranslations('question') - const [status, setStatus] = useState<'known' | 'review' | null>(null) - const [revealed, setRevealed] = useState(false) - - useEffect(() => { - setStatus(getQuestionStatus(question.path)) - setRevealed(false) - }, [question.path]) - - return ( -
-
- - 🎲 {t('randomTitle')} - - -
- -
-
- - {tQuestion(`difficulty.${question.difficulty}`)} - - {question.tags.slice(0, 4).map(tag => ( - {tag} - ))} - {status === 'known' && } - {status === 'review' && 🔄} -
- -

- {question.title} -

- - {!revealed ? ( - - ) : ( -
-
-

- {t('quickAnswerLabel')} -

-

- {question.quickAnswer || '—'} -

-
- - {t('openQuestion')} → - -
- )} -
-
- ) -} - type Props = { questions: QuestionMeta[] } @@ -108,6 +31,8 @@ export function QuestionListClient({ questions }: Props) { const [tagFocused, setTagFocused] = useState(false) const [randomQuestion, setRandomQuestion] = useState(null) const [seenSlugs, setSeenSlugs] = useState>(new Set()) + const [history, setHistory] = useState([]) + const [historyIndex, setHistoryIndex] = useState(-1) const inputRef = useRef(null) @@ -139,6 +64,8 @@ export function QuestionListClient({ questions }: Props) { ) setRandomQuestion(null) setSeenSlugs(new Set()) + setHistory([]) + setHistoryIndex(-1) } const toggleTag = (tag: string) => { @@ -147,6 +74,8 @@ export function QuestionListClient({ questions }: Props) { ) setRandomQuestion(null) setSeenSlugs(new Set()) + setHistory([]) + setHistoryIndex(-1) } const addTag = (tag: string) => { @@ -154,6 +83,8 @@ export function QuestionListClient({ questions }: Props) { setSelectedTags(prev => [...prev, tag]) setRandomQuestion(null) setSeenSlugs(new Set()) + setHistory([]) + setHistoryIndex(-1) } setTagInput('') inputRef.current?.focus() @@ -165,6 +96,8 @@ export function QuestionListClient({ questions }: Props) { setTagInput('') setRandomQuestion(null) setSeenSlugs(new Set()) + setHistory([]) + setHistoryIndex(-1) } const handleTagKeyDown = (e: React.KeyboardEvent) => { @@ -191,11 +124,25 @@ export function QuestionListClient({ questions }: Props) { const pick = available[Math.floor(Math.random() * available.length)] setRandomQuestion(pick) setSeenSlugs(new Set([...nextSeen, pick.slug])) - }, [filtered, seenSlugs]) + setHistory(prev => { + const truncated = prev.slice(0, historyIndex + 1) + return [...truncated, pick] + }) + setHistoryIndex(prev => prev + 1) + }, [filtered, seenSlugs, historyIndex]) + + const handlePrev = useCallback(() => { + if (historyIndex <= 0) return + const newIndex = historyIndex - 1 + setHistoryIndex(newIndex) + setRandomQuestion(history[newIndex]) + }, [history, historyIndex]) const closeRandom = () => { setRandomQuestion(null) setSeenSlugs(new Set()) + setHistory([]) + setHistoryIndex(-1) } const isFiltering = selectedTags.length > 0 || selectedLevels.length > 0 @@ -302,10 +249,13 @@ export function QuestionListClient({ questions }: Props) { - {/* Random question card */} + {/* Focus mode overlay */} {randomQuestion && ( - 0} onClose={closeRandom} /> )} diff --git a/src/components/questions/__tests__/FocusMode.test.tsx b/src/components/questions/__tests__/FocusMode.test.tsx new file mode 100644 index 0000000..0d7d306 --- /dev/null +++ b/src/components/questions/__tests__/FocusMode.test.tsx @@ -0,0 +1,100 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import { FocusMode } from '../FocusMode' +import type { QuestionMeta } from '@/lib/content' + +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string) => key, + useLocale: () => 'pt', +})) + +vi.mock('@/lib/progress', () => ({ + getQuestionStatus: vi.fn(() => null), +})) + +const mockQuestion: QuestionMeta = { + slug: 'test-question', + title: 'What is closure?', + category: 'frontend', + subcategory: 'javascript', + path: 'frontend/javascript/test-question', + difficulty: 'junior', + tags: ['javascript', 'closures'], + quickAnswer: 'A closure captures its lexical scope.', + lang: 'pt', +} + +describe('FocusMode', () => { + const defaultProps = { + question: mockQuestion, + onNext: vi.fn(), + onPrev: vi.fn(), + hasPrev: false, + onClose: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('renders the question title', () => { + render() + expect(screen.getByText('What is closure?')).toBeInTheDocument() + }) + + it('calls onClose when × button is clicked', () => { + render() + fireEvent.click(screen.getByLabelText('focusModeClose')) + expect(defaultProps.onClose).toHaveBeenCalledOnce() + }) + + it('calls onClose when Escape key is pressed', () => { + render() + fireEvent.keyDown(document, { key: 'Escape' }) + expect(defaultProps.onClose).toHaveBeenCalledOnce() + }) + + it('shows show-answer button initially, hides quick answer', () => { + render() + expect(screen.getByText('showAnswer')).toBeInTheDocument() + expect(screen.queryByText('A closure captures its lexical scope.')).not.toBeInTheDocument() + }) + + it('reveals quick answer after clicking show answer', () => { + render() + fireEvent.click(screen.getByText('showAnswer')) + expect(screen.getByText('A closure captures its lexical scope.')).toBeInTheDocument() + }) + + it('disables Anterior button when hasPrev is false', () => { + render() + expect(screen.getByText(/focusModePrev/)).toBeDisabled() + }) + + it('enables Anterior button when hasPrev is true', () => { + render() + expect(screen.getByText(/focusModePrev/)).not.toBeDisabled() + }) + + it('calls onPrev when Anterior button is clicked and hasPrev is true', () => { + render() + fireEvent.click(screen.getByText(/focusModePrev/)) + expect(defaultProps.onPrev).toHaveBeenCalledOnce() + }) + + it('calls onNext when Próxima button is clicked', () => { + render() + fireEvent.click(screen.getByText(/focusModeNext/)) + expect(defaultProps.onNext).toHaveBeenCalledOnce() + }) + + it('resets revealed state when question changes', () => { + const { rerender } = render() + fireEvent.click(screen.getByText('showAnswer')) + expect(screen.queryByText('showAnswer')).not.toBeInTheDocument() + + const nextQuestion = { ...mockQuestion, slug: 'other', title: 'Other question', path: 'frontend/javascript/other' } + rerender() + expect(screen.getByText('showAnswer')).toBeInTheDocument() + }) +}) diff --git a/src/messages/en.json b/src/messages/en.json index b05ef80..295aafe 100644 --- a/src/messages/en.json +++ b/src/messages/en.json @@ -50,6 +50,9 @@ "showAnswer": "Show answer", "quickAnswerLabel": "Quick answer", "openQuestion": "See full question", + "focusModeNext": "Next", + "focusModePrev": "Previous", + "focusModeClose": "Close", "frontend": "Frontend", "backend": "Backend", "devops": "DevOps", diff --git a/src/messages/pt.json b/src/messages/pt.json index 7cf4688..95fcbbf 100644 --- a/src/messages/pt.json +++ b/src/messages/pt.json @@ -50,6 +50,9 @@ "showAnswer": "Ver resposta", "quickAnswerLabel": "Resposta rápida", "openQuestion": "Ver pergunta completa", + "focusModeNext": "Próxima", + "focusModePrev": "Anterior", + "focusModeClose": "Fechar", "frontend": "Frontend", "backend": "Backend", "devops": "DevOps",