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) */} + + 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・助動詞選択)穴埋め演習 + +{/* 穴埋め演習用コンポーネントの読み込み */} + + + + 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) */} + + 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) */} + + 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) */} + + 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 ( +
+ {/* 設定パネル(カテゴリ選択・出題数・制限時間) */} +
+
+ {/* カテゴリ選択 */} +
+ + カテゴリ: + +
+ + {categories.map((cat) => ( + + ))} +
+
+ + {/* 出題数選択 */} +
+ 出題数: +
+ {[ + { label: "5問", value: "5" }, + { label: "10問", value: "10" }, + { label: "20問", value: "20" }, + { label: "全問", value: "all" }, + ].map((opt) => ( + + ))} +
+
+ + {/* 制限時間選択(ゲーム要素: 15秒, 30秒, 45秒) */} +
+ + 制限時間: + +
+ {[ + { label: "なし", value: "off" }, + { label: "15秒", value: "15" }, + { label: "30秒", value: "30" }, + { label: "45秒", value: "45" }, + ].map((opt) => ( + + ))} +
+
+
+
+ + {isFinished ? ( + /* 全問題完了時のスコア結果画面 */ +
+

演習終了!

+

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

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

+ 💡 ↑ / ↓ 矢印キー で文字にアクセント記号(é, è, ê, à, ç, œ + など)を付与・切替できます。 +

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

+ ✨ 正解です! +

+ {currentQuestion?.explanation && ( +

+ 💡 解説: {currentQuestion.explanation} +

+ )} +
+ ) : ( +
+

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

+

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

+ {currentQuestion?.explanation && ( +

+ 💡 解説: {currentQuestion.explanation} +

+ )} +
+ )} +
+ )} + + {/* 操作ボタン */} +
+ {isAnswered ? ( + + ) : ( + <> + + + + )} +
+
+ + )} +
+ ); +} diff --git a/components/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) => ( + + ))} +
+
+ + {/* 問題数選択 */} +
+ 問題数: +
+ {[ + { label: "5問", value: "5" }, + { label: "10問", value: "10" }, + { label: "20問", value: "20" }, + { label: "全問", value: "all" }, + ].map((opt) => ( + + ))} +
+
+ + {/* 制限時間選択 */} +
+ + 制限時間: + +
+ {[ + { label: "なし", value: "off" }, + { label: "15秒", value: "15" }, + { label: "30秒", value: "30" }, + { label: "45秒", value: "45" }, + ].map((opt) => ( + + ))} +
+
+
+
+ + {isFinished ? ( + /* 全問題完了時のスコア結果画面 */ +
+

演習終了!

+

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

+ +
+ ) : ( + /* 演習中画面 */ + <> + {/* 進捗・現在スコア・タイマー表示 */} +
+ + 問題{" "} + + {currentIndex + 1} + {" "} + / {questions.length} + + + {/* 制限時間カウントダウンバッジ */} + {selectedTimer !== "off" && ( +
+ ⏱️ 残り時間: + + {timeLeft !== null ? `${timeLeft}秒` : "-"} + +
+ )} + + + 正解数:{" "} + {score} + +
+ + {/* 制限時間のプログレスバー */} + {selectedTimer !== "off" && ( +
+
+
+ )} + + {/* 出題表示 */} +
+ + 数字に対応するフランス語のスペルを入力してください + +
+ {currentQuestion?.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) => ( + + ))} +
+ +

+ 💡 ↑ / ↓ 矢印キー で文字にアクセント記号(é, è, ê, à, ç, œ + など)を付与・切替できます。 +

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

+ ✨ 正解です! +

+
+ ) : ( +
+

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

+

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

+
+ )} +

+ [Enter] キーを押すと次に進みます +

