Skip to content

Commit c15bb44

Browse files
authored
Merge pull request #89 from kc3hack/feature/issue-86-exercise-selection
feat: #86 演習選択画面を実装
2 parents fd10ff1 + 2aa34c0 commit c15bb44

File tree

6 files changed

+399
-11
lines changed

6 files changed

+399
-11
lines changed
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
'use client';
2+
3+
import { use } from 'react';
4+
import Link from 'next/link';
5+
import { ExerciseList } from '@/features/exercise/components/ExerciseList';
6+
7+
interface PageProps {
8+
params: Promise<{
9+
category: string;
10+
}>;
11+
}
12+
13+
export default function ExerciseCategoryPage({ params }: PageProps) {
14+
const resolvedParams = use(params);
15+
const category = resolvedParams.category;
16+
17+
// カテゴリー名を表示用に変換
18+
const getCategoryDisplayName = (cat: string) => {
19+
const names: Record<string, string> = {
20+
web: 'Web',
21+
ai: 'AI',
22+
security: 'Security',
23+
mobile: 'Mobile',
24+
game: 'Game',
25+
design: 'Design',
26+
infrastructure: 'Infrastructure',
27+
network: 'Network',
28+
};
29+
return names[cat.toLowerCase()] || cat;
30+
};
31+
32+
return (
33+
<div className="flex h-full flex-col bg-[#FDFEF0]">
34+
{/* Header */}
35+
<div className="bg-[#14532D] border-b-4 border-black px-6 py-4">
36+
<div className="flex items-center gap-4">
37+
<Link
38+
href="/exercises"
39+
className="flex items-center justify-center w-10 h-10 bg-[#4ADE80] border-4 border-black hover:bg-[#86EFAC] transition-colors shadow-[4px_4px_0_black] active:shadow-none active:translate-x-[4px] active:translate-y-[4px] font-bold"
40+
>
41+
<span className="text-xl"></span>
42+
</Link>
43+
<h1 className="text-2xl font-bold text-[#4ADE80] flex items-center gap-2 tracking-widest drop-shadow-[2px_2px_0_black]">
44+
<span className="text-3xl">🎓</span>
45+
{getCategoryDisplayName(category)} 演習
46+
</h1>
47+
</div>
48+
</div>
49+
50+
{/* Main Content */}
51+
<div className="flex-1 px-6 py-6 overflow-auto">
52+
<ExerciseList category={category} />
53+
</div>
54+
</div>
55+
);
56+
}
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import { Exercise } from '../types';
2+
3+
export const getExercisesByCategory = async (category: string): Promise<Exercise[]> => {
4+
// TODO: Replace with actual API call
5+
await new Promise((resolve) => setTimeout(resolve, 300));
6+
7+
const exercises: Record<string, Exercise[]> = {
8+
web: [
9+
{
10+
id: 'web-1',
11+
title: 'HTML/CSSの基礎',
12+
category: 'web',
13+
difficulty: 'beginner',
14+
status: 'completed',
15+
estimatedTime: 30,
16+
description: 'HTMLとCSSの基本的な使い方を学びます',
17+
completionRate: 100,
18+
},
19+
{
20+
id: 'web-2',
21+
title: 'レスポンシブデザイン入門',
22+
category: 'web',
23+
difficulty: 'beginner',
24+
status: 'in-progress',
25+
estimatedTime: 45,
26+
description: 'モバイルフレンドリーなWebサイトの作り方',
27+
completionRate: 60,
28+
},
29+
{
30+
id: 'web-3',
31+
title: 'JavaScript基礎',
32+
category: 'web',
33+
difficulty: 'intermediate',
34+
status: 'not-started',
35+
estimatedTime: 60,
36+
description: 'JavaScriptの基本文法とDOM操作',
37+
},
38+
{
39+
id: 'web-4',
40+
title: 'React入門',
41+
category: 'web',
42+
difficulty: 'intermediate',
43+
status: 'not-started',
44+
estimatedTime: 90,
45+
description: 'Reactコンポーネントの作成方法',
46+
},
47+
{
48+
id: 'web-5',
49+
title: 'Next.jsでアプリ開発',
50+
category: 'web',
51+
difficulty: 'advanced',
52+
status: 'not-started',
53+
estimatedTime: 120,
54+
description: 'Next.jsを使ったフルスタックアプリケーション',
55+
},
56+
{
57+
id: 'web-6',
58+
title: 'パフォーマンス最適化',
59+
category: 'web',
60+
difficulty: 'expert',
61+
status: 'not-started',
62+
estimatedTime: 90,
63+
description: 'Webアプリケーションのパフォーマンス改善手法',
64+
},
65+
],
66+
ai: [
67+
{
68+
id: 'ai-1',
69+
title: '機械学習の基礎',
70+
category: 'ai',
71+
difficulty: 'beginner',
72+
status: 'not-started',
73+
estimatedTime: 60,
74+
description: '機械学習の基本概念を学びます',
75+
},
76+
{
77+
id: 'ai-2',
78+
title: 'Python for AI',
79+
category: 'ai',
80+
difficulty: 'beginner',
81+
status: 'not-started',
82+
estimatedTime: 45,
83+
description: 'AI開発に必要なPythonの基礎',
84+
},
85+
{
86+
id: 'ai-3',
87+
title: 'ニューラルネットワーク入門',
88+
category: 'ai',
89+
difficulty: 'intermediate',
90+
status: 'not-started',
91+
estimatedTime: 90,
92+
description: 'ニューラルネットワークの仕組み',
93+
},
94+
{
95+
id: 'ai-4',
96+
title: 'ディープラーニング実践',
97+
category: 'ai',
98+
difficulty: 'advanced',
99+
status: 'not-started',
100+
estimatedTime: 120,
101+
description: 'CNNやRNNを使った実装',
102+
},
103+
],
104+
security: [
105+
{
106+
id: 'security-1',
107+
title: 'セキュリティ基礎',
108+
category: 'security',
109+
difficulty: 'beginner',
110+
status: 'not-started',
111+
estimatedTime: 30,
112+
description: '情報セキュリティの基本',
113+
},
114+
{
115+
id: 'security-2',
116+
title: 'Webセキュリティ',
117+
category: 'security',
118+
difficulty: 'intermediate',
119+
status: 'not-started',
120+
estimatedTime: 60,
121+
description: 'XSSやCSRFなどの脆弱性対策',
122+
},
123+
{
124+
id: 'security-3',
125+
title: 'ペネトレーションテスト',
126+
category: 'security',
127+
difficulty: 'advanced',
128+
status: 'not-started',
129+
estimatedTime: 120,
130+
description: 'セキュリティテストの実践',
131+
},
132+
],
133+
};
134+
135+
const categoryKey = category.toLowerCase();
136+
return exercises[categoryKey] || [];
137+
};
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
'use client';
2+
3+
import React, { useState, useEffect } from 'react';
4+
import { Exercise, DifficultyLevel, DIFFICULTY_LABELS } from '../types';
5+
import { ExerciseListItem } from './ExerciseListItem';
6+
import { getExercisesByCategory } from '../api/mock';
7+
8+
interface ExerciseListProps {
9+
category: string;
10+
}
11+
12+
const DIFFICULTY_TABS: DifficultyLevel[] = ['beginner', 'intermediate', 'advanced', 'expert'];
13+
14+
export function ExerciseList({ category }: ExerciseListProps) {
15+
const [exercises, setExercises] = useState<Exercise[]>([]);
16+
const [loading, setLoading] = useState(true);
17+
const [activeDifficulty, setActiveDifficulty] = useState<DifficultyLevel>('beginner');
18+
19+
useEffect(() => {
20+
const fetchExercises = async () => {
21+
setLoading(true);
22+
try {
23+
const data = await getExercisesByCategory(category);
24+
setExercises(data);
25+
} catch (error) {
26+
console.error('Failed to fetch exercises:', error);
27+
} finally {
28+
setLoading(false);
29+
}
30+
};
31+
32+
fetchExercises();
33+
}, [category]);
34+
35+
const filteredExercises = exercises.filter((ex) => ex.difficulty === activeDifficulty);
36+
37+
const handleExerciseClick = (exerciseId: string) => {
38+
// TODO: Navigate to exercise detail page
39+
console.log(`Navigate to exercise: ${exerciseId}`);
40+
// router.push(`/exercise/${category}/${exerciseId}`);
41+
};
42+
43+
if (loading) {
44+
return (
45+
<div className="flex justify-center items-center h-64">
46+
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-[#3A7E56]"></div>
47+
</div>
48+
);
49+
}
50+
51+
return (
52+
<div className="flex flex-col h-full">
53+
{/* Difficulty Tabs */}
54+
<div className="flex gap-2 mb-6 border-b-2 border-[#3A7E56] pb-2">
55+
{DIFFICULTY_TABS.map((difficulty) => {
56+
const isActive = activeDifficulty === difficulty;
57+
const count = exercises.filter((ex) => ex.difficulty === difficulty).length;
58+
59+
return (
60+
<button
61+
key={difficulty}
62+
onClick={() => setActiveDifficulty(difficulty)}
63+
className={`px-6 py-2 font-bold text-lg transition-all ${
64+
isActive
65+
? 'text-[#2C5F2D] border-b-4 border-[#2C5F2D] -mb-[10px]'
66+
: 'text-[#6B7280] hover:text-[#3A7E56]'
67+
}`}
68+
>
69+
{DIFFICULTY_LABELS[difficulty]}
70+
{count > 0 && (
71+
<span className="ml-2 text-sm opacity-70">({count})</span>
72+
)}
73+
</button>
74+
);
75+
})}
76+
</div>
77+
78+
{/* Exercise List */}
79+
<div className="flex-1 space-y-3">
80+
{filteredExercises.length > 0 ? (
81+
filteredExercises.map((exercise) => (
82+
<ExerciseListItem
83+
key={exercise.id}
84+
exercise={exercise}
85+
onClick={() => handleExerciseClick(exercise.id)}
86+
/>
87+
))
88+
) : (
89+
<div className="flex flex-col items-center justify-center h-48 text-[#6B7280]">
90+
<div className="text-6xl mb-4">📝</div>
91+
<p className="text-lg">この難易度の演習はまだありません</p>
92+
</div>
93+
)}
94+
</div>
95+
</div>
96+
);
97+
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
'use client';
2+
3+
import React from 'react';
4+
import { Exercise } from '../types';
5+
6+
interface ExerciseListItemProps {
7+
exercise: Exercise;
8+
onClick: () => void;
9+
}
10+
11+
export function ExerciseListItem({ exercise, onClick }: ExerciseListItemProps) {
12+
const getStatusIcon = (status: Exercise['status']) => {
13+
switch (status) {
14+
case 'completed':
15+
return '✓';
16+
case 'in-progress':
17+
return '▶';
18+
case 'not-started':
19+
return '○';
20+
}
21+
};
22+
23+
const getStatusColor = (status: Exercise['status']) => {
24+
switch (status) {
25+
case 'completed':
26+
return 'text-[#4ADE80]';
27+
case 'in-progress':
28+
return 'text-[#FCD34D]';
29+
case 'not-started':
30+
return 'text-[#94A3B8]';
31+
}
32+
};
33+
34+
return (
35+
<button
36+
onClick={onClick}
37+
className="w-full bg-[#FDFEF0] border-2 border-[#3A7E56] px-6 py-4 text-left transition-all hover:bg-[#F0F7F0] hover:border-[#2C5F2D] hover:shadow-md group"
38+
>
39+
<div className="flex items-center justify-between">
40+
<div className="flex items-center gap-4 flex-1">
41+
<span className={`text-2xl font-bold ${getStatusColor(exercise.status)}`}>
42+
{getStatusIcon(exercise.status)}
43+
</span>
44+
<div className="flex-1">
45+
<h3 className="text-lg font-bold text-[#2C5F2D] group-hover:text-[#1a4023]">
46+
{exercise.title}
47+
</h3>
48+
{exercise.completionRate !== undefined && exercise.status === 'in-progress' && (
49+
<div className="mt-1 flex items-center gap-2">
50+
<div className="w-32 h-2 bg-[#D1D5DB] rounded-full overflow-hidden">
51+
<div
52+
className="h-full bg-[#4ADE80] transition-all"
53+
style={{ width: `${exercise.completionRate}%` }}
54+
/>
55+
</div>
56+
<span className="text-xs text-[#6B7280]">{exercise.completionRate}%</span>
57+
</div>
58+
)}
59+
</div>
60+
</div>
61+
<div className="flex items-center gap-4 text-sm text-[#6B7280]">
62+
<span className="font-medium">{exercise.estimatedTime}</span>
63+
<span className="text-2xl text-[#3A7E56] group-hover:translate-x-1 transition-transform"></span>
64+
</div>
65+
</div>
66+
</button>
67+
);
68+
}

0 commit comments

Comments
 (0)