diff --git a/app/my-notebooks/[notebookId]/CardRow.tsx b/app/my-notebooks/[notebookId]/CardRow.tsx index a816229..5f10c74 100644 --- a/app/my-notebooks/[notebookId]/CardRow.tsx +++ b/app/my-notebooks/[notebookId]/CardRow.tsx @@ -26,6 +26,20 @@ function SaveButton() { ); } +// Excelの結合セルのように、ある列で連続する組(行)の値が同じであればまとめてrowSpanで +// 1つのセルにする。先頭行以外はnull(描画しない)を返し、代わりに直前のセルのrowSpanを伸ばす。 +// 空文字同士は結合しない(未入力のセルが1つの大きな空欄に見えて紛らわしいのを避けるため) +function computeRowSpans(values: string[]): number[] { + const spans = values.map(() => 1); + for (let i = values.length - 1; i > 0; i -= 1) { + if (values[i] !== "" && values[i] === values[i - 1]) { + spans[i - 1] += spans[i]; + spans[i] = 0; + } + } + return spans; +} + export default function CardRow({ notebookId, columns, @@ -54,7 +68,8 @@ export default function CardRow({ } } - const senseColumns = columns.slice(1); + // 見出し語を除いた列。表示順そのまま + const bodyColumns = columns.slice(1); // toggleStarの結果(サーバーの往復)を待たず、クリックした瞬間に★・回数・色を切り替えるための // 楽観的UI。これが無いと、往復の間だけ古い状態(トグル前の☆)が表示され続けてしまい、 @@ -77,44 +92,41 @@ export default function CardRow({ const starColor = starColorFor(optimisticStar.starCount, starColors); if (!editing) { - // 意味が0件でも見出し語だけの行を1行表示する(表側の "senses" が空配列にならないようフォールバック) - const senses = card.data.senses.length > 0 ? card.data.senses : [{}]; + // 見出し語1件につき、rows(組)の件数ぶんを並べる。見出し語セルと操作セルは + // Excelの結合セルのように rowSpan で全組にまたがらせ、本文の各列は列ごとに + // 連続して同じ値が続く区間だけをrowSpanでまとめる(結合・分割) + const rowCount = card.data.rows.length; + const bodySpansByColumn = bodyColumns.map((column) => + computeRowSpans(card.data.rows.map((row) => row[column] ?? "")), + ); - // 1つの見出し語(1枚のカード)が複数の意味を持つ場合、 - // をsenses件数ぶん並べて表現する。見出し語セルと操作セル(編集/削除)は - // rowSpan={senses.length} で縦に結合し、1行目にだけレンダリングする。 - // 2行目以降は意味の列だけを持つ行になり、破線の罫線(border-dashed)で - // 「同じ見出し語グループの続き」であることを視覚的に示す return ( <> - {senses.map((sense, index) => ( - - {index === 0 && ( + {card.data.rows.map((row, rowIndex) => ( + + {rowIndex === 0 && ( {card.data.head} )} - {/* 意味側の列は、senseColumns(columnsの2列目以降)の順番通りに1セルずつ描画する */} - {senseColumns.map((column) => ( - - {sense[column] ?? ""} - - ))} - {index === 0 && ( - + {bodyColumns.map((column, columnIndex) => { + const span = bodySpansByColumn[columnIndex][rowIndex]; + if (span === 0) return null; + return ( + + {row[column] ?? ""} + + ); + })} + {rowIndex === 0 && ( + {/* handleToggleStarは楽観的UIでoptimisticStarを即座に切り替えてからtoggleStarを呼ぶ。 表示はcard.starredではなくoptimisticStar.starredを見ることで、 サーバーの往復を待たずに★・色が切り替わる。 @@ -185,19 +197,18 @@ export default function CardRow({ ); } - // 編集モードでは、表示モード時の複数を1つのにまとめ、 - // colSpan(見出し語1列 + 意味の列数 + 操作列1列)で全カラムぶんを1セルに潰して + // 編集モードでは、colSpan(見出し語1列 + 列数 + 操作列1列)で全カラムぶんを1セルに潰して // その中にフォームを丸ごと展開する return ( - + {/* action={formAction} に渡すことで、Server Actionの結果がuseActionStateのstateに反映される */}
- {/* defaultHead/defaultSensesで現在の値を初期表示し、そこから編集する */} + {/* defaultHead/defaultRowsで現在の値を初期表示し、そこから編集する */}
diff --git a/app/my-notebooks/[notebookId]/page.tsx b/app/my-notebooks/[notebookId]/page.tsx index 483164f..f7b85ea 100644 --- a/app/my-notebooks/[notebookId]/page.tsx +++ b/app/my-notebooks/[notebookId]/page.tsx @@ -5,9 +5,12 @@ import { prisma } from "@/lib/prisma"; import { requireUser } from "@/lib/session"; import CardRow from "./CardRow"; import CreateCardForm from "./CreateCardForm"; +import ColumnsEditor from "@/components/my-notebooks/ColumnsEditor"; +import ImportCardsForm from "@/components/my-notebooks/ImportCardsForm"; import ResetAllStarsButton from "@/components/my-notebooks/ResetAllStarsButton"; import ShareNotebookButton from "@/components/my-notebooks/ShareNotebookButton"; -import type { CardData } from "@/lib/card-data"; +import { normalizeCardData } from "@/lib/card-data"; +import { normalizeColumns } from "@/lib/notebook-columns"; // DBの最新状態を常に表示するため、ビルド時の静的プリレンダリングを避けてリクエスト時にレンダリングする export const dynamic = "force-dynamic"; @@ -28,10 +31,11 @@ export default async function NotebookPage(props: PageProps<"/my-notebooks/[note notFound(); } - // columns は「1列目=見出し語、2列目以降=意味の列名」という順序付き配列。 - // 列数・列名はNotebookごとに異なる(Excel由来)ため、テーブルのヘッダーや - // 各行の入力欄は columns をループして動的に組み立てる - const columns = notebook.columns as string[]; + // columns は「1列目=見出し語、2列目以降=意味・発音などの列名」という順序付き配列。 + // 各列の値は見出し語につき1件〜複数件を自由に持てる(列ごとの件数は完全に独立)。 + // 列数・列名はNotebookごとに異なるため、テーブルのヘッダーや各行の入力欄は + // columns をループして動的に組み立てる + const columns = normalizeColumns(notebook.columns); // ★がついている単語の件数。1件以上あれば「復習」への導線を出す const starredCount = notebook.cards.filter((card) => card.starred).length; // ★の回数が1回でも付いている単語があれば「一括リセット」の導線を出す @@ -80,7 +84,12 @@ export default async function NotebookPage(props: PageProps<"/my-notebooks/[note )}
-
+
+ + +
+ +
@@ -116,7 +125,7 @@ export default async function NotebookPage(props: PageProps<"/my-notebooks/[note columns={columns} card={{ id: card.id, - data: card.data as CardData, + data: normalizeCardData(card.data), starred: card.starred, starCount: card.starCount, viewCount: card.viewCount, diff --git a/app/my-notebooks/[notebookId]/review/page.tsx b/app/my-notebooks/[notebookId]/review/page.tsx index 4663970..61333b1 100644 --- a/app/my-notebooks/[notebookId]/review/page.tsx +++ b/app/my-notebooks/[notebookId]/review/page.tsx @@ -3,7 +3,8 @@ import { notFound, redirect } from "next/navigation"; import { prisma } from "@/lib/prisma"; import { requireUser } from "@/lib/session"; import StudyDeck from "../study/StudyDeck"; -import type { CardData } from "@/lib/card-data"; +import { normalizeCardData } from "@/lib/card-data"; +import { normalizeColumns } from "@/lib/notebook-columns"; export default async function ReviewPage(props: PageProps<"/my-notebooks/[notebookId]/review">) { const user = await requireUser(); @@ -25,10 +26,10 @@ export default async function ReviewPage(props: PageProps<"/my-notebooks/[notebo // 表示・フリップ・★の付け外しはすべてクライアント側のStudyDeckが担当するため、 // ここではサーバーでDBから取得したデータをそのまま整形して渡すだけ - const columns = notebook.columns as string[]; + const columns = normalizeColumns(notebook.columns); const cards = notebook.cards.map((card) => ({ id: card.id, - data: card.data as CardData, + data: normalizeCardData(card.data), starred: card.starred, starCount: card.starCount, viewCount: card.viewCount, diff --git a/app/my-notebooks/[notebookId]/study/StudyDeck.tsx b/app/my-notebooks/[notebookId]/study/StudyDeck.tsx index 2502bac..41b86e8 100644 --- a/app/my-notebooks/[notebookId]/study/StudyDeck.tsx +++ b/app/my-notebooks/[notebookId]/study/StudyDeck.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useOptimistic, useRef, useState } from "react"; +import { useEffect, useMemo, useOptimistic, useRef, useState } from "react"; import Link from "next/link"; import { incrementViewCount, toggleStar } from "../../actions"; @@ -19,6 +19,29 @@ type Card = { viewCount: number; }; +// 暗記学習で実際に1枚のカード(多角柱)として出題する単位。 +// 見出し語1件(1つのCard)が複数の意味・発音などを持つ場合でも、 +// 従来は1枚のカードの1面に複数値を「/」区切りでまとめて表示していたが、 +// それだと見にくいため「1組につき1枚のカード」に展開する。 +// card.data.rowsは既に「同じ添字の値同士が列をまたいで対応する組」の配列 +// (例: 訳と発音が同じ添字なら対応する意味・発音)になっているため、 +// そのまま1組=1枚のStudyUnitとして使う +type StudyUnit = { + card: Card; + // 列名 → この組での値(見出し語列を除く。値が無い列はキー自体が存在しない) + values: Record; +}; + +function expandToUnits(card: Card, bodyColumns: string[]): StudyUnit[] { + return card.data.rows.map((row) => { + const values: Record = {}; + for (const column of bodyColumns) { + if (row[column] !== undefined) values[column] = row[column]; + } + return { card, values }; + }); +} + // Fisher-Yatesシャッフル: [0, 1, ..., length-1] という「カードの元の並び順(インデックス)」の // 配列を作り、末尾から先頭に向かって「自分より前(自分を含む)」のランダムな位置と1つずつ // 交換していくことで、すべての並び替えパターンが等確率で出現するようにする。 @@ -42,62 +65,72 @@ export default function StudyDeck({ columns: string[]; cards: Card[]; }) { - // order: 「何番目に何のカード(cards配列のインデックス)を出すか」を表す並び替えテーブル。 - // 初期状態は [0, 1, 2, ...] で、cardsをそのままの順番で出す - const [order, setOrder] = useState(() => cards.map((_, i) => i)); + // 見出し語1件(1つのCard)を、意味などの行数に応じて複数の出題単位(StudyUnit)に展開する + const units = useMemo(() => { + const bodyColumns = columns.slice(1); + return cards.flatMap((card) => expandToUnits(card, bodyColumns)); + }, [cards, columns]); + + // order: 「何番目に何のカード(unitsのインデックス)を出すか」を表す並び替えテーブル。 + // 初期状態は [0, 1, 2, ...] で、unitsをそのままの順番で出す + const [order, setOrder] = useState(() => units.map((_, i) => i)); // index: order の何番目(=現在何枚目)を表示しているか const [index, setIndex] = useState(0); // flipped: 今のカードが表(見出し語)か裏(意味)のどちらを向いているか const [flipped, setFlipped] = useState(false); - const [frontColumn, setFrontColumn] = useState(columns[0]); + const [frontColumn, setFrontColumn] = useState(columns[0] ?? ""); - // 現在表示すべきカードは、order[index](実際のcardsインデックス)から引く - const current = cards[order[index]]; - const primarySense = current?.data.senses[0] || {}; + // 現在表示すべき出題単位は、order[index](実際のunitsインデックス)から引く + const current = units[order[index]]; + const currentCard = current.card; + + // 指定した列の値を取得する。見出し語列はdata.head(1件)、 + // それ以外はcurrent.values[列名](この行に割り当てられた1件、無ければ無し) + function valuesFor(column: string, isHead: boolean): string[] { + if (isHead) return currentCard.data.head ? [currentCard.data.head] : []; + const value = current.values[column]; + return value !== undefined ? [value] : []; + } // 今のカードでデータが存在する列一覧 - const rawActiveColumns = columns.filter((col, idx) => { - if (idx === 0) return Boolean(current?.data.head); - return Boolean(primarySense[col]); - }); + const rawActiveColumns = columns.filter((col, idx) => valuesFor(col, idx === 0).length > 0); // 選択された frontColumn が先頭(1面目)に来るように並び替える const activeColumns = rawActiveColumns.includes(frontColumn) ? [frontColumn, ...rawActiveColumns.filter((col) => col !== frontColumn)] : rawActiveColumns; - const senseColumns = activeColumns.slice(1); + const bodyColumns = activeColumns.slice(1); // 表示しようとしているカードの要素数が3個以上の時だけ3Dモードにする判定 const is3DMode = activeColumns.length >= 3; - // 3D多角柱のそれぞれの面に入れるコンテンツの準備 - + // 3D多角柱のそれぞれの面に入れるコンテンツの準備。 + // 展開済みのStudyUnitでは各列は必ず1件の値に揃っているため、そのまま表示する const faces = activeColumns.map((colName) => { const isHead = colName === columns[0]; - const value = isHead ? current?.data.head : primarySense[colName]; - - return ; + const values = valuesFor(colName, isHead); + return ; }); // toggleStarの結果(サーバーの往復)を待たず、クリックした瞬間に★・回数・色を切り替えるためのUI // idも保持し、往復の間にカードを送り進めても別カードへ誤って適用されないようにする const [optimisticStar, setOptimisticStar] = useOptimistic( - { id: current.id, starred: current.starred, starCount: current.starCount }, + { id: currentCard.id, starred: currentCard.starred, starCount: currentCard.starCount }, (_state, next: { id: string; starred: boolean; starCount: number }) => next, ); const displayedStar = - optimisticStar.id === current.id + optimisticStar.id === currentCard.id ? optimisticStar - : { id: current.id, starred: current.starred, starCount: current.starCount }; + : { id: currentCard.id, starred: currentCard.starred, starCount: currentCard.starCount }; async function handleToggleStar() { setOptimisticStar( - current.starred - ? { id: current.id, starred: false, starCount: current.starCount } - : { id: current.id, starred: true, starCount: current.starCount + 1 }, + currentCard.starred + ? { id: currentCard.id, starred: false, starCount: currentCard.starCount } + : { id: currentCard.id, starred: true, starCount: currentCard.starCount + 1 }, ); - await toggleStar(current.id, notebookId); + await toggleStar(currentCard.id, notebookId); } // ★を付けた回数(displayedStar.starCount)に応じた色。0回(未使用)ならundefinedになりニュートラル表示にする @@ -106,13 +139,14 @@ export default function StudyDeck({ // カードが切り替わる(=暗記モードでこの単語が表示される)たびに、表示回数を1増やす // countedIdRefで直前に数えたカードIDを覚えておき、同じidに対して二重に数えないようにする - // (開発時のStrictModeによるeffect二重発火対策も兼ねる) + // (開発時のStrictModeによるeffect二重発火対策も兼ねる。同じ見出し語の別の意味へ + // 移動しただけ=idが変わらない場合も、ここで重複カウントを防いでいる) const countedIdRef = useRef(null); useEffect(() => { - if (countedIdRef.current === current.id) return; - countedIdRef.current = current.id; - incrementViewCount(current.id, notebookId).catch(() => {}); - }, [current.id, notebookId]); + if (countedIdRef.current === currentCard.id) return; + countedIdRef.current = currentCard.id; + incrementViewCount(currentCard.id, notebookId).catch(() => {}); + }, [currentCard.id, notebookId]); // 次のカードへ。indexが末尾を超えないようMath.minでクランプし、 // カードが切り替わったら必ず表向きに戻す @@ -129,7 +163,7 @@ export default function StudyDeck({ // 出題順を丸ごとシャッフルし直し、1枚目(index=0)・表向きの状態からやり直す function shuffle() { - setOrder(shuffleOrder(cards.length)); + setOrder(shuffleOrder(units.length)); setIndex(0); setFlipped(false); } @@ -168,7 +202,9 @@ export default function StudyDeck({ /* 3つの要素があるときは三角柱 */
- {current.data.head || "—"} + {currentCard.data.head || "—"} - ) : senseColumns.length > 0 && current.data.senses.length > 0 ? ( - // 裏面: 意味が1件以上あれば、多義語すべてを「意味1」「意味2」…として順番に表示する + ) : bodyColumns.length > 0 ? ( + // 裏面: 列ごとに、この行に割り当てられた1件の値を表示する
- {current.data.senses.map((sense, senseIndex) => ( -
- {current.data.senses.length > 1 && ( -

- 意味 {senseIndex + 1} + {bodyColumns.map((column) => { + const value = current.values[column]; + if (value === undefined) return null; + return ( +

+

+ {column}

- )} - {senseColumns.map((column) => ( -
-

- {column} -

-

- {sense[column] || "—"} -

-
- ))} -
- ))} +

{value}

+
+ ); + })}
) : ( - // 意味の列自体が無い、またはこのカードに意味が1件も登録されていない場合のフォールバック表示 + // 意味の列自体が無い、またはこのカードに項目が1件も登録されていない場合のフォールバック表示

他に項目がありません

)} @@ -256,7 +285,7 @@ export default function StudyDeck({ - {current.viewCount} + {currentCard.viewCount}
diff --git a/app/my-notebooks/[notebookId]/study/page.tsx b/app/my-notebooks/[notebookId]/study/page.tsx index 7ea7134..ea97905 100644 --- a/app/my-notebooks/[notebookId]/study/page.tsx +++ b/app/my-notebooks/[notebookId]/study/page.tsx @@ -3,7 +3,8 @@ import { notFound, redirect } from "next/navigation"; import { prisma } from "@/lib/prisma"; import { requireUser } from "@/lib/session"; import StudyDeck from "./StudyDeck"; -import type { CardData } from "@/lib/card-data"; +import { normalizeCardData } from "@/lib/card-data"; +import { normalizeColumns } from "@/lib/notebook-columns"; // DBの最新状態を常に表示するため、ビルド時の静的プリレンダリングを避けてリクエスト時にレンダリングする export const dynamic = "force-dynamic"; @@ -29,10 +30,10 @@ export default async function StudyPage(props: PageProps<"/my-notebooks/[noteboo // シャッフルやフリップ等のインタラクションはすべてクライアント側のStudyDeckが担当するため、 // ここではサーバーでDBから取得したデータをそのまま整形して渡すだけ - const columns = notebook.columns as string[]; + const columns = normalizeColumns(notebook.columns); const cards = notebook.cards.map((card) => ({ id: card.id, - data: card.data as CardData, + data: normalizeCardData(card.data), starred: card.starred, starCount: card.starCount, viewCount: card.viewCount, diff --git a/app/my-notebooks/actions.ts b/app/my-notebooks/actions.ts index eac8480..732b028 100644 --- a/app/my-notebooks/actions.ts +++ b/app/my-notebooks/actions.ts @@ -6,61 +6,54 @@ import { redirect } from "next/navigation"; import { prisma } from "@/lib/prisma"; import { requireUser } from "@/lib/session"; import { ExcelParseError, parseExcelWorkbook } from "@/lib/excel"; -import type { CardData } from "@/lib/card-data"; +import { normalizeCardData, type CardData } from "@/lib/card-data"; +import { normalizeColumns } from "@/lib/notebook-columns"; export type FormState = { error?: string }; function readCardData(columns: string[], formData: FormData): CardData { const head = String(formData.get("head") ?? "").trim(); - const senseColumns = columns.slice(1); - - // senseKeys:見つかった意味ブロックの識別キーを、出現順に並べて格納する配列(最終的な戻り値) - // seenKeys:「もうこのキーは見た」を判定するためのSet - const senseKeys: string[] = []; - const seenKeys = new Set(); - // formData に含まれる全ての入力欄の名前を1つずつ見ていく - for (const key of formData.keys()) { - const match = /^sense:([^:]+):/.exec(key); - if (match && !seenKeys.has(match[1])) { - seenKeys.add(match[1]); - senseKeys.push(match[1]); + const bodyColumns = columns.slice(1); + + // `row:0:列名`, `row:1:列名`, ... という連番の入力欄を、存在する分だけ読み取る + // (CardFieldsForm側は必ず0番から連番でレンダリングする)。同じ連番内の列同士は + // 同じ組(Excelの1行に相当)として対応付けられる。空文字の値は保存しない + const rows: Record[] = []; + let i = 0; + while (bodyColumns.some((column) => formData.has(`row:${i}:${column}`))) { + const row: Record = {}; + for (const column of bodyColumns) { + const value = String(formData.get(`row:${i}:${column}`) ?? "").trim(); + if (value !== "") row[column] = value; } + rows.push(row); + i += 1; } - // ステップ2〜3: キーごとに列名分の値を集めて1つの意味オブジェクトにし、 - // 全列が空文字だったものだけをフィルタで除外する - const senses = senseKeys - .map((senseKey) => { - const sense: Record = {}; - for (const column of senseColumns) { - sense[column] = String(formData.get(`sense:${senseKey}:${column}`) ?? "").trim(); - } - return sense; - }) - .filter((sense) => Object.values(sense).some((value) => value !== "")); - - return { head, senses }; + return { head, rows: rows.length > 0 ? rows : [{}] }; } -// Excelファイルから新しい単語帳を作成する -export async function importNotebookFromExcel( +// 既存の単語帳へ、Excelファイルから単語をまとめて追加する。 +// Excel側の列名(見出し語列を除く)を既存の単語帳の列と名前で突き合わせ、 +// 一致する列名があればそこに追加し、無ければ単語帳の末尾に新しい列として追加する +export async function importCardsFromExcel( + notebookId: string, _prevState: FormState, formData: FormData, ): Promise { const user = await requireUser(); - const title = String(formData.get("title") ?? "").trim(); const file = formData.get("file"); - - // タイトルが欠けている場合をはじく - if (!title) { - return { error: "単語帳のタイトルを入力してください。" }; - } - // ファイルサイズが0の場合をはじく if (!(file instanceof File) || file.size === 0) { return { error: "Excelファイル(.xlsx)を選択してください。" }; } + const notebook = await prisma.notebook.findUniqueOrThrow({ + where: { id: notebookId, userId: user.id }, + select: { columns: true }, + }); + const existingColumns = normalizeColumns(notebook.columns); + // Excelを解析(1行目=列名、2行目以降=単語データに変換)。 // 解析に失敗した場合はエラーメッセージをフォームに戻す(それ以外の例外は再送出) let parsed; @@ -73,24 +66,55 @@ export async function importNotebookFromExcel( throw error; } - // Notebook本体とCard群を1回のPrisma呼び出しでまとめて作成する(ネストwrite)。 - // position には行の並び順(Excelの出現順)をそのままインデックスとして採番する + // 見出し語列(1列目)は名前が違っても無視してよい(Card.dataではheadという固定フィールドで + // 扱われ、列名に依存しないため)。2列目以降だけを既存の列名リストに突き合わせる + const excelBodyColumns = parsed.columns.slice(1); + const newColumns = [...existingColumns]; + for (const name of excelBodyColumns) { + if (!newColumns.includes(name)) newColumns.push(name); + } + + // 追加するカードは既存カードの最大positionの続きから採番する + const last = await prisma.card.aggregate({ + where: { notebookId }, + _max: { position: true }, + }); + let nextPosition = (last._max.position ?? -1) + 1; + + await prisma.$transaction([ + prisma.notebook.update({ where: { id: notebookId }, data: { columns: newColumns } }), + ...parsed.rows.map((data) => + prisma.card.create({ data: { notebookId, data, position: nextPosition++ } }), + ), + ]); + + revalidatePath(`/my-notebooks/${notebookId}`); + return {}; +} + +// Excelを介さず、アプリ上でゼロから単語帳を作成する。 +// 「見出し語」「意味」の2列・単語0件で作成し、以降は列の追加/変更(ColumnsEditor)と +// 単語の追加(CreateCardForm)をこの単語帳のページ上でそのまま行える +export async function createBlankNotebook( + _prevState: FormState, + formData: FormData, +): Promise { + const user = await requireUser(); + + const title = String(formData.get("title") ?? "").trim(); + if (!title) { + return { error: "単語帳のタイトルを入力してください。" }; + } + const notebook = await prisma.notebook.create({ data: { title, userId: user.id, - columns: parsed.columns, - cards: { - // data:その行の見出し語・意味などの情報 - // position:Excel内の行の並び順として採番 - create: parsed.rows.map((data, index) => ({ data, position: index })), - }, + columns: ["見出し語", "意味"], }, }); - // 単語帳一覧ページのキャッシュを無効化し、新しく作った単語帳を一覧に反映 revalidatePath("/my-notebooks"); - // 作成された単語帳の詳細ページへ自動的に遷移 redirect(`/my-notebooks/${notebook.id}`); } @@ -115,7 +139,7 @@ export async function createCard( where: { id: notebookId, userId: user.id }, select: { columns: true }, }); - const columns = notebook.columns as string[]; + const columns = normalizeColumns(notebook.columns); const data = readCardData(columns, formData); if (!data.head) { @@ -150,7 +174,7 @@ export async function updateCard( where: { id: notebookId, userId: user.id }, select: { columns: true }, }); - const columns = notebook.columns as string[]; + const columns = normalizeColumns(notebook.columns); const data = readCardData(columns, formData); if (!data.head) { @@ -166,6 +190,143 @@ export async function updateCard( return {}; } +// 全カードのデータを取得し、変換関数を適用してまとめて保存するヘルパー。 +// 列の追加・変更に伴うデータ移行はすべてこの形で行う +async function migrateAllCards( + notebookId: string, + newColumns: string[], + transform: (data: CardData) => CardData, +) { + const cards = await prisma.card.findMany({ + where: { notebookId }, + select: { id: true, data: true }, + }); + + await prisma.$transaction([ + prisma.notebook.update({ where: { id: notebookId }, data: { columns: newColumns } }), + ...cards.map((card) => { + const data = transform(normalizeCardData(card.data)); + return prisma.card.update({ where: { id: card.id }, data: { data } }); + }), + ]); +} + +// 単語帳の末尾に列(意味・発音などの列)を1つ追加する。 +// 新設列は既存カードのどのデータにも存在しないだけなので、移行は不要 +// (CardFieldsForm側でキー無し=空欄として表示される) +export async function addNotebookColumn( + notebookId: string, + _prevState: FormState, + formData: FormData, +): Promise { + const user = await requireUser(); + const name = String(formData.get("name") ?? "").trim(); + + if (!name) { + return { error: "列名を入力してください。" }; + } + + const notebook = await prisma.notebook.findUniqueOrThrow({ + where: { id: notebookId, userId: user.id }, + select: { columns: true }, + }); + const columns = normalizeColumns(notebook.columns); + + if (columns.includes(name)) { + return { error: "同じ名前の列がすでにあります。" }; + } + + await prisma.notebook.update({ + where: { id: notebookId }, + data: { columns: [...columns, name] }, + }); + + revalidatePath(`/my-notebooks/${notebookId}`); + return {}; +} + +// 単語帳の列名を変更する。1列目(見出し語)はラベルの変更のみで済むが、 +// 2列目以降は既存カードのcellsオブジェクトのキー名も +// 古い列名→新しい列名へ一括で付け替えないと、値が宙に浮いて表示されなくなる +export async function renameNotebookColumn( + notebookId: string, + columnIndex: number, + _prevState: FormState, + formData: FormData, +): Promise { + const user = await requireUser(); + const newName = String(formData.get("name") ?? "").trim(); + + if (!newName) { + return { error: "列名を入力してください。" }; + } + + const notebook = await prisma.notebook.findUniqueOrThrow({ + where: { id: notebookId, userId: user.id }, + select: { columns: true }, + }); + const columns = normalizeColumns(notebook.columns); + const oldName = columns[columnIndex]; + + if (oldName === undefined) { + return { error: "指定された列が見つかりません。" }; + } + if (newName === oldName) { + return {}; + } + if (columns.includes(newName)) { + return { error: "同じ名前の列がすでにあります。" }; + } + + const newColumns = columns.map((column, index) => (index === columnIndex ? newName : column)); + + if (columnIndex === 0) { + // 見出し語列はラベルの変更のみ(card.data.headはキーではなく固定フィールドのため移行不要) + await prisma.notebook.update({ where: { id: notebookId }, data: { columns: newColumns } }); + } else { + await migrateAllCards(notebookId, newColumns, (data) => { + const rows = data.rows.map((row) => { + if (!(oldName in row)) return row; + const { [oldName]: value, ...rest } = row; + return { ...rest, [newName]: value }; + }); + return { ...data, rows }; + }); + } + + revalidatePath(`/my-notebooks/${notebookId}`); + return {}; +} + +// 単語帳の列(見出し語列以外)を削除する。見出し語列は構造上削除不可。 +// 既存カードのcellsから該当キーも取り除く +export async function deleteNotebookColumn(notebookId: string, columnIndex: number) { + const user = await requireUser(); + + const notebook = await prisma.notebook.findUniqueOrThrow({ + where: { id: notebookId, userId: user.id }, + select: { columns: true }, + }); + const columns = normalizeColumns(notebook.columns); + const name = columns[columnIndex]; + + // 見出し語列(0番目)は削除不可。存在しない列指定は何もしない + if (columnIndex <= 0 || name === undefined) { + return; + } + + const newColumns = columns.filter((_, index) => index !== columnIndex); + + await migrateAllCards(notebookId, newColumns, (data) => { + const rows = data.rows.map((row) => + Object.fromEntries(Object.entries(row).filter(([key]) => key !== name)), + ); + return { ...data, rows }; + }); + + revalidatePath(`/my-notebooks/${notebookId}`); +} + // 単語帳内の単語を1件、削除する export async function deleteCard(cardId: string, notebookId: string) { const user = await requireUser(); diff --git a/app/my-notebooks/page.tsx b/app/my-notebooks/page.tsx index bf4f302..82a2cca 100644 --- a/app/my-notebooks/page.tsx +++ b/app/my-notebooks/page.tsx @@ -1,7 +1,7 @@ import Link from "next/link"; import { prisma } from "@/lib/prisma"; -import ImportForm, { ImportTemplate } from "@/components/my-notebooks/ImportForm"; +import CreateBlankNotebookForm from "@/components/my-notebooks/CreateBlankNotebookForm"; import { requireUser } from "@/lib/session"; import DeleteNotebookButton from "@/components/my-notebooks/DeleteNotebookButton"; import StarColorSettings from "@/components/StarColorSettings"; @@ -27,29 +27,22 @@ export default async function MyNotebooksPage() { My単語帳

- Excelファイルから自分だけの単語帳を作成できます。 + 単語帳を新規作成し、Excelファイルから単語をまとめて追加できます。

- {/*以下の2つのセクションをパソコンでは横並びで、スマホではたて並びで表示する*/} -
-
-

- 自分のExcelから新規作成 -

- -
- -
-

- テンプレートから新規作成 -

- -
-
+
+

+ 新規作成 +

+ +

+ 作成後の単語帳ページの「Excelから単語を追加」から、テンプレートのダウンロードとExcelファイルの取り込みができます。 +

+

@@ -58,7 +51,7 @@ export default async function MyNotebooksPage() { {/* 単語帳が1件も無ければ空状態のメッセージ、あれば一覧をレンダリング */} {notebooks.length === 0 ? (

- まだ単語帳がありません。上のフォームからExcelファイルを取り込んでみましょう。 + まだ単語帳がありません。上のフォームから作成してみましょう。

) : (
    diff --git a/app/share/[notebookId]/page.tsx b/app/share/[notebookId]/page.tsx index 1e744cd..c5cc9f5 100644 --- a/app/share/[notebookId]/page.tsx +++ b/app/share/[notebookId]/page.tsx @@ -1,13 +1,27 @@ -import { Fragment } from "react"; import Link from "next/link"; import { notFound } from "next/navigation"; import { prisma } from "@/lib/prisma"; -import type { CardData } from "@/lib/card-data"; +import { normalizeCardData } from "@/lib/card-data"; +import { normalizeColumns } from "@/lib/notebook-columns"; // 公開中の単語帳をログイン無しで閲覧できるページ。公開状態はDBの最新値を都度見る必要があるため静的化しない export const dynamic = "force-dynamic"; +// Excelの結合セルのように、ある列で連続する組(行)の値が同じであればまとめてrowSpanで +// 1つのセルにする。先頭行以外は0を返し、呼び出し側でそのセルの描画をスキップする。 +// 空文字同士は結合しない(未入力のセルが1つの大きな空欄に見えて紛らわしいのを避けるため) +function computeRowSpans(values: string[]): number[] { + const spans = values.map(() => 1); + for (let i = values.length - 1; i > 0; i -= 1) { + if (values[i] !== "" && values[i] === values[i - 1]) { + spans[i - 1] += spans[i]; + spans[i] = 0; + } + } + return spans; +} + export default async function SharedNotebookPage(props: PageProps<"/share/[notebookId]">) { const { notebookId } = await props.params; @@ -21,9 +35,9 @@ export default async function SharedNotebookPage(props: PageProps<"/share/[noteb notFound(); } - const columns = notebook.columns as string[]; - const senseColumns = columns.slice(1); - const cards = notebook.cards.map((card) => ({ id: card.id, data: card.data as CardData })); + const columns = normalizeColumns(notebook.columns); + const bodyColumns = columns.slice(1); + const cards = notebook.cards.map((card) => ({ id: card.id, data: normalizeCardData(card.data) })); return (
    @@ -72,39 +86,42 @@ export default async function SharedNotebookPage(props: PageProps<"/share/[noteb

) : ( - cards.map((card) => { - const senses = card.data.senses.length > 0 ? card.data.senses : [{}]; - return ( - - {senses.map((sense, index) => ( - - {index === 0 && ( - - )} - {senseColumns.map((column) => ( - - ))} - - ))} - + cards.flatMap((card) => { + // Excelの結合セルのように、見出し語セルは組(rows)の件数ぶんrowSpanでまたがらせ、 + // 本文の各列は連続して同じ値が続く区間だけをrowSpanでまとめる + const rowCount = card.data.rows.length; + const bodySpansByColumn = bodyColumns.map((column) => + computeRowSpans(card.data.rows.map((row) => row[column] ?? "")), ); + + return card.data.rows.map((row, rowIndex) => ( + + {rowIndex === 0 && ( + + )} + {bodyColumns.map((column, columnIndex) => { + const span = bodySpansByColumn[columnIndex][rowIndex]; + if (span === 0) return null; + return ( + + ); + })} + + )); }) )} diff --git a/app/share/[notebookId]/study/PublicStudyDeck.tsx b/app/share/[notebookId]/study/PublicStudyDeck.tsx index 8ed2b2f..5f5c249 100644 --- a/app/share/[notebookId]/study/PublicStudyDeck.tsx +++ b/app/share/[notebookId]/study/PublicStudyDeck.tsx @@ -1,13 +1,36 @@ "use client"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import Link from "next/link"; import type { CardData } from "@/lib/card-data"; -import { TriangularCard } from "@/components/my-notebooks/ThreeElement"; +import MultiElementCard from "@/components/my-notebooks/MultiElement"; type Card = { id: string; data: CardData }; +// 暗記学習で実際に1枚のカード(多角柱)として出題する単位。 +// 見出し語1件(1つのCard)が複数の意味・発音などを持つ場合でも、 +// 従来は1枚のカードの1面に複数値を「/」区切りでまとめて表示していたが、 +// それだと見にくいため「1組につき1枚のカード」に展開する。 +// card.data.rowsは既に「同じ添字の値同士が列をまたいで対応する組」の配列 +// (例: 訳と発音が同じ添字なら対応する意味・発音)になっているため、 +// そのまま1組=1枚のStudyUnitとして使う +type StudyUnit = { + card: Card; + // 列名 → この組での値(見出し語列を除く。値が無い列はキー自体が存在しない) + values: Record; +}; + +function expandToUnits(card: Card, bodyColumns: string[]): StudyUnit[] { + return card.data.rows.map((row) => { + const values: Record = {}; + for (const column of bodyColumns) { + if (row[column] !== undefined) values[column] = row[column]; + } + return { card, values }; + }); +} + // StudyDeck(my-notebooks側)のFisher-Yatesシャッフルと同じロジック。 // 閲覧専用ページでは★・表示回数の記録は行わないため、それらに関わる部分だけを省いている function shuffleOrder(length: number): number[] { @@ -28,42 +51,57 @@ export default function PublicStudyDeck({ columns: string[]; cards: Card[]; }) { - const [order, setOrder] = useState(() => cards.map((_, i) => i)); + // 見出し語1件(1つのCard)を、意味などの行数に応じて複数の出題単位(StudyUnit)に展開する + const units = useMemo(() => { + const bodyColumns = columns.slice(1); + return cards.flatMap((card) => expandToUnits(card, bodyColumns)); + }, [cards, columns]); + + const [order, setOrder] = useState(() => units.map((_, i) => i)); const [index, setIndex] = useState(0); const [flipped, setFlipped] = useState(false); - const current = cards[order[index]]; - const frontColumn = columns[0]; - const senseColumns = columns.slice(1); - const is3DMode = columns.length === 3; - - const primarySense = current?.data.senses[0] || {}; - const faces: [React.ReactNode, React.ReactNode, React.ReactNode] = [ -
- - {columns[0]} - - - {current?.data.head || "—"} - -
, -
- - {columns[1]} - - - {primarySense[columns[1]] || "—"} - -
, -
- - {columns[2]} - - - {primarySense[columns[2]] || "—"} - -
, - ]; + const current = units[order[index]]; + const currentCard = current.card; + const frontColumn = columns[0] ?? ""; + + // 指定した列の値を取得する。見出し語列はdata.head(1件)、 + // それ以外はcurrent.values[列名](この行に割り当てられた1件、無ければ無し) + function valuesFor(column: string, isHead: boolean): string[] { + if (isHead) return currentCard.data.head ? [currentCard.data.head] : []; + const value = current.values[column]; + return value !== undefined ? [value] : []; + } + + // 今のカードでデータが存在する列一覧(見出し語は先頭で固定) + const activeColumns = columns.filter((col, idx) => valuesFor(col, idx === 0).length > 0); + const bodyColumns = activeColumns.slice(1); + + // 表示しようとしているカードの要素数が3個以上の時だけ3Dモードにする判定 + const is3DMode = activeColumns.length >= 3; + + // 3D多角柱のそれぞれの面に入れるコンテンツの準備。 + // 展開済みのStudyUnitでは各列は必ず1件の値に揃っているため、そのまま表示する + const faces = activeColumns.map((colName) => { + const isHead = colName === columns[0]; + const values = valuesFor(colName, isHead); + return ( +
+ + {colName} + + + {values[0] ?? "—"} + +
+ ); + }); function goNext() { setFlipped(false); @@ -76,7 +114,7 @@ export default function PublicStudyDeck({ } function shuffle() { - setOrder(shuffleOrder(cards.length)); + setOrder(shuffleOrder(units.length)); setIndex(0); setFlipped(false); } @@ -90,15 +128,17 @@ export default function PublicStudyDeck({
{is3DMode ? (
- - クリックして次の面へ回転 + クリックして次の面へ回転({activeColumns.length}角柱)
) : ( @@ -113,30 +153,23 @@ export default function PublicStudyDeck({ {frontColumn} - {current.data.head || "—"} + {currentCard.data.head || "—"} - ) : senseColumns.length > 0 && current.data.senses.length > 0 ? ( + ) : bodyColumns.length > 0 ? (
- {current.data.senses.map((sense, senseIndex) => ( -
- {current.data.senses.length > 1 && ( -

- 意味 {senseIndex + 1} + {bodyColumns.map((column) => { + const value = current.values[column]; + if (value === undefined) return null; + return ( +

+

+ {column}

- )} - {senseColumns.map((column) => ( -
-

- {column} -

-

- {sense[column] || "—"} -

-
- ))} -
- ))} +

{value}

+
+ ); + })}
) : (

他に項目がありません

diff --git a/app/share/[notebookId]/study/page.tsx b/app/share/[notebookId]/study/page.tsx index e83b1d3..df261d9 100644 --- a/app/share/[notebookId]/study/page.tsx +++ b/app/share/[notebookId]/study/page.tsx @@ -2,7 +2,8 @@ import { notFound, redirect } from "next/navigation"; import { prisma } from "@/lib/prisma"; import PublicStudyDeck from "./PublicStudyDeck"; -import type { CardData } from "@/lib/card-data"; +import { normalizeCardData } from "@/lib/card-data"; +import { normalizeColumns } from "@/lib/notebook-columns"; export const dynamic = "force-dynamic"; @@ -21,8 +22,8 @@ export default async function SharedStudyPage(props: PageProps<"/share/[notebook redirect(`/share/${notebook.id}`); } - const columns = notebook.columns as string[]; - const cards = notebook.cards.map((card) => ({ id: card.id, data: card.data as CardData })); + const columns = normalizeColumns(notebook.columns); + const cards = notebook.cards.map((card) => ({ id: card.id, data: normalizeCardData(card.data) })); return (
diff --git a/components/my-notebooks/CardFieldsForm.tsx b/components/my-notebooks/CardFieldsForm.tsx index 33c9142..8d76d36 100644 --- a/components/my-notebooks/CardFieldsForm.tsx +++ b/components/my-notebooks/CardFieldsForm.tsx @@ -1,39 +1,184 @@ "use client"; -import { useRef, useState } from "react"; +import { useLayoutEffect, useRef, useState } from "react"; const inputClassName = "rounded border border-black/[.1] bg-transparent px-2 py-1 text-sm outline-none focus:border-black/[.3] dark:border-white/[.15] dark:focus:border-white/[.4]"; +// 結合セル用:枠線は中のinputではなく、結合行数ぶんの高さに広げるラッパー側に持たせる +// (ネイティブのinput要素自体をJSで動的に伸ばすと、Chromiumで再描画がずれることがあるため、 +// 見た目の枠はプレーンなdivに持たせ、inputは自然な高さのまま中に収める) +const mergedWrapperClassName = + "flex flex-col gap-1 rounded border border-black/[.1] p-1.5 dark:border-white/[.15]"; +const mergedInputClassName = "bg-transparent px-1 py-0.5 text-sm outline-none"; -// 単語カード1件分の入力欄。見出し語(1列目)は単一、 -// 意味(2列目以降)は多義語に対応するため複数持てるようにする。 -// name="head" / name="sense:キー:列名" という形式でSubmit時に読み取られる +type RowState = { + key: number; + // 列名 → この行が「グループの先頭」のときに使う入力値 + values: Record; + // 列名 → true なら、このセルは1つ上の行と結合されている(Excelの結合セルと同じ)。 + // 結合中のセルは自分の入力欄を持たず、グループ先頭の値をそのまま引き継いで送信する + mergedUp: Record; +}; + +type Group = { startIndex: number; length: number }; + +function emptyMerge(bodyColumns: string[]): Record { + return Object.fromEntries(bodyColumns.map((column) => [column, false])); +} + +// 初期表示時、既存データの時点で列の値が隣接して一致していれば結合済みとして表示する +// (一覧表示のセル結合と同じ基準に揃えることで、編集を開いても見た目が変わらないようにする) +function buildInitialRows( + defaultRows: Record[], + bodyColumns: string[], +): RowState[] { + const source = defaultRows.length > 0 ? defaultRows : [{}]; + return source.map((values, index) => { + const mergedUp: Record = {}; + for (const column of bodyColumns) { + const prev = source[index - 1]?.[column]; + const curr = values[column]; + mergedUp[column] = index > 0 && !!curr && curr === prev; + } + return { key: index, values, mergedUp }; + }); +} + +// 列1つぶんの「結合グループ」一覧を、先頭行の添字ごとに引けるMapとして求める +function groupStartsFor(rows: RowState[], column: string): Map { + const map = new Map(); + let i = 0; + while (i < rows.length) { + let length = 1; + while (i + length < rows.length && rows[i + length].mergedUp[column]) { + length += 1; + } + map.set(i, { startIndex: i, length }); + i += length; + } + return map; +} + +// 単語カード1件分の入力欄。 +// 見出し語(1列目)は単一。それ以外の列は、Excelの表のように「組」単位で行を増減できる +// うえ、列ごとに隣接するセルを結合できる(結合中は1つの入力欄に1回入力するだけで、 +// 結合された全ての行に同じ値が保存される)。 +// name="head" / name="row:連番:列名" という形式でSubmit時に読み取られる +// (結合されたセルは、行番号ごとに同じ値を持つ隠し入力を追加することで、 +// 読み取り側(actions.tsのreadCardData)を変更せずに対応している) export default function CardFieldsForm({ columns, defaultHead = "", - defaultSenses = [], + defaultRows = [], }: { columns: string[]; defaultHead?: string; - defaultSenses?: Record[]; + defaultRows?: Record[]; }) { - const headColumn = columns[0]; - const senseColumns = columns.slice(1); - - const nextKey = useRef(Math.max(defaultSenses.length, 1)); - const [senses, setSenses] = useState<{ key: number; defaults: Record }[]>(() => - (defaultSenses.length > 0 ? defaultSenses : [{}]).map((defaults, index) => ({ - key: index, - defaults, - })), - ); + const headColumn = columns[0] ?? ""; + const bodyColumns = columns.slice(1); + + const [rows, setRows] = useState(() => buildInitialRows(defaultRows, bodyColumns)); + // 追加される行のkey採番。初期行の件数から続ける(refはrender中に読めないため) + const nextKey = useRef(rows.length); - function addSense() { - setSenses((list) => [...list, { key: nextKey.current++, defaults: {} }]); + // 結合セル(`列名:先頭行番号` → 要素)のref。 + // rowSpanしたtd自体は「結合されていない他の列」の行の高さぶんだけ自動的に高くなるが、 + // その中の子要素にheight:100%を指定してもテーブルセルでは解決されず引き伸ばされない。 + // かといってposition:absoluteで敷き詰める方法は、横スクロール用のoverflow-x-autoな + // 祖先要素があると(overflow-xを指定するとoverflow-yも自動的にautoになる仕様のせいで) + // 縦方向にクリップされてしまう(実機で確認済み)。 + // そのため、tdの実際の高さ(clientHeight)をJSで測り、中の入力欄コンテナに直接pxで + // 指定する。DOMへ直接書き込むだけなのでReact stateは使わず、再レンダーごとに + // (行の追加・削除・結合・分割のたびに)読み直して同期する + const mergedTdRefs = useRef>(new Map()); + const mergedWrapperRefs = useRef>(new Map()); + + useLayoutEffect(() => { + mergedTdRefs.current.forEach((td, key) => { + const wrapper = mergedWrapperRefs.current.get(key); + if (!wrapper) return; + // td.clientHeight は内容+padding(rowSpanで決まる高さ)。tdのpadding(4px×2)を + // 引いた値を指定することで、結合行数ぶんの高さいっぱいに広げる + wrapper.style.height = `${Math.max(0, td.clientHeight - 8)}px`; + }); + }); + + function addRow() { + setRows((prev) => [ + ...prev, + { key: nextKey.current++, values: {}, mergedUp: emptyMerge(bodyColumns) }, + ]); + } + + function removeRow(key: number) { + setRows((prev) => { + if (prev.length <= 1) return prev; + const index = prev.findIndex((row) => row.key === key); + if (index === -1) return prev; + + const next = prev.map((row) => ({ + ...row, + values: { ...row.values }, + mergedUp: { ...row.mergedUp }, + })); + const removed = next[index]; + const follower = next[index + 1]; + if (follower) { + for (const column of bodyColumns) { + // 削除する行がグループの先頭で、直後の行がそのグループに吸収されていた場合だけ、 + // 直後の行を新しい先頭に昇格させ、表示していた値を引き継がせる + // (そうしないと、削除直後にグループの表示値が消えてしまう) + if (!removed.mergedUp[column] && follower.mergedUp[column]) { + follower.values[column] = removed.values[column] ?? ""; + follower.mergedUp[column] = false; + } + } + } + next.splice(index, 1); + return next; + }); } - function removeSense(key: number) { - setSenses((list) => (list.length > 1 ? list.filter((sense) => sense.key !== key) : list)); + function updateValue(key: number, column: string, value: string) { + setRows((prev) => + prev.map((row) => + row.key === key ? { ...row, values: { ...row.values, [column]: value } } : row, + ), + ); + } + + // 添字indexの行(column)を、1つ上のグループへ結合する。 + // 結合される行が独自の値を持っていた場合はグループ先頭の値に統一されて失われるため確認する + function mergeUp(index: number, column: string) { + setRows((prev) => { + const existing = prev[index]?.values[column] ?? ""; + if (existing !== "") { + const ok = window.confirm( + `結合すると「${existing}」は上のセルの値に統一されます。よろしいですか?`, + ); + if (!ok) return prev; + } + return prev.map((row, i) => + i === index ? { ...row, mergedUp: { ...row.mergedUp, [column]: true } } : row, + ); + }); + } + + // グループの最後の行だけを分割して独立させる。値は空欄から入力し直す + // (Excelのセル結合解除と同じく、結合前の値は先頭セルに残ったまま) + function splitLast(index: number, column: string) { + setRows((prev) => + prev.map((row, i) => + i === index + ? { + ...row, + mergedUp: { ...row.mergedUp, [column]: false }, + values: { ...row.values, [column]: "" }, + } + : row, + ), + ); } return ( @@ -43,42 +188,141 @@ export default function CardFieldsForm({
- {senseColumns.length > 0 && ( -
- {senses.map((sense, index) => ( -
- {senses.length > 1 && ( - 意味{index + 1} - )} - {senseColumns.map((column) => ( -
- - -
- ))} - -
- ))} + {bodyColumns.length > 0 && ( +
+
+
- {card.data.head} - - {sense[column] ?? ""} -
+ {card.data.head} + + {row[column] ?? ""} +
+ + + {bodyColumns.map((column) => ( + + ))} + + + + {rows.map((row, index) => ( + + {bodyColumns.map((column) => { + // 結合グループに吸収されているセル(=先頭ではない)は描画しない + if (row.mergedUp[column]) return null; + + const group = groupStartsFor(rows, column).get(index); + if (!group) return null; + const hasNext = index + group.length < rows.length; + // 結合セルの中身は「分割」ボタンが常にセル下端に来るため、結合して + // いない列(訳・操作)もalign-bottomにして下端をそろえる + const isMerged = group.length > 1; + const cellKey = `${column}:${index}`; + + return ( + + ); + })} + + + ))} + +
+ {column} + +
{ + if (el) mergedTdRefs.current.set(cellKey, el); + else mergedTdRefs.current.delete(cellKey); + } + : undefined + } + rowSpan={group.length} + className="px-1 py-1 align-bottom" + > +
{ + if (el) mergedWrapperRefs.current.set(cellKey, el); + else mergedWrapperRefs.current.delete(cellKey); + } + : undefined + } + className={isMerged ? mergedWrapperClassName : "flex flex-col gap-1"} + > + updateValue(row.key, column, e.target.value)} + className={isMerged ? mergedInputClassName : inputClassName} + /> + {/* 結合されている行にも同じ値を送信するための隠し入力 */} + {Array.from({ length: group.length - 1 }, (_, k) => index + 1 + k).map( + (absorbedIndex) => ( + + ), + )} + {/* 結合セルではinputを伸ばさず、この操作行をmt-autoで下端に固定する + (ネイティブinputをflex-1で伸ばす方式は実機で描画崩れが起きたため) */} +
+ {group.length > 1 && ( + + )} + {hasNext && ( + + )} +
+
+
+ +
+
+

+ 「⬇ + 次の行と結合」で列ごとにセルを結合すると、1回の入力が結合した行すべてに反映されます。 +

)} diff --git a/components/my-notebooks/ColumnsEditor.tsx b/components/my-notebooks/ColumnsEditor.tsx new file mode 100644 index 0000000..fb5fd66 --- /dev/null +++ b/components/my-notebooks/ColumnsEditor.tsx @@ -0,0 +1,173 @@ +"use client"; + +import { useActionState, useState } from "react"; +import { useFormStatus } from "react-dom"; + +import { + addNotebookColumn, + deleteNotebookColumn, + renameNotebookColumn, + type FormState, +} from "@/app/my-notebooks/actions"; + +const initialState: FormState = {}; + +const inputClassName = + "rounded border border-black/[.1] bg-transparent px-2 py-1 text-sm outline-none focus:border-black/[.3] dark:border-white/[.15] dark:focus:border-white/[.4]"; + +function SubmitButton({ label, pendingLabel }: { label: string; pendingLabel: string }) { + const { pending } = useFormStatus(); + return ( + + ); +} + +// 列1つぶんの行。列名の変更フォームと(見出し語列以外は)削除ボタンを持つ +function ColumnRow({ + notebookId, + index, + name, + isHeadColumn, +}: { + notebookId: string; + index: number; + name: string; + isHeadColumn: boolean; +}) { + const [state, formAction] = useActionState( + renameNotebookColumn.bind(null, notebookId, index), + initialState, + ); + + return ( +
  • +
    + + {isHeadColumn ? "見出し語" : `列${index + 1}`} + +
    + + + + {!isHeadColumn && ( +
    { + if ( + !window.confirm( + `列「${name}」を削除しますか?この列に入力済みのデータはすべて失われます。`, + ) + ) { + event.preventDefault(); + } + }} + > + +
    + )} +
    + {state?.error && ( +

    + {state.error} +

    + )} +
  • + ); +} + +// 新しい列(意味・発音など)を末尾に追加するフォーム。 +// 見出し語につき何件の値を持てるかは列単位で決めず、追加後にCardFieldsFormの +// 「+ 値を追加」でセルごとに自由に増やせる +function AddColumnForm({ notebookId }: { notebookId: string }) { + const [state, formAction] = useActionState( + addNotebookColumn.bind(null, notebookId), + initialState, + ); + // 追加成功のたびにkeyを変えてフォームを作り直し、入力欄を空に戻す(CreateCardFormと同じ手法) + const [formKey, setFormKey] = useState(0); + const [prevState, setPrevState] = useState(state); + if (state !== prevState) { + setPrevState(state); + if (!state.error) { + setFormKey((key) => key + 1); + } + } + + return ( +
    +
    + + + + {state?.error && ( +

    + {state.error} +

    + )} +
    + ); +} + +// 単語帳の列(見出し語1列+任意個の列)の追加・変更・削除を行うパネル。 +// 普段は折りたたんでおき、必要なときだけ「列を編集」で開く +export default function ColumnsEditor({ + notebookId, + columns, +}: { + notebookId: string; + columns: string[]; +}) { + const [expanded, setExpanded] = useState(false); + + if (!expanded) { + return ( + + ); + } + + return ( +
    +
    +

    列を編集

    + +
    +
      + {columns.map((name, index) => ( + + ))} +
    +
    + +
    +
    + ); +} diff --git a/components/my-notebooks/CreateBlankNotebookForm.tsx b/components/my-notebooks/CreateBlankNotebookForm.tsx new file mode 100644 index 0000000..7024ef2 --- /dev/null +++ b/components/my-notebooks/CreateBlankNotebookForm.tsx @@ -0,0 +1,67 @@ +"use client"; + +import { useActionState } from "react"; +import { useFormStatus } from "react-dom"; + +import { createBlankNotebook, type FormState } from "@/app/my-notebooks/actions"; + +const initialState: FormState = {}; + +function SubmitButton() { + const { pending } = useFormStatus(); + + return ( + + ); +} + +// Excelファイル無しで、タイトルだけを入力して単語帳を作成するフォーム。 +// 作成直後は「見出し語」「意味」の2列・単語0件の状態になり、 +// 単語帳ページ上の「列を編集」「単語を追加」でそのまま組み立てていける +export default function CreateBlankNotebookForm() { + const [state, formAction] = useActionState(createBlankNotebook, initialState); + + return ( +
    +
    + + +
    + +

    + 「見出し語」「意味」の2列・単語0件から始まります。列や単語は作成後のページで自由に追加・編集できます。 +

    + + {state?.error && ( +

    + {state.error} +

    + )} + +
    + +
    +
    + ); +} diff --git a/components/my-notebooks/ImportCardsForm.tsx b/components/my-notebooks/ImportCardsForm.tsx new file mode 100644 index 0000000..872c0dd --- /dev/null +++ b/components/my-notebooks/ImportCardsForm.tsx @@ -0,0 +1,190 @@ +"use client"; + +import { useActionState, useState } from "react"; +import { useFormStatus } from "react-dom"; + +import { importCardsFromExcel, type FormState } from "@/app/my-notebooks/actions"; + +const initialState: FormState = {}; + +const languageOptions = ["フランス語", "ドイツ語", "スペイン語", "中国語", "英語"]; + +// フォーム送信中はボタンを disabled にし、ラベルを差し替える +function SubmitButton() { + const { pending } = useFormStatus(); + + return ( + + ); +} + +// 単語帳作成の参考になるテンプレートExcelをダウンロードするパネル。 +// 「Excelから単語を追加」パネルの中に置かれ、普段は折りたたんでおく。 +// ここでダウンロードしたファイルへ記入した後は、同じパネルの取り込みフォームでそのまま追加できる +function ImportTemplate() { + const [expanded, setExpanded] = useState(false); + const [templateName, setTemplateName] = useState(""); + const [selectedLanguage, setSelectedLanguage] = useState("英語"); + + const downloadTemplate = async () => { + const lang = selectedLanguage ?? "英語"; + const url = `/my-notebooks/template?filename=${encodeURIComponent(templateName || "単語帳テンプレート")}&language=${encodeURIComponent(lang)}`; + window.location.href = url; + }; + + if (!expanded) { + return ( + + ); + } + + return ( +
    +
    +

    + テンプレートをダウンロード +

    + +
    + +
    + + setTemplateName(e.target.value)} + placeholder="例: French_1" + className="rounded-lg border border-black/[.08] bg-transparent px-3 py-2 text-sm outline-none focus:border-black/[.3] dark:border-white/[.145] dark:focus:border-white/[.4]" + /> +
    + +
    +

    言語を選択

    +
    + {languageOptions.map((language) => { + const isSelected = selectedLanguage === language; + return ( + + ); + })} +
    +
    + + +
    + ); +} + +// 単語帳ページ内で、Excelファイルから単語をまとめて追加するフォーム。 +// 普段は折りたたんでおき、必要なときだけ「Excelから単語を追加」で開く。 +// 列名が既存の単語帳の列と一致すればそこに値が追加され、一致しない列名は +// 単語帳の末尾に新しい列として自動で追加される +export default function ImportCardsForm({ notebookId }: { notebookId: string }) { + const [state, formAction] = useActionState( + importCardsFromExcel.bind(null, notebookId), + initialState, + ); + const [expanded, setExpanded] = useState(false); + // 取り込み成功のたびにkeyを変えてフォームを作り直し、ファイル選択欄を空に戻す + const [formKey, setFormKey] = useState(0); + const [prevState, setPrevState] = useState(state); + if (state !== prevState) { + setPrevState(state); + if (!state.error) { + setFormKey((key) => key + 1); + } + } + + if (!expanded) { + return ( + + ); + } + + return ( +
    +
    +

    + Excelから単語を追加 +

    + +
    + +
    + +

    + 1行目を見出し行として読み取ります。既存の列と同じ名前の列はそこに追加され、無い列名は新しい列として追加されます。 +

    + {state?.error && ( +

    + {state.error} +

    + )} +
    + +
    +
    +
    + ); +} diff --git a/components/my-notebooks/ImportForm.tsx b/components/my-notebooks/ImportForm.tsx deleted file mode 100644 index ab6ddc9..0000000 --- a/components/my-notebooks/ImportForm.tsx +++ /dev/null @@ -1,155 +0,0 @@ -"use client"; - -import { useActionState } from "react"; -import { useFormStatus } from "react-dom"; -import { useState } from "react"; - -import { importNotebookFromExcel, type FormState } from "@/app/my-notebooks/actions"; - -const initialState: FormState = {}; - -const languageOptions = ["フランス語", "ドイツ語", "スペイン語", "中国語", "英語"]; - -// フォーム送信中はボタンを disabled にし、ラベルを差し替える -function SubmitButton() { - const { pending } = useFormStatus(); - - return ( - - ); -} - -export function ImportTemplate() { - // useActionStateは、Server Actionの戻り値({ error }など)を - // 前回の実行結果として保持してくれるReactのフック - const [state, formAction] = useActionState(importNotebookFromExcel, initialState); - - const [templateName, setTemplateName] = useState(""); - const [selectedLanguage, setSelectedLanguage] = useState("英語"); - - const downloadTemplate = async () => { - const lang = selectedLanguage ?? "英語"; - const url = `/my-notebooks/template?filename=${encodeURIComponent(templateName || "単語帳テンプレート")}&language=${encodeURIComponent(lang)}`; - window.location.href = url; - }; - - return ( -
    -
    - - setTemplateName(e.target.value)} - placeholder="例: French_1" - className="rounded-lg border border-black/[.08] bg-transparent px-3 py-2 text-sm outline-none focus:border-black/[.3] dark:border-white/[.145] dark:focus:border-white/[.4]" - /> -
    - -
    -

    言語を選択

    -
    - {languageOptions.map((language) => { - const isSelected = selectedLanguage === language; - return ( - - ); - })} -
    -
    - - - - {state?.error && ( -

    - {state.error} -

    - )} -
    - ); -} - -export default function ImportForm() { - // useActionStateは、Server Actionの戻り値({ error }など)を - // 前回の実行結果として保持してくれるReactのフック - const [state, formAction] = useActionState(importNotebookFromExcel, initialState); - - return ( -
    -
    - - -
    - -
    - - -

    - 1行目を見出し行として自動で読み取ります。列の数や名前は自由です。 -

    -
    - - {state?.error && ( -

    - {state.error} -

    - )} - -
    - -
    -
    - ); -} diff --git a/components/my-notebooks/ThreeElement.tsx b/components/my-notebooks/ThreeElement.tsx deleted file mode 100644 index c98bb45..0000000 --- a/components/my-notebooks/ThreeElement.tsx +++ /dev/null @@ -1,120 +0,0 @@ -"use client"; - -import React, { useState } from "react"; - -//カードに使うデータ型の設定 -interface TriangularCardProps { - faces: [React.ReactNode, React.ReactNode, React.ReactNode]; - columnNames?: [string, string, string]; - width?: number; - height?: number; -} - -//以下三要素単語帳のカード -export const TriangularCard: React.FC = ({ - faces, - columnNames = ["面1", "面2", "面3"], - width = 340, - height = 220, -}) => { - //回転回数の記録 - const [rotationStep, setRotationStep] = useState(0); - - // 正三角形の重心から面までの距離(奥行き押し出し量) - const tz = Math.round(width / (2 * Math.sqrt(3))); - - // クリック位置(左側か右側か)に応じて回転方向を分岐 - const handleClick = (e: React.MouseEvent) => { - const rect = e.currentTarget.getBoundingClientRect(); - const clickX = e.clientX - rect.left; - const halfWidth = rect.width / 2; - //回転回数の更新 - if (clickX < halfWidth) { - setRotationStep((prev) => prev - 1); - } else { - setRotationStep((prev) => prev + 1); - } - }; - //表示角度の決定 - const targetAngle = rotationStep * -120; - - // 今見ている面 (0, 1, 2) を割り出す - const currentIndex = ((rotationStep % 3) + 3) % 3; - - // 左クリック(前へ)で行く面の名前を取得 - const prevIndex = (currentIndex + 2) % 3; - const prevLabel = columnNames[prevIndex] || "前へ"; - - // 右クリック(次へ)で行く面の名前を取得 - const nextIndex = (currentIndex + 1) % 3; - const nextLabel = columnNames[nextIndex] || "次へ"; - - return ( -
    - {/* 3D 視界領域 (perspective) */} -
    - {/* 三角柱本体 */} -
    - {faces.map((content, index) => { - const angle = index * 120; - return ( -
    - {content} -
    - ); - })} -
    - - {/* 左側ホバー時の矢印ガイド(前へ+前の要素) */} -
    - - ◀ - {prevLabel} - -
    - - {/* 右側ホバー時の矢印ガイド(次の要素+次へ) */} -
    - - {nextLabel} - ▶ - -
    -
    - - {/* 操作ガイド */} -
    - ◀ 左: 戻る - | - 右: 進む ▶ -
    -
    - ); -}; diff --git a/lib/card-data.ts b/lib/card-data.ts index 0f83bd1..6d429b8 100644 --- a/lib/card-data.ts +++ b/lib/card-data.ts @@ -1,7 +1,87 @@ // 1枚のカード(見出し語1つ)が持つデータ構造。 -// 多義語のように1つの見出し語が複数の意味を持てるよう、 -// 2列目以降(意味・例文など)は senses の配列として保持する。 +// rows: 見出し語につく「組」の並び(Excelの1行に相当)。同じ添字の値同士が +// 列をまたいで対応する(例: rows[1].訳 と rows[1].発音 は同じ組=対応する意味・発音)。 +// 見出し語1件で複数の意味を持たせたいときは rows を複数件にする export type CardData = { head: string; - senses: Record[]; + rows: Record[]; }; + +// 列ごとに独立した配列(cells)を、添字を揃えて組(rows)に変換する。 +// 添字がそのまま対応関係になるため、Excelから取り込んだ直後のように +// 各列の並び順が揃っている場合はそのまま正しく組み直される +function cellsToRows(cells: Record): Record[] { + const rowCount = Math.max(0, ...Object.values(cells).map((values) => values.length)); + if (rowCount === 0) return [{}]; + + const rows: Record[] = []; + for (let i = 0; i < rowCount; i += 1) { + const row: Record = {}; + for (const [key, values] of Object.entries(cells)) { + const value = values[i]; + if (value) row[key] = value; + } + rows.push(row); + } + return rows; +} + +function normalizeRow(raw: unknown): Record { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {}; + const row: Record = {}; + for (const [key, value] of Object.entries(raw as Record)) { + if (typeof value === "string" && value !== "") row[key] = value; + } + return row; +} + +// DBから読んだcard.dataを正規化する。 +// 過去に存在した別形式(cells: 列名→値の配列で列同士が完全に独立した形式、 +// さらに古いsingle+senses形式)で保存されたデータも rows 形式へ変換して読み込む +export function normalizeCardData(raw: unknown): CardData { + const data = (raw ?? {}) as { + head?: unknown; + rows?: unknown; + cells?: unknown; + single?: unknown; + senses?: unknown; + }; + const head = typeof data.head === "string" ? data.head : ""; + + // 最新形式: { head, rows } + if (Array.isArray(data.rows)) { + const rows = data.rows.map(normalizeRow); + return { head, rows: rows.length > 0 ? rows : [{}] }; + } + + // 旧形式(cells: 列名→値の配列、列ごとに独立)からの変換。 + // 添字で突き合わせて組に変換する(元がExcel由来なら添字は本来の対応関係と一致する) + if (data.cells && typeof data.cells === "object" && !Array.isArray(data.cells)) { + const cells: Record = {}; + for (const [key, value] of Object.entries(data.cells as Record)) { + if (Array.isArray(value)) { + cells[key] = value.filter((v): v is string => typeof v === "string" && v !== ""); + } + } + return { head, rows: cellsToRows(cells) }; + } + + // さらに旧形式(single + senses)からの変換 + const cells: Record = {}; + const single = + data.single && typeof data.single === "object" && !Array.isArray(data.single) + ? (data.single as Record) + : {}; + for (const [key, value] of Object.entries(single)) { + if (value !== "") cells[key] = [value]; + } + const senses = Array.isArray(data.senses) ? (data.senses as Record[]) : []; + for (const sense of senses) { + for (const [key, value] of Object.entries(sense)) { + if (value === "") continue; + (cells[key] ??= []).push(value); + } + } + + return { head, rows: cellsToRows(cells) }; +} diff --git a/lib/excel.ts b/lib/excel.ts index 414ddfc..639df92 100644 --- a/lib/excel.ts +++ b/lib/excel.ts @@ -92,8 +92,8 @@ export async function parseExcelWorkbook(buffer: ArrayBuffer): Promise(); @@ -114,30 +114,37 @@ export async function parseExcelWorkbook(buffer: ArrayBuffer): Promise = {}; - senseColumns.forEach((name, index) => { - sense[name] = cellValues[index + 1]; - }); const groupKey = head !== "" ? `h:${head}` : `b:${blankHeadCount++}`; let groupIndex = groupIndexByHead.get(groupKey); // 初出のグループなら新規カードを作成 if (groupIndex === undefined) { - groupIndex = rows.length; + groupIndex = cards.length; groupIndexByHead.set(groupKey, groupIndex); - rows.push({ head, senses: [] }); + cards.push({ head, rows: [] }); } - // 既出のグループなら既存カードに追記 - if (senseColumns.length > 0 && Object.values(sense).some((value) => value !== "")) { - rows[groupIndex].senses.push(sense); + // Excelの物理的な1行を、そのままこの見出し語の「組」1件として追加する。 + // 同じ行にある列同士(例: 訳・発音)は自動的にその行の添字で対応付けられる + const cardRow: Record = {}; + bodyColumns.forEach((name, index) => { + const value = cellValues[index + 1]; + if (value !== "") cardRow[name] = value; + }); + if (Object.keys(cardRow).length > 0) { + cards[groupIndex].rows.push(cardRow); } } - if (rows.length === 0) { + // 見出し語のみで本文列が1件も無かったカードには、空の組を1件持たせておく + // (CardFieldsForm・表示側は常に1件以上のrowsがある前提のため) + for (const card of cards) { + if (card.rows.length === 0) card.rows.push({}); + } + + if (cards.length === 0) { throw new ExcelParseError("2行目以降にデータが見つかりませんでした。"); } - return { columns, rows }; + return { columns, rows: cards }; } diff --git a/lib/notebook-columns.ts b/lib/notebook-columns.ts new file mode 100644 index 0000000..4e09646 --- /dev/null +++ b/lib/notebook-columns.ts @@ -0,0 +1,12 @@ +// DBに保存されているcolumnsを、列名の配列に正規化する。 +// 過去に一時的に「列ごとの設定を持つオブジェクト({name, repeatable})」の形式で +// 保存されたことがあるため、その形式が来た場合も名前だけを取り出して吸収する +export function normalizeColumns(raw: unknown): string[] { + if (!Array.isArray(raw)) return []; + return raw.map((entry) => { + if (entry && typeof entry === "object" && "name" in (entry as Record)) { + return String((entry as { name: unknown }).name); + } + return String(entry); + }); +}