From e77da163e6b07cfff59780ed226afec4cfc43003 Mon Sep 17 00:00:00 2001 From: raito Date: Thu, 10 Sep 2026 21:18:06 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20=E3=82=B9=E3=83=9A=E3=82=A4?= =?UTF-8?q?=E3=83=B3=E8=AA=9E=E3=81=AE=E7=A9=B4=E5=9F=8B=E3=82=81=E6=96=87?= =?UTF-8?q?=E6=B3=95=E5=95=8F=E9=A1=8C=E3=81=AE=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/learn/spanish/04/page.mdx | 51 ++ components/spanish/SpanishFillInPractice.tsx | 459 ++++++++++++++++++ .../spanish/SpanishFillInPracticeLoader.tsx | 68 +++ 3 files changed, 578 insertions(+) create mode 100644 app/learn/spanish/04/page.mdx create mode 100644 components/spanish/SpanishFillInPractice.tsx create mode 100644 components/spanish/SpanishFillInPracticeLoader.tsx diff --git a/app/learn/spanish/04/page.mdx b/app/learn/spanish/04/page.mdx new file mode 100644 index 0000000..0677c5e --- /dev/null +++ b/app/learn/spanish/04/page.mdx @@ -0,0 +1,51 @@ +import SpanishFillInPracticeLoader from "@/components/spanish/SpanishFillInPracticeLoader"; + +{/* セクションタイトルの定義(サイドバーや一覧ページで自動取得・表示される) */} +export const title = "紛らわしい文法(ser/estar・点過去/線過去)穴埋め演習"; + +# 紛らわしい文法(ser/estar・点過去/線過去)穴埋め演習 + +{/* 穴埋め演習用コンポーネントの読み込み */} + + + +{/_ +【出題データ定義テーブル】 +表示上は display: none (hidden) で非表示にしていますが、 +サーバー側(SpanishFillInPracticeLoader)がこのファイルを読み込んで +穴埋め出題データを自動抽出・パースするためのデータソースとして機能します。 +_/} + + diff --git a/components/spanish/SpanishFillInPractice.tsx b/components/spanish/SpanishFillInPractice.tsx new file mode 100644 index 0000000..a195820 --- /dev/null +++ b/components/spanish/SpanishFillInPractice.tsx @@ -0,0 +1,459 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; + +// 穴埋め問題アイテムの型定義 +export type FillInQuestionItem = { + id: string; + categoryTitle: string; + sentence: string; + translation: string; + answer: 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"; + +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 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); + }, + [], + ); + + // 初期化時に items から直ちに問題セットを生成(useEffect内でのsetState呼び出しによるカスケードレンダリングを回避) + const [questions, setQuestions] = useState(() => + buildQuestions(items, "all", "10"), + ); + + const [currentIndex, setCurrentIndex] = useState(0); + const [userAnswer, setUserAnswer] = useState(""); + const [isAnswered, setIsAnswered] = useState(false); + const [isCorrect, setIsCorrect] = useState(null); + const [score, setScore] = useState(0); + const [isFinished, setIsFinished] = useState(false); + + const inputRef = useRef(null); + + /** + * 範囲選択や出題数が変更された際、または再挑戦時に問題をリセットする処理 + */ + const resetQuiz = useCallback( + (cat: string, count: CountOption) => { + const newQuestions = buildQuestions(items, cat, count); + setQuestions(newQuestions); + setCurrentIndex(0); + setScore(0); + setIsFinished(false); + setIsAnswered(false); + setUserAnswer(""); + setIsCorrect(null); + }, + [items, buildQuestions], + ); + + // 新しい問題が表示された際、入力欄へ自動フォーカスをあてる + useEffect(() => { + if (!isFinished && !isAnswered) { + inputRef.current?.focus(); + } + }, [currentIndex, isFinished, isAnswered]); + + const currentQuestion = questions[currentIndex]; + + const handleCategoryChange = (cat: string) => { + setSelectedCategory(cat); + resetQuiz(cat, selectedCount); + }; + + const handleCountChange = (count: CountOption) => { + setSelectedCount(count); + resetQuiz(selectedCategory, count); + }; + + /** + * 解答の送信および次問題への遷移処理 + */ + const handleSubmit = () => { + 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); + } + }; + + /** + * 次の問題に進むか、全問題終了画面へ移行する処理 + */ + const goNext = () => { + if (currentIndex + 1 < questions.length) { + setCurrentIndex((i) => i + 1); + setUserAnswer(""); + setIsAnswered(false); + setIsCorrect(null); + } else { + setIsFinished(true); + } + }; + + const handleSkip = () => { + goNext(); + }; + + /** + * キーボード入力ハンドラ。 + * ↑ / ↓ 矢印キーで文字のアクセント記号切り替えを行い、Enterキーで解答送信する。 + */ + const handleKeyDown = (e: React.KeyboardEvent) => { + 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(); + 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 ( +
+

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

