diff --git a/app/learn/spanish/04/page.mdx b/app/learn/spanish/04/page.mdx new file mode 100644 index 0000000..c16bc0f --- /dev/null +++ b/app/learn/spanish/04/page.mdx @@ -0,0 +1,44 @@ +import SpanishFillInPracticeLoader from "@/components/spanish/SpanishFillInPracticeLoader"; + +{/* セクションタイトルの定義(サイドバーや一覧ページで自動取得・表示される) */} +export const title = "紛らわしい文法(ser/estar・点過去/線過去)穴埋め演習"; + +# 紛らわしい文法(ser/estar・点過去/線過去)穴埋め演習 + +{/* 穴埋め演習用コンポーネントの読み込み */} + + + + diff --git a/components/spanish/SpanishFillInPractice.tsx b/components/spanish/SpanishFillInPractice.tsx new file mode 100644 index 0000000..5e74c14 --- /dev/null +++ b/components/spanish/SpanishFillInPractice.tsx @@ -0,0 +1,634 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; + +// 穴埋め問題アイテムの型定義 +export type FillInQuestionItem = { + id: string; + categoryTitle: string; + sentence: string; + translation: string; + answer: string; + explanation?: string; +}; + +// アルファベット基本文字とアクセント付き特殊文字の対応マップ +const ACCENT_MAP: Record = { + a: ["a", "á"], + e: ["e", "é"], + i: ["i", "í"], + o: ["o", "ó"], + u: ["u", "ú", "ü"], + n: ["n", "ñ"], + A: ["A", "Á"], + E: ["E", "É"], + I: ["I", "Í"], + O: ["O", "Ó"], + U: ["U", "Ú", "Ü"], + N: ["N", "Ñ"], +}; + +// 逆引きルックアップ用マップ +const ACCENT_GROUP_KEY: Record = {}; +for (const [base, list] of Object.entries(ACCENT_MAP)) { + for (const char of list) { + ACCENT_GROUP_KEY[char] = base; + } +} + +/** + * 矢印キー操作によりアクセント記号を順次切り替える関数 + */ +function cycleChar(char: string, direction: "up" | "down"): string { + const groupKey = ACCENT_GROUP_KEY[char]; + if (!groupKey) return char; + const list = ACCENT_MAP[groupKey]; + if (!list) return char; + const currentIndex = list.indexOf(char); + if (currentIndex === -1) return char; + + const delta = direction === "up" ? 1 : -1; + const nextIndex = (currentIndex + delta + list.length) % list.length; + return list[nextIndex]; +} + +const SPECIAL_KEYS = ["á", "é", "í", "ó", "ú", "ñ", "ü", "¿", "¡"]; + +// 出題数の選択肢型定義 +export type CountOption = "5" | "10" | "20" | "all"; + +// 制限時間の選択肢型定義(秒単位、"off"は無制限) +export type TimerOption = "off" | "15" | "30" | "45"; + +function filterItemsByCategory( + items: FillInQuestionItem[], + category: string, +): FillInQuestionItem[] { + if (category === "all") return items; + return items.filter((item) => item.categoryTitle === category); +} + +function shuffleArray(array: T[]): T[] { + const arr = [...array]; + for (let i = arr.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [arr[i], arr[j]] = [arr[j], arr[i]]; + } + return arr; +} + +export default function SpanishFillInPractice({ items }: { items: FillInQuestionItem[] }) { + // ユーザーが選択した設定状態(カテゴリ・問題数・制限時間) + const [selectedCategory, setSelectedCategory] = useState("all"); + const [selectedCount, setSelectedCount] = useState("10"); + const [selectedTimer, setSelectedTimer] = useState("15"); + + // タイマー用の残り時間(秒) + const [timeLeft, setTimeLeft] = useState(null); + + // カテゴリ一覧の取得 + const categories = Array.from(new Set(items.map((item) => item.categoryTitle))); + + // 問題リストの構築純粋関数 + const buildQuestions = useCallback( + (itemsList: FillInQuestionItem[], cat: string, count: CountOption) => { + const filtered = filterItemsByCategory(itemsList, cat); + const shuffled = shuffleArray(filtered); + const countNum = count === "all" ? shuffled.length : parseInt(count, 10); + return shuffled.slice(0, countNum); + }, + [], + ); + + // 初回マウントフラグ + const isMountedRef = useRef(false); + + // クライアントハイドレーション一致のための問題状態 + const [questions, setQuestions] = useState([]); + const [currentIndex, setCurrentIndex] = useState(0); + const [userAnswer, setUserAnswer] = useState(""); + const [isAnswered, setIsAnswered] = useState(false); + const [isCorrect, setIsCorrect] = useState(null); + const [isTimeOut, setIsTimeOut] = useState(false); + const [score, setScore] = useState(0); + const [isFinished, setIsFinished] = useState(false); + + const inputRef = useRef(null); + + /** + * 次の問題に進むか、全問題終了画面へ移行する処理 + */ + const goNext = useCallback(() => { + if (currentIndex + 1 < questions.length) { + setCurrentIndex((i) => i + 1); + setUserAnswer(""); + setIsAnswered(false); + setIsCorrect(null); + setIsTimeOut(false); + if (selectedTimer !== "off") { + setTimeLeft(parseInt(selectedTimer, 10)); + } else { + setTimeLeft(null); + } + } else { + setIsFinished(true); + setTimeLeft(null); + } + }, [currentIndex, questions.length, selectedTimer]); + + /** + * 範囲選択や出題数・タイマー設定が変更された際、または再挑戦時に問題をリセットする処理 + */ + const resetQuiz = useCallback( + (cat: string, count: CountOption, timer: TimerOption = selectedTimer) => { + const newQuestions = buildQuestions(items, cat, count); + setQuestions(newQuestions); + setCurrentIndex(0); + setScore(0); + setIsFinished(false); + setIsAnswered(false); + setUserAnswer(""); + setIsCorrect(null); + setIsTimeOut(false); + if (timer !== "off") { + setTimeLeft(parseInt(timer, 10)); + } else { + setTimeLeft(null); + } + }, + [items, buildQuestions, selectedTimer], + ); + + // クライアントサイドでのマウント時に初回問題を生成 + useEffect(() => { + if (!isMountedRef.current) { + isMountedRef.current = true; + resetQuiz("all", "10", "15"); + } + }, [resetQuiz]); + + // タイムアウト時の自動解答処理 + const handleTimeOut = useCallback(() => { + if (isAnswered || isFinished) return; + setIsTimeOut(true); + setIsCorrect(false); + setIsAnswered(true); + }, [isAnswered, isFinished]); + + // 各問題の開始時、またはタイマー設定に応じたカウントダウン制御 + useEffect(() => { + if (isFinished || isAnswered || selectedTimer === "off" || questions.length === 0) { + return; + } + + const timerId = setInterval(() => { + setTimeLeft((prev) => { + if (prev === null || prev <= 1) { + clearInterval(timerId); + handleTimeOut(); + return 0; + } + return prev - 1; + }); + }, 1000); + + return () => clearInterval(timerId); + }, [currentIndex, isAnswered, isFinished, selectedTimer, questions.length, handleTimeOut]); + + // 新しい問題が表示された際、入力欄へ自動フォーカスをあてる + useEffect(() => { + if (!isFinished && !isAnswered && questions.length > 0) { + inputRef.current?.focus(); + } + }, [currentIndex, isFinished, isAnswered, questions.length]); + + const currentQuestion = questions[currentIndex]; + + const handleCategoryChange = (cat: string) => { + setSelectedCategory(cat); + resetQuiz(cat, selectedCount, selectedTimer); + }; + + const handleCountChange = (count: CountOption) => { + setSelectedCount(count); + resetQuiz(selectedCategory, count, selectedTimer); + }; + + const handleTimerChange = (timer: TimerOption) => { + setSelectedTimer(timer); + resetQuiz(selectedCategory, selectedCount, timer); + }; + + /** + * 解答の送信および次問題への遷移処理 + */ + const handleSubmit = useCallback(() => { + if (isAnswered) { + goNext(); + return; + } + + if (!userAnswer.trim() || !currentQuestion) return; + + const normalizedUser = userAnswer.trim().toLowerCase(); + const normalizedTarget = currentQuestion.answer.trim().toLowerCase(); + const correct = normalizedUser === normalizedTarget; + + setIsCorrect(correct); + setIsAnswered(true); + if (correct) { + setScore((s) => s + 1); + } + }, [isAnswered, goNext, userAnswer, currentQuestion]); + + // 結果表示状態でフォーカスが外れていてもEnterキーを押せば次へ進むキーボードリスナー + useEffect(() => { + if (!isAnswered || isFinished) return; + + const handleGlobalKeyDown = (e: KeyboardEvent) => { + if (e.key === "Enter") { + if (e.isComposing) return; + e.preventDefault(); + goNext(); + } + }; + + window.addEventListener("keydown", handleGlobalKeyDown); + return () => window.removeEventListener("keydown", handleGlobalKeyDown); + }, [isAnswered, isFinished, goNext]); + + const handleSkip = () => { + goNext(); + }; + + /** + * 入力欄でのキーボード入力ハンドラ。 + * ↑ / ↓ 矢印キーで文字のアクセント記号切り替えを行い、Enterキーで解答送信する。 + */ + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.nativeEvent.isComposing) return; + + if (e.key === "ArrowUp" || e.key === "ArrowDown") { + e.preventDefault(); + const input = inputRef.current; + if (!input || !userAnswer) return; + + const selStart = input.selectionStart ?? userAnswer.length; + + let targetIdx = -1; + for (let i = Math.min(selStart - 1, userAnswer.length - 1); i >= 0; i--) { + if (ACCENT_GROUP_KEY[userAnswer[i]]) { + targetIdx = i; + break; + } + } + + if (targetIdx === -1) { + for (let i = selStart; i < userAnswer.length; i++) { + if (ACCENT_GROUP_KEY[userAnswer[i]]) { + targetIdx = i; + break; + } + } + } + + if (targetIdx !== -1) { + const charToCycle = userAnswer[targetIdx]; + const newChar = cycleChar(charToCycle, e.key === "ArrowUp" ? "up" : "down"); + const newVal = userAnswer.slice(0, targetIdx) + newChar + userAnswer.slice(targetIdx + 1); + setUserAnswer(newVal); + + requestAnimationFrame(() => { + if (inputRef.current) { + inputRef.current.setSelectionRange(selStart, selStart); + } + }); + } + } else if (e.key === "Enter") { + e.preventDefault(); + e.stopPropagation(); + handleSubmit(); + } + }; + + /** + * 画面上の特殊文字ボタンをクリックした際、カーソル位置へ文字を挿入する処理 + */ + const handleInsertSpecialChar = (char: string) => { + const input = inputRef.current; + const selStart = input?.selectionStart ?? userAnswer.length; + const selEnd = input?.selectionEnd ?? userAnswer.length; + + const newVal = userAnswer.slice(0, selStart) + char + userAnswer.slice(selEnd); + setUserAnswer(newVal); + + requestAnimationFrame(() => { + if (inputRef.current) { + inputRef.current.focus(); + const newPos = selStart + char.length; + inputRef.current.setSelectionRange(newPos, newPos); + } + }); + }; + + if (!items || items.length === 0) { + return ( +
+

