Skip to content

Commit ac7f33d

Browse files
davidagustinclaude
andcommitted
feat(ui): add mode navigation and question count slider
- Add mode navigation bar to language layout with links to all modes (Drill, Quiz, Exercises, Reference, Interview) - Create QuestionCountSlider component with slider, input, and presets - Update drill page to use QuestionCountSlider instead of preset buttons - Update quiz page to use QuestionCountSlider for flexible question count - Change QuizConfig.questionCount from union type to number - Add cursor-pointer to all interactive navigation elements Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent a7377e4 commit ac7f33d

File tree

5 files changed

+209
-82
lines changed

5 files changed

+209
-82
lines changed

app/[language]/drill/page.tsx

Lines changed: 8 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import { useParams, useRouter } from 'next/navigation';
44
import { useCallback, useEffect, useRef, useState } from 'react';
5+
import { QuestionCountSlider } from '@/components/QuestionCountSlider';
56
import { formatOutput, validateProblemAnswer } from '@/lib/codeValidator';
67
import { problemsByLanguage } from '@/lib/problems/index';
78
import type { Difficulty, LanguageId, Problem } from '@/lib/types';
@@ -195,65 +196,6 @@ function Chip({ label, selected, onClick }: ChipProps) {
195196
);
196197
}
197198

198-
interface CountSelectorProps {
199-
value: number;
200-
onChange: (value: number) => void;
201-
}
202-
203-
function CountSelector({ value, onChange }: CountSelectorProps) {
204-
const presets = [5, 10, 15, 20];
205-
const [customValue, setCustomValue] = useState('');
206-
const [showCustom, setShowCustom] = useState(false);
207-
208-
return (
209-
<div className="flex flex-wrap gap-2">
210-
{presets.map((preset) => (
211-
<button
212-
type="button"
213-
key={preset}
214-
onClick={() => {
215-
onChange(preset);
216-
setShowCustom(false);
217-
}}
218-
className={`px-4 py-2 rounded-lg text-sm font-medium transition-all ${
219-
value === preset && !showCustom
220-
? 'bg-blue-600 text-white'
221-
: 'bg-zinc-800 text-zinc-300 hover:bg-zinc-700'
222-
}`}
223-
>
224-
{preset}
225-
</button>
226-
))}
227-
<button
228-
type="button"
229-
onClick={() => setShowCustom(true)}
230-
className={`px-4 py-2 rounded-lg text-sm font-medium transition-all ${
231-
showCustom ? 'bg-blue-600 text-white' : 'bg-zinc-800 text-zinc-300 hover:bg-zinc-700'
232-
}`}
233-
>
234-
Custom
235-
</button>
236-
{showCustom && (
237-
<input
238-
type="number"
239-
min="1"
240-
max="50"
241-
value={customValue}
242-
onChange={(e) => {
243-
setCustomValue(e.target.value);
244-
const num = parseInt(e.target.value, 10);
245-
if (num >= 1 && num <= 50) {
246-
onChange(num);
247-
}
248-
}}
249-
placeholder="1-50"
250-
className="w-20 px-3 py-2 rounded-lg border border-zinc-700 bg-zinc-800 text-sm text-white"
251-
/>
252-
)}
253-
</div>
254-
);
255-
}
256-
257199
interface DifficultyFilterProps {
258200
value: Difficulty | 'all';
259201
onChange: (value: Difficulty | 'all') => void;
@@ -361,8 +303,13 @@ function SetupPhase({ language, onStart }: SetupPhaseProps) {
361303

362304
{/* Question Count */}
363305
<div>
364-
<span className="block text-sm font-medium text-zinc-300 mb-3">Number of Questions</span>
365-
<CountSelector value={questionCount} onChange={setQuestionCount} />
306+
<QuestionCountSlider
307+
value={questionCount}
308+
onChange={setQuestionCount}
309+
min={1}
310+
max={Math.min(50, availableCount || 50)}
311+
label="Number of Questions"
312+
/>
366313
</div>
367314

368315
{/* Difficulty */}

app/[language]/layout.tsx

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,15 @@ import {
99
} from './config';
1010
import { LanguageIcon } from './LanguageIcon';
1111

12+
// Mode definitions for navigation
13+
const MODES = [
14+
{ slug: 'drill', label: 'Drill', icon: '🎯' },
15+
{ slug: 'quiz', label: 'Quiz', icon: '🧠' },
16+
{ slug: 'exercises', label: 'Exercises', icon: '🔄' },
17+
{ slug: 'reference', label: 'Reference', icon: '📚' },
18+
{ slug: 'interview', label: 'Interview', icon: '🤖' },
19+
] as const;
20+
1221
interface LayoutProps {
1322
children: React.ReactNode;
1423
params: Promise<{ language: string }>;
@@ -40,6 +49,39 @@ export function generateStaticParams() {
4049
}));
4150
}
4251