+
+ )} + + {/* 操作ボタン */} +
+ {isAnswered ? ( + + ) : ( + <> + + + + )} +
+
+ + )} +
+ ); +} 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 ( +
+ {/* 設定・カテゴリ選択パネル */} +
+
+ {/* カテゴリ選択 */} +
+ + カテゴリ: + +
+ + {tables.map((t) => ( + + ))} +
+
+ + {/* 空欄割合選択 */} +
+ + 空欄の割合: + +
+ {[ + { label: "25%", value: "25" }, + { label: "50%", value: "50" }, + { label: "75%", value: "75" }, + { label: "100%", value: "100" }, + ].map((opt) => ( + + ))} +
+
+
+
+ + {/* 特殊文字ボタンパレット */} +
+ + 特殊文字入力パレット (選択中セルへ挿入) + +
+ {SPECIAL_KEYS.map((char) => ( + + ))} +
+

+ 💡 入力セルで ↑ / ↓ 矢印キー を押してもアクセント記号(é, è, ê, à, ç, œ + など)へ切り替えられます。 +

+
+ + {/* 文法表完成グリッド */} +
+ {filteredTables.map((table, tIdx) => ( +
+

+ 【{table.categoryTitle}】 +

+ + + + {table.headers.map((h, hIdx) => ( + + ))} + + + + {table.rows.map((row, rIdx) => ( + + + {row.cells.map((cell, cIdx) => { + const key = `${tIdx}-${rIdx}-${cIdx}`; + const isBlankTarget = blankMask[key]; + const val = gridAnswers[key] || ""; + const isCellCorrect = + isChecked && val.trim().toLowerCase() === cell.value.trim().toLowerCase(); + const isCellWrong = isChecked && isBlankTarget && !isCellCorrect; + + return ( + + ); + })} + + ))} + +
+ {h} +
+ {row.label} + + {isBlankTarget ? ( +
+ { + if (el) inputRefs.current.set(key, el); + else inputRefs.current.delete(key); + }} + type="text" + value={val} + disabled={isChecked} + onFocus={() => setActiveInputKey(key)} + onChange={(e) => handleCellChange(key, e.target.value)} + onKeyDown={(e) => handleKeyDown(e, key, val)} + placeholder="..." + className={`w-full rounded-xl border-2 px-3 py-2 text-base font-semibold outline-none transition-colors dark:bg-zinc-900 dark:text-zinc-50 ${ + isChecked + ? isCellCorrect + ? "border-emerald-500 bg-emerald-50 text-emerald-900 dark:bg-emerald-950/60 dark:text-emerald-300" + : "border-rose-500 bg-rose-50 text-rose-900 dark:bg-rose-950/60 dark:text-rose-300" + : "border-zinc-300 bg-white focus:border-teal-500 focus:ring-4 focus:ring-teal-500/20 dark:border-zinc-700 dark:focus:border-teal-400" + }`} + /> + {isCellWrong && ( + + 正解: {cell.value} + + )} +
+ ) : ( + + {cell.value} + + )} +
+
+ ))} +
+ + {/* スコア・結果表示バッジ & 操作ボタンエリア */} +
+ {isChecked && ( +
+

+ 答え合わせ結果:{" "} + + {correctCount} + {" "} + / {totalBlankCells} 空欄正解 ( + {totalBlankCells > 0 ? Math.round((correctCount / totalBlankCells) * 100) : 0}%) +

+
+ )} + +
+ {!isChecked ? ( + + ) : ( + + )} + +
+
+
+ ); +} 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) => ( + + ))} +
+
+ + {/* 問題数選択 */} +
+ 問題数: +
+ {[ + { label: "5問", value: "5" }, + { label: "10問", value: "10" }, + { label: "20問", value: "20" }, + { label: "全問", value: "all" }, + ].map((opt) => ( + + ))} +
+
+ + {/* 制限時間選択 */} +
+ + 制限時間: + +
+ {[ + { label: "なし", value: "off" }, + { label: "15秒", value: "15" }, + { label: "30秒", value: "30" }, + { label: "45秒", value: "45" }, + ].map((opt) => ( + + ))} +
+
+
+
+ + {isFinished ? ( + /* 全問題完了時のスコア結果画面 */ +
+

演習終了!

+

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

+ +
+ ) : ( + /* 演習中画面 */ + <> + {/* 進捗・現在スコア・タイマー表示 */} +
+ + 問題{" "} + + {currentIndex + 1} + {" "} + / {questions.length} + + + {/* 制限時間カウントダウンバッジ */} + {selectedTimer !== "off" && ( +
+ ⏱️ 残り時間: + + {timeLeft !== null ? `${timeLeft}秒` : "-"} + +
+ )} + + + 正解数:{" "} + {score} + +
+ + {/* 制限時間のプログレスバー */} + {selectedTimer !== "off" && ( +
+
+
+ )} + + {/* 出題表示 */} +
+ + 数字に対応するドイツ語のスペルを入力してください + +
+ {currentQuestion?.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) => ( + + ))} +
+ +