+ 出題データが見つかりませんでした。 +

+
+ ); + } + + // 制限時間バーの計算 + const maxTimerSeconds = selectedTimer !== "off" ? parseInt(selectedTimer, 10) : 1; + const timerPercentage = + timeLeft !== null && selectedTimer !== "off" + ? Math.max(0, Math.min(100, (timeLeft / maxTimerSeconds) * 100)) + : 100; + + return ( +
+ {/* 設定パネル(カテゴリ選択・出題数・制限時間) */} +
+
+ {/* カテゴリ選択 */} +
+ + カテゴリ: + +
+ + {categories.map((cat) => ( + + ))} +
+
+ + {/* 出題数選択 */} +
+ 出題数: +
+ {[ + { label: "5問", value: "5" }, + { label: "10問", value: "10" }, + { label: "20問", value: "20" }, + { label: "全問", value: "all" }, + ].map((opt) => ( + + ))} +
+
+ + {/* 制限時間選択(ゲーム要素: 15秒, 30秒, 45秒) */} +
+ + 制限時間: + +
+ {[ + { label: "なし", value: "off" }, + { label: "15秒", value: "15" }, + { label: "30秒", value: "30" }, + { label: "45秒", value: "45" }, + ].map((opt) => ( + + ))} +
+
+
+
+ + {isFinished ? ( + /* 全問題完了時のスコア結果画面 */ +
+

演習終了!

+

+ スコア:{" "} + {score} /{" "} + {questions.length} ( + {questions.length > 0 ? Math.round((score / questions.length) * 100) : 0}%) +

+ +
+ ) : ( + /* 演習中画面 */ + <> + {/* 進捗・現在スコア・タイマー表示 */} +
+ + 問題{" "} + + {currentIndex + 1} + {" "} + / {questions.length} + + + {/* 制限時間カウントダウンバッジ */} + {selectedTimer !== "off" && ( +
+ ⏱️ 残り時間: + + {timeLeft !== null ? `${timeLeft}秒` : "-"} + +
+ )} + + + 正解数:{" "} + {score} + +
+ + {/* 制限時間のプログレスバー */} + {selectedTimer !== "off" && ( +
+
+
+ )} + + {/* 出題カード表示 */} +
+ + {currentQuestion?.categoryTitle} + +
+ {currentQuestion?.sentence} +
+
+ {currentQuestion?.translation} +
+
+ + {/* 解答入力エリア */} +
+
+ setUserAnswer(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="空欄に入る適切な語形を入力..." + className="w-full rounded-2xl border-2 border-zinc-300 bg-white px-5 py-4 text-center text-2xl font-semibold text-zinc-900 outline-none transition-colors focus:border-teal-500 focus:ring-4 focus:ring-teal-500/20 disabled:bg-zinc-100 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-50 dark:focus:border-teal-400 dark:disabled:bg-zinc-800" + /> + + {/* 特殊文字ボタンキーボード */} +
+ {SPECIAL_KEYS.map((char) => ( + + ))} +
+ +

+ 💡 ↑ / ↓ 矢印キー で文字にアクセント記号(á, é, í, ó, ú, ñ + など)を付与・切替できます。 +

+
+ + {/* 判定結果バッジ */} + {isAnswered && ( +
+ {isCorrect ? ( +
+

+ ✨ 正解です! +

+ {currentQuestion?.explanation && ( +

+ 💡 解説: {currentQuestion.explanation} +

+ )} +
+ ) : ( +
+

+ {isTimeOut ? "⏰ 時間切れです!" : "❌ 不正解です"} +

+

+ 正解:{" "} + + {currentQuestion?.answer} + +

+ {currentQuestion?.explanation && ( +

+ 💡 解説: {currentQuestion.explanation} +

+ )} +
+ )} +
+ )} + + {/* 操作ボタン */} +
+ {isAnswered ? ( + + ) : ( + <> + + + + )} +
+
+ + )} +
+ ); +} diff --git a/components/spanish/SpanishFillInPracticeLoader.tsx b/components/spanish/SpanishFillInPracticeLoader.tsx new file mode 100644 index 0000000..b2df466 --- /dev/null +++ b/components/spanish/SpanishFillInPracticeLoader.tsx @@ -0,0 +1,69 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import SpanishFillInPractice, { type FillInQuestionItem } from "./SpanishFillInPractice"; + +/** + * MDX教材ファイル(app/learn/spanish/04/page.mdx)から + * 穴埋め文法問題データを動的に抽出する関数。 + * データのベタ打ちを排除し、教材ファイルを唯一のデータソースとして使用する。 + */ +async function getFillInQuestionsFromMdx(): Promise { + const filePath = path.join(process.cwd(), "app", "learn", "spanish", "04", "page.mdx"); + const items: FillInQuestionItem[] = []; + + try { + const content = await fs.readFile(filePath, "utf-8"); + const lines = content.split("\n"); + + let currentCategory = "文法問題"; + let qIndex = 0; + + for (const line of lines) { + const trimmed = line.trim(); + + // 見出し3(###)でカテゴリ切り替え + if (trimmed.startsWith("###")) { + currentCategory = trimmed.replace(/^###\s*/, "").trim(); + } else if (trimmed.startsWith("|") && trimmed.endsWith("|")) { + const cells = trimmed + .split("|") + .slice(1, -1) + .map((c) => c.trim()); + + if (cells.length >= 3) { + // 区切り行(| --- | --- | --- |)やヘッダー行は除外 + if ( + cells.every((c) => /^[-:\s]+$/.test(c)) || + cells[0] === "例文 (問題文)" || + cells[2] === "正解" + ) { + continue; + } + + qIndex += 1; + items.push({ + id: `q-${qIndex}`, + categoryTitle: currentCategory, + sentence: cells[0], + translation: cells[1], + answer: cells[2], + explanation: cells[3] || undefined, + }); + } + } + } + } catch { + // 読込エラー時は空配列を返す + } + + return items; +} + +/** + * サーバー側でMDXから穴埋め出題データを読み込み、 + * 穴埋め演習コンポーネント(SpanishFillInPractice)へ受け渡すServer Component。 + */ +export default async function SpanishFillInPracticeLoader() { + const items = await getFillInQuestionsFromMdx(); + return ; +}