+
+ ); + } + + return ( +
+ {/* 設定パネル(カテゴリ選択 & 出題数) */} +
+
+ {/* カテゴリ選択 */} +
+ + カテゴリ: + +
+ + {categories.map((cat) => ( + + ))} +
+
+ + {/* 出題数選択 */} +
+ 出題数: +
+ {[ + { label: "5問", value: "5" }, + { label: "10問", value: "10" }, + { label: "20問", value: "20" }, + { label: "全問", value: "all" }, + ].map((opt) => ( + + ))} +
+
+
+
+ + {isFinished ? ( + /* 全問題完了時のスコア結果画面 */ +
+

演習終了!

+

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

+ +
+ ) : ( + /* 演習中画面 */ + <> + {/* 進捗と現在スコア表示 */} +
+ + 問題 {currentIndex + 1} / {questions.length} + + 正解数: {score} +
+ + {/* 出題カード表示 */} +
+ + {currentQuestion?.categoryTitle} + +
+ {currentQuestion?.sentence} +
+
+ {currentQuestion?.translation} +
+
+ + {/* 解答入力エリア */} +
+
+ setUserAnswer(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="空欄に入る適切な語形を入力..." + className="w-full rounded-xl border border-zinc-300 bg-white px-4 py-3 text-center text-lg font-medium text-zinc-900 outline-none transition-colors focus:border-teal-500 focus:ring-2 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?.answer} +

+
+ )} +
+ )} + + {/* 操作ボタン */} +
+ {isAnswered ? ( + + ) : ( + <> + + + + )} +
+
+ + )} +
+ ); +} diff --git a/components/spanish/SpanishFillInPracticeLoader.tsx b/components/spanish/SpanishFillInPracticeLoader.tsx new file mode 100644 index 0000000..e9497ed --- /dev/null +++ b/components/spanish/SpanishFillInPracticeLoader.tsx @@ -0,0 +1,68 @@ +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], + }); + } + } + } + } catch { + // 読込エラー時は空配列を返す + } + + return items; +} + +/** + * サーバー側でMDXから穴埋め出題データを読み込み、 + * 穴埋め演習コンポーネント(SpanishFillInPractice)へ受け渡すServer Component。 + */ +export default async function SpanishFillInPracticeLoader() { + const items = await getFillInQuestionsFromMdx(); + return ; +} From 45334cb7fb4634f9b0080a4a5b5c9d07ce5ad2c9 Mon Sep 17 00:00:00 2001 From: raito Date: Thu, 10 Sep 2026 21:33:37 +0900 Subject: [PATCH 2/5] =?UTF-8?q?fix:=20mdx=E3=83=95=E3=82=A1=E3=82=A4?= =?UTF-8?q?=E3=83=AB=E3=81=AE=E3=82=A8=E3=83=A9=E3=83=BC=E3=81=AE=E4=BF=AE?= =?UTF-8?q?=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/learn/spanish/04/page.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/learn/spanish/04/page.mdx b/app/learn/spanish/04/page.mdx index 0677c5e..a2c57fe 100644 --- a/app/learn/spanish/04/page.mdx +++ b/app/learn/spanish/04/page.mdx @@ -9,12 +9,12 @@ export const title = "紛らわしい文法(ser/estar・点過去/線過去) -{/_ +{/* 【出題データ定義テーブル】 表示上は display: none (hidden) で非表示にしていますが、 サーバー側(SpanishFillInPracticeLoader)がこのファイルを読み込んで 穴埋め出題データを自動抽出・パースするためのデータソースとして機能します。 -_/} +*/} From 7ef712cb85b1f38fe59de75019c0b28960045dfd Mon Sep 17 00:00:00 2001 From: raito Date: Wed, 16 Sep 2026 20:45:12 +0900 Subject: [PATCH 5/5] =?UTF-8?q?feat:=20=E6=99=82=E9=96=93=E5=88=B6?= =?UTF-8?q?=E9=99=90=E3=81=A8=E5=85=A5=E5=8A=9B=E6=A9=9F=E8=83=BD=E3=80=81?= =?UTF-8?q?=E6=96=87=E6=B3=95=E8=A7=A3=E8=AA=AC=E3=81=AE=E8=BF=BD=E5=8A=A0?= =?UTF-8?q?=E3=81=A8=E3=80=81=E6=96=87=E5=AD=97=E3=82=B5=E3=82=A4=E3=82=BA?= =?UTF-8?q?=E3=81=AE=E5=A4=89=E6=9B=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/learn/spanish/04/page.mdx | 48 +-- components/spanish/SpanishFillInPractice.tsx | 335 +++++++++++++----- .../spanish/SpanishFillInPracticeLoader.tsx | 1 + 3 files changed, 280 insertions(+), 104 deletions(-) diff --git a/app/learn/spanish/04/page.mdx b/app/learn/spanish/04/page.mdx index ac2d14e..c16bc0f 100644 --- a/app/learn/spanish/04/page.mdx +++ b/app/learn/spanish/04/page.mdx @@ -13,32 +13,32 @@ export const title = "紛らわしい文法(ser/estar・点過去/線過去) ### ser と estar の使い分け -| 例文 (問題文) | 日本語訳・ヒント | 正解 | -| ------------------------- | -------------------------------- | ------- | -| Ella \_ profesora. | 彼女は教員です。 | es | -| Hoy \_ nublado. | 今日は曇っています。 | está | -| Nosotros \_ en el parque. | 私たちは公園にいます。 | estamos | -| Juan \_ alto y simpático. | フアンは背が高くて親切です。 | es | -| La sopa \_ muy fría. | スープがとても冷えています。 | está | -| Madrid \_ en España. | マドリードはスペインにあります。 | está | -| Mis padres \_ abogados. | 私の両親は弁護士です。 | son | -| ¿Dónde \_ las llaves? | 鍵はどこにありますか? | están | -| Mañana \_ domingo. | 明日は日曜日です。 | es | -| El café \_ muy caliente. | コーヒーがとても熱いです。 | está | +| 例文 (問題文) | 日本語訳・ヒント | 正解 | 解説 | +| ------------------------- | -------------------------------- | ------- | ------------------------------------------------------------------ | +| Ella \_ profesora. | 彼女は教員です。 | es | 職業・身分を表すため ser(三名単: es)を使用します。 | +| Hoy \_ nublado. | 今日は曇っています。 | está | 一時的な天候・状態を表すため estar(三名単: está)を使用します。 | +| Nosotros \_ en el parque. | 私たちは公園にいます。 | estamos | 居場所・所在を表すため estar(一人複: estamos)を使用します。 | +| Juan \_ alto y simpático. | フアンは背が高くて親切です。 | es | 性質・特徴を表すため ser(三名単: es)を使用します。 | +| La sopa \_ muy fría. | スープがとても冷えています。 | está | 一時的な状態(温度)を表すため estar(三名単: está)を使用します。 | +| Madrid \_ en España. | マドリードはスペインにあります。 | está | 位置・所在を表すため estar(三名単: está)を使用します。 | +| Mis padres \_ abogados. | 私の両親は弁護士です。 | son | 職業・身分を表すため ser(三人複: son)を使用します。 | +| ¿Dónde \_ las llaves? | 鍵はどこにありますか? | están | 物の所在を表すため estar(三人複: están)を使用します。 | +| Mañana \_ domingo. | 明日は日曜日です。 | es | 日時・曜日を表すため ser(三名単: es)を使用します。 | +| El café \_ muy caliente. | コーヒーがとても熱いです。 | está | 一時的な状態(温度)を表すため estar(三名単: está)を使用します。 | ### 線過去 と 点過去 の使い分け -| 例文 (問題文) | 日本語訳・ヒント | 正解 | -| ----------------------------------- | --------------------------------------------------- | ------ | -| Yo \_ con María ayer. | 私は昨日マリアと話した(hablar)。 | hablé | -| Cuando yo \_ niño, vivía en Madrid. | 私が子供だったとき(ser)、マドリードに住んでいた。 | era | -| Ayer \_ un día maravilloso. | 昨日は素晴らしい一日だった(ser)。 | fue | -| Mientras yo estudiaba, él \_. | 私は勉強していた間、彼は寝ていた(dormir)。 | dormía | -| Todos los días yo \_ al parque. | 毎日私は公園に行っていた(ir)。 | iba | -| De repente, \_ a llover. | 突然、雨が降り始めた(empezar)。 | empezó | -| Cuando la vi, ella \_ 10 años. | 私が彼女に会ったとき、彼女は10歳だった(tener)。 | tenía | -| El año pasado \_ a España. | 去年、スペインへ旅行した(viajar)。 | viajé | -| Siempre \_ en la calle. | いつも通りで遊んでいた(jugar)。 | jugaba | -| Ayer \_ mucho frío. | 昨日はとても寒かった(hacer)。 | hizo | +| 例文 (問題文) | 日本語訳・ヒント | 正解 | 解説 | +| ----------------------------------- | --------------------------------------------------- | ------ | -------------------------------------------------------------------------- | +| Yo \_ con María ayer. | 私は昨日マリアと話した(hablar)。 | hablé | 「昨日(ayer)」完了した特定の過去の動作のため点過去を使用します。 | +| Cuando yo \_ niño, vivía en Madrid. | 私が子供だったとき(ser)、マドリードに住んでいた。 | era | 過去の継続的な状態(子供時代)を表すため線過去を使用します。 | +| Ayer \_ un día maravilloso. | 昨日は素晴らしい一日だった(ser)。 | fue | 「昨日(ayer)」という完了した期間の事実のため点過去を使用します。 | +| Mientras yo estudiaba, él \_. | 私は勉強していた間、彼は寝ていた(dormir)。 | dormía | 並行して続いていた過去の動作を表すため線過去を使用します。 | +| Todos los días yo \_ al parque. | 毎日私は公園に行っていた(ir)。 | iba | 「毎日(Todos los días)」繰り返された過去の習慣のため線過去を使用します。 | +| De repente, \_ a llover. | 突然、雨が降り始めた(empezar)。 | empezó | 「突然(De repente)」発生した過去の出来事・起点の文脈のため点過去を使用。 | +| Cuando la vi, ella \_ 10 años. | 私が彼女に会ったとき、彼女は10歳だった(tener)。 | tenía | 過去の年齢・状態の背景描写のため線過去を使用します。 | +| El año pasado \_ a España. | 去年、スペインへ旅行した(viajar)。 | viajé | 「去年(El año pasado)」完了した過去の出来事のため点過去を使用します。 | +| Siempre \_ en la calle. | いつも通りで遊んでいた(jugar)。 | jugaba | 「いつも(Siempre)」行っていた過去の習慣・反復動作のため線過去を使用。 | +| Ayer \_ mucho frío. | 昨日はとても寒かった(hacer)。 | hizo | 「昨日(ayer)」完了した過去の特定の天候・気候状態のため点過去を使用。 | diff --git a/components/spanish/SpanishFillInPractice.tsx b/components/spanish/SpanishFillInPractice.tsx index a195820..5e74c14 100644 --- a/components/spanish/SpanishFillInPractice.tsx +++ b/components/spanish/SpanishFillInPractice.tsx @@ -9,6 +9,7 @@ export type FillInQuestionItem = { sentence: string; translation: string; answer: string; + explanation?: string; }; // アルファベット基本文字とアクセント付き特殊文字の対応マップ @@ -53,8 +54,12 @@ function cycleChar(char: string, direction: "up" | "down"): string { const SPECIAL_KEYS = ["á", "é", "í", "ó", "ú", "ñ", "ü", "¿", "¡"]; +// 出題数の選択肢型定義 export type CountOption = "5" | "10" | "20" | "all"; +// 制限時間の選択肢型定義(秒単位、"off"は無制限) +export type TimerOption = "off" | "15" | "30" | "45"; + function filterItemsByCategory( items: FillInQuestionItem[], category: string, @@ -73,8 +78,13 @@ function shuffleArray(array: T[]): T[] { } 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))); @@ -90,25 +100,47 @@ export default function SpanishFillInPractice({ items }: { items: FillInQuestion [], ); - // 初期化時に items から直ちに問題セットを生成(useEffect内でのsetState呼び出しによるカスケードレンダリングを回避) - const [questions, setQuestions] = useState(() => - buildQuestions(items, "all", "10"), - ); + // 初回マウントフラグ + 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) => { + (cat: string, count: CountOption, timer: TimerOption = selectedTimer) => { const newQuestions = buildQuestions(items, cat, count); setQuestions(newQuestions); setCurrentIndex(0); @@ -117,33 +149,80 @@ export default function SpanishFillInPractice({ items }: { items: FillInQuestion setIsAnswered(false); setUserAnswer(""); setIsCorrect(null); + setIsTimeOut(false); + if (timer !== "off") { + setTimeLeft(parseInt(timer, 10)); + } else { + setTimeLeft(null); + } }, - [items, buildQuestions], + [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) { + if (!isFinished && !isAnswered && questions.length > 0) { inputRef.current?.focus(); } - }, [currentIndex, isFinished, isAnswered]); + }, [currentIndex, isFinished, isAnswered, questions.length]); const currentQuestion = questions[currentIndex]; const handleCategoryChange = (cat: string) => { setSelectedCategory(cat); - resetQuiz(cat, selectedCount); + resetQuiz(cat, selectedCount, selectedTimer); }; const handleCountChange = (count: CountOption) => { setSelectedCount(count); - resetQuiz(selectedCategory, count); + resetQuiz(selectedCategory, count, selectedTimer); + }; + + const handleTimerChange = (timer: TimerOption) => { + setSelectedTimer(timer); + resetQuiz(selectedCategory, selectedCount, timer); }; /** * 解答の送信および次問題への遷移処理 */ - const handleSubmit = () => { + const handleSubmit = useCallback(() => { if (isAnswered) { goNext(); return; @@ -160,31 +239,35 @@ export default function SpanishFillInPractice({ items }: { items: FillInQuestion if (correct) { setScore((s) => s + 1); } - }; + }, [isAnswered, goNext, userAnswer, currentQuestion]); - /** - * 次の問題に進むか、全問題終了画面へ移行する処理 - */ - const goNext = () => { - if (currentIndex + 1 < questions.length) { - setCurrentIndex((i) => i + 1); - setUserAnswer(""); - setIsAnswered(false); - setIsCorrect(null); - } else { - setIsFinished(true); - } - }; + // 結果表示状態でフォーカスが外れていても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; @@ -223,6 +306,7 @@ export default function SpanishFillInPractice({ items }: { items: FillInQuestion } } else if (e.key === "Enter") { e.preventDefault(); + e.stopPropagation(); handleSubmit(); } }; @@ -249,32 +333,39 @@ export default function SpanishFillInPractice({ items }: { items: FillInQuestion 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 ( -
- {/* 設定パネル(カテゴリ選択 & 出題数) */} -
-
+
+ {/* 設定パネル(カテゴリ選択・出題数・制限時間) */} +
+
{/* カテゴリ選択 */} -
- +
+ カテゴリ: -
+
{/* 出題数選択 */} -
- 出題数: -
+
+ 出題数: +
{[ { label: "5問", value: "5" }, { label: "10問", value: "10" }, @@ -310,10 +401,38 @@ export default function SpanishFillInPractice({ items }: { items: FillInQuestion key={opt.value} type="button" onClick={() => handleCountChange(opt.value as CountOption)} - className={`rounded-lg px-2.5 py-1 text-xs font-semibold transition-colors ${ + className={`rounded-lg px-3 py-1.5 text-sm font-semibold transition-colors ${ selectedCount === opt.value - ? "bg-teal-600 text-white dark:bg-teal-500" - : "bg-white text-zinc-600 hover:bg-zinc-200 dark:bg-zinc-800 dark:text-zinc-300 dark:hover:bg-zinc-700" + ? "bg-teal-600 text-white shadow-sm dark:bg-teal-500" + : "bg-white text-zinc-700 hover:bg-zinc-200 dark:bg-zinc-800 dark:text-zinc-200 dark:hover:bg-zinc-700" + }`} + > + {opt.label} + + ))} +
+
+ + {/* 制限時間選択(ゲーム要素: 15秒, 30秒, 45秒) */} +
+ + 制限時間: + +
+ {[ + { label: "なし", value: "off" }, + { label: "15秒", value: "15" }, + { label: "30秒", value: "30" }, + { label: "45秒", value: "45" }, + ].map((opt) => ( + @@ -344,30 +464,66 @@ export default function SpanishFillInPractice({ items }: { items: FillInQuestion ) : ( /* 演習中画面 */ <> - {/* 進捗と現在スコア表示 */} -
+ {/* 進捗・現在スコア・タイマー表示 */} +
+ + 問題{" "} + + {currentIndex + 1} + {" "} + / {questions.length} + + + {/* 制限時間カウントダウンバッジ */} + {selectedTimer !== "off" && ( +
+ ⏱️ 残り時間: + + {timeLeft !== null ? `${timeLeft}秒` : "-"} + +
+ )} + - 問題 {currentIndex + 1} / {questions.length} + 正解数:{" "} + {score} - 正解数: {score}
+ {/* 制限時間のプログレスバー */} + {selectedTimer !== "off" && ( +
+
+
+ )} + {/* 出題カード表示 */} -
- +
+ {currentQuestion?.categoryTitle} -
+
{currentQuestion?.sentence}
-
+
{currentQuestion?.translation}
{/* 解答入力エリア */} -
-
+
+
setUserAnswer(e.target.value)} onKeyDown={handleKeyDown} placeholder="空欄に入る適切な語形を入力..." - className="w-full rounded-xl border border-zinc-300 bg-white px-4 py-3 text-center text-lg font-medium text-zinc-900 outline-none transition-colors focus:border-teal-500 focus:ring-2 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" + 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) => ( ))}
-

+

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

@@ -403,32 +559,51 @@ export default function SpanishFillInPractice({ items }: { items: FillInQuestion {/* 判定結果バッジ */} {isAnswered && (
{isCorrect ? ( -

正解です!

+
+

+ ✨ 正解です! +

+ {currentQuestion?.explanation && ( +

+ 💡 解説: {currentQuestion.explanation} +

+ )} +
) : (
-

不正解です

-

- 正解: {currentQuestion?.answer} +

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

+

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

+ {currentQuestion?.explanation && ( +

+ 💡 解説: {currentQuestion.explanation} +

+ )}
)}
)} {/* 操作ボタン */} -
+
{isAnswered ? ( @@ -437,14 +612,14 @@ export default function SpanishFillInPractice({ items }: { items: FillInQuestion diff --git a/components/spanish/SpanishFillInPracticeLoader.tsx b/components/spanish/SpanishFillInPracticeLoader.tsx index e9497ed..b2df466 100644 --- a/components/spanish/SpanishFillInPracticeLoader.tsx +++ b/components/spanish/SpanishFillInPracticeLoader.tsx @@ -47,6 +47,7 @@ async function getFillInQuestionsFromMdx(): Promise { sentence: cells[0], translation: cells[1], answer: cells[2], + explanation: cells[3] || undefined, }); } }