+ 💡 ↑ / ↓ 矢印キー で文字にウムラウト・エスツェット(ä, ö, ü, ß + など)を付与・切替できます。 +

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

+ ✨ 正解です! +

+
+ ) : ( +
+

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

+

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

+
+ )} +

+ [Enter] キーを押すと次に進みます +

+
+ )} + + {/* 操作ボタン */} +
+ {isAnswered ? ( + + ) : ( + <> + + + + )} +
+
+ + )} +
+ ); +} 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 ( +
+ {/* 設定・カテゴリ選択パネル */} +
+
+ {/* カテゴリ選択 */} +
+ + カテゴリ: + +
+ + {tables.map((t) => ( + + ))} +
+
+ + {/* 空欄割合選択 */} +
+ + 空欄の割合: + +
+ {[ + { label: "25%", value: "25" }, + { label: "50%", value: "50" }, + { label: "75%", value: "75" }, + { label: "100%", value: "100" }, + ].map((opt) => ( + + ))} +
+
+
+
+ + {/* 特殊文字ボタンパレット */} +
+ + 特殊文字入力パレット (選択中セルへ挿入) + +
+ {SPECIAL_KEYS.map((char) => ( + + ))} +
+

+ 💡 入力セルで ↑ / ↓ 矢印キー を押してもウムラウト・エスツェット(ä, ö, ü, + ß など)へ切り替えられます。 +

+
+ + {/* 文法表完成グリッド */} +
+ {filteredTables.map((table, tIdx) => ( +
+

+ 【{table.categoryTitle}】 +

+ + + + {table.headers.map((h, hIdx) => ( + + ))} + + + + {table.rows.map((row, rIdx) => ( + + + {row.cells.map((cell, cIdx) => { + const key = `${tIdx}-${rIdx}-${cIdx}`; + const isBlankTarget = blankMask[key]; + const val = gridAnswers[key] || ""; + const isCellCorrect = + isChecked && val.trim().toLowerCase() === cell.value.trim().toLowerCase(); + const isCellWrong = isChecked && isBlankTarget && !isCellCorrect; + + return ( + + ); + })} + + ))} + +
+ {h} +
+ {row.label} + + {isBlankTarget ? ( +
+ { + if (el) inputRefs.current.set(key, el); + else inputRefs.current.delete(key); + }} + type="text" + value={val} + disabled={isChecked} + onFocus={() => setActiveInputKey(key)} + onChange={(e) => handleCellChange(key, e.target.value)} + onKeyDown={(e) => handleKeyDown(e, key, val)} + placeholder="..." + className={`w-full rounded-xl border-2 px-3 py-2 text-base font-semibold outline-none transition-colors dark:bg-zinc-900 dark:text-zinc-50 ${ + isChecked + ? isCellCorrect + ? "border-emerald-500 bg-emerald-50 text-emerald-900 dark:bg-emerald-950/60 dark:text-emerald-300" + : "border-rose-500 bg-rose-50 text-rose-900 dark:bg-rose-950/60 dark:text-rose-300" + : "border-zinc-300 bg-white focus:border-teal-500 focus:ring-4 focus:ring-teal-500/20 dark:border-zinc-700 dark:focus:border-teal-400" + }`} + /> + {isCellWrong && ( + + 正解: {cell.value} + + )} +
+ ) : ( + + {cell.value} + + )} +
+
+ ))} +
+ + {/* スコア・結果表示バッジ & 操作ボタンエリア */} +
+ {isChecked && ( +
+

+ 答え合わせ結果:{" "} + + {correctCount} + {" "} + / {totalBlankCells} 空欄正解 ( + {totalBlankCells > 0 ? Math.round((correctCount / totalBlankCells) * 100) : 0}%) +

+
+ )} + +
+ {!isChecked ? ( + + ) : ( + + )} + +
+
+
+ ); +} 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;