52+
// Client component for mode navigation (needs usePathname)
53+
function ModeNav({
54+
language,
55+
config,
56+
}: {
57+
language: string;
58+
config: (typeof LANGUAGE_CONFIG)[SupportedLanguage];
59+
}) {
60+
return (
61+
<nav className={`border-b ${config.borderColor} bg-zinc-900/30`}>
62+
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
63+
<div className="flex items-center gap-1 overflow-x-auto py-2 scrollbar-hide">
64+
{MODES.map((mode) => (
65+
<Link
66+
key={mode.slug}
67+
href={`/${language}/${mode.slug}`}
68+
className={`
69+
flex items-center gap-2 px-4 py-2 rounded-lg
70+
text-sm font-medium whitespace-nowrap
71+
transition-all duration-200 cursor-pointer
72+
text-zinc-400 hover:text-white hover:bg-zinc-800
73+
`}
74+
>
75+
<span>{mode.icon}</span>
76+
<span>{mode.label}</span>
77+
</Link>
78+
))}
79+
</div>
80+
</div>
81+
</nav>
82+
);
83+
}
84+
4385
export default async function LanguageLayout({ children, params }: LayoutProps) {
4486
const { language } = await params;
4587

@@ -61,15 +103,18 @@ export default async function LanguageLayout({ children, params }: LayoutProps)
61103
<div className="flex items-center gap-3">
62104
<Link
63105
href="/"
64-
className="text-zinc-400 hover:text-white transition-colors font-medium"
106+
className="text-zinc-400 hover:text-white transition-colors font-medium cursor-pointer"
65107
>
66108
Coding Drills
67109
</Link>
68110
<span className="text-zinc-600">/</span>
69-
<div className="flex items-center gap-2">
111+
<Link
112+
href={`/${language}`}
113+
className="flex items-center gap-2 cursor-pointer hover:opacity-80 transition-opacity"
114+
>
70115
<LanguageIcon language={language as SupportedLanguage} className="w-5 h-5" />
71116
<span className={`font-semibold ${config.color}`}>{config.name}</span>
72-
</div>
117+
</Link>
73118
</div>
74119

75120
{/* Language indicator badge */}
@@ -83,6 +128,9 @@ export default async function LanguageLayout({ children, params }: LayoutProps)
83128
</div>
84129
</header>
85130

131+
{/* Mode Navigation */}
132+
<ModeNav language={language} config={config} />
133+
86134
{/* Main content */}
87135
<main>{children}</main>
88136
</div>

app/[language]/quiz/page.tsx

Lines changed: 8 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import { useParams } from 'next/navigation';
44
import { useCallback, useEffect, useRef, useState } from 'react';
5+
import { QuestionCountSlider } from '@/components/QuestionCountSlider';
56
import { getCategoriesForLanguage } from '@/lib/problems';
67
import {
78
addToLeaderboard,
@@ -121,7 +122,6 @@ interface SetupPhaseProps {
121122
function SetupPhase({ config, onConfigChange, onStart, availableCategories }: SetupPhaseProps) {
122123
const [soundEnabled, setSoundEnabled] = useState(true);
123124

124-
const questionCountOptions = [5, 10, 15] as const;
125125
const timeOptions = [10, 15, 20, 30] as const;
126126

127127
const toggleCategory = (category: string) => {
@@ -198,22 +198,13 @@ function SetupPhase({ config, onConfigChange, onStart, availableCategories }: Se
198198
{/* Question Count */}
199199
<div className="bg-slate-800/50 rounded-2xl p-6 mb-6 border border-slate-700/50">
200200
<h2 className="text-xl font-semibold mb-4">Number of Questions</h2>
201-
<div className="flex gap-3">
202-
{questionCountOptions.map((count) => (
203-
<button
204-
type="button"
205-
key={count}
206-
onClick={() => onConfigChange({ ...config, questionCount: count })}
207-
className={`flex-1 py-3 rounded-lg font-semibold text-lg transition-all duration-200 ${
208-
config.questionCount === count
209-
? 'bg-purple-500 text-white shadow-lg shadow-purple-500/25'
210-
: 'bg-slate-700/50 text-slate-300 hover:bg-slate-700'
211-
}`}
212-
>
213-
{count}
214-
</button>
215-
))}
216-
</div>
201+
<QuestionCountSlider
202+
value={config.questionCount}
203+
onChange={(value) => onConfigChange({ ...config, questionCount: value })}
204+
min={1}
205+
max={30}
206+
showLabel={false}
207+
/>
217208
</div>
218209

219210
{/* Time Per Question */}

components/QuestionCountSlider.tsx

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
'use client';
2+
3+
import { useCallback, useState } from 'react';
4+
5+
interface QuestionCountSliderProps {
6+
/** Current value */
7+
value: number;
8+
/** Callback when value changes */
9+
onChange: (value: number) => void;
10+
/** Minimum value (default: 1) */
11+
min?: number;
12+
/** Maximum value (default: 50) */
13+
max?: number;
14+
/** Label text (default: "Questions") */
15+
label?: string;
16+
/** Whether to show the label */
17+
showLabel?: boolean;
18+
}
19+
20+
export function QuestionCountSlider({
21+
value,
22+
onChange,
23+
min = 1,
24+
max = 50,
25+
label = 'Questions',
26+
showLabel = true,
27+
}: QuestionCountSliderProps) {
28+
const [inputValue, setInputValue] = useState(value.toString());
29+
30+
// Handle slider change
31+
const handleSliderChange = useCallback(
32+
(e: React.ChangeEvent<HTMLInputElement>) => {
33+
const newValue = parseInt(e.target.value, 10);
34+
onChange(newValue);
35+
setInputValue(newValue.toString());
36+
},
37+
[onChange],
38+
);
39+
40+
// Handle input change
41+
const handleInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
42+
setInputValue(e.target.value);
43+
}, []);
44+
45+
// Handle input blur - validate and apply
46+
const handleInputBlur = useCallback(() => {
47+
const num = parseInt(inputValue, 10);
48+
if (Number.isNaN(num)) {
49+
setInputValue(value.toString());
50+
} else {
51+
const clampedValue = Math.min(Math.max(num, min), max);
52+
onChange(clampedValue);
53+
setInputValue(clampedValue.toString());
54+
}
55+
}, [inputValue, value, min, max, onChange]);
56+
57+
// Handle Enter key
58+
const handleKeyDown = useCallback(
59+
(e: React.KeyboardEvent<HTMLInputElement>) => {
60+
if (e.key === 'Enter') {
61+
handleInputBlur();
62+
}
63+
},
64+
[handleInputBlur],
65+
);
66+
67+
// Calculate percentage for slider track fill
68+
const percentage = ((value - min) / (max - min)) * 100;
69+
70+
return (
71+
<div className="space-y-3">
72+
{showLabel && (
73+
<div className="flex items-center justify-between">
74+
<label htmlFor="question-count-slider" className="text-sm font-medium text-zinc-300">
75+
{label}
76+
</label>
77+
<span className="text-sm text-zinc-500">
78+
{min} - {max}
79+
</span>
80+
</div>
81+
)}
82+
83+
<div className="flex items-center gap-4">
84+
{/* Slider */}
85+
<div className="flex-1 relative">
86+
<input
87+
id="question-count-slider"
88+
type="range"
89+
min={min}
90+
max={max}
91+
value={value}
92+
onChange={handleSliderChange}
93+
className="w-full h-2 appearance-none cursor-pointer rounded-full bg-zinc-700 focus:outline-none focus:ring-2 focus:ring-blue-500/50"
94+
style={{
95+
background: `linear-gradient(to right, #3b82f6 0%, #3b82f6 ${percentage}%, #3f3f46 ${percentage}%, #3f3f46 100%)`,
96+
}}
97+
/>
98+
</div>
99+
100+
{/* Number Input */}
101+
<div className="flex items-center gap-2">
102+
<input
103+
type="number"
104+
min={min}
105+
max={max}
106+
value={inputValue}
107+
onChange={handleInputChange}
108+
onBlur={handleInputBlur}
109+
onKeyDown={handleKeyDown}
110+
className="w-20 px-3 py-2 text-center rounded-lg border border-zinc-700 bg-zinc-800 text-white text-sm font-medium focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
111+
aria-label="Number of questions"
112+
/>
113+
</div>
114+
</div>
115+
116+
{/* Quick preset buttons */}
117+
<div className="flex flex-wrap gap-2">
118+
{[5, 10, 15, 20, 25, 30].map((preset) => (
119+
<button
120+
key={preset}
121+
type="button"
122+
onClick={() => {
123+
onChange(preset);
124+
setInputValue(preset.toString());
125+
}}
126+
disabled={preset > max}
127+
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-all cursor-pointer ${
128+
value === preset
129+
? 'bg-blue-600 text-white'
130+
: 'bg-zinc-800 text-zinc-400 hover:bg-zinc-700 hover:text-zinc-200'
131+
} ${preset > max ? 'opacity-50 cursor-not-allowed' : ''}`}
132+
>
133+
{preset}
134+
</button>
135+
))}
136+
</div>
137+
</div>
138+
);
139+
}
140+
141+
export default QuestionCountSlider;

lib/quizGenerator.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import type { Difficulty, LanguageId, Method, QuizQuestion } from './types';
44
export interface QuizConfig {
55
language: LanguageId;
66
categories: string[];
7-
questionCount: 5 | 10 | 15;
7+
questionCount: number;
88
timePerQuestion: 10 | 15 | 20 | 30;
99
}
1010

0 commit comments

Comments
 (0)