diff --git a/app/learn/french/04/page.mdx b/app/learn/french/04/page.mdx
new file mode 100644
index 0000000..90aceb7
--- /dev/null
+++ b/app/learn/french/04/page.mdx
@@ -0,0 +1,84 @@
+import FrenchTablePracticeLoader from "@/components/french/FrenchTablePracticeLoader";
+
+export const title = "人称代名詞・所有形容詞・冠詞・指示詞の表完成演習";
+
+# 人称代名詞・所有形容詞・冠詞・指示詞の表完成演習
+
+
+
+{/* 出題データ定義テーブル (display:none) */}
+
+
+
+### 人称代名詞 (主格・直接目的格・間接目的格・強勢形・再帰代名詞)
+
+| 人称・性数 | 主格 (〜は) | 直接目的格 (COD) | 間接目的格 (COI) | 強勢形 (前置詞の後) | 再帰代名詞 (自分を/に) |
+| ------------- | ----------- | ---------------- | ---------------- | ------------------- | ---------------------- |
+| 1人称単数 | je | me | me | moi | me |
+| 2人称単数 | tu | te | te | toi | te |
+| 3人称単数男性 | il | le | lui | lui | se |
+| 3人称単数女性 | elle | la | lui | elle | se |
+| 総称・不特定 | on | se | se | soi | se |
+| 1人称複数 | nous | nous | nous | nous | nous |
+| 2人称複数 | vous | vous | vous | vous | vous |
+| 3人称複数男性 | ils | les | leur | eux | se |
+| 3人称複数女性 | elles | les | leur | elles | se |
+
+### 所有形容詞 (被所有物の性数に一致)
+
+| 所有者 | 男性単数名詞 | 女性単数名詞 | 複数名詞 (男女とも) |
+| ----------------------------- | ------------ | ------------ | ------------------- |
+| 1人称単数 (私の) | mon | ma | mes |
+| 2人称単数 (君の) | ton | ta | tes |
+| 3人称単数 (彼の/彼女の) | son | sa | ses |
+| 1人称複数 (私たちの) | notre | notre | nos |
+| 2人称複数 (君たちの/あなたの) | votre | votre | vos |
+| 3人称複数 (彼らの/彼女らの) | leur | leur | leurs |
+
+### 所有代名詞 (〜のもの)
+
+| 所有者 | 男性単数 | 女性単数 | 男性複数 | 女性複数 |
+| --------------------------- | -------- | --------- | ---------- | ----------- |
+| 1人称単数 (私のもの) | le mien | la mienne | les miens | les miennes |
+| 2人称単数 (君のもの) | le tien | la tienne | les tiens | les tiennes |
+| 3人称単数 (彼の/彼女のもの) | le sien | la sienne | les siens | les siennes |
+| 1人称複数 (私たちのもの) | le nôtre | la nôtre | les nôtres | les nôtres |
+| 2人称複数 (君たちのもの) | le vôtre | la vôtre | les vôtres | les vôtres |
+| 3人称複数 (彼らのもの) | le leur | la leur | les leurs | les leurs |
+
+### 冠詞 (定冠詞・不定冠詞・部分冠詞)
+
+| 冠詞の種類 | 男性単数 | 女性単数 | 複数 (男女とも) |
+| -------------------------- | -------- | -------- | --------------- |
+| 定冠詞 (その) | le | la | les |
+| 不定冠詞 (ある/いくつかの) | un | une | des |
+| 部分冠詞 (いくらかの) | du | de la | des |
+
+### 指示形容詞 (この/その/あの)
+
+| 名詞の性数 | 指示形容詞 |
+| ------------------------------ | ---------- |
+| 男性単数 (子音字の前) | ce |
+| 男性単数 (母音字・無音のhの前) | cet |
+| 女性単数 | cette |
+| 複数 (男女とも) | ces |
+
+### 指示代名詞 (単純形)
+
+| 性数 | 指示代名詞 |
+| -------- | ---------- |
+| 男性単数 | celui |
+| 女性単数 | celle |
+| 男性複数 | ceux |
+| 女性複数 | celles |
+
+### 指示代名詞 (中性形)
+
+| 用法 | 指示代名詞 |
+| --------------------------- | ---------- |
+| être の前 (〜です) | ce |
+| これ (近称・書き言葉) | ceci |
+| それ・あれ (遠称・書き言葉) | cela |
+| それ (口語・cela の代用) | ça |
+
+
diff --git a/app/learn/french/05/page.mdx b/app/learn/french/05/page.mdx
new file mode 100644
index 0000000..f6cde40
--- /dev/null
+++ b/app/learn/french/05/page.mdx
@@ -0,0 +1,74 @@
+import FrenchFillInPracticeLoader from "@/components/french/FrenchFillInPracticeLoader";
+
+{/* セクションタイトルの定義(サイドバーや一覧ページで自動取得・表示される) */}
+export const title = "紛らわしい文法(複合過去/半過去・c'est/il est・à/pour・助動詞選択)穴埋め演習";
+
+# 紛らわしい文法(複合過去/半過去・c'est/il est・à/pour・助動詞選択)穴埋め演習
+
+{/* 穴埋め演習用コンポーネントの読み込み */}
+
+
+
+
+
+### 複合過去 と 半過去 の使い分け
+
+| 例文 (問題文) | 日本語訳・ヒント | 正解 | 解説 |
+| ------------------------------------------------------------ | -------------------------------------------- | ------------- | -------------------------------------------------------------------------- |
+| Hier soir, je \_ (regarder) un film avec mes amis. | 昨夜、私は友人たちと映画を見た。 | ai regardé | 「昨夜」に完了した特定の過去の動作を表すため複合過去を使用します。 |
+| Quand j'\_ (être) petit, j'habitais à la campagne. | 私が小さかったとき、田舎に住んでいた。 | étais | 過去の継続的な状態(子供だったこと)を表すため半過去を使用します。 |
+| Je \_ (regarder) la télé quand le téléphone a sonné. | 電話が鳴ったとき、私はテレビを見ていた。 | regardais | 別の動作に中断された、進行中だった過去の動作を表すため半過去を使用します。 |
+| Soudain, il \_ (commencer) à pleuvoir. | 突然、雨が降り始めた。 | a commencé | 「突然」起きた完了済みの出来事を表すため複合過去を使用します。 |
+| Tous les dimanches, nous \_ (aller) chez nos grands-parents. | 毎週日曜日、私たちは祖父母の家に行っていた。 | allions | 「毎週」繰り返された過去の習慣を表すため半過去を使用します。 |
+| L'année dernière, je \_ (voyager) en Italie. | 去年、私はイタリアを旅行した。 | ai voyagé | 「去年」に完了した一回限りの過去の出来事を表すため複合過去を使用します。 |
+| Il faisait beau et les oiseaux \_ (chanter). | 天気が良く、鳥たちが歌っていた。 | chantaient | 場面の背景描写(継続していた状態)を表すため半過去を使用します。 |
+| Ce matin, je \_ (perdre) mes clés. | 今朝、私は鍵をなくした。 | ai perdu | 「今朝」に起きた特定の完了した出来事を表すため複合過去を使用します。 |
+| Quand elle \_ (avoir) dix ans, elle jouait du piano. | 彼女が10歳だったとき、ピアノを弾いていた。 | avait | 過去の年齢という状態の描写を表すため半過去を使用します。 |
+| Tout à coup, la lumière \_ (s'éteindre). | 突然、明かりが消えた。 | s'est éteinte | 「突然」起きた一回限りの完了した出来事を表すため複合過去を使用します。 |
+
+### c'est / il(elle) est の使い分け
+
+| 例文 (問題文) | 日本語訳・ヒント | 正解 | 解説 |
+| ----------------------------------------------- | ------------------------------------------ | -------- | ----------------------------------------------------------------------------------- |
+| \_ un excellent restaurant. | これは素晴らしいレストランです。 | C'est | 不定冠詞+名詞(un excellent restaurant)を提示する構文のため C'est を使用します。 |
+| Regarde cette voiture ! \_ très rapide. | あの車を見て!とても速いよ。 | Elle est | 既に話題になっている特定の女性名詞(cette voiture)を形容詞だけで説明するため使用。 |
+| \_ trois heures de l'après-midi. | 午後3時です。 | Il est | 時刻を表す非人称構文のため Il est を使用します。 |
+| \_ médecin, mon frère. | 私の兄は医者です。 | Il est | 職業を無冠詞の名詞(médecin)だけで述べる場合は Il est を使用します。 |
+| \_ mon frère, le médecin dont je t'ai parlé. | これが私の兄、話していた医者だよ。 | C'est | 人物を名詞句(mon frère)で紹介・提示する構文のため C'est を使用します。 |
+| \_ vrai que Paris est la capitale de la France. | パリがフランスの首都だというのは本当です。 | C'est | 一般的・非人称的な判断を表す形容詞の前では C'est を使用します。 |
+| \_ très gentille, ta sœur. | 君のお姉さん、とても優しいね。 | Elle est | 既知の特定の女性(ta sœur)を形容詞だけで描写しているため使用します。 |
+| \_ moi qui ai téléphoné hier soir. | 昨夜電話したのは私です。 | C'est | 強勢形代名詞(moi)を提示する構文のため C'est を使用します。 |
+| \_ tard, il faut rentrer. | 遅い時間だ、帰らなくちゃ。 | Il est | 時間に関する非人称表現のため Il est を使用します。 |
+| \_ des amis très fidèles. | 彼らはとても忠実な友人たちです。 | Ce sont | 複数名詞(des amis)を提示するときは、ce も複数形の ce sont に一致させます。 |
+
+### à と pour の使い分け(「~するためのもの」の目的用法)
+
+| 例文 (問題文) | 日本語訳・ヒント | 正解 | 解説 |
+| -------------------------------------------------------------------- | -------------------------------------------------- | ---- | -------------------------------------------------------------------------------------- |
+| C'est un couteau \_ couper le pain. | パンを切るための包丁です。 | à | couteau à pain のように、物の本来備わった固有の用途を表す複合名詞には à を使用します。 |
+| J'ai besoin d'un couteau \_ couper cette corde tout de suite. | 今すぐこのロープを切るためのナイフが必要です。 | pour | その場限りの具体的な目的(今このロープを切る)を表す場合は pour を使用します。 |
+| Elle a acheté une machine \_ laver toute neuve. | 彼女は真新しい洗濯機を買った。 | à | machine à laver(洗濯機)は物の固有の機能を表す固定表現のため à を使用します。 |
+| Il a pris son parapluie \_ se protéger de la pluie. | 彼は雨から身を守るために傘を持って行った。 | pour | 「~するために」という行為の目的・意図を表す場合は pour を使用します。 |
+| Ce verre \_ vin est en cristal. | このワイングラスはクリスタル製です。 | à | verre à vin(ワイングラス)はグラスの種類・用途を表す固定表現のため à を使用します。 |
+| Elle m'a prêté un verre \_ boire de l'eau. | 彼女は水を飲むためのコップを貸してくれた。 | pour | 「その場で水を飲む」という一時的・個別的な目的のため pour を使用します。 |
+| Range les brosses \_ dents dans le tiroir. | 歯ブラシを引き出しにしまってください。 | à | brosse à dents(歯ブラシ)は物の用途を表す固定した複合名詞のため à を使用します。 |
+| Nous avons besoin de plus de chaises \_ accueillir tous les invités. | すべての招待客を迎えるためにもっと椅子が必要です。 | pour | 「招待客全員を迎える」という具体的な行為の目的を表すため pour を使用します。 |
+| Le fer \_ repasser est cassé. | アイロンが壊れている。 | à | fer à repasser(アイロン)は道具の固有の用途を表す固定表現のため à を使用します。 |
+| Elle utilise cette table \_ écrire ses lettres ce soir. | 彼女は今夜、手紙を書くためにこのテーブルを使う。 | pour | 「今夜手紙を書く」という一時的・個別の行為の目的を表すため pour を使用します。 |
+
+### 複合過去の助動詞(avoir / être)の選択
+
+| 例文 (問題文) | 日本語訳・ヒント | 正解 | 解説 |
+| ------------------------------------------------ | -------------------------------------- | ------------ | ----------------------------------------------------------------------------------------------------- |
+| Hier, elle \_ (aller) au cinéma avec ses amies. | 昨日、彼女は友人たちと映画館に行った。 | est allée | 場所の移動を表す自動詞 aller の複合過去には être を使用し、主語(女性単数)に過去分詞を一致させます。 |
+| Nous \_ (manger) une pizza délicieuse hier soir. | 私たちは昨夜美味しいピザを食べた。 | avons mangé | 直接目的語をとる他動詞 manger の複合過去には avoir を使用します。 |
+| Ils \_ (partir) tôt ce matin. | 彼らは今朝早く出発した。 | sont partis | 移動を表す自動詞 partir の複合過去には être を使用し、主語(男性複数)に一致させます。 |
+| Elle \_ (se lever) à sept heures ce matin. | 彼女は今朝7時に起きた。 | s'est levée | 代名動詞(se lever)の複合過去は常に être を使用し、主語に過去分詞を一致させます。 |
+| Mon grand-père \_ (mourir) en 1998. | 私の祖父は1998年に亡くなった。 | est mort | 状態変化を表す自動詞 mourir の複合過去には être を使用します。 |
+| Elle \_ (naître) à Paris. | 彼女はパリで生まれた。 | est née | 自動詞 naître の複合過去には être を使用し、主語(女性単数)に一致させます。 |
+| Nous \_ (voir) un bon film hier. | 私たちは昨日良い映画を見た。 | avons vu | 直接目的語をとる他動詞 voir の複合過去には avoir を使用します。 |
+| Le train \_ (arriver) en retard. | 列車は遅れて到着した。 | est arrivé | 移動を表す自動詞 arriver の複合過去には être を使用します。 |
+| Elles \_ (rester) à la maison toute la journée. | 彼女たちは一日中家にいた。 | sont restées | 状態の継続を表す自動詞 rester の複合過去には être を使用し、主語(女性複数)に一致させます。 |
+| Il \_ (finir) ses devoirs avant le dîner. | 彼は夕食前に宿題を終えた。 | a fini | 直接目的語をとる他動詞 finir の複合過去には avoir を使用します。 |
+
+
diff --git a/app/learn/french/06/page.mdx b/app/learn/french/06/page.mdx
new file mode 100644
index 0000000..49d086e
--- /dev/null
+++ b/app/learn/french/06/page.mdx
@@ -0,0 +1,69 @@
+import FrenchNumberPracticeLoader from "@/components/french/FrenchNumberPracticeLoader";
+
+export const title = "数字タイピング演習";
+
+# 数字タイピング演習
+
+
+
+{/* 出題データ定義テーブル (display:none) */}
+
+
+
+| 数 | 綴り |
+| --- | --------------------- |
+| 0 | zéro |
+| 1 | un |
+| 2 | deux |
+| 3 | trois |
+| 4 | quatre |
+| 5 | cinq |
+| 6 | six |
+| 7 | sept |
+| 8 | huit |
+| 9 | neuf |
+| 10 | dix |
+| 11 | onze |
+| 12 | douze |
+| 13 | treize |
+| 14 | quatorze |
+| 15 | quinze |
+| 16 | seize |
+| 17 | dix-sept |
+| 18 | dix-huit |
+| 19 | dix-neuf |
+| 20 | vingt |
+| 21 | vingt et un |
+| 22 | vingt-deux |
+| 23 | vingt-trois |
+| 24 | vingt-quatre |
+| 25 | vingt-cinq |
+| 26 | vingt-six |
+| 30 | trente |
+| 31 | trente et un |
+| 40 | quarante |
+| 41 | quarante et un |
+| 50 | cinquante |
+| 51 | cinquante et un |
+| 60 | soixante |
+| 61 | soixante et un |
+| 70 | soixante-dix |
+| 71 | soixante et onze |
+| 72 | soixante-douze |
+| 73 | soixante-treize |
+| 74 | soixante-quatorze |
+| 75 | soixante-quinze |
+| 76 | soixante-seize |
+| 77 | soixante-dix-sept |
+| 78 | soixante-dix-huit |
+| 79 | soixante-dix-neuf |
+| 80 | quatre-vingts |
+| 81 | quatre-vingt-un |
+| 82 | quatre-vingt-deux |
+| 90 | quatre-vingt-dix |
+| 91 | quatre-vingt-onze |
+| 92 | quatre-vingt-douze |
+| 99 | quatre-vingt-dix-neuf |
+| 100 | cent |
+
+
diff --git a/app/learn/german/03/page.tsx b/app/learn/german/03/page.tsx
new file mode 100644
index 0000000..dd0176b
--- /dev/null
+++ b/app/learn/german/03/page.tsx
@@ -0,0 +1,21 @@
+"use client";
+
+import { ConjugationPractice } from "@/components/verbs/ConjugationPractice";
+import { germanConjugationConfig } from "@/lib/conjugation/de/config";
+import { verbs } from "./verbs";
+
+export const title = "動詞の活用";
+
+export default function GermanVerbConjugationPage() {
+ return (
+
+
動詞の活用
+
+ 動詞・時制・法を選んで、活用形を入力しながら覚えましょう。
+
+
+
+
+
+ );
+}
diff --git a/app/learn/german/03/verbs.ts b/app/learn/german/03/verbs.ts
new file mode 100644
index 0000000..a0dcfd1
--- /dev/null
+++ b/app/learn/german/03/verbs.ts
@@ -0,0 +1,554 @@
+import type { IrregularVerb, RegularVerb, VerbEntry } from "@/lib/conjugation/de/types";
+
+// ==== 弱変化動詞(規則動詞) ====
+// 語幹と少数の音韻フラグだけで活用を機械的に導出できる動詞
+const regularVerbs: RegularVerb[] = [
+ {
+ id: "machen",
+ infinitive: "machen",
+ meaning: "する、作る",
+ kind: "regular",
+ auxiliary: "haben",
+ },
+ { id: "sagen", infinitive: "sagen", meaning: "言う", kind: "regular", auxiliary: "haben" },
+ {
+ id: "spielen",
+ infinitive: "spielen",
+ meaning: "遊ぶ、演奏する",
+ kind: "regular",
+ auxiliary: "haben",
+ },
+ { id: "wohnen", infinitive: "wohnen", meaning: "住む", kind: "regular", auxiliary: "haben" },
+ { id: "kaufen", infinitive: "kaufen", meaning: "買う", kind: "regular", auxiliary: "haben" },
+ { id: "lernen", infinitive: "lernen", meaning: "学ぶ", kind: "regular", auxiliary: "haben" },
+ { id: "hoeren", infinitive: "hören", meaning: "聞く", kind: "regular", auxiliary: "haben" },
+ { id: "kochen", infinitive: "kochen", meaning: "料理する", kind: "regular", auxiliary: "haben" },
+ { id: "lieben", infinitive: "lieben", meaning: "愛する", kind: "regular", auxiliary: "haben" },
+ {
+ id: "brauchen",
+ infinitive: "brauchen",
+ meaning: "必要とする",
+ kind: "regular",
+ auxiliary: "haben",
+ },
+ {
+ id: "arbeiten",
+ infinitive: "arbeiten",
+ meaning: "働く",
+ kind: "regular",
+ auxiliary: "haben",
+ linkingE: true,
+ },
+ {
+ id: "oeffnen",
+ infinitive: "öffnen",
+ meaning: "開ける",
+ kind: "regular",
+ auxiliary: "haben",
+ linkingE: true,
+ },
+ {
+ id: "warten",
+ infinitive: "warten",
+ meaning: "待つ",
+ kind: "regular",
+ auxiliary: "haben",
+ linkingE: true,
+ },
+ {
+ id: "reisen",
+ infinitive: "reisen",
+ meaning: "旅行する",
+ kind: "regular",
+ auxiliary: "sein",
+ sibilantStem: true,
+ },
+ {
+ id: "tanzen",
+ infinitive: "tanzen",
+ meaning: "踊る",
+ kind: "regular",
+ auxiliary: "haben",
+ sibilantStem: true,
+ },
+ {
+ id: "studieren",
+ infinitive: "studieren",
+ meaning: "(大学で)勉強する",
+ kind: "regular",
+ auxiliary: "haben",
+ noGePrefix: true,
+ },
+ {
+ id: "besuchen",
+ infinitive: "besuchen",
+ meaning: "訪れる",
+ kind: "regular",
+ auxiliary: "haben",
+ noGePrefix: true,
+ },
+ {
+ id: "verkaufen",
+ infinitive: "verkaufen",
+ meaning: "売る",
+ kind: "regular",
+ auxiliary: "haben",
+ noGePrefix: true,
+ },
+ {
+ id: "erzaehlen",
+ infinitive: "erzählen",
+ meaning: "語る、話す",
+ kind: "regular",
+ auxiliary: "haben",
+ noGePrefix: true,
+ },
+];
+
+// ==== 強変化動詞・混合変化動詞(不規則動詞) ====
+// 現在人称変化・過去基本形・過去分詞・接続法II式の語幹を明示的に指定する
+const irregularVerbs: IrregularVerb[] = [
+ {
+ id: "sein",
+ infinitive: "sein",
+ meaning: "〜である、いる",
+ kind: "irregular",
+ auxiliary: "sein",
+ präsens: ["bin", "bist", "ist", "sind", "seid", "sind"],
+ präteritum: ["war", "warst", "war", "waren", "wart", "waren"],
+ partizipII: "gewesen",
+ konjunktivIIBase: "wär",
+ konjunktivIOverride: ["sei", "seist", "sei", "seien", "seiet", "seien"],
+ },
+ {
+ id: "haben",
+ infinitive: "haben",
+ meaning: "持っている",
+ kind: "irregular",
+ auxiliary: "haben",
+ präsens: ["habe", "hast", "hat", "haben", "habt", "haben"],
+ präteritum: ["hatte", "hattest", "hatte", "hatten", "hattet", "hatten"],
+ partizipII: "gehabt",
+ konjunktivIIBase: "hätt",
+ },
+ {
+ id: "werden",
+ infinitive: "werden",
+ meaning: "〜になる",
+ kind: "irregular",
+ auxiliary: "sein",
+ präsens: ["werde", "wirst", "wird", "werden", "werdet", "werden"],
+ präteritum: ["wurde", "wurdest", "wurde", "wurden", "wurdet", "wurden"],
+ partizipII: "geworden",
+ konjunktivIIBase: "würd",
+ },
+ {
+ id: "koennen",
+ infinitive: "können",
+ meaning: "〜できる",
+ kind: "irregular",
+ auxiliary: "haben",
+ präsens: ["kann", "kannst", "kann", "können", "könnt", "können"],
+ präteritum: ["konnte", "konntest", "konnte", "konnten", "konntet", "konnten"],
+ partizipII: "gekonnt",
+ konjunktivIIBase: "könnt",
+ },
+ {
+ id: "muessen",
+ infinitive: "müssen",
+ meaning: "〜しなければならない",
+ kind: "irregular",
+ auxiliary: "haben",
+ präsens: ["muss", "musst", "muss", "müssen", "müsst", "müssen"],
+ präteritum: ["musste", "musstest", "musste", "mussten", "musstet", "mussten"],
+ partizipII: "gemusst",
+ konjunktivIIBase: "müsst",
+ },
+ {
+ id: "wollen",
+ infinitive: "wollen",
+ meaning: "〜したい",
+ kind: "irregular",
+ auxiliary: "haben",
+ präsens: ["will", "willst", "will", "wollen", "wollt", "wollen"],
+ präteritum: ["wollte", "wolltest", "wollte", "wollten", "wolltet", "wollten"],
+ partizipII: "gewollt",
+ konjunktivIIBase: "wollt",
+ },
+ {
+ id: "sollen",
+ infinitive: "sollen",
+ meaning: "〜すべきだ",
+ kind: "irregular",
+ auxiliary: "haben",
+ präsens: ["soll", "sollst", "soll", "sollen", "sollt", "sollen"],
+ präteritum: ["sollte", "solltest", "sollte", "sollten", "solltet", "sollten"],
+ partizipII: "gesollt",
+ konjunktivIIBase: "sollt",
+ },
+ {
+ id: "duerfen",
+ infinitive: "dürfen",
+ meaning: "〜してもよい",
+ kind: "irregular",
+ auxiliary: "haben",
+ präsens: ["darf", "darfst", "darf", "dürfen", "dürft", "dürfen"],
+ präteritum: ["durfte", "durftest", "durfte", "durften", "durftet", "durften"],
+ partizipII: "gedurft",
+ konjunktivIIBase: "dürft",
+ },
+ {
+ id: "moegen",
+ infinitive: "mögen",
+ meaning: "好む",
+ kind: "irregular",
+ auxiliary: "haben",
+ präsens: ["mag", "magst", "mag", "mögen", "mögt", "mögen"],
+ präteritum: ["mochte", "mochtest", "mochte", "mochten", "mochtet", "mochten"],
+ partizipII: "gemocht",
+ konjunktivIIBase: "möcht",
+ },
+ {
+ id: "wissen",
+ infinitive: "wissen",
+ meaning: "知っている(事柄)",
+ kind: "irregular",
+ auxiliary: "haben",
+ präsens: ["weiß", "weißt", "weiß", "wissen", "wisst", "wissen"],
+ präteritum: ["wusste", "wusstest", "wusste", "wussten", "wusstet", "wussten"],
+ partizipII: "gewusst",
+ konjunktivIIBase: "wüsst",
+ },
+ {
+ id: "gehen",
+ infinitive: "gehen",
+ meaning: "行く",
+ kind: "irregular",
+ auxiliary: "sein",
+ präsens: ["gehe", "gehst", "geht", "gehen", "geht", "gehen"],
+ präteritum: ["ging", "gingst", "ging", "gingen", "gingt", "gingen"],
+ partizipII: "gegangen",
+ konjunktivIIBase: "ging",
+ },
+ {
+ id: "kommen",
+ infinitive: "kommen",
+ meaning: "来る",
+ kind: "irregular",
+ auxiliary: "sein",
+ präsens: ["komme", "kommst", "kommt", "kommen", "kommt", "kommen"],
+ präteritum: ["kam", "kamst", "kam", "kamen", "kamt", "kamen"],
+ partizipII: "gekommen",
+ konjunktivIIBase: "käm",
+ },
+ {
+ id: "fahren",
+ infinitive: "fahren",
+ meaning: "(乗り物で)行く",
+ kind: "irregular",
+ auxiliary: "sein",
+ präsens: ["fahre", "fährst", "fährt", "fahren", "fahrt", "fahren"],
+ präteritum: ["fuhr", "fuhrst", "fuhr", "fuhren", "fuhrt", "fuhren"],
+ partizipII: "gefahren",
+ konjunktivIIBase: "führ",
+ },
+ {
+ id: "laufen",
+ infinitive: "laufen",
+ meaning: "走る、歩く",
+ kind: "irregular",
+ auxiliary: "sein",
+ präsens: ["laufe", "läufst", "läuft", "laufen", "lauft", "laufen"],
+ präteritum: ["lief", "liefst", "lief", "liefen", "lieft", "liefen"],
+ partizipII: "gelaufen",
+ konjunktivIIBase: "lief",
+ },
+ {
+ id: "fliegen",
+ infinitive: "fliegen",
+ meaning: "飛ぶ",
+ kind: "irregular",
+ auxiliary: "sein",
+ präsens: ["fliege", "fliegst", "fliegt", "fliegen", "fliegt", "fliegen"],
+ präteritum: ["flog", "flogst", "flog", "flogen", "flogt", "flogen"],
+ partizipII: "geflogen",
+ konjunktivIIBase: "flög",
+ },
+ {
+ id: "schwimmen",
+ infinitive: "schwimmen",
+ meaning: "泳ぐ",
+ kind: "irregular",
+ auxiliary: "sein",
+ präsens: ["schwimme", "schwimmst", "schwimmt", "schwimmen", "schwimmt", "schwimmen"],
+ präteritum: ["schwamm", "schwammst", "schwamm", "schwammen", "schwammt", "schwammen"],
+ partizipII: "geschwommen",
+ konjunktivIIBase: "schwömm",
+ },
+ {
+ id: "sehen",
+ infinitive: "sehen",
+ meaning: "見る",
+ kind: "irregular",
+ auxiliary: "haben",
+ präsens: ["sehe", "siehst", "sieht", "sehen", "seht", "sehen"],
+ präteritum: ["sah", "sahst", "sah", "sahen", "saht", "sahen"],
+ partizipII: "gesehen",
+ konjunktivIIBase: "säh",
+ },
+ {
+ id: "geben",
+ infinitive: "geben",
+ meaning: "与える",
+ kind: "irregular",
+ auxiliary: "haben",
+ präsens: ["gebe", "gibst", "gibt", "geben", "gebt", "geben"],
+ präteritum: ["gab", "gabst", "gab", "gaben", "gabt", "gaben"],
+ partizipII: "gegeben",
+ konjunktivIIBase: "gäb",
+ },
+ {
+ id: "nehmen",
+ infinitive: "nehmen",
+ meaning: "取る",
+ kind: "irregular",
+ auxiliary: "haben",
+ präsens: ["nehme", "nimmst", "nimmt", "nehmen", "nehmt", "nehmen"],
+ präteritum: ["nahm", "nahmst", "nahm", "nahmen", "nahmt", "nahmen"],
+ partizipII: "genommen",
+ konjunktivIIBase: "nähm",
+ },
+ {
+ id: "sprechen",
+ infinitive: "sprechen",
+ meaning: "話す",
+ kind: "irregular",
+ auxiliary: "haben",
+ präsens: ["spreche", "sprichst", "spricht", "sprechen", "sprecht", "sprechen"],
+ präteritum: ["sprach", "sprachst", "sprach", "sprachen", "spracht", "sprachen"],
+ partizipII: "gesprochen",
+ konjunktivIIBase: "spräch",
+ },
+ {
+ id: "essen",
+ infinitive: "essen",
+ meaning: "食べる",
+ kind: "irregular",
+ auxiliary: "haben",
+ präsens: ["esse", "isst", "isst", "essen", "esst", "essen"],
+ präteritum: ["aß", "aßt", "aß", "aßen", "aßt", "aßen"],
+ partizipII: "gegessen",
+ konjunktivIIBase: "äß",
+ },
+ {
+ id: "lesen",
+ infinitive: "lesen",
+ meaning: "読む",
+ kind: "irregular",
+ auxiliary: "haben",
+ präsens: ["lese", "liest", "liest", "lesen", "lest", "lesen"],
+ präteritum: ["las", "last", "las", "lasen", "last", "lasen"],
+ partizipII: "gelesen",
+ konjunktivIIBase: "läs",
+ },
+ {
+ id: "schreiben",
+ infinitive: "schreiben",
+ meaning: "書く",
+ kind: "irregular",
+ auxiliary: "haben",
+ präsens: ["schreibe", "schreibst", "schreibt", "schreiben", "schreibt", "schreiben"],
+ präteritum: ["schrieb", "schriebst", "schrieb", "schrieben", "schriebt", "schrieben"],
+ partizipII: "geschrieben",
+ konjunktivIIBase: "schrieb",
+ },
+ {
+ id: "bleiben",
+ infinitive: "bleiben",
+ meaning: "とどまる",
+ kind: "irregular",
+ auxiliary: "sein",
+ präsens: ["bleibe", "bleibst", "bleibt", "bleiben", "bleibt", "bleiben"],
+ präteritum: ["blieb", "bliebst", "blieb", "blieben", "bliebt", "blieben"],
+ partizipII: "geblieben",
+ konjunktivIIBase: "blieb",
+ },
+ {
+ id: "trinken",
+ infinitive: "trinken",
+ meaning: "飲む",
+ kind: "irregular",
+ auxiliary: "haben",
+ präsens: ["trinke", "trinkst", "trinkt", "trinken", "trinkt", "trinken"],
+ präteritum: ["trank", "trankst", "trank", "tranken", "trankt", "tranken"],
+ partizipII: "getrunken",
+ konjunktivIIBase: "tränk",
+ },
+ {
+ id: "singen",
+ infinitive: "singen",
+ meaning: "歌う",
+ kind: "irregular",
+ auxiliary: "haben",
+ präsens: ["singe", "singst", "singt", "singen", "singt", "singen"],
+ präteritum: ["sang", "sangst", "sang", "sangen", "sangt", "sangen"],
+ partizipII: "gesungen",
+ konjunktivIIBase: "säng",
+ },
+ {
+ id: "finden",
+ infinitive: "finden",
+ meaning: "見つける",
+ kind: "irregular",
+ auxiliary: "haben",
+ präsens: ["finde", "findest", "findet", "finden", "findet", "finden"],
+ präteritum: ["fand", "fandest", "fand", "fanden", "fandet", "fanden"],
+ partizipII: "gefunden",
+ konjunktivIIBase: "fänd",
+ },
+ {
+ id: "helfen",
+ infinitive: "helfen",
+ meaning: "手伝う",
+ kind: "irregular",
+ auxiliary: "haben",
+ präsens: ["helfe", "hilfst", "hilft", "helfen", "helft", "helfen"],
+ präteritum: ["half", "halfst", "half", "halfen", "halft", "halfen"],
+ partizipII: "geholfen",
+ konjunktivIIBase: "hülf",
+ },
+ {
+ id: "tragen",
+ infinitive: "tragen",
+ meaning: "運ぶ、着る",
+ kind: "irregular",
+ auxiliary: "haben",
+ präsens: ["trage", "trägst", "trägt", "tragen", "tragt", "tragen"],
+ präteritum: ["trug", "trugst", "trug", "trugen", "trugt", "trugen"],
+ partizipII: "getragen",
+ konjunktivIIBase: "trüg",
+ },
+ {
+ id: "schlafen",
+ infinitive: "schlafen",
+ meaning: "眠る",
+ kind: "irregular",
+ auxiliary: "haben",
+ präsens: ["schlafe", "schläfst", "schläft", "schlafen", "schlaft", "schlafen"],
+ präteritum: ["schlief", "schliefst", "schlief", "schliefen", "schlieft", "schliefen"],
+ partizipII: "geschlafen",
+ konjunktivIIBase: "schlief",
+ },
+ {
+ id: "waschen",
+ infinitive: "waschen",
+ meaning: "洗う",
+ kind: "irregular",
+ auxiliary: "haben",
+ präsens: ["wasche", "wäschst", "wäscht", "waschen", "wascht", "waschen"],
+ präteritum: ["wusch", "wuschst", "wusch", "wuschen", "wuscht", "wuschen"],
+ partizipII: "gewaschen",
+ konjunktivIIBase: "wüsch",
+ },
+ {
+ id: "stehen",
+ infinitive: "stehen",
+ meaning: "立つ",
+ kind: "irregular",
+ auxiliary: "haben",
+ präsens: ["stehe", "stehst", "steht", "stehen", "steht", "stehen"],
+ präteritum: ["stand", "standest", "stand", "standen", "standet", "standen"],
+ partizipII: "gestanden",
+ konjunktivIIBase: "stünd",
+ },
+ {
+ id: "verstehen",
+ infinitive: "verstehen",
+ meaning: "理解する",
+ kind: "irregular",
+ auxiliary: "haben",
+ präsens: ["verstehe", "verstehst", "versteht", "verstehen", "versteht", "verstehen"],
+ präteritum: ["verstand", "verstandest", "verstand", "verstanden", "verstandet", "verstanden"],
+ partizipII: "verstanden",
+ konjunktivIIBase: "verstünd",
+ },
+ {
+ id: "denken",
+ infinitive: "denken",
+ meaning: "考える",
+ kind: "irregular",
+ auxiliary: "haben",
+ präsens: ["denke", "denkst", "denkt", "denken", "denkt", "denken"],
+ präteritum: ["dachte", "dachtest", "dachte", "dachten", "dachtet", "dachten"],
+ partizipII: "gedacht",
+ konjunktivIIBase: "dächt",
+ },
+ {
+ id: "bringen",
+ infinitive: "bringen",
+ meaning: "持ってくる",
+ kind: "irregular",
+ auxiliary: "haben",
+ präsens: ["bringe", "bringst", "bringt", "bringen", "bringt", "bringen"],
+ präteritum: ["brachte", "brachtest", "brachte", "brachten", "brachtet", "brachten"],
+ partizipII: "gebracht",
+ konjunktivIIBase: "brächt",
+ },
+ {
+ id: "kennen",
+ infinitive: "kennen",
+ meaning: "知っている(人・物)",
+ kind: "irregular",
+ auxiliary: "haben",
+ präsens: ["kenne", "kennst", "kennt", "kennen", "kennt", "kennen"],
+ präteritum: ["kannte", "kanntest", "kannte", "kannten", "kanntet", "kannten"],
+ partizipII: "gekannt",
+ konjunktivIIBase: "kennt",
+ },
+ {
+ id: "nennen",
+ infinitive: "nennen",
+ meaning: "名づける",
+ kind: "irregular",
+ auxiliary: "haben",
+ präsens: ["nenne", "nennst", "nennt", "nennen", "nennt", "nennen"],
+ präteritum: ["nannte", "nanntest", "nannte", "nannten", "nanntet", "nannten"],
+ partizipII: "genannt",
+ konjunktivIIBase: "nennt",
+ },
+ {
+ id: "schneiden",
+ infinitive: "schneiden",
+ meaning: "切る",
+ kind: "irregular",
+ auxiliary: "haben",
+ präsens: ["schneide", "schneidest", "schneidet", "schneiden", "schneidet", "schneiden"],
+ präteritum: ["schnitt", "schnittest", "schnitt", "schnitten", "schnittet", "schnitten"],
+ partizipII: "geschnitten",
+ konjunktivIIBase: "schnitt",
+ },
+ {
+ id: "treffen",
+ infinitive: "treffen",
+ meaning: "会う",
+ kind: "irregular",
+ auxiliary: "haben",
+ präsens: ["treffe", "triffst", "trifft", "treffen", "trefft", "treffen"],
+ präteritum: ["traf", "trafst", "traf", "trafen", "traft", "trafen"],
+ partizipII: "getroffen",
+ konjunktivIIBase: "träf",
+ },
+ {
+ id: "schliessen",
+ infinitive: "schließen",
+ meaning: "閉める",
+ kind: "irregular",
+ auxiliary: "haben",
+ präsens: ["schließe", "schließt", "schließt", "schließen", "schließt", "schließen"],
+ präteritum: ["schloss", "schlosst", "schloss", "schlossen", "schlosst", "schlossen"],
+ partizipII: "geschlossen",
+ konjunktivIIBase: "schlöss",
+ },
+];
+
+export const verbs: VerbEntry[] = [...irregularVerbs, ...regularVerbs];
diff --git a/app/learn/german/04/page.mdx b/app/learn/german/04/page.mdx
new file mode 100644
index 0000000..3d96725
--- /dev/null
+++ b/app/learn/german/04/page.mdx
@@ -0,0 +1,77 @@
+import GermanTablePracticeLoader from "@/components/german/GermanTablePracticeLoader";
+
+export const title = "人称代名詞・所有冠詞・冠詞・指示代名詞の表完成演習";
+
+# 人称代名詞・所有冠詞・冠詞・指示代名詞の表完成演習
+
+
+
+{/* 出題データ定義テーブル (display:none) */}
+
+
+
+### 人称代名詞の格変化 (1格〜4格)
+
+| 人称・性数 | 1格 Nominativ (〜は) | 2格 Genitiv (〜の) | 3格 Dativ (〜に) | 4格 Akkusativ (〜を) |
+| ------------- | -------------------- | ------------------ | ---------------- | -------------------- |
+| 1人称単数 | ich | meiner | mir | mich |
+| 2人称単数 | du | deiner | dir | dich |
+| 3人称単数男性 | er | seiner | ihm | ihn |
+| 3人称単数女性 | sie | ihrer | ihr | sie |
+| 3人称単数中性 | es | seiner | ihm | es |
+| 1人称複数 | wir | unser | uns | uns |
+| 2人称複数 | ihr | euer | euch | euch |
+| 3人称複数 | sie | ihrer | ihnen | sie |
+| 敬称 (Sie) | Sie | Ihrer | Ihnen | Sie |
+
+### 所有冠詞の語幹 (人称ごと)
+
+| 人称 | 語幹 |
+| -------------------- | ----- |
+| ich (私の) | mein |
+| du (君の) | dein |
+| er (彼の) | sein |
+| sie (彼女の) | ihr |
+| es (それの) | sein |
+| wir (私たちの) | unser |
+| ihr (君たちの) | euer |
+| sie (彼らの) | ihr |
+| Sie (あなたの・敬称) | Ihr |
+
+### 所有冠詞の語尾変化 (1格〜4格・ein型共通)
+
+| 格 | 男性語尾 | 女性語尾 | 中性語尾 | 複数語尾 |
+| --------------- | -------- | -------- | -------- | -------- |
+| 1格 (Nominativ) | (なし) | e | (なし) | e |
+| 2格 (Genitiv) | es | er | es | er |
+| 3格 (Dativ) | em | er | em | en |
+| 4格 (Akkusativ) | en | e | (なし) | e |
+
+### 定冠詞の格変化 (1格〜4格: der/die/das)
+
+| 格 | 男性 | 女性 | 中性 | 複数 |
+| --------------- | ---- | ---- | ---- | ---- |
+| 1格 (Nominativ) | der | die | das | die |
+| 2格 (Genitiv) | des | der | des | der |
+| 3格 (Dativ) | dem | der | dem | den |
+| 4格 (Akkusativ) | den | die | das | die |
+
+### 不定冠詞・否定冠詞の格変化 (1格〜4格: ein/keine)
+
+| 格 | 男性 (ein) | 女性 (eine) | 中性 (ein) | 複数 (keine) |
+| --------------- | ---------- | ----------- | ---------- | ------------ |
+| 1格 (Nominativ) | ein | eine | ein | keine |
+| 2格 (Genitiv) | eines | einer | eines | keiner |
+| 3格 (Dativ) | einem | einer | einem | keinen |
+| 4格 (Akkusativ) | einen | eine | ein | keine |
+
+### 指示代名詞 dieser の格変化 (1格〜4格)
+
+| 格 | 男性 | 女性 | 中性 | 複数 |
+| --------------- | ------ | ------ | ------ | ------ |
+| 1格 (Nominativ) | dieser | diese | dieses | diese |
+| 2格 (Genitiv) | dieses | dieser | dieses | dieser |
+| 3格 (Dativ) | diesem | dieser | diesem | diesen |
+| 4格 (Akkusativ) | diesen | diese | dieses | diese |
+
+
diff --git a/app/learn/german/05/page.mdx b/app/learn/german/05/page.mdx
new file mode 100644
index 0000000..c95e6dc
--- /dev/null
+++ b/app/learn/german/05/page.mdx
@@ -0,0 +1,69 @@
+import GermanNumberPracticeLoader from "@/components/german/GermanNumberPracticeLoader";
+
+export const title = "数字タイピング演習";
+
+# 数字タイピング演習
+
+
+
+{/* 出題データ定義テーブル (display:none) */}
+
+
+
+| 数 | 綴り |
+| --- | ---------------- |
+| 0 | null |
+| 1 | eins |
+| 2 | zwei |
+| 3 | drei |
+| 4 | vier |
+| 5 | fünf |
+| 6 | sechs |
+| 7 | sieben |
+| 8 | acht |
+| 9 | neun |
+| 10 | zehn |
+| 11 | elf |
+| 12 | zwölf |
+| 13 | dreizehn |
+| 14 | vierzehn |
+| 15 | fünfzehn |
+| 16 | sechzehn |
+| 17 | siebzehn |
+| 18 | achtzehn |
+| 19 | neunzehn |
+| 20 | zwanzig |
+| 21 | einundzwanzig |
+| 22 | zweiundzwanzig |
+| 23 | dreiundzwanzig |
+| 24 | vierundzwanzig |
+| 25 | fünfundzwanzig |
+| 26 | sechsundzwanzig |
+| 30 | dreißig |
+| 31 | einunddreißig |
+| 40 | vierzig |
+| 41 | einundvierzig |
+| 50 | fünfzig |
+| 51 | einundfünfzig |
+| 60 | sechzig |
+| 61 | einundsechzig |
+| 70 | siebzig |
+| 71 | einundsiebzig |
+| 72 | zweiundsiebzig |
+| 73 | dreiundsiebzig |
+| 74 | vierundsiebzig |
+| 75 | fünfundsiebzig |
+| 76 | sechsundsiebzig |
+| 77 | siebenundsiebzig |
+| 78 | achtundsiebzig |
+| 79 | neunundsiebzig |
+| 80 | achtzig |
+| 81 | einundachtzig |
+| 82 | zweiundachtzig |
+| 90 | neunzig |
+| 91 | einundneunzig |
+| 92 | zweiundneunzig |
+| 99 | neunundneunzig |
+| 100 | hundert |
+
+
diff --git a/app/learn/spanish/03/page.mdx b/app/learn/spanish/06/page.mdx
similarity index 100%
rename from app/learn/spanish/03/page.mdx
rename to app/learn/spanish/06/page.mdx
diff --git a/components/french/FrenchFillInPractice.tsx b/components/french/FrenchFillInPractice.tsx
new file mode 100644
index 0000000..9ceba60
--- /dev/null
+++ b/components/french/FrenchFillInPractice.tsx
@@ -0,0 +1,636 @@
+"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", "ù", "û", "ü"],
+ y: ["y", "ÿ"],
+ c: ["c", "ç"],
+ A: ["A", "À", "Â"],
+ E: ["E", "É", "È", "Ê", "Ë"],
+ I: ["I", "Î", "Ï"],
+ O: ["O", "Ô"],
+ U: ["U", "Ù", "Û", "Ü"],
+ Y: ["Y", "Ÿ"],
+ C: ["C", "Ç"],
+};
+
+// 逆引きルックアップ用マップ
+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 FrenchFillInPractice({ 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 (
+
+ {/* 設定パネル(カテゴリ選択・出題数・制限時間) */}
+
+
+ {/* カテゴリ選択 */}
+
+
+ カテゴリ:
+
+
+ handleCategoryChange("all")}
+ className={`rounded-lg px-3 py-1.5 text-sm font-semibold transition-colors ${
+ selectedCategory === "all"
+ ? "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"
+ }`}
+ >
+ 全問題
+
+ {categories.map((cat) => (
+ handleCategoryChange(cat)}
+ className={`rounded-lg px-3 py-1.5 text-sm font-semibold transition-colors ${
+ selectedCategory === cat
+ ? "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"
+ }`}
+ >
+ {cat}
+
+ ))}
+
+
+
+ {/* 出題数選択 */}
+
+
出題数:
+
+ {[
+ { label: "5問", value: "5" },
+ { label: "10問", value: "10" },
+ { label: "20問", value: "20" },
+ { label: "全問", value: "all" },
+ ].map((opt) => (
+ handleCountChange(opt.value as CountOption)}
+ className={`rounded-lg px-3 py-1.5 text-sm font-semibold transition-colors ${
+ selectedCount === opt.value
+ ? "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) => (
+ handleTimerChange(opt.value as TimerOption)}
+ className={`rounded-lg px-3 py-1.5 text-sm font-semibold transition-colors ${
+ selectedTimer === opt.value
+ ? "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}
+
+ ))}
+
+
+
+
+
+ {isFinished ? (
+ /* 全問題完了時のスコア結果画面 */
+
+
演習終了!
+
+ スコア:{" "}
+ {score} /{" "}
+ {questions.length} (
+ {questions.length > 0 ? Math.round((score / questions.length) * 100) : 0}%)
+
+
resetQuiz(selectedCategory, selectedCount, selectedTimer)}
+ className="mt-8 inline-flex items-center rounded-xl bg-teal-600 px-8 py-3.5 text-base font-bold text-white shadow-md transition-colors hover:bg-teal-700 dark:bg-teal-500 dark:hover:bg-teal-600"
+ >
+ もう一度挑戦する
+
+
+ ) : (
+ /* 演習中画面 */
+ <>
+ {/* 進捗・現在スコア・タイマー表示 */}
+
+
+ 問題{" "}
+
+ {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) => (
+ handleInsertSpecialChar(char)}
+ disabled={isAnswered}
+ className="h-11 w-11 rounded-xl border border-zinc-300 bg-zinc-50 text-lg font-bold text-zinc-800 shadow-sm transition-transform active:scale-95 hover:bg-zinc-100 disabled:opacity-40 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100 dark:hover:bg-zinc-700"
+ >
+ {char}
+
+ ))}
+
+
+
+ 💡 ↑ / ↓ 矢印キー で文字にアクセント記号(é, è, ê, à, ç, œ
+ など)を付与・切替できます。
+
+
+
+ {/* 判定結果バッジ */}
+ {isAnswered && (
+
+ {isCorrect ? (
+
+
+ ✨ 正解です!
+
+ {currentQuestion?.explanation && (
+
+ 💡 解説: {currentQuestion.explanation}
+
+ )}
+
+ ) : (
+
+
+ {isTimeOut ? "⏰ 時間切れです!" : "❌ 不正解です"}
+
+
+ 正解:{" "}
+
+ {currentQuestion?.answer}
+
+
+ {currentQuestion?.explanation && (
+
+ 💡 解説: {currentQuestion.explanation}
+
+ )}
+
+ )}
+
+ )}
+
+ {/* 操作ボタン */}
+
+ {isAnswered ? (
+
+ {currentIndex + 1 < questions.length ? "次の問題へ" : "結果を見る"}
+
+ ) : (
+ <>
+
+ 回答する
+
+
+ パス
+
+ >
+ )}
+
+
+ >
+ )}
+
+ );
+}
diff --git a/components/french/FrenchFillInPracticeLoader.tsx b/components/french/FrenchFillInPracticeLoader.tsx
new file mode 100644
index 0000000..e7f5c41
--- /dev/null
+++ b/components/french/FrenchFillInPracticeLoader.tsx
@@ -0,0 +1,69 @@
+import fs from "node:fs/promises";
+import path from "node:path";
+import FrenchFillInPractice, { type FillInQuestionItem } from "./FrenchFillInPractice";
+
+/**
+ * MDX教材ファイル(app/learn/french/05/page.mdx)から
+ * 穴埋め文法問題データを動的に抽出する関数。
+ * データのベタ打ちを排除し、教材ファイルを唯一のデータソースとして使用する。
+ */
+async function getFillInQuestionsFromMdx(): Promise {
+ const filePath = path.join(process.cwd(), "app", "learn", "french", "05", "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から穴埋め出題データを読み込み、
+ * 穴埋め演習コンポーネント(FrenchFillInPractice)へ受け渡すServer Component。
+ */
+export default async function FrenchFillInPracticeLoader() {
+ const items = await getFillInQuestionsFromMdx();
+ return ;
+}
diff --git a/components/french/FrenchNumberPractice.tsx b/components/french/FrenchNumberPractice.tsx
new file mode 100644
index 0000000..2808d54
--- /dev/null
+++ b/components/french/FrenchNumberPractice.tsx
@@ -0,0 +1,639 @@
+"use client";
+
+import { useCallback, useEffect, useRef, useState } from "react";
+
+// 出題データアイテムの型定義
+export type FrenchNumberItem = {
+ num: string;
+ french: string;
+};
+
+// アルファベット基本文字とアクセント付き特殊文字の対応マップ
+const ACCENT_MAP: Record = {
+ a: ["a", "à", "â"],
+ e: ["e", "é", "è", "ê", "ë"],
+ i: ["i", "î", "ï"],
+ o: ["o", "ô"],
+ u: ["u", "ù", "û", "ü"],
+ y: ["y", "ÿ"],
+ c: ["c", "ç"],
+ A: ["A", "À", "Â"],
+ E: ["E", "É", "È", "Ê", "Ë"],
+ I: ["I", "Î", "Ï"],
+ O: ["O", "Ô"],
+ U: ["U", "Ù", "Û", "Ü"],
+ Y: ["Y", "Ÿ"],
+ C: ["C", "Ç"],
+};
+
+// 逆引きルックアップ用(アクセント文字から基本文字グループのキーを引くためのオブジェクト)
+const ACCENT_GROUP_KEY: Record = {};
+for (const [base, list] of Object.entries(ACCENT_MAP)) {
+ for (const char of list) {
+ ACCENT_GROUP_KEY[char] = base;
+ }
+}
+
+/**
+ * 上下キー操作により、指定された文字をアクセントバリエーション内で順次サイクル切り替えする関数。
+ * @param char 対象の文字
+ * @param direction "up" (ArrowUp) の場合は次のバリエーション、"down" (ArrowDown) の場合は前のバリエーションへ
+ */
+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;
+
+ // Modulo演算を用いて配列内を巡回する
+ const delta = direction === "up" ? 1 : -1;
+ const nextIndex = (currentIndex + delta + list.length) % list.length;
+ return list[nextIndex];
+}
+
+// 画面上にボタンとして配置するフランス語特殊文字のリスト
+const SPECIAL_KEYS = ["à", "â", "é", "è", "ê", "ë", "î", "ï", "ô", "ù", "û", "ü", "ç", "œ"];
+
+// 出題範囲のプリセット型定義
+export type RangePreset = "all" | "0-19" | "20-50" | "50-100";
+
+// 出題数の選択肢型定義
+export type CountOption = "5" | "10" | "20" | "all";
+
+// 制限時間の選択肢型定義(秒単位、"off"は無制限)
+export type TimerOption = "off" | "15" | "30" | "45";
+
+/**
+ * 選択された出題範囲プリセットに基づき、全問題の中から対象数値をフィルタリングする関数
+ */
+function filterItemsByRange(items: FrenchNumberItem[], range: RangePreset): FrenchNumberItem[] {
+ return items.filter((item) => {
+ const val = parseInt(item.num, 10);
+ if (isNaN(val)) return true;
+ if (range === "0-19") return val >= 0 && val <= 19;
+ if (range === "20-50") return val >= 20 && val <= 50;
+ if (range === "50-100") return val >= 50 && val <= 100;
+ return true;
+ });
+}
+
+/**
+ * Fisher-Yatesシャッフルアルゴリズムを用いて配列をランダムに並び替える関数
+ */
+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 FrenchNumberPractice({ items }: { items: FrenchNumberItem[] }) {
+ // ユーザーが選択した設定状態(出題範囲・問題数・制限時間)
+ const [selectedRange, setSelectedRange] = useState("all");
+ const [selectedCount, setSelectedCount] = useState("10");
+ const [selectedTimer, setSelectedTimer] = useState("15");
+
+ // タイマー用の残り時間(秒)
+ const [timeLeft, setTimeLeft] = useState(null);
+
+ // 選択条件(出題範囲・出題数)に基づきランダムシャッフルされた問題リストを構築するヘルパー
+ const buildQuestions = useCallback(
+ (itemsList: FrenchNumberItem[], range: RangePreset, count: CountOption) => {
+ const filtered = filterItemsByRange(itemsList, range);
+ 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 resetQuiz = useCallback(
+ (range: RangePreset, count: CountOption, timer: TimerOption = selectedTimer) => {
+ const newQuestions = buildQuestions(items, range, 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 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 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 handleRangeChange = (range: RangePreset) => {
+ setSelectedRange(range);
+ resetQuiz(range, selectedCount, selectedTimer);
+ };
+
+ // 出題数変更ハンドラ
+ const handleCountChange = (count: CountOption) => {
+ setSelectedCount(count);
+ resetQuiz(selectedRange, count, selectedTimer);
+ };
+
+ // タイマー設定変更ハンドラ
+ const handleTimerChange = (timer: TimerOption) => {
+ setSelectedTimer(timer);
+ resetQuiz(selectedRange, selectedCount, timer);
+ };
+
+ /**
+ * 解答の送信処理または結果確認状態からの次問題へ進む処理
+ */
+ const handleSubmit = useCallback(() => {
+ if (isAnswered) {
+ goNext();
+ return;
+ }
+
+ if (!userAnswer.trim() || !currentQuestion) return;
+
+ // 前後の空白を除去し、小文字に正規化して正誤判定
+ const normalizedUser = userAnswer.trim().toLowerCase();
+ const normalizedTarget = currentQuestion.french.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") {
+ // IME変換確定時などの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 (
+
+ {/* 設定パネル(出題範囲・問題数・制限時間のカスタマイズ) */}
+
+
+ {/* 出題範囲選択 */}
+
+
+ 出題範囲:
+
+
+ {[
+ { label: "全範囲", value: "all" },
+ { label: "0〜19", value: "0-19" },
+ { label: "20〜50", value: "20-50" },
+ { label: "50〜100", value: "50-100" },
+ ].map((opt) => (
+ handleRangeChange(opt.value as RangePreset)}
+ className={`rounded-lg px-3 py-1.5 text-sm font-semibold transition-colors ${
+ selectedRange === opt.value
+ ? "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}
+
+ ))}
+
+
+
+ {/* 問題数選択 */}
+
+
問題数:
+
+ {[
+ { label: "5問", value: "5" },
+ { label: "10問", value: "10" },
+ { label: "20問", value: "20" },
+ { label: "全問", value: "all" },
+ ].map((opt) => (
+ handleCountChange(opt.value as CountOption)}
+ className={`rounded-lg px-3 py-1.5 text-sm font-semibold transition-colors ${
+ selectedCount === opt.value
+ ? "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}
+
+ ))}
+
+
+
+ {/* 制限時間選択 */}
+
+
+ 制限時間:
+
+
+ {[
+ { label: "なし", value: "off" },
+ { label: "15秒", value: "15" },
+ { label: "30秒", value: "30" },
+ { label: "45秒", value: "45" },
+ ].map((opt) => (
+ handleTimerChange(opt.value as TimerOption)}
+ className={`rounded-lg px-3 py-1.5 text-sm font-semibold transition-colors ${
+ selectedTimer === opt.value
+ ? "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}
+
+ ))}
+
+
+
+
+
+ {isFinished ? (
+ /* 全問題完了時のスコア結果画面 */
+
+
演習終了!
+
+ スコア:{" "}
+ {score} /{" "}
+ {questions.length} (
+ {questions.length > 0 ? Math.round((score / questions.length) * 100) : 0}%)
+
+
resetQuiz(selectedRange, selectedCount, selectedTimer)}
+ className="mt-8 inline-flex items-center rounded-xl bg-teal-600 px-8 py-3.5 text-base font-bold text-white shadow-md transition-colors hover:bg-teal-700 dark:bg-teal-500 dark:hover:bg-teal-600"
+ >
+ もう一度挑戦する
+
+
+ ) : (
+ /* 演習中画面 */
+ <>
+ {/* 進捗・現在スコア・タイマー表示 */}
+
+
+ 問題{" "}
+
+ {currentIndex + 1}
+ {" "}
+ / {questions.length}
+
+
+ {/* 制限時間カウントダウンバッジ */}
+ {selectedTimer !== "off" && (
+
+ ⏱️ 残り時間:
+
+ {timeLeft !== null ? `${timeLeft}秒` : "-"}
+
+
+ )}
+
+
+ 正解数:{" "}
+ {score}
+
+
+
+ {/* 制限時間のプログレスバー */}
+ {selectedTimer !== "off" && (
+
+ )}
+
+ {/* 出題表示 */}
+
+
+ 数字に対応するフランス語のスペルを入力してください
+
+
+ {currentQuestion?.num}
+
+
+
+ {/* 解答入力エリア */}
+
+
+
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) => (
+ handleInsertSpecialChar(char)}
+ disabled={isAnswered}
+ className="h-11 w-11 rounded-xl border border-zinc-300 bg-zinc-50 text-lg font-bold text-zinc-800 shadow-sm transition-transform active:scale-95 hover:bg-zinc-100 disabled:opacity-40 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100 dark:hover:bg-zinc-700"
+ >
+ {char}
+
+ ))}
+
+
+
+ 💡 ↑ / ↓ 矢印キー で文字にアクセント記号(é, è, ê, à, ç, œ
+ など)を付与・切替できます。
+
+
+
+ {/* 判定結果バッジ */}
+ {isAnswered && (
+
+ {isCorrect ? (
+
+ ) : (
+
+
+ {isTimeOut ? "⏰ 時間切れです!" : "❌ 不正解です"}
+
+
+ 正解:{" "}
+
+ {currentQuestion?.french}
+
+
+
+ )}
+
+ [Enter] キーを押すと次に進みます
+
+
+ )}
+
+ {/* 操作ボタン */}
+
+ {isAnswered ? (
+
+ {currentIndex + 1 < questions.length ? "次の問題へ" : "結果を見る"}
+
+ ) : (
+ <>
+
+ 回答する
+
+
+ パス
+
+ >
+ )}
+
+
+ >
+ )}
+
+ );
+}
diff --git a/components/french/FrenchNumberPracticeLoader.tsx b/components/french/FrenchNumberPracticeLoader.tsx
new file mode 100644
index 0000000..7cedc68
--- /dev/null
+++ b/components/french/FrenchNumberPracticeLoader.tsx
@@ -0,0 +1,80 @@
+import fs from "node:fs/promises";
+import path from "node:path";
+import FrenchNumberPractice, { type FrenchNumberItem } from "./FrenchNumberPractice";
+
+/**
+ * フランス語学習教材のMDXファイル群(app/learn/french/[section]/page.mdx)から
+ * 数字と表記のペアを動的に抽出する関数。
+ *
+ * 【設計のポイント】
+ * ハードコーディング(ベタ打ち)を回避し、MDX教材ファイル自身を単一のデータソース(Single Source of Truth)
+ * として扱うことで、データの二重管理を防止している。
+ */
+async function getFrenchNumbersFromMdx(): Promise {
+ // フランス語教材が配置されているルートディレクトリの絶対パスを取得
+ const frenchDir = path.join(process.cwd(), "app", "learn", "french");
+ const items: FrenchNumberItem[] = [];
+
+ try {
+ // ディレクトリ内のすべてのエントリ("01", "02" などのセクションフォルダ)を取得
+ const entries = await fs.readdir(frenchDir, { withFileTypes: true });
+
+ for (const entry of entries) {
+ if (entry.isDirectory()) {
+ const mdxPath = path.join(frenchDir, entry.name, "page.mdx");
+ try {
+ // MDX ファイルのテキスト内容を非同期で読み込み
+ const content = await fs.readFile(mdxPath, "utf-8");
+ const lines = content.split("\n");
+
+ for (const line of lines) {
+ const trimmed = line.trim();
+ // マークダウンテーブルの行("|" で始まり "|" で終わる行)を検出
+ if (trimmed.startsWith("|") && trimmed.endsWith("|")) {
+ // 先頭と末尾の空要素を除去し、各セルの文字列を取り出す
+ const cells = trimmed
+ .split("|")
+ .slice(1, -1)
+ .map((c) => c.trim());
+
+ if (cells.length >= 2) {
+ const num = cells[0];
+ const french = cells[1];
+
+ // 1列目が数字のみで構成され、かつヘッダー行や区切り行でないデータを抽出
+ if (
+ num &&
+ french &&
+ /^\d+$/.test(num) &&
+ french !== "-" &&
+ french !== "綴り" &&
+ french !== "数"
+ ) {
+ items.push({ num, french });
+ }
+ }
+ }
+ }
+ } catch {
+ // 指定フォルダ内に page.mdx が存在しない、または読込不能な場合はスキップ
+ }
+ }
+ }
+ } catch {
+ // ディレクトリ読込エラー時は空配列を返す
+ }
+
+ return items;
+}
+
+/**
+ * サーバー側でMDXデータを読み込み、クライアントUIコンポーネント(FrenchNumberPractice)
+ * へデータを受け渡すためのReact Server Component。
+ */
+export default async function FrenchNumberPracticeLoader() {
+ // サーバー側で既存ファイルから出題データ一覧を取得
+ const items = await getFrenchNumbersFromMdx();
+
+ // クライアントコンポーネントへ引き渡し
+ return ;
+}
diff --git a/components/french/FrenchTablePractice.tsx b/components/french/FrenchTablePractice.tsx
new file mode 100644
index 0000000..ce05e8e
--- /dev/null
+++ b/components/french/FrenchTablePractice.tsx
@@ -0,0 +1,553 @@
+"use client";
+
+import { useCallback, useEffect, useRef, useState } from "react";
+
+// 文法表データの型定義
+export type GrammarTableRow = {
+ label: string;
+ cells: GrammarTableCell[];
+};
+
+export type GrammarTableCell = {
+ value: string;
+ colSpan: number;
+};
+
+export type GrammarTableData = {
+ categoryKey: string;
+ categoryTitle: string;
+ headers: string[];
+ rows: GrammarTableRow[];
+};
+
+// アルファベット基本文字とアクセント付き特殊文字の対応マップ
+const ACCENT_MAP: Record = {
+ a: ["a", "à", "â"],
+ e: ["e", "é", "è", "ê", "ë"],
+ i: ["i", "î", "ï"],
+ o: ["o", "ô"],
+ u: ["u", "ù", "û", "ü"],
+ y: ["y", "ÿ"],
+ c: ["c", "ç"],
+ A: ["A", "À", "Â"],
+ E: ["E", "É", "È", "Ê", "Ë"],
+ I: ["I", "Î", "Ï"],
+ O: ["O", "Ô"],
+ U: ["U", "Ù", "Û", "Ü"],
+ Y: ["Y", "Ÿ"],
+ C: ["C", "Ç"],
+};
+
+// 逆引きルックアップ用マップ
+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 BlankRatioOption = "25" | "50" | "75" | "100";
+
+export default function FrenchTablePractice({ tables }: { tables: GrammarTableData[] }) {
+ // カテゴリ選択状態("all" または 各カテゴリのタイトル)
+ const [selectedCategory, setSelectedCategory] = useState("all");
+ // 空欄の指定割合(25%, 50%, 75%, 100%)
+ const [blankRatio, setBlankRatio] = useState("50");
+
+ // 空欄対象セルのマスクマップ: key = `${tableIndex}-${rowIndex}-${colIndex}` -> boolean
+ const [blankMask, setBlankMask] = useState>({});
+
+ // 入力グリッドの回答データ保持: key = `${tableIndex}-${rowIndex}-${colIndex}` -> value
+ const [gridAnswers, setGridAnswers] = useState>({});
+ const [isChecked, setIsChecked] = useState(false);
+
+ // 現在フォーカス中の入力欄のキー
+ const [activeInputKey, setActiveInputKey] = useState(null);
+
+ // 各入力欄への参照を保持するMap
+ const inputRefs = useRef>(new Map());
+
+ // 初回マウント参照
+ const isMountedRef = useRef(false);
+
+ // 選択フィルタリング後のテーブル一覧
+ const filteredTables = tables.filter(
+ (t) => selectedCategory === "all" || t.categoryTitle === selectedCategory,
+ );
+
+ /**
+ * 選択された割合に応じて、空欄にするセルをランダムに選出するマスク作成関数
+ */
+ const generateBlankMask = useCallback(
+ (targetTables: GrammarTableData[], ratioStr: BlankRatioOption) => {
+ const ratio = parseInt(ratioStr, 10) / 100;
+ const mask: Record = {};
+
+ const allKeys: string[] = [];
+ targetTables.forEach((table, tIdx) => {
+ table.rows.forEach((row, rIdx) => {
+ row.cells.forEach((_, cIdx) => {
+ allKeys.push(`${tIdx}-${rIdx}-${cIdx}`);
+ });
+ });
+ });
+
+ if (ratioStr === "100") {
+ allKeys.forEach((k) => {
+ mask[k] = true;
+ });
+ } else {
+ // 対象のキーをランダムにシャッフルし、指定割合分を true(空欄)に設定
+ const keysCopy = [...allKeys];
+ for (let i = keysCopy.length - 1; i > 0; i--) {
+ const j = Math.floor(Math.random() * (i + 1));
+ [keysCopy[i], keysCopy[j]] = [keysCopy[j], keysCopy[i]];
+ }
+ const blankCount = Math.max(1, Math.round(allKeys.length * ratio));
+ keysCopy.slice(0, blankCount).forEach((k) => {
+ mask[k] = true;
+ });
+ }
+
+ return mask;
+ },
+ [],
+ );
+
+ /**
+ * 演習のリセット処理
+ */
+ const resetPractice = useCallback(
+ (cat: string, ratio: BlankRatioOption = blankRatio) => {
+ const tList = tables.filter((t) => cat === "all" || t.categoryTitle === cat);
+ const newMask = generateBlankMask(tList, ratio);
+ setBlankMask(newMask);
+ setGridAnswers({});
+ setIsChecked(false);
+ },
+ [tables, blankRatio, generateBlankMask],
+ );
+
+ // マウント時にランダム空欄マスクを生成
+ useEffect(() => {
+ if (!isMountedRef.current && tables.length > 0) {
+ isMountedRef.current = true;
+ resetPractice("all", "50");
+ }
+ }, [tables, resetPractice]);
+
+ /**
+ * セルに入力があったときの更新処理
+ */
+ const handleCellChange = (cellKey: string, value: string) => {
+ setGridAnswers((prev) => ({
+ ...prev,
+ [cellKey]: value,
+ }));
+ };
+
+ /**
+ * 答え合わせ実行処理
+ */
+ const handleCheckAnswers = () => {
+ setIsChecked(true);
+ };
+
+ /**
+ * カテゴリ切替処理
+ */
+ const handleCategoryChange = (cat: string) => {
+ setSelectedCategory(cat);
+ resetPractice(cat, blankRatio);
+ };
+
+ /**
+ * 空欄割合変更処理
+ */
+ const handleBlankRatioChange = (ratio: BlankRatioOption) => {
+ setBlankRatio(ratio);
+ resetPractice(selectedCategory, ratio);
+ };
+
+ /**
+ * 入力キーボードイベント(↑ / ↓ 矢印キーでアクセント変換、Enterキーで次の空欄へ移動または再挑戦)
+ */
+ const handleKeyDown = (
+ e: React.KeyboardEvent,
+ cellKey: string,
+ currentValue: string,
+ ) => {
+ if (e.nativeEvent.isComposing) return;
+
+ if (e.key === "ArrowUp" || e.key === "ArrowDown") {
+ e.preventDefault();
+ const input = inputRefs.current.get(cellKey);
+ if (!input || !currentValue) return;
+
+ const selStart = input.selectionStart ?? currentValue.length;
+
+ let targetIdx = -1;
+ for (let i = Math.min(selStart - 1, currentValue.length - 1); i >= 0; i--) {
+ if (ACCENT_GROUP_KEY[currentValue[i]]) {
+ targetIdx = i;
+ break;
+ }
+ }
+
+ if (targetIdx === -1) {
+ for (let i = selStart; i < currentValue.length; i++) {
+ if (ACCENT_GROUP_KEY[currentValue[i]]) {
+ targetIdx = i;
+ break;
+ }
+ }
+ }
+
+ if (targetIdx !== -1) {
+ const charToCycle = currentValue[targetIdx];
+ const newChar = cycleChar(charToCycle, e.key === "ArrowUp" ? "up" : "down");
+ const newVal =
+ currentValue.slice(0, targetIdx) + newChar + currentValue.slice(targetIdx + 1);
+
+ handleCellChange(cellKey, newVal);
+
+ requestAnimationFrame(() => {
+ const updatedInput = inputRefs.current.get(cellKey);
+ if (updatedInput) {
+ updatedInput.setSelectionRange(selStart, selStart);
+ }
+ });
+ }
+ } else if (e.key === "Enter") {
+ e.preventDefault();
+ e.stopPropagation();
+ if (!isChecked) {
+ const idx = orderedBlankKeys.indexOf(cellKey);
+ const nextKey = idx !== -1 ? orderedBlankKeys[idx + 1] : undefined;
+ if (nextKey) {
+ inputRefs.current.get(nextKey)?.focus();
+ } else {
+ inputRefs.current.get(cellKey)?.blur();
+ }
+ } else {
+ resetPractice(selectedCategory, blankRatio);
+ }
+ }
+ };
+
+ // 答え合わせ状態でのEnterキーサポート
+ useEffect(() => {
+ if (!isChecked) return;
+
+ const handleGlobalKeyDown = (e: KeyboardEvent) => {
+ if (e.key === "Enter") {
+ if (e.isComposing) return;
+ e.preventDefault();
+ resetPractice(selectedCategory, blankRatio);
+ }
+ };
+ window.addEventListener("keydown", handleGlobalKeyDown);
+ return () => window.removeEventListener("keydown", handleGlobalKeyDown);
+ }, [isChecked, resetPractice, selectedCategory, blankRatio]);
+
+ /**
+ * 特殊文字ボタンクリックでアクティブな入力欄へ文字挿入
+ */
+ const handleInsertSpecialChar = (char: string) => {
+ if (!activeInputKey) return;
+ const input = inputRefs.current.get(activeInputKey);
+ const val = gridAnswers[activeInputKey] || "";
+ const selStart = input?.selectionStart ?? val.length;
+ const selEnd = input?.selectionEnd ?? val.length;
+
+ const newVal = val.slice(0, selStart) + char + val.slice(selEnd);
+ handleCellChange(activeInputKey, newVal);
+
+ requestAnimationFrame(() => {
+ if (input) {
+ input.focus();
+ const newPos = selStart + char.length;
+ input.setSelectionRange(newPos, newPos);
+ }
+ });
+ };
+
+ if (!tables || tables.length === 0) {
+ return (
+
+
+ 文法表データが見つかりませんでした。
+
+
+ );
+ }
+
+ // Enterキーでの次項目移動用: 空欄対象セルキーを表示順に並べたリスト
+ const orderedBlankKeys: string[] = [];
+ filteredTables.forEach((table, tIdx) => {
+ table.rows.forEach((row, rIdx) => {
+ row.cells.forEach((_, cIdx) => {
+ const key = `${tIdx}-${rIdx}-${cIdx}`;
+ if (blankMask[key]) orderedBlankKeys.push(key);
+ });
+ });
+ });
+
+ // 正解数の計算 (空欄対象に指定されたセルのみを計算)
+ let totalBlankCells = 0;
+ let correctCount = 0;
+ filteredTables.forEach((table, tIdx) => {
+ table.rows.forEach((row, rIdx) => {
+ row.cells.forEach((cell, cIdx) => {
+ const key = `${tIdx}-${rIdx}-${cIdx}`;
+ if (blankMask[key]) {
+ totalBlankCells += 1;
+ const userVal = (gridAnswers[key] || "").trim().toLowerCase();
+ if (userVal === cell.value.trim().toLowerCase()) {
+ correctCount += 1;
+ }
+ }
+ });
+ });
+ });
+
+ return (
+
+ {/* 設定・カテゴリ選択パネル */}
+
+
+ {/* カテゴリ選択 */}
+
+
+ カテゴリ:
+
+
+ handleCategoryChange("all")}
+ className={`rounded-lg px-3 py-1.5 text-sm font-semibold transition-colors ${
+ selectedCategory === "all"
+ ? "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"
+ }`}
+ >
+ 全カテゴリ
+
+ {tables.map((t) => (
+ handleCategoryChange(t.categoryTitle)}
+ className={`rounded-lg px-3 py-1.5 text-sm font-semibold transition-colors ${
+ selectedCategory === t.categoryTitle
+ ? "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"
+ }`}
+ >
+ {t.categoryTitle}
+
+ ))}
+
+
+
+ {/* 空欄割合選択 */}
+
+
+ 空欄の割合:
+
+
+ {[
+ { label: "25%", value: "25" },
+ { label: "50%", value: "50" },
+ { label: "75%", value: "75" },
+ { label: "100%", value: "100" },
+ ].map((opt) => (
+ handleBlankRatioChange(opt.value as BlankRatioOption)}
+ className={`rounded-lg px-3 py-1.5 text-sm font-semibold transition-colors ${
+ blankRatio === opt.value
+ ? "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}
+
+ ))}
+
+
+
+
+
+ {/* 特殊文字ボタンパレット */}
+
+
+ 特殊文字入力パレット (選択中セルへ挿入)
+
+
+ {SPECIAL_KEYS.map((char) => (
+ handleInsertSpecialChar(char)}
+ className="h-11 w-11 rounded-xl border border-zinc-300 bg-white text-lg font-bold text-zinc-800 shadow-sm transition-transform active:scale-95 hover:bg-zinc-100 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100 dark:hover:bg-zinc-700"
+ >
+ {char}
+
+ ))}
+
+
+ 💡 入力セルで ↑ / ↓ 矢印キー を押してもアクセント記号(é, è, ê, à, ç, œ
+ など)へ切り替えられます。
+
+
+
+ {/* 文法表完成グリッド */}
+
+ {filteredTables.map((table, tIdx) => (
+
+
+ 【{table.categoryTitle}】
+
+
+
+ ))}
+
+
+ {/* スコア・結果表示バッジ & 操作ボタンエリア */}
+
+ {isChecked && (
+
+
+ 答え合わせ結果:{" "}
+
+ {correctCount}
+ {" "}
+ / {totalBlankCells} 空欄正解 (
+ {totalBlankCells > 0 ? Math.round((correctCount / totalBlankCells) * 100) : 0}%)
+
+
+ )}
+
+
+ {!isChecked ? (
+
+ 答え合わせ
+
+ ) : (
+ resetPractice(selectedCategory, blankRatio)}
+ className="rounded-2xl bg-teal-600 px-8 py-3.5 text-base font-bold text-white shadow-md transition-colors hover:bg-teal-700 dark:bg-teal-500 dark:hover:bg-teal-600"
+ >
+ もう一度挑戦する
+
+ )}
+ resetPractice(selectedCategory, blankRatio)}
+ className="rounded-2xl border-2 border-zinc-200 px-6 py-3.5 text-base font-semibold text-zinc-600 transition-colors hover:bg-zinc-100 dark:border-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-800"
+ >
+ リセット
+
+
+
+
+ );
+}
diff --git a/components/french/FrenchTablePracticeLoader.tsx b/components/french/FrenchTablePracticeLoader.tsx
new file mode 100644
index 0000000..e5a9d8f
--- /dev/null
+++ b/components/french/FrenchTablePracticeLoader.tsx
@@ -0,0 +1,104 @@
+import fs from "node:fs/promises";
+import path from "node:path";
+import FrenchTablePractice, {
+ type GrammarTableCell,
+ type GrammarTableData,
+ type GrammarTableRow,
+} from "./FrenchTablePractice";
+
+function mergeAdjacentCells(cells: string[]): GrammarTableCell[] {
+ const mergedCells: GrammarTableCell[] = [];
+
+ for (const value of cells) {
+ const previousCell = mergedCells.at(-1);
+ if (previousCell?.value === value) {
+ previousCell.colSpan += 1;
+ } else {
+ mergedCells.push({ value, colSpan: 1 });
+ }
+ }
+
+ return mergedCells;
+}
+
+/**
+ * MDX教材ファイル(app/learn/french/04/page.mdx)から
+ * 人称代名詞・冠詞・指示詞の各文法表データを動的に抽出する関数。
+ * データのベタ打ちを排除し、教材ファイルを唯一のデータソースとして使用する。
+ */
+async function getGrammarTablesFromMdx(): Promise {
+ const filePath = path.join(process.cwd(), "app", "learn", "french", "04", "page.mdx");
+ const tables: GrammarTableData[] = [];
+
+ try {
+ const content = await fs.readFile(filePath, "utf-8");
+ const lines = content.split("\n");
+
+ let currentCategory = "文法表";
+ let currentHeaders: string[] = [];
+ let currentRows: GrammarTableRow[] = [];
+
+ for (const line of lines) {
+ const trimmed = line.trim();
+
+ // 見出し3(###)でカテゴリ切り替え
+ if (trimmed.startsWith("###")) {
+ if (currentHeaders.length > 0 && currentRows.length > 0) {
+ tables.push({
+ categoryKey: currentCategory.toLowerCase(),
+ categoryTitle: currentCategory,
+ headers: currentHeaders,
+ rows: currentRows,
+ });
+ }
+ currentCategory = trimmed.replace(/^###\s*/, "").trim();
+ currentHeaders = [];
+ currentRows = [];
+ } else if (trimmed.startsWith("|") && trimmed.endsWith("|")) {
+ const cells = trimmed
+ .split("|")
+ .slice(1, -1)
+ .map((c) => c.trim());
+
+ if (cells.length >= 2) {
+ // 区切り行(| --- | --- |)は無視
+ if (cells.every((c) => /^[-:\s]+$/.test(c))) {
+ continue;
+ }
+
+ // 最初のデータ行をヘッダーとして取得
+ if (currentHeaders.length === 0) {
+ currentHeaders = cells;
+ } else {
+ const label = cells[0];
+ const rowCells = mergeAdjacentCells(cells.slice(1));
+ currentRows.push({ label, cells: rowCells });
+ }
+ }
+ }
+ }
+
+ // 最後のテーブルを追加
+ if (currentHeaders.length > 0 && currentRows.length > 0) {
+ tables.push({
+ categoryKey: currentCategory.toLowerCase(),
+ categoryTitle: currentCategory,
+ headers: currentHeaders,
+ rows: currentRows,
+ });
+ }
+ } catch {
+ // 読込エラー時は空配列を返す
+ }
+
+ return tables;
+}
+
+/**
+ * サーバー側でMDXから文法表データを読み込み、
+ * 表完成演習コンポーネント(FrenchTablePractice)へ受け渡すServer Component。
+ */
+export default async function FrenchTablePracticeLoader() {
+ const tables = await getGrammarTablesFromMdx();
+ return ;
+}
diff --git a/components/german/GermanNumberPractice.tsx b/components/german/GermanNumberPractice.tsx
new file mode 100644
index 0000000..d3303c6
--- /dev/null
+++ b/components/german/GermanNumberPractice.tsx
@@ -0,0 +1,632 @@
+"use client";
+
+import { useCallback, useEffect, useRef, useState } from "react";
+
+// 出題データアイテムの型定義
+export type GermanNumberItem = {
+ num: string;
+ german: string;
+};
+
+// アルファベット基本文字とウムラウト・エスツェットの対応マップ
+const ACCENT_MAP: Record = {
+ a: ["a", "ä"],
+ o: ["o", "ö"],
+ u: ["u", "ü"],
+ s: ["s", "ß"],
+ A: ["A", "Ä"],
+ O: ["O", "Ö"],
+ U: ["U", "Ü"],
+};
+
+// 逆引きルックアップ用(ウムラウト文字から基本文字グループのキーを引くためのオブジェクト)
+const ACCENT_GROUP_KEY: Record = {};
+for (const [base, list] of Object.entries(ACCENT_MAP)) {
+ for (const char of list) {
+ ACCENT_GROUP_KEY[char] = base;
+ }
+}
+
+/**
+ * 上下キー操作により、指定された文字をウムラウトバリエーション内で順次サイクル切り替えする関数。
+ * @param char 対象の文字
+ * @param direction "up" (ArrowUp) の場合は次のバリエーション、"down" (ArrowDown) の場合は前のバリエーションへ
+ */
+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;
+
+ // Modulo演算を用いて配列内を巡回する
+ const delta = direction === "up" ? 1 : -1;
+ const nextIndex = (currentIndex + delta + list.length) % list.length;
+ return list[nextIndex];
+}
+
+// 画面上にボタンとして配置するドイツ語特殊文字のリスト
+const SPECIAL_KEYS = ["ä", "ö", "ü", "ß", "Ä", "Ö", "Ü"];
+
+// 出題範囲のプリセット型定義
+export type RangePreset = "all" | "0-19" | "20-50" | "50-100";
+
+// 出題数の選択肢型定義
+export type CountOption = "5" | "10" | "20" | "all";
+
+// 制限時間の選択肢型定義(秒単位、"off"は無制限)
+export type TimerOption = "off" | "15" | "30" | "45";
+
+/**
+ * 選択された出題範囲プリセットに基づき、全問題の中から対象数値をフィルタリングする関数
+ */
+function filterItemsByRange(items: GermanNumberItem[], range: RangePreset): GermanNumberItem[] {
+ return items.filter((item) => {
+ const val = parseInt(item.num, 10);
+ if (isNaN(val)) return true;
+ if (range === "0-19") return val >= 0 && val <= 19;
+ if (range === "20-50") return val >= 20 && val <= 50;
+ if (range === "50-100") return val >= 50 && val <= 100;
+ return true;
+ });
+}
+
+/**
+ * Fisher-Yatesシャッフルアルゴリズムを用いて配列をランダムに並び替える関数
+ */
+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 GermanNumberPractice({ items }: { items: GermanNumberItem[] }) {
+ // ユーザーが選択した設定状態(出題範囲・問題数・制限時間)
+ const [selectedRange, setSelectedRange] = useState("all");
+ const [selectedCount, setSelectedCount] = useState("10");
+ const [selectedTimer, setSelectedTimer] = useState("15");
+
+ // タイマー用の残り時間(秒)
+ const [timeLeft, setTimeLeft] = useState(null);
+
+ // 選択条件(出題範囲・出題数)に基づきランダムシャッフルされた問題リストを構築するヘルパー
+ const buildQuestions = useCallback(
+ (itemsList: GermanNumberItem[], range: RangePreset, count: CountOption) => {
+ const filtered = filterItemsByRange(itemsList, range);
+ 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 resetQuiz = useCallback(
+ (range: RangePreset, count: CountOption, timer: TimerOption = selectedTimer) => {
+ const newQuestions = buildQuestions(items, range, 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 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 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 handleRangeChange = (range: RangePreset) => {
+ setSelectedRange(range);
+ resetQuiz(range, selectedCount, selectedTimer);
+ };
+
+ // 出題数変更ハンドラ
+ const handleCountChange = (count: CountOption) => {
+ setSelectedCount(count);
+ resetQuiz(selectedRange, count, selectedTimer);
+ };
+
+ // タイマー設定変更ハンドラ
+ const handleTimerChange = (timer: TimerOption) => {
+ setSelectedTimer(timer);
+ resetQuiz(selectedRange, selectedCount, timer);
+ };
+
+ /**
+ * 解答の送信処理または結果確認状態からの次問題へ進む処理
+ */
+ const handleSubmit = useCallback(() => {
+ if (isAnswered) {
+ goNext();
+ return;
+ }
+
+ if (!userAnswer.trim() || !currentQuestion) return;
+
+ // 前後の空白を除去し、小文字に正規化して正誤判定
+ const normalizedUser = userAnswer.trim().toLowerCase();
+ const normalizedTarget = currentQuestion.german.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") {
+ // IME変換確定時などの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 (
+
+ {/* 設定パネル(出題範囲・問題数・制限時間のカスタマイズ) */}
+
+
+ {/* 出題範囲選択 */}
+
+
+ 出題範囲:
+
+
+ {[
+ { label: "全範囲", value: "all" },
+ { label: "0〜19", value: "0-19" },
+ { label: "20〜50", value: "20-50" },
+ { label: "50〜100", value: "50-100" },
+ ].map((opt) => (
+ handleRangeChange(opt.value as RangePreset)}
+ className={`rounded-lg px-3 py-1.5 text-sm font-semibold transition-colors ${
+ selectedRange === opt.value
+ ? "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}
+
+ ))}
+
+
+
+ {/* 問題数選択 */}
+
+
問題数:
+
+ {[
+ { label: "5問", value: "5" },
+ { label: "10問", value: "10" },
+ { label: "20問", value: "20" },
+ { label: "全問", value: "all" },
+ ].map((opt) => (
+ handleCountChange(opt.value as CountOption)}
+ className={`rounded-lg px-3 py-1.5 text-sm font-semibold transition-colors ${
+ selectedCount === opt.value
+ ? "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}
+
+ ))}
+
+
+
+ {/* 制限時間選択 */}
+
+
+ 制限時間:
+
+
+ {[
+ { label: "なし", value: "off" },
+ { label: "15秒", value: "15" },
+ { label: "30秒", value: "30" },
+ { label: "45秒", value: "45" },
+ ].map((opt) => (
+ handleTimerChange(opt.value as TimerOption)}
+ className={`rounded-lg px-3 py-1.5 text-sm font-semibold transition-colors ${
+ selectedTimer === opt.value
+ ? "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}
+
+ ))}
+
+
+
+
+
+ {isFinished ? (
+ /* 全問題完了時のスコア結果画面 */
+
+
演習終了!
+
+ スコア:{" "}
+ {score} /{" "}
+ {questions.length} (
+ {questions.length > 0 ? Math.round((score / questions.length) * 100) : 0}%)
+
+
resetQuiz(selectedRange, selectedCount, selectedTimer)}
+ className="mt-8 inline-flex items-center rounded-xl bg-teal-600 px-8 py-3.5 text-base font-bold text-white shadow-md transition-colors hover:bg-teal-700 dark:bg-teal-500 dark:hover:bg-teal-600"
+ >
+ もう一度挑戦する
+
+
+ ) : (
+ /* 演習中画面 */
+ <>
+ {/* 進捗・現在スコア・タイマー表示 */}
+
+
+ 問題{" "}
+
+ {currentIndex + 1}
+ {" "}
+ / {questions.length}
+
+
+ {/* 制限時間カウントダウンバッジ */}
+ {selectedTimer !== "off" && (
+
+ ⏱️ 残り時間:
+
+ {timeLeft !== null ? `${timeLeft}秒` : "-"}
+
+
+ )}
+
+
+ 正解数:{" "}
+ {score}
+
+
+
+ {/* 制限時間のプログレスバー */}
+ {selectedTimer !== "off" && (
+
+ )}
+
+ {/* 出題表示 */}
+
+
+ 数字に対応するドイツ語のスペルを入力してください
+
+
+ {currentQuestion?.num}
+
+
+
+ {/* 解答入力エリア */}
+
+
+
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) => (
+ handleInsertSpecialChar(char)}
+ disabled={isAnswered}
+ className="h-11 w-11 rounded-xl border border-zinc-300 bg-zinc-50 text-lg font-bold text-zinc-800 shadow-sm transition-transform active:scale-95 hover:bg-zinc-100 disabled:opacity-40 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100 dark:hover:bg-zinc-700"
+ >
+ {char}
+
+ ))}
+
+
+
+ 💡 ↑ / ↓ 矢印キー で文字にウムラウト・エスツェット(ä, ö, ü, ß
+ など)を付与・切替できます。
+
+
+
+ {/* 判定結果バッジ */}
+ {isAnswered && (
+
+ {isCorrect ? (
+
+ ) : (
+
+
+ {isTimeOut ? "⏰ 時間切れです!" : "❌ 不正解です"}
+
+
+ 正解:{" "}
+
+ {currentQuestion?.german}
+
+
+
+ )}
+
+ [Enter] キーを押すと次に進みます
+
+
+ )}
+
+ {/* 操作ボタン */}
+
+ {isAnswered ? (
+
+ {currentIndex + 1 < questions.length ? "次の問題へ" : "結果を見る"}
+
+ ) : (
+ <>
+
+ 回答する
+
+
+ パス
+
+ >
+ )}
+
+
+ >
+ )}
+
+ );
+}
diff --git a/components/german/GermanNumberPracticeLoader.tsx b/components/german/GermanNumberPracticeLoader.tsx
new file mode 100644
index 0000000..c1d100b
--- /dev/null
+++ b/components/german/GermanNumberPracticeLoader.tsx
@@ -0,0 +1,80 @@
+import fs from "node:fs/promises";
+import path from "node:path";
+import GermanNumberPractice, { type GermanNumberItem } from "./GermanNumberPractice";
+
+/**
+ * ドイツ語学習教材のMDXファイル群(app/learn/german/[section]/page.mdx)から
+ * 数字と表記のペアを動的に抽出する関数。
+ *
+ * 【設計のポイント】
+ * ハードコーディング(ベタ打ち)を回避し、MDX教材ファイル自身を単一のデータソース(Single Source of Truth)
+ * として扱うことで、データの二重管理を防止している。
+ */
+async function getGermanNumbersFromMdx(): Promise {
+ // ドイツ語教材が配置されているルートディレクトリの絶対パスを取得
+ const germanDir = path.join(process.cwd(), "app", "learn", "german");
+ const items: GermanNumberItem[] = [];
+
+ try {
+ // ディレクトリ内のすべてのエントリ("01", "02" などのセクションフォルダ)を取得
+ const entries = await fs.readdir(germanDir, { withFileTypes: true });
+
+ for (const entry of entries) {
+ if (entry.isDirectory()) {
+ const mdxPath = path.join(germanDir, entry.name, "page.mdx");
+ try {
+ // MDX ファイルのテキスト内容を非同期で読み込み
+ const content = await fs.readFile(mdxPath, "utf-8");
+ const lines = content.split("\n");
+
+ for (const line of lines) {
+ const trimmed = line.trim();
+ // マークダウンテーブルの行("|" で始まり "|" で終わる行)を検出
+ if (trimmed.startsWith("|") && trimmed.endsWith("|")) {
+ // 先頭と末尾の空要素を除去し、各セルの文字列を取り出す
+ const cells = trimmed
+ .split("|")
+ .slice(1, -1)
+ .map((c) => c.trim());
+
+ if (cells.length >= 2) {
+ const num = cells[0];
+ const german = cells[1];
+
+ // 1列目が数字のみで構成され、かつヘッダー行や区切り行でないデータを抽出
+ if (
+ num &&
+ german &&
+ /^\d+$/.test(num) &&
+ german !== "-" &&
+ german !== "綴り" &&
+ german !== "数"
+ ) {
+ items.push({ num, german });
+ }
+ }
+ }
+ }
+ } catch {
+ // 指定フォルダ内に page.mdx が存在しない、または読込不能な場合はスキップ
+ }
+ }
+ }
+ } catch {
+ // ディレクトリ読込エラー時は空配列を返す
+ }
+
+ return items;
+}
+
+/**
+ * サーバー側でMDXデータを読み込み、クライアントUIコンポーネント(GermanNumberPractice)
+ * へデータを受け渡すためのReact Server Component。
+ */
+export default async function GermanNumberPracticeLoader() {
+ // サーバー側で既存ファイルから出題データ一覧を取得
+ const items = await getGermanNumbersFromMdx();
+
+ // クライアントコンポーネントへ引き渡し
+ return ;
+}
diff --git a/components/german/GermanTablePractice.tsx b/components/german/GermanTablePractice.tsx
new file mode 100644
index 0000000..27d53ef
--- /dev/null
+++ b/components/german/GermanTablePractice.tsx
@@ -0,0 +1,546 @@
+"use client";
+
+import { useCallback, useEffect, useRef, useState } from "react";
+
+// 文法表データの型定義
+export type GrammarTableRow = {
+ label: string;
+ cells: GrammarTableCell[];
+};
+
+export type GrammarTableCell = {
+ value: string;
+ colSpan: number;
+};
+
+export type GrammarTableData = {
+ categoryKey: string;
+ categoryTitle: string;
+ headers: string[];
+ rows: GrammarTableRow[];
+};
+
+// アルファベット基本文字とウムラウト・エスツェットの対応マップ
+const ACCENT_MAP: Record = {
+ a: ["a", "ä"],
+ o: ["o", "ö"],
+ u: ["u", "ü"],
+ s: ["s", "ß"],
+ A: ["A", "Ä"],
+ O: ["O", "Ö"],
+ U: ["U", "Ü"],
+};
+
+// 逆引きルックアップ用マップ
+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 BlankRatioOption = "25" | "50" | "75" | "100";
+
+export default function GermanTablePractice({ tables }: { tables: GrammarTableData[] }) {
+ // カテゴリ選択状態("all" または 各カテゴリのタイトル)
+ const [selectedCategory, setSelectedCategory] = useState("all");
+ // 空欄の指定割合(25%, 50%, 75%, 100%)
+ const [blankRatio, setBlankRatio] = useState("50");
+
+ // 空欄対象セルのマスクマップ: key = `${tableIndex}-${rowIndex}-${colIndex}` -> boolean
+ const [blankMask, setBlankMask] = useState>({});
+
+ // 入力グリッドの回答データ保持: key = `${tableIndex}-${rowIndex}-${colIndex}` -> value
+ const [gridAnswers, setGridAnswers] = useState>({});
+ const [isChecked, setIsChecked] = useState(false);
+
+ // 現在フォーカス中の入力欄のキー
+ const [activeInputKey, setActiveInputKey] = useState(null);
+
+ // 各入力欄への参照を保持するMap
+ const inputRefs = useRef>(new Map());
+
+ // 初回マウント参照
+ const isMountedRef = useRef(false);
+
+ // 選択フィルタリング後のテーブル一覧
+ const filteredTables = tables.filter(
+ (t) => selectedCategory === "all" || t.categoryTitle === selectedCategory,
+ );
+
+ /**
+ * 選択された割合に応じて、空欄にするセルをランダムに選出するマスク作成関数
+ */
+ const generateBlankMask = useCallback(
+ (targetTables: GrammarTableData[], ratioStr: BlankRatioOption) => {
+ const ratio = parseInt(ratioStr, 10) / 100;
+ const mask: Record = {};
+
+ const allKeys: string[] = [];
+ targetTables.forEach((table, tIdx) => {
+ table.rows.forEach((row, rIdx) => {
+ row.cells.forEach((_, cIdx) => {
+ allKeys.push(`${tIdx}-${rIdx}-${cIdx}`);
+ });
+ });
+ });
+
+ if (ratioStr === "100") {
+ allKeys.forEach((k) => {
+ mask[k] = true;
+ });
+ } else {
+ // 対象のキーをランダムにシャッフルし、指定割合分を true(空欄)に設定
+ const keysCopy = [...allKeys];
+ for (let i = keysCopy.length - 1; i > 0; i--) {
+ const j = Math.floor(Math.random() * (i + 1));
+ [keysCopy[i], keysCopy[j]] = [keysCopy[j], keysCopy[i]];
+ }
+ const blankCount = Math.max(1, Math.round(allKeys.length * ratio));
+ keysCopy.slice(0, blankCount).forEach((k) => {
+ mask[k] = true;
+ });
+ }
+
+ return mask;
+ },
+ [],
+ );
+
+ /**
+ * 演習のリセット処理
+ */
+ const resetPractice = useCallback(
+ (cat: string, ratio: BlankRatioOption = blankRatio) => {
+ const tList = tables.filter((t) => cat === "all" || t.categoryTitle === cat);
+ const newMask = generateBlankMask(tList, ratio);
+ setBlankMask(newMask);
+ setGridAnswers({});
+ setIsChecked(false);
+ },
+ [tables, blankRatio, generateBlankMask],
+ );
+
+ // マウント時にランダム空欄マスクを生成
+ useEffect(() => {
+ if (!isMountedRef.current && tables.length > 0) {
+ isMountedRef.current = true;
+ resetPractice("all", "50");
+ }
+ }, [tables, resetPractice]);
+
+ /**
+ * セルに入力があったときの更新処理
+ */
+ const handleCellChange = (cellKey: string, value: string) => {
+ setGridAnswers((prev) => ({
+ ...prev,
+ [cellKey]: value,
+ }));
+ };
+
+ /**
+ * 答え合わせ実行処理
+ */
+ const handleCheckAnswers = () => {
+ setIsChecked(true);
+ };
+
+ /**
+ * カテゴリ切替処理
+ */
+ const handleCategoryChange = (cat: string) => {
+ setSelectedCategory(cat);
+ resetPractice(cat, blankRatio);
+ };
+
+ /**
+ * 空欄割合変更処理
+ */
+ const handleBlankRatioChange = (ratio: BlankRatioOption) => {
+ setBlankRatio(ratio);
+ resetPractice(selectedCategory, ratio);
+ };
+
+ /**
+ * 入力キーボードイベント(↑ / ↓ 矢印キーでウムラウト変換、Enterキーで次の空欄へ移動または再挑戦)
+ */
+ const handleKeyDown = (
+ e: React.KeyboardEvent,
+ cellKey: string,
+ currentValue: string,
+ ) => {
+ if (e.nativeEvent.isComposing) return;
+
+ if (e.key === "ArrowUp" || e.key === "ArrowDown") {
+ e.preventDefault();
+ const input = inputRefs.current.get(cellKey);
+ if (!input || !currentValue) return;
+
+ const selStart = input.selectionStart ?? currentValue.length;
+
+ let targetIdx = -1;
+ for (let i = Math.min(selStart - 1, currentValue.length - 1); i >= 0; i--) {
+ if (ACCENT_GROUP_KEY[currentValue[i]]) {
+ targetIdx = i;
+ break;
+ }
+ }
+
+ if (targetIdx === -1) {
+ for (let i = selStart; i < currentValue.length; i++) {
+ if (ACCENT_GROUP_KEY[currentValue[i]]) {
+ targetIdx = i;
+ break;
+ }
+ }
+ }
+
+ if (targetIdx !== -1) {
+ const charToCycle = currentValue[targetIdx];
+ const newChar = cycleChar(charToCycle, e.key === "ArrowUp" ? "up" : "down");
+ const newVal =
+ currentValue.slice(0, targetIdx) + newChar + currentValue.slice(targetIdx + 1);
+
+ handleCellChange(cellKey, newVal);
+
+ requestAnimationFrame(() => {
+ const updatedInput = inputRefs.current.get(cellKey);
+ if (updatedInput) {
+ updatedInput.setSelectionRange(selStart, selStart);
+ }
+ });
+ }
+ } else if (e.key === "Enter") {
+ e.preventDefault();
+ e.stopPropagation();
+ if (!isChecked) {
+ const idx = orderedBlankKeys.indexOf(cellKey);
+ const nextKey = idx !== -1 ? orderedBlankKeys[idx + 1] : undefined;
+ if (nextKey) {
+ inputRefs.current.get(nextKey)?.focus();
+ } else {
+ inputRefs.current.get(cellKey)?.blur();
+ }
+ } else {
+ resetPractice(selectedCategory, blankRatio);
+ }
+ }
+ };
+
+ // 答え合わせ状態でのEnterキーサポート
+ useEffect(() => {
+ if (!isChecked) return;
+
+ const handleGlobalKeyDown = (e: KeyboardEvent) => {
+ if (e.key === "Enter") {
+ if (e.isComposing) return;
+ e.preventDefault();
+ resetPractice(selectedCategory, blankRatio);
+ }
+ };
+ window.addEventListener("keydown", handleGlobalKeyDown);
+ return () => window.removeEventListener("keydown", handleGlobalKeyDown);
+ }, [isChecked, resetPractice, selectedCategory, blankRatio]);
+
+ /**
+ * 特殊文字ボタンクリックでアクティブな入力欄へ文字挿入
+ */
+ const handleInsertSpecialChar = (char: string) => {
+ if (!activeInputKey) return;
+ const input = inputRefs.current.get(activeInputKey);
+ const val = gridAnswers[activeInputKey] || "";
+ const selStart = input?.selectionStart ?? val.length;
+ const selEnd = input?.selectionEnd ?? val.length;
+
+ const newVal = val.slice(0, selStart) + char + val.slice(selEnd);
+ handleCellChange(activeInputKey, newVal);
+
+ requestAnimationFrame(() => {
+ if (input) {
+ input.focus();
+ const newPos = selStart + char.length;
+ input.setSelectionRange(newPos, newPos);
+ }
+ });
+ };
+
+ if (!tables || tables.length === 0) {
+ return (
+
+
+ 文法表データが見つかりませんでした。
+
+
+ );
+ }
+
+ // Enterキーでの次項目移動用: 空欄対象セルキーを表示順に並べたリスト
+ const orderedBlankKeys: string[] = [];
+ filteredTables.forEach((table, tIdx) => {
+ table.rows.forEach((row, rIdx) => {
+ row.cells.forEach((_, cIdx) => {
+ const key = `${tIdx}-${rIdx}-${cIdx}`;
+ if (blankMask[key]) orderedBlankKeys.push(key);
+ });
+ });
+ });
+
+ // 正解数の計算 (空欄対象に指定されたセルのみを計算)
+ let totalBlankCells = 0;
+ let correctCount = 0;
+ filteredTables.forEach((table, tIdx) => {
+ table.rows.forEach((row, rIdx) => {
+ row.cells.forEach((cell, cIdx) => {
+ const key = `${tIdx}-${rIdx}-${cIdx}`;
+ if (blankMask[key]) {
+ totalBlankCells += 1;
+ const userVal = (gridAnswers[key] || "").trim().toLowerCase();
+ if (userVal === cell.value.trim().toLowerCase()) {
+ correctCount += 1;
+ }
+ }
+ });
+ });
+ });
+
+ return (
+
+ {/* 設定・カテゴリ選択パネル */}
+
+
+ {/* カテゴリ選択 */}
+
+
+ カテゴリ:
+
+
+ handleCategoryChange("all")}
+ className={`rounded-lg px-3 py-1.5 text-sm font-semibold transition-colors ${
+ selectedCategory === "all"
+ ? "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"
+ }`}
+ >
+ 全カテゴリ
+
+ {tables.map((t) => (
+ handleCategoryChange(t.categoryTitle)}
+ className={`rounded-lg px-3 py-1.5 text-sm font-semibold transition-colors ${
+ selectedCategory === t.categoryTitle
+ ? "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"
+ }`}
+ >
+ {t.categoryTitle}
+
+ ))}
+
+
+
+ {/* 空欄割合選択 */}
+
+
+ 空欄の割合:
+
+
+ {[
+ { label: "25%", value: "25" },
+ { label: "50%", value: "50" },
+ { label: "75%", value: "75" },
+ { label: "100%", value: "100" },
+ ].map((opt) => (
+ handleBlankRatioChange(opt.value as BlankRatioOption)}
+ className={`rounded-lg px-3 py-1.5 text-sm font-semibold transition-colors ${
+ blankRatio === opt.value
+ ? "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}
+
+ ))}
+
+
+
+
+
+ {/* 特殊文字ボタンパレット */}
+
+
+ 特殊文字入力パレット (選択中セルへ挿入)
+
+
+ {SPECIAL_KEYS.map((char) => (
+ handleInsertSpecialChar(char)}
+ className="h-11 w-11 rounded-xl border border-zinc-300 bg-white text-lg font-bold text-zinc-800 shadow-sm transition-transform active:scale-95 hover:bg-zinc-100 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100 dark:hover:bg-zinc-700"
+ >
+ {char}
+
+ ))}
+
+
+ 💡 入力セルで ↑ / ↓ 矢印キー を押してもウムラウト・エスツェット(ä, ö, ü,
+ ß など)へ切り替えられます。
+
+
+
+ {/* 文法表完成グリッド */}
+
+ {filteredTables.map((table, tIdx) => (
+
+
+ 【{table.categoryTitle}】
+
+
+
+ ))}
+
+
+ {/* スコア・結果表示バッジ & 操作ボタンエリア */}
+
+ {isChecked && (
+
+
+ 答え合わせ結果:{" "}
+
+ {correctCount}
+ {" "}
+ / {totalBlankCells} 空欄正解 (
+ {totalBlankCells > 0 ? Math.round((correctCount / totalBlankCells) * 100) : 0}%)
+
+
+ )}
+
+
+ {!isChecked ? (
+
+ 答え合わせ
+
+ ) : (
+ resetPractice(selectedCategory, blankRatio)}
+ className="rounded-2xl bg-teal-600 px-8 py-3.5 text-base font-bold text-white shadow-md transition-colors hover:bg-teal-700 dark:bg-teal-500 dark:hover:bg-teal-600"
+ >
+ もう一度挑戦する
+
+ )}
+ resetPractice(selectedCategory, blankRatio)}
+ className="rounded-2xl border-2 border-zinc-200 px-6 py-3.5 text-base font-semibold text-zinc-600 transition-colors hover:bg-zinc-100 dark:border-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-800"
+ >
+ リセット
+
+
+
+
+ );
+}
diff --git a/components/german/GermanTablePracticeLoader.tsx b/components/german/GermanTablePracticeLoader.tsx
new file mode 100644
index 0000000..adb66e0
--- /dev/null
+++ b/components/german/GermanTablePracticeLoader.tsx
@@ -0,0 +1,104 @@
+import fs from "node:fs/promises";
+import path from "node:path";
+import GermanTablePractice, {
+ type GrammarTableCell,
+ type GrammarTableData,
+ type GrammarTableRow,
+} from "./GermanTablePractice";
+
+function mergeAdjacentCells(cells: string[]): GrammarTableCell[] {
+ const mergedCells: GrammarTableCell[] = [];
+
+ for (const value of cells) {
+ const previousCell = mergedCells.at(-1);
+ if (previousCell?.value === value) {
+ previousCell.colSpan += 1;
+ } else {
+ mergedCells.push({ value, colSpan: 1 });
+ }
+ }
+
+ return mergedCells;
+}
+
+/**
+ * MDX教材ファイル(app/learn/german/04/page.mdx)から
+ * 人称代名詞・所有冠詞・冠詞・指示詞の各文法表データを動的に抽出する関数。
+ * データのベタ打ちを排除し、教材ファイルを唯一のデータソースとして使用する。
+ */
+async function getGrammarTablesFromMdx(): Promise {
+ const filePath = path.join(process.cwd(), "app", "learn", "german", "04", "page.mdx");
+ const tables: GrammarTableData[] = [];
+
+ try {
+ const content = await fs.readFile(filePath, "utf-8");
+ const lines = content.split("\n");
+
+ let currentCategory = "文法表";
+ let currentHeaders: string[] = [];
+ let currentRows: GrammarTableRow[] = [];
+
+ for (const line of lines) {
+ const trimmed = line.trim();
+
+ // 見出し3(###)でカテゴリ切り替え
+ if (trimmed.startsWith("###")) {
+ if (currentHeaders.length > 0 && currentRows.length > 0) {
+ tables.push({
+ categoryKey: currentCategory.toLowerCase(),
+ categoryTitle: currentCategory,
+ headers: currentHeaders,
+ rows: currentRows,
+ });
+ }
+ currentCategory = trimmed.replace(/^###\s*/, "").trim();
+ currentHeaders = [];
+ currentRows = [];
+ } else if (trimmed.startsWith("|") && trimmed.endsWith("|")) {
+ const cells = trimmed
+ .split("|")
+ .slice(1, -1)
+ .map((c) => c.trim());
+
+ if (cells.length >= 2) {
+ // 区切り行(| --- | --- |)は無視
+ if (cells.every((c) => /^[-:\s]+$/.test(c))) {
+ continue;
+ }
+
+ // 最初のデータ行をヘッダーとして取得
+ if (currentHeaders.length === 0) {
+ currentHeaders = cells;
+ } else {
+ const label = cells[0];
+ const rowCells = mergeAdjacentCells(cells.slice(1));
+ currentRows.push({ label, cells: rowCells });
+ }
+ }
+ }
+ }
+
+ // 最後のテーブルを追加
+ if (currentHeaders.length > 0 && currentRows.length > 0) {
+ tables.push({
+ categoryKey: currentCategory.toLowerCase(),
+ categoryTitle: currentCategory,
+ headers: currentHeaders,
+ rows: currentRows,
+ });
+ }
+ } catch {
+ // 読込エラー時は空配列を返す
+ }
+
+ return tables;
+}
+
+/**
+ * サーバー側でMDXから文法表データを読み込み、
+ * 表完成演習コンポーネント(GermanTablePractice)へ受け渡すServer Component。
+ */
+export default async function GermanTablePracticeLoader() {
+ const tables = await getGrammarTablesFromMdx();
+ return ;
+}
diff --git a/components/spanish/SpanishTablePractice.tsx b/components/spanish/SpanishTablePractice.tsx
index 6cdb3d7..4f7594f 100644
--- a/components/spanish/SpanishTablePractice.tsx
+++ b/components/spanish/SpanishTablePractice.tsx
@@ -187,7 +187,7 @@ export default function SpanishTablePractice({ tables }: { tables: GrammarTableD
};
/**
- * 入力キーボードイベント(↑ / ↓ 矢印キーでアクセント変換、Enterキーで答え合わせ送信または再挑戦)
+ * 入力キーボードイベント(↑ / ↓ 矢印キーでアクセント変換、Enterキーで次の空欄へ移動または再挑戦)
*/
const handleKeyDown = (
e: React.KeyboardEvent,
@@ -239,7 +239,13 @@ export default function SpanishTablePractice({ tables }: { tables: GrammarTableD
e.preventDefault();
e.stopPropagation();
if (!isChecked) {
- handleCheckAnswers();
+ const idx = orderedBlankKeys.indexOf(cellKey);
+ const nextKey = idx !== -1 ? orderedBlankKeys[idx + 1] : undefined;
+ if (nextKey) {
+ inputRefs.current.get(nextKey)?.focus();
+ } else {
+ inputRefs.current.get(cellKey)?.blur();
+ }
} else {
resetPractice(selectedCategory, blankRatio);
}
@@ -293,6 +299,17 @@ export default function SpanishTablePractice({ tables }: { tables: GrammarTableD
);
}
+ // Enterキーでの次項目移動用: 空欄対象セルキーを表示順に並べたリスト
+ const orderedBlankKeys: string[] = [];
+ filteredTables.forEach((table, tIdx) => {
+ table.rows.forEach((row, rIdx) => {
+ row.cells.forEach((_, cIdx) => {
+ const key = `${tIdx}-${rIdx}-${cIdx}`;
+ if (blankMask[key]) orderedBlankKeys.push(key);
+ });
+ });
+ });
+
// 正解数の計算 (空欄対象に指定されたセルのみを計算)
let totalBlankCells = 0;
let correctCount = 0;
diff --git a/lib/conjugation/de/auxiliaries.ts b/lib/conjugation/de/auxiliaries.ts
new file mode 100644
index 0000000..65b86c1
--- /dev/null
+++ b/lib/conjugation/de/auxiliaries.ts
@@ -0,0 +1,25 @@
+import type { SixForms } from "./types";
+
+interface AuxiliaryForms {
+ präsens: SixForms;
+ präteritum: SixForms;
+ konjunktivII: SixForms;
+ konjunktivI: SixForms;
+}
+
+export const HABEN_FORMS: AuxiliaryForms = {
+ präsens: ["habe", "hast", "hat", "haben", "habt", "haben"],
+ präteritum: ["hatte", "hattest", "hatte", "hatten", "hattet", "hatten"],
+ konjunktivII: ["hätte", "hättest", "hätte", "hätten", "hättet", "hätten"],
+ konjunktivI: ["habe", "habest", "habe", "haben", "habet", "haben"],
+};
+
+export const SEIN_FORMS: AuxiliaryForms = {
+ präsens: ["bin", "bist", "ist", "sind", "seid", "sind"],
+ präteritum: ["war", "warst", "war", "waren", "wart", "waren"],
+ konjunktivII: ["wäre", "wärest", "wäre", "wären", "wäret", "wären"],
+ konjunktivI: ["sei", "seist", "sei", "seien", "seiet", "seien"],
+};
+
+// Futur I・II を作るための werden の直説法現在人称変化
+export const WERDEN_PRÄSENS: SixForms = ["werde", "wirst", "wird", "werden", "werdet", "werden"];
diff --git a/lib/conjugation/de/config.ts b/lib/conjugation/de/config.ts
new file mode 100644
index 0000000..72939d0
--- /dev/null
+++ b/lib/conjugation/de/config.ts
@@ -0,0 +1,25 @@
+import type { ConjugationLanguageConfig } from "@/components/verbs/ConjugationPractice";
+import { speak } from "@/lib/speech";
+import { buildConjugation } from "./engine";
+import { PERSONS, TENSE_OPTIONS, formsFor, type ConjugationTable, type VerbEntry } from "./types";
+
+const ACCENT_CYCLES: string[][] = [
+ ["a", "ä"],
+ ["o", "ö"],
+ ["u", "ü"],
+ ["s", "ß"],
+];
+
+const TOOLBAR_CHARS = ["ä", "ö", "ü", "ß", "Ä", "Ö", "Ü"];
+
+export const germanConjugationConfig: ConjugationLanguageConfig = {
+ languageSlug: "german",
+ persons: PERSONS,
+ tenseOptions: TENSE_OPTIONS,
+ buildConjugation,
+ formsFor,
+ speak: (text, handlers) => speak(text, "de-DE", handlers),
+ accentCycles: ACCENT_CYCLES,
+ toolbarChars: TOOLBAR_CHARS,
+ inputPlaceholder: "活用形を入力...",
+};
diff --git a/lib/conjugation/de/engine.ts b/lib/conjugation/de/engine.ts
new file mode 100644
index 0000000..37c37f0
--- /dev/null
+++ b/lib/conjugation/de/engine.ts
@@ -0,0 +1,101 @@
+import type { Auxiliary, ConjugationTable, RegularVerb, SixForms, VerbEntry } from "./types";
+import { HABEN_FORMS, SEIN_FORMS, WERDEN_PRÄSENS } from "./auxiliaries";
+
+const KONJUNKTIV_ENDINGS = ["e", "est", "e", "en", "et", "en"];
+
+function withEndings(stem: string, endings: string[]): SixForms {
+ return endings.map((ending) => stem + ending) as SixForms;
+}
+
+// 原形から接続法I式(sein以外は必ず規則的に作れる)を導出する
+// 例: gehen -> geh -> gehe/gehest/gehe/gehen/gehet/gehen
+function konjunktivIFromInfinitive(infinitive: string): SixForms {
+ const stem = infinitive.endsWith("en") ? infinitive.slice(0, -2) : infinitive.slice(0, -1);
+ return withEndings(stem, KONJUNKTIV_ENDINGS);
+}
+
+function auxiliaryForms(auxiliary: Auxiliary) {
+ return auxiliary === "sein" ? SEIN_FORMS : HABEN_FORMS;
+}
+
+function buildCompound(auxSix: SixForms, partizipII: string): SixForms {
+ return auxSix.map((f) => `${f} ${partizipII}`) as SixForms;
+}
+
+function buildFuturII(partizipII: string, auxInfinitive: string): SixForms {
+ return WERDEN_PRÄSENS.map((f) => `${f} ${partizipII} ${auxInfinitive}`) as SixForms;
+}
+
+interface RegularBase {
+ präsens: SixForms;
+ präteritum: SixForms;
+ partizipII: string;
+}
+
+// 弱変化動詞(規則動詞)を語幹+語尾のルールから機械的に導出する
+function conjugateRegularBase(verb: RegularVerb): RegularBase {
+ const stem = verb.infinitive.replace(/en$/, "");
+ // 語幹が d/t などで終わる動詞は、子音で始まる語尾の前に連結母音 -e- を挟む(arbeiten -> arbeitet)
+ const linkedStem = verb.linkingE ? stem + "e" : stem;
+
+ const duEnding = verb.sibilantStem ? "t" : verb.linkingE ? "est" : "st";
+
+ const präsens: SixForms = [
+ stem + "e",
+ stem + duEnding,
+ linkedStem + "t",
+ stem + "en",
+ linkedStem + "t",
+ stem + "en",
+ ];
+
+ const präteritum: SixForms = [
+ linkedStem + "te",
+ linkedStem + "test",
+ linkedStem + "te",
+ linkedStem + "ten",
+ linkedStem + "tet",
+ linkedStem + "ten",
+ ];
+
+ const partizipII = (verb.noGePrefix ? "" : "ge") + linkedStem + "t";
+
+ return { präsens, präteritum, partizipII };
+}
+
+export function buildConjugation(verb: VerbEntry): ConjugationTable {
+ const base =
+ verb.kind === "regular"
+ ? conjugateRegularBase(verb)
+ : { präsens: verb.präsens, präteritum: verb.präteritum, partizipII: verb.partizipII };
+
+ // 弱変化動詞の接続法II式は過去形と同形。強変化・混合変化動詞は過去基本形にウムラウトをかけた語幹から作る
+ const konjunktivIIPräsens =
+ verb.kind === "regular"
+ ? base.präteritum
+ : withEndings(verb.konjunktivIIBase, KONJUNKTIV_ENDINGS);
+
+ const konjunktivIPräsens = verb.konjunktivIOverride ?? konjunktivIFromInfinitive(verb.infinitive);
+
+ const aux = auxiliaryForms(verb.auxiliary);
+ const auxInfinitive = verb.auxiliary;
+
+ return {
+ indikativ: {
+ präsens: base.präsens,
+ präteritum: base.präteritum,
+ perfekt: buildCompound(aux.präsens, base.partizipII),
+ plusquamperfekt: buildCompound(aux.präteritum, base.partizipII),
+ futurI: WERDEN_PRÄSENS.map((f) => `${f} ${verb.infinitive}`) as SixForms,
+ futurII: buildFuturII(base.partizipII, auxInfinitive),
+ },
+ konjunktivII: {
+ präsens: konjunktivIIPräsens,
+ perfekt: buildCompound(aux.konjunktivII, base.partizipII),
+ },
+ konjunktivI: {
+ präsens: konjunktivIPräsens,
+ perfekt: buildCompound(aux.konjunktivI, base.partizipII),
+ },
+ };
+}
diff --git a/lib/conjugation/de/types.ts b/lib/conjugation/de/types.ts
new file mode 100644
index 0000000..a449015
--- /dev/null
+++ b/lib/conjugation/de/types.ts
@@ -0,0 +1,90 @@
+import type { SixForms, TenseOption } from "@/lib/conjugation/shared";
+
+export type { SixForms };
+
+export const PERSONS: readonly string[] = ["ich", "du", "er / sie / es", "wir", "ihr", "sie / Sie"];
+
+export type IndikativTense =
+ | "präsens"
+ | "präteritum"
+ | "perfekt"
+ | "plusquamperfekt"
+ | "futurI"
+ | "futurII";
+
+export type KonjunktivIITense = "präsens" | "perfekt";
+export type KonjunktivITense = "präsens" | "perfekt";
+
+export type Mood = "indikativ" | "konjunktivII" | "konjunktivI";
+
+export const TENSE_OPTIONS: TenseOption[] = [
+ { mood: "indikativ", tense: "präsens", label: "直説法 現在" },
+ { mood: "indikativ", tense: "präteritum", label: "直説法 過去" },
+ { mood: "indikativ", tense: "perfekt", label: "直説法 現在完了" },
+ { mood: "indikativ", tense: "plusquamperfekt", label: "直説法 過去完了" },
+ { mood: "indikativ", tense: "futurI", label: "直説法 未来I" },
+ { mood: "indikativ", tense: "futurII", label: "直説法 未来II" },
+ { mood: "konjunktivII", tense: "präsens", label: "接続法II式 現在" },
+ { mood: "konjunktivII", tense: "perfekt", label: "接続法II式 過去" },
+ { mood: "konjunktivI", tense: "präsens", label: "接続法I式 現在" },
+ { mood: "konjunktivI", tense: "perfekt", label: "接続法I式 過去" },
+];
+
+export { tenseKey } from "@/lib/conjugation/shared";
+
+export interface ConjugationTable {
+ indikativ: Record;
+ konjunktivII: Record;
+ konjunktivI: Record;
+}
+
+export function formsFor(table: ConjugationTable, mood: string, tense: string): SixForms {
+ switch (mood) {
+ case "indikativ":
+ return table.indikativ[tense as IndikativTense];
+ case "konjunktivII":
+ return table.konjunktivII[tense as KonjunktivIITense];
+ case "konjunktivI":
+ return table.konjunktivI[tense as KonjunktivITense];
+ default:
+ throw new Error(`unknown mood: ${mood}`);
+ }
+}
+
+// 完了時制で haben / sein のどちらを助動詞にとるか
+export type Auxiliary = "haben" | "sein";
+
+// 弱変化動詞(規則動詞): 語尾変化のルールだけで活用可能なため、原形と少数の音韻フラグのみ保持する
+export interface RegularVerb {
+ id: string;
+ infinitive: string;
+ meaning: string;
+ kind: "regular";
+ auxiliary: Auxiliary;
+ // 語幹が d/t/chn/gn/dn などの子音で終わり、活用語尾の前に連結母音 -e- が必要な動詞(arbeiten, öffnen など)
+ linkingE?: boolean;
+ // 語幹が s/ß/z/x などの歯擦音で終わり、du形の語尾が -st ではなく -t になる動詞(reisen, heißen など)
+ sibilantStem?: boolean;
+ // -ieren動詞や be-/ver-/ent- などの非分離前綴りをもつ動詞など、過去分詞に ge- を付けない動詞
+ noGePrefix?: boolean;
+ // 接続法I式が原形からの規則的な導出に従わない動詞(sein のみ)で使う上書き
+ konjunktivIOverride?: SixForms;
+}
+
+// 強変化動詞・混合変化動詞(不規則動詞): 現在人称変化・過去基本形・過去分詞を明示的に保持する
+export interface IrregularVerb {
+ id: string;
+ infinitive: string;
+ meaning: string;
+ kind: "irregular";
+ auxiliary: Auxiliary;
+ präsens: SixForms;
+ präteritum: SixForms;
+ partizipII: string;
+ // 接続法II式の語幹(+ e/est/e/en/et/en を付けて活用形を作る)。過去基本形にウムラウトをかけたもの
+ konjunktivIIBase: string;
+ // 接続法I式が原形からの規則的な導出に従わない動詞(sein, tun など)で使う上書き
+ konjunktivIOverride?: SixForms;
+}
+
+export type VerbEntry = RegularVerb | IrregularVerb;