Skip to content

Commit fc96d7c

Browse files
BuildToolsInlet-back
authored andcommitted
feat: #98 AI生成演習の結果表示画面を実装
- POST /api/v1/quest/generate のレスポンスを /exercises/generate/result で表示 - QuestResultHeader / QuestObjectives / QuestStepCard / QuestStepList / QuestResources コンポーネント追加 - generateQuest API クライアント追加(document_content のみ送信、user_rank は別 Issue で対応) - 遷移フロー: /exercises(問題生成タブ) API /exercises/generate/result - sessionStorage でページ間のレスポンスデータを受け渡し - AIGeneration.tsx: console.log 削除のみ(UI構造は変更なし)
1 parent bd7ba88 commit fc96d7c

File tree

9 files changed

+476
-11
lines changed

9 files changed

+476
-11
lines changed
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
"use client";
2+
3+
import { useEffect, useState } from "react";
4+
import { useRouter } from "next/navigation";
5+
import { withAuth } from "@/lib/auth/withAuth";
6+
import type { QuestGenerationResponse } from "@/features/exercise/types";
7+
import { QuestResultHeader } from "@/features/exercise/components/QuestResultHeader";
8+
import { QuestObjectives } from "@/features/exercise/components/QuestObjectives";
9+
import { QuestStepList } from "@/features/exercise/components/QuestStepList";
10+
import { QuestResources } from "@/features/exercise/components/QuestResources";
11+
12+
function ExerciseGenerateResultPage() {
13+
const router = useRouter();
14+
const [quest, setQuest] = useState<QuestGenerationResponse | null>(null);
15+
const [error, setError] = useState<string | null>(null);
16+
17+
useEffect(() => {
18+
const raw = sessionStorage.getItem("questGenerationResult");
19+
if (!raw) {
20+
setError("生成結果が見つかりません。演習生成ページからやり直してください。");
21+
return;
22+
}
23+
try {
24+
setQuest(JSON.parse(raw) as QuestGenerationResponse);
25+
} catch {
26+
setError("データの読み込みに失敗しました。演習生成ページからやり直してください。");
27+
}
28+
}, []);
29+
30+
// エラー状態
31+
if (error) {
32+
return (
33+
<div className="min-h-screen bg-[#FDFEF0] flex items-center justify-center">
34+
<div className="text-center max-w-md mx-auto px-6">
35+
<div
36+
className="bg-white border-4 border-red-500 p-8 mb-6"
37+
style={{ boxShadow: "6px 6px 0 #000" }}
38+
>
39+
<p className="text-red-600 font-bold text-xl mb-3">⚠️ エラー</p>
40+
<p className="text-[#14532D]">{error}</p>
41+
</div>
42+
<button
43+
onClick={() => router.push("/exercises")}
44+
className="inline-flex items-center gap-2 px-6 py-3 bg-[#FDFEF0] border-4 border-[#14532D] text-[#14532D] font-bold hover:bg-[#4ADE80] transition-colors"
45+
style={{ boxShadow: "4px 4px 0 #000" }}
46+
>
47+
<span className="text-xl"></span>
48+
演習生成ページへ戻る
49+
</button>
50+
</div>
51+
</div>
52+
);
53+
}
54+
55+
// ローディング状態
56+
if (!quest) {
57+
return (
58+
<div className="min-h-screen bg-[#FDFEF0] flex items-center justify-center">
59+
<div className="text-[#14532D] text-2xl font-bold">Loading...</div>
60+
</div>
61+
);
62+
}
63+
64+
return (
65+
<div className="min-h-screen bg-[#FDFEF0] p-8">
66+
<div className="max-w-5xl mx-auto">
67+
{/* 戻るボタン */}
68+
<div className="mb-8">
69+
<button
70+
onClick={() => router.push("/exercises")}
71+
className="inline-flex items-center gap-2 px-6 py-3 bg-[#FDFEF0] border-4 border-[#14532D] text-[#14532D] font-bold hover:bg-[#4ADE80] transition-colors mb-6"
72+
style={{ boxShadow: "4px 4px 0 #000" }}
73+
>
74+
<span className="text-xl"></span>
75+
<span>戻る</span>
76+
</button>
77+
78+
{/* ヘッダー */}
79+
<QuestResultHeader quest={quest} />
80+
</div>
81+
82+
{/* 学習目標 */}
83+
<QuestObjectives objectives={quest.learning_objectives} />
84+
85+
{/* ステップ一覧 */}
86+
<QuestStepList steps={quest.steps} />
87+
88+
{/* 参考リソース */}
89+
<QuestResources resources={quest.resources} />
90+
91+
{/* 底部アクション */}
92+
<div className="flex flex-wrap gap-4 mt-8">
93+
<button
94+
onClick={() => {
95+
sessionStorage.removeItem("questGenerationResult");
96+
router.push("/exercises");
97+
}}
98+
className="inline-flex items-center gap-2 px-6 py-3 bg-[#4ADE80] border-4 border-[#14532D] text-[#14532D] font-bold hover:bg-[#86EFAC] transition-colors"
99+
style={{ boxShadow: "4px 4px 0 #000" }}
100+
>
101+
<span>🔄</span>
102+
もう一度生成する
103+
</button>
104+
<button
105+
onClick={() => router.push("/exercises")}
106+
className="inline-flex items-center gap-2 px-6 py-3 bg-[#FDFEF0] border-4 border-[#14532D] text-[#14532D] font-bold hover:bg-[#4ADE80] transition-colors"
107+
style={{ boxShadow: "4px 4px 0 #000" }}
108+
>
109+
<span>🎮</span>
110+
演習一覧へ
111+
</button>
112+
</div>
113+
</div>
114+
</div>
115+
);
116+
}
117+
118+
export default withAuth(ExerciseGenerateResultPage);
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
/**
2+
* AI Quest Generation API Client(Issue #98)
3+
* POST /api/v1/quest/generate
4+
*/
5+
6+
import type { QuestGenerationRequest, QuestGenerationResponse } from "../types";
7+
8+
function getApiBaseUrl(): string {
9+
return process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
10+
}
11+
12+
/**
13+
* ドキュメントからAIハンズオン演習を生成する
14+
*
15+
* @param req - 生成リクエスト(document_content のみ送信。user_rank は別 Issue で対応)
16+
* @returns 生成されたQuestGenerationResponse
17+
* @throws APIエラー時にエラーをスロー
18+
*/
19+
export async function generateQuest(
20+
req: QuestGenerationRequest,
21+
): Promise<QuestGenerationResponse> {
22+
const url = `${getApiBaseUrl()}/api/v1/quest/generate`;
23+
24+
const response = await fetch(url, {
25+
method: "POST",
26+
headers: {
27+
"Content-Type": "application/json",
28+
},
29+
credentials: "include",
30+
body: JSON.stringify(req),
31+
});
32+
33+
if (!response.ok) {
34+
const errorText = await response.text();
35+
let detail = `ステータス: ${response.status}`;
36+
try {
37+
const json = JSON.parse(errorText);
38+
if (json.detail) detail = json.detail;
39+
} catch {
40+
// ignore
41+
}
42+
throw new Error(`演習の生成に失敗しました。${detail}`);
43+
}
44+
45+
return response.json() as Promise<QuestGenerationResponse>;
46+
}

frontend/src/features/exercise/components/AIGeneration.tsx

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -19,30 +19,25 @@ export function AIGeneration() {
1919
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
2020
e.preventDefault();
2121
setIsDragging(false);
22-
22+
2323
const files = Array.from(e.dataTransfer.files);
2424
const pdfFiles = files.filter(file => file.type === "application/pdf");
25-
25+
2626
if (pdfFiles.length > 0) {
27-
console.log("PDF files dropped:", pdfFiles);
2827
alert(`${pdfFiles.length}個のPDFファイルをアップロードしました`);
2928
}
3029
};
3130

3231
const handleFileInput = (e: React.ChangeEvent<HTMLInputElement>) => {
3332
const files = Array.from(e.target.files || []);
3433
if (files.length > 0) {
35-
console.log("PDF files selected:", files);
3634
alert(`${files.length}個のPDFファイルを選択しました`);
3735
}
3836
};
3937

4038
const handleUrlSubmit = () => {
41-
if (url.trim()) {
42-
console.log("URL submitted:", url);
43-
alert(`URL: ${url} から問題を生成します`);
44-
setUrl("");
45-
}
39+
if (!url.trim()) return;
40+
alert(`URLから問題を生成します: ${url}`);
4641
};
4742

4843
return (
@@ -75,7 +70,7 @@ export function AIGeneration() {
7570
onChange={handleFileInput}
7671
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
7772
/>
78-
73+
7974
<div className="pointer-events-none">
8075
<div className="flex justify-center mb-4">
8176
<div className="text-6xl animate-bounce">
@@ -119,14 +114,15 @@ export function AIGeneration() {
119114
/>
120115
<button
121116
onClick={handleUrlSubmit}
122-
className="px-8 py-3 bg-[#4ADE80] border-4 border-[#14532D] text-[#14532D] font-bold hover:bg-[#86EFAC] transition-all shadow-[4px_4px_0_#000] active:shadow-none active:translate-x-[4px] active:translate-y-[4px] flex items-center gap-2"
117+
className="px-8 py-3 bg-[#4ADE80] border-4 border-[#14532D] text-[#14532D] font-bold hover:bg-[#86EFAC] transition-all shadow-[4px_4px_0_#000] active:shadow-none active:translate-x-[4px] active:translate-y-[4px]"
123118
>
124119
<span className="text-lg">生成</span>
125120
<span className="text-xl">🚀</span>
126121
</button>
127122
</div>
128123
<p className="text-[#559C71] text-sm mt-3 ml-1">💡 記事、ブログ、技術ドキュメントなどのURLを入力</p>
129124
</div>
125+
130126
</div>
131127
);
132128
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
"use client";
2+
3+
interface Props {
4+
objectives: string[];
5+
}
6+
7+
export function QuestObjectives({ objectives }: Props) {
8+
if (objectives.length === 0) return null;
9+
10+
return (
11+
<div
12+
className="bg-white border-4 border-[#14532D] p-6 mb-6"
13+
style={{ boxShadow: "6px 6px 0 #14532D" }}
14+
>
15+
<h2 className="text-xl font-bold text-[#14532D] mb-4">🎯 学習目標</h2>
16+
<ul className="space-y-3">
17+
{objectives.map((obj, i) => (
18+
<li key={i} className="flex items-start gap-3">
19+
<div className="flex-shrink-0 w-7 h-7 bg-[#4ADE80] border-2 border-[#14532D] flex items-center justify-center">
20+
<span className="text-[#14532D] font-bold text-xs"></span>
21+
</div>
22+
<span className="text-[#14532D] font-medium leading-relaxed pt-0.5">{obj}</span>
23+
</li>
24+
))}
25+
</ul>
26+
</div>
27+
);
28+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
"use client";
2+
3+
interface Props {
4+
resources: string[];
5+
}
6+
7+
export function QuestResources({ resources }: Props) {
8+
if (resources.length === 0) return null;
9+
10+
return (
11+
<div
12+
className="bg-white border-4 border-[#14532D] p-6 mb-6"
13+
style={{ boxShadow: "6px 6px 0 #14532D" }}
14+
>
15+
<h2 className="text-xl font-bold text-[#14532D] mb-4">🔗 参考リソース</h2>
16+
<ul className="space-y-3">
17+
{resources.map((url, i) => (
18+
<li key={i} className="flex items-start gap-2">
19+
<span className="text-[#14532D] font-bold mt-0.5"></span>
20+
<a
21+
href={url}
22+
target="_blank"
23+
rel="noopener noreferrer"
24+
className="text-[#14532D] font-medium underline hover:text-[#4ADE80] transition-colors break-all"
25+
>
26+
{url}
27+
</a>
28+
</li>
29+
))}
30+
</ul>
31+
</div>
32+
);
33+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
"use client";
2+
3+
import type { QuestGenerationResponse } from "../types";
4+
import { QUEST_DIFFICULTY_LABELS } from "../types";
5+
6+
const DIFFICULTY_COLOR: Record<string, string> = {
7+
beginner: "bg-[#FCD34D]",
8+
intermediate: "bg-[#FB923C]",
9+
advanced: "bg-[#F87171]",
10+
};
11+
12+
interface Props {
13+
quest: Pick<
14+
QuestGenerationResponse,
15+
"title" | "difficulty" | "estimated_time_minutes"
16+
>;
17+
}
18+
19+
export function QuestResultHeader({ quest }: Props) {
20+
const diffLabel =
21+
QUEST_DIFFICULTY_LABELS[quest.difficulty] ?? quest.difficulty;
22+
const diffColor = DIFFICULTY_COLOR[quest.difficulty] ?? "bg-gray-300";
23+
24+
return (
25+
<div
26+
className="bg-[#4ADE80] border-4 border-black p-6 mb-8"
27+
style={{ boxShadow: "8px 8px 0 #000" }}
28+
>
29+
<div className="flex items-center gap-4 mb-2">
30+
<span
31+
className={`px-4 py-1 ${diffColor} border-2 border-black text-black font-bold text-sm`}
32+
>
33+
{diffLabel}
34+
</span>
35+
<span className="flex items-center gap-1 text-black font-bold text-sm">
36+
⏱ 約 {quest.estimated_time_minutes}
37+
</span>
38+
</div>
39+
<h1 className="text-3xl sm:text-4xl font-bold text-black">
40+
{quest.title}
41+
</h1>
42+
</div>
43+
);
44+
}

0 commit comments

Comments
 (0)