From 5c1b7fec40432bd1ae5e87eefd62e7813358d503 Mon Sep 17 00:00:00 2001 From: Tatsu723 Date: Fri, 18 Sep 2026 22:43:17 +0900 Subject: [PATCH 1/4] =?UTF-8?q?=E7=B7=A8=E9=9B=86=E6=99=82=E3=81=AB?= =?UTF-8?q?=E5=88=97=E8=BF=BD=E5=8A=A0=E5=8F=AF=E8=83=BD=E3=81=AB+?= =?UTF-8?q?=E3=83=8D=E3=82=B9=E3=83=88=E6=A7=8B=E9=80=A0=E3=81=AB=E3=82=82?= =?UTF-8?q?=E5=AF=BE=E5=BF=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/my-notebooks/[notebookId]/CardRow.tsx | 209 ++++++++---------- app/my-notebooks/[notebookId]/page.tsx | 21 +- app/my-notebooks/[notebookId]/review/page.tsx | 7 +- .../[notebookId]/study/StudyDeck.tsx | 73 +++--- app/my-notebooks/[notebookId]/study/page.tsx | 7 +- app/my-notebooks/actions.ts | 176 ++++++++++++--- app/share/[notebookId]/page.tsx | 66 +++--- .../[notebookId]/study/PublicStudyDeck.tsx | 56 +++-- app/share/[notebookId]/study/page.tsx | 7 +- components/my-notebooks/CardFieldsForm.tsx | 127 ++++++----- components/my-notebooks/ColumnsEditor.tsx | 173 +++++++++++++++ lib/card-data.ts | 51 ++++- lib/excel.ts | 22 +- lib/notebook-columns.ts | 12 + 14 files changed, 688 insertions(+), 319 deletions(-) create mode 100644 components/my-notebooks/ColumnsEditor.tsx create mode 100644 lib/notebook-columns.ts diff --git a/app/my-notebooks/[notebookId]/CardRow.tsx b/app/my-notebooks/[notebookId]/CardRow.tsx index a816229..9275277 100644 --- a/app/my-notebooks/[notebookId]/CardRow.tsx +++ b/app/my-notebooks/[notebookId]/CardRow.tsx @@ -26,6 +26,20 @@ function SaveButton() { ); } +// セル1つ分の表示。値が1件なら普通のテキスト、複数件なら番号付きリストで +// セル内に積んで表示する(列同士の行は揃えない。この単語帳が持つセルはそれぞれ独立) +function CellValues({ values }: { values: string[] }) { + if (values.length === 0) return null; + if (values.length === 1) return <>{values[0]}; + return ( +
    + {values.map((value, index) => ( +
  1. {value}
  2. + ))} +
+ ); +} + 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,128 +92,98 @@ 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つの見出し語(1枚のカード)が複数の意味を持つ場合、 - // をsenses件数ぶん並べて表現する。見出し語セルと操作セル(編集/削除)は - // rowSpan={senses.length} で縦に結合し、1行目にだけレンダリングする。 - // 2行目以降は意味の列だけを持つ行になり、破線の罫線(border-dashed)で - // 「同じ見出し語グループの続き」であることを視覚的に示す + // 見出し語1件につき常に1行。各セルは値が複数あればセル内で積んで表示する + // (列ごとの件数は完全に独立しているため、行を分けて揃える必要がない) return ( - <> - {senses.map((sense, index) => ( - + + {card.data.head} + + {bodyColumns.map((column) => ( + - {index === 0 && ( - - {card.data.head} - - )} - {/* 意味側の列は、senseColumns(columnsの2列目以降)の順番通りに1セルずつ描画する */} - {senseColumns.map((column) => ( - - {sense[column] ?? ""} - - ))} - {index === 0 && ( - - {/* handleToggleStarは楽観的UIでoptimisticStarを即座に切り替えてからtoggleStarを呼ぶ。 - 表示はcard.starredではなくoptimisticStar.starredを見ることで、 - サーバーの往復を待たずに★・色が切り替わる。 - 回数の表示・修正はStarCountEditorに分離し、誤クリック時に - 手動での書き換え・0へのリセットができるようにしている */} - -
- -
- - {/* 目アイコンは暗記モード(/study, /review)でこのカードが表示された累計回数。 - ★(左)の右隣に並べ、修正対象ではないのでただの表示に留める。 - サイズは★の文字サイズ(text-sm)に合わせている */} - - - {card.viewCount} - -
- - {/* deleteCardをこのカード専用にbindし、form actionとして直接渡す。 - 確認ダイアログで「キャンセル」された場合はpreventDefaultで送信自体を止める */} -
{ - if (!window.confirm("この単語を削除しますか?")) { - event.preventDefault(); - } - }} - > - -
- - )} - + + ))} - + + {/* handleToggleStarは楽観的UIでoptimisticStarを即座に切り替えてからtoggleStarを呼ぶ。 + 表示はcard.starredではなくoptimisticStar.starredを見ることで、 + サーバーの往復を待たずに★・色が切り替わる。 + 回数の表示・修正はStarCountEditorに分離し、誤クリック時に + 手動での書き換え・0へのリセットができるようにしている */} + +
+ +
+ + {/* 目アイコンは暗記モード(/study, /review)でこのカードが表示された累計回数。 + ★(左)の右隣に並べ、修正対象ではないのでただの表示に留める。 + サイズは★の文字サイズ(text-sm)に合わせている */} + + + {card.viewCount} + +
+ + {/* deleteCardをこのカード専用にbindし、form actionとして直接渡す。 + 確認ダイアログで「キャンセル」された場合はpreventDefaultで送信自体を止める */} +
{ + if (!window.confirm("この単語を削除しますか?")) { + event.preventDefault(); + } + }} + > + +
+ + ); } - // 編集モードでは、表示モード時の複数を1つのにまとめ、 - // colSpan(見出し語1列 + 意味の列数 + 操作列1列)で全カラムぶんを1セルに潰して + // 編集モードでは、colSpan(見出し語1列 + 列数 + 操作列1列)で全カラムぶんを1セルに潰して // その中にフォームを丸ごと展開する return ( - + {/* action={formAction} に渡すことで、Server Actionの結果がuseActionStateのstateに反映される */}
- {/* defaultHead/defaultSensesで現在の値を初期表示し、そこから編集する */} - + {/* defaultHead/defaultCellsで現在の値を初期表示し、そこから編集する */} +
-
+
+ +
+ +
@@ -116,7 +123,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..21204f4 100644 --- a/app/my-notebooks/[notebookId]/study/StudyDeck.tsx +++ b/app/my-notebooks/[notebookId]/study/StudyDeck.tsx @@ -50,35 +50,39 @@ export default function StudyDeck({ // 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] || {}; + + // 指定した列の値のリストを取得する。見出し語列はdata.head(1件)、 + // それ以外はdata.cells[列名](0〜複数件、列ごとに独立) + function valuesFor(column: string, isHead: boolean): string[] { + if (isHead) return current?.data.head ? [current.data.head] : []; + return current?.data.cells[column] ?? []; + } // 今のカードでデータが存在する列一覧 - 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多角柱のそれぞれの面に入れるコンテンツの準備。 + // 面のスペースは限られるため、複数値の列は「/」でつないでコンパクトに表示する 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 @@ -195,31 +199,34 @@ export default function StudyDeck({ {current.data.head || "—"} - ) : senseColumns.length > 0 && current.data.senses.length > 0 ? ( - // 裏面: 意味が1件以上あれば、多義語すべてを「意味1」「意味2」…として順番に表示する + ) : bodyColumns.length > 0 ? ( + // 裏面: 列ごとに値を表示する。値が複数あれば番号付きリストで積んで表示する
- {current.data.senses.map((sense, senseIndex) => ( -
- {current.data.senses.length > 1 && ( -

- 意味 {senseIndex + 1} + {bodyColumns.map((column) => { + const values = current.data.cells[column] ?? []; + if (values.length === 0) return null; + return ( +

+

+ {column}

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

- {column} -

-

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

-
- ))} -
- ))} + {values.length === 1 ? ( +

{values[0]}

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

他に項目がありません

)} 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..970a1c0 100644 --- a/app/my-notebooks/actions.ts +++ b/app/my-notebooks/actions.ts @@ -6,40 +6,31 @@ 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); + + // 各列につき、`cell:列名:0`, `cell:列名:1`, ... という連番の入力欄を + // 存在する分だけ読み取る(CardFieldsForm側は必ず0番から連番でレンダリングする)。 + // 空文字の値は保存しない + const cells: Record = {}; + for (const column of bodyColumns) { + const values: string[] = []; + let i = 0; + while (formData.has(`cell:${column}:${i}`)) { + const value = String(formData.get(`cell:${column}:${i}`) ?? "").trim(); + if (value !== "") values.push(value); + i += 1; } + if (values.length > 0) cells[column] = values; } - // ステップ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, cells }; } // Excelファイルから新しい単語帳を作成する @@ -115,7 +106,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 +141,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 +157,135 @@ 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) => { + if (!(oldName in data.cells)) return data; + const { [oldName]: value, ...rest } = data.cells; + return { ...data, cells: { ...rest, [newName]: value } }; + }); + } + + 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 cells = Object.fromEntries(Object.entries(data.cells).filter(([key]) => key !== name)); + return { ...data, cells }; + }); + + revalidatePath(`/my-notebooks/${notebookId}`); +} + // 単語帳内の単語を1件、削除する export async function deleteCard(cardId: string, notebookId: string) { const user = await requireUser(); diff --git a/app/share/[notebookId]/page.tsx b/app/share/[notebookId]/page.tsx index 1e744cd..548259d 100644 --- a/app/share/[notebookId]/page.tsx +++ b/app/share/[notebookId]/page.tsx @@ -1,9 +1,9 @@ -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"; @@ -21,9 +21,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,40 +72,32 @@ 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) => ( - ( + + + {bodyColumns.map((column) => { + const values = card.data.cells[column] ?? []; + return ( + + {values.length <= 1 ? ( + (values[0] ?? "") + ) : ( +
    + {values.map((value, i) => ( +
  1. {value}
  2. + ))} +
)} - {senseColumns.map((column) => ( - - ))} - - ))} - - ); - }) + + ); + })} + + )) )}
+ {card.data.head} + - {index === 0 && ( - - {card.data.head} - - {sense[column] ?? ""} -
diff --git a/app/share/[notebookId]/study/PublicStudyDeck.tsx b/app/share/[notebookId]/study/PublicStudyDeck.tsx index 8ed2b2f..89355b6 100644 --- a/app/share/[notebookId]/study/PublicStudyDeck.tsx +++ b/app/share/[notebookId]/study/PublicStudyDeck.tsx @@ -33,11 +33,16 @@ export default function PublicStudyDeck({ const [flipped, setFlipped] = useState(false); const current = cards[order[index]]; - const frontColumn = columns[0]; - const senseColumns = columns.slice(1); + const frontColumn = columns[0] ?? ""; + const bodyColumns = columns.slice(1); const is3DMode = columns.length === 3; - const primarySense = current?.data.senses[0] || {}; + // 指定した列(見出し語を除く)の値のリスト。3D面では省スペースのため「/」でつなぐ + function bodyValuesFor(column: string | undefined): string[] { + if (!column) return []; + return current?.data.cells[column] ?? []; + } + const faces: [React.ReactNode, React.ReactNode, React.ReactNode] = [
@@ -52,7 +57,7 @@ export default function PublicStudyDeck({ {columns[1]} - {primarySense[columns[1]] || "—"} + {bodyValuesFor(columns[1]).join(" / ") || "—"}
,
@@ -60,7 +65,7 @@ export default function PublicStudyDeck({ {columns[2]} - {primarySense[columns[2]] || "—"} + {bodyValuesFor(columns[2]).join(" / ") || "—"}
, ]; @@ -116,27 +121,30 @@ export default function PublicStudyDeck({ {current.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 values = current.data.cells[column] ?? []; + if (values.length === 0) return null; + return ( +

+

+ {column}

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

- {column} -

-

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

-
- ))} -
- ))} + {values.length === 1 ? ( +

{values[0]}

+ ) : ( +
    + {values.map((value, i) => ( +
  1. + {value} +
  2. + ))} +
+ )} +
+ ); + })}
) : (

他に項目がありません

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..a7cbfe4 100644 --- a/components/my-notebooks/CardFieldsForm.tsx +++ b/components/my-notebooks/CardFieldsForm.tsx @@ -5,35 +5,56 @@ import { 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]"; -// 単語カード1件分の入力欄。見出し語(1列目)は単一、 -// 意味(2列目以降)は多義語に対応するため複数持てるようにする。 -// name="head" / name="sense:キー:列名" という形式でSubmit時に読み取られる +type Slot = { key: number; defaultValue: string }; + +// 単語カード1件分の入力欄。 +// 見出し語(1列目)は単一。それ以外の各列は、列ごとに独立して +// 値を1件〜複数件持てる(例: 発音は1件のまま、意味だけ「+値を追加」で増やす)。 +// name="head" / name="cell:列名:連番" という形式でSubmit時に読み取られる export default function CardFieldsForm({ columns, defaultHead = "", - defaultSenses = [], + defaultCells = {}, }: { columns: string[]; defaultHead?: string; - defaultSenses?: Record[]; + defaultCells?: Record; }) { - const headColumn = columns[0]; - const senseColumns = columns.slice(1); + const headColumn = columns[0] ?? ""; + const bodyColumns = 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 [cellsState, setCellsState] = useState>(() => { + // 初期スロットのkeyはこの場限りのローカルカウンターで採番する(refはrender中に読めないため) + let key = 0; + const initial: Record = {}; + for (const column of bodyColumns) { + const values = defaultCells[column]; + const list = values && values.length > 0 ? values : [""]; + initial[column] = list.map((value) => ({ key: key++, defaultValue: value })); + } + return initial; + }); + // 初期表示後に追加されるスロットのkey採番。初期スロットの最大key+1から続ける + const nextKey = useRef( + Object.values(cellsState).reduce( + (max, slots) => slots.reduce((m, slot) => Math.max(m, slot.key + 1), max), + 0, + ), ); - function addSense() { - setSenses((list) => [...list, { key: nextKey.current++, defaults: {} }]); + function addValue(column: string) { + setCellsState((state) => ({ + ...state, + [column]: [...(state[column] ?? []), { key: nextKey.current++, defaultValue: "" }], + })); } - function removeSense(key: number) { - setSenses((list) => (list.length > 1 ? list.filter((sense) => sense.key !== key) : list)); + function removeValue(column: string, key: number) { + setCellsState((state) => { + const list = state[column] ?? []; + if (list.length <= 1) return state; + return { ...state, [column]: list.filter((slot) => slot.key !== key) }; + }); } return ( @@ -43,45 +64,41 @@ export default function CardFieldsForm({
- {senseColumns.length > 0 && ( -
- {senses.map((sense, index) => ( -
- {senses.length > 1 && ( - 意味{index + 1} - )} - {senseColumns.map((column) => ( -
- - -
- ))} - -
- ))} - -
- )} + + {slots.map((slot, index) => ( +
+ + +
+ ))} + +
+ ); + })} ); } 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/lib/card-data.ts b/lib/card-data.ts index 0f83bd1..3657819 100644 --- a/lib/card-data.ts +++ b/lib/card-data.ts @@ -1,7 +1,52 @@ // 1枚のカード(見出し語1つ)が持つデータ構造。 -// 多義語のように1つの見出し語が複数の意味を持てるよう、 -// 2列目以降(意味・例文など)は senses の配列として保持する。 +// cells: 列名 → その列の値のリスト。通常1件だが、同じ見出し語で複数の値 +// (多義語の意味、複数ある発音など)を持たせたいときだけ配列を伸ばす。 +// 列同士は完全に独立しており、「この意味とこの発音が対応している」といった +// セル間の対応付けは持たない(列ごとに自由に件数を変えられる) export type CardData = { head: string; - senses: Record[]; + cells: Record; }; + +// DBから読んだcard.dataを正規化する。 +// 過去に一時的に存在した別形式(single: Record + senses: Record[]) +// との互換性のため、そちらの形式で保存されたデータも cells 形式へ変換して読み込む +export function normalizeCardData(raw: unknown): CardData { + const data = (raw ?? {}) as { + head?: unknown; + cells?: unknown; + single?: unknown; + senses?: unknown; + }; + const head = typeof data.head === "string" ? data.head : ""; + + // 最新形式: { head, cells } + 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, 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, cells }; +} diff --git a/lib/excel.ts b/lib/excel.ts index 414ddfc..7c58522 100644 --- a/lib/excel.ts +++ b/lib/excel.ts @@ -92,7 +92,7 @@ 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); @@ -126,13 +121,18 @@ export async function parseExcelWorkbook(buffer: ArrayBuffer): Promise 0 && Object.values(sense).some((value) => value !== "")) { - rows[groupIndex].senses.push(sense); - } + // 同じ見出し語の行から、列ごとに値を集めて追記していく(空文字は追加しない)。 + // これにより、値が1行にしか無い列は自然と1件だけ、複数行にまたがる列は + // 複数件のリストになる(列ごとの単値/多値を事前に決める必要が無い) + const cells = rows[groupIndex].cells; + bodyColumns.forEach((name, index) => { + const value = cellValues[index + 1]; + if (value === "") return; + (cells[name] ??= []).push(value); + }); } if (rows.length === 0) { 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); + }); +} From 4cfb496e934c2e51efc155efd5abef38a3dcf018 Mon Sep 17 00:00:00 2001 From: Tatsu723 Date: Sat, 19 Sep 2026 21:40:35 +0900 Subject: [PATCH 2/4] =?UTF-8?q?=E5=88=97=E3=81=AE=E7=B5=90=E5=90=88?= =?UTF-8?q?=E3=83=BB=E5=88=86=E5=89=B2=E3=82=92=E5=8F=AF=E8=83=BD=E3=81=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/my-notebooks/[notebookId]/CardRow.tsx | 206 +++++----- .../[notebookId]/study/StudyDeck.tsx | 112 +++--- app/my-notebooks/actions.ts | 41 +- app/share/[notebookId]/page.tsx | 68 ++-- .../[notebookId]/study/PublicStudyDeck.tsx | 135 ++++--- components/my-notebooks/CardFieldsForm.tsx | 353 ++++++++++++++---- components/my-notebooks/ThreeElement.tsx | 120 ------ lib/card-data.ts | 57 ++- lib/excel.ts | 29 +- 9 files changed, 678 insertions(+), 443 deletions(-) delete mode 100644 components/my-notebooks/ThreeElement.tsx diff --git a/app/my-notebooks/[notebookId]/CardRow.tsx b/app/my-notebooks/[notebookId]/CardRow.tsx index 9275277..914ade1 100644 --- a/app/my-notebooks/[notebookId]/CardRow.tsx +++ b/app/my-notebooks/[notebookId]/CardRow.tsx @@ -26,18 +26,18 @@ function SaveButton() { ); } -// セル1つ分の表示。値が1件なら普通のテキスト、複数件なら番号付きリストで -// セル内に積んで表示する(列同士の行は揃えない。この単語帳が持つセルはそれぞれ独立) -function CellValues({ values }: { values: string[] }) { - if (values.length === 0) return null; - if (values.length === 1) return <>{values[0]}; - return ( -
      - {values.map((value, index) => ( -
    1. {value}
    2. - ))} -
    - ); +// 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({ @@ -92,86 +92,108 @@ export default function CardRow({ const starColor = starColorFor(optimisticStar.starCount, starColors); if (!editing) { - // 見出し語1件につき常に1行。各セルは値が複数あればセル内で積んで表示する - // (列ごとの件数は完全に独立しているため、行を分けて揃える必要がない) + // 見出し語1件につき、rows(組)の件数ぶんを並べる。見出し語セルと操作セルは + // Excelの結合セルのように rowSpan で全組にまたがらせ、本文の各列は列ごとに + // 連続して同じ値が続く区間だけをrowSpanでまとめる(結合・分割) + const rowCount = card.data.rows.length; + const bodySpansByColumn = bodyColumns.map((column) => + computeRowSpans(card.data.rows.map((row) => row[column] ?? "")), + ); + return ( - - - {card.data.head} - - {bodyColumns.map((column) => ( - - - - ))} - - {/* handleToggleStarは楽観的UIでoptimisticStarを即座に切り替えてからtoggleStarを呼ぶ。 - 表示はcard.starredではなくoptimisticStar.starredを見ることで、 - サーバーの往復を待たずに★・色が切り替わる。 - 回数の表示・修正はStarCountEditorに分離し、誤クリック時に - 手動での書き換え・0へのリセットができるようにしている */} - -
    - -
    - - {/* 目アイコンは暗記モード(/study, /review)でこのカードが表示された累計回数。 - ★(左)の右隣に並べ、修正対象ではないのでただの表示に留める。 - サイズは★の文字サイズ(text-sm)に合わせている */} - - - {card.viewCount} - -
    - - {/* deleteCardをこのカード専用にbindし、form actionとして直接渡す。 - 確認ダイアログで「キャンセル」された場合はpreventDefaultで送信自体を止める */} -
    { - if (!window.confirm("この単語を削除しますか?")) { - event.preventDefault(); - } - }} - > - -
    - - + {card.data.head} + + )} + {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を見ることで、 + サーバーの往復を待たずに★・色が切り替わる。 + 回数の表示・修正はStarCountEditorに分離し、誤クリック時に + 手動での書き換え・0へのリセットができるようにしている */} + +
    + +
    + + {/* 目アイコンは暗記モード(/study, /review)でこのカードが表示された累計回数。 + ★(左)の右隣に並べ、修正対象ではないのでただの表示に留める。 + サイズは★の文字サイズ(text-sm)に合わせている */} + + + {card.viewCount} + +
    + + {/* deleteCardをこのカード専用にbindし、form actionとして直接渡す。 + 確認ダイアログで「キャンセル」された場合はpreventDefaultで送信自体を止める */} +
    { + if (!window.confirm("この単語を削除しますか?")) { + event.preventDefault(); + } + }} + > + +
    + + )} + + ))} + ); } @@ -182,8 +204,8 @@ export default function CardRow({ {/* action={formAction} に渡すことで、Server Actionの結果がuseActionStateのstateに反映される */}
    - {/* defaultHead/defaultCellsで現在の値を初期表示し、そこから編集する */} - + {/* defaultHead/defaultRowsで現在の値を初期表示し、そこから編集する */} +
    - {current.viewCount} + {currentCard.viewCount}
    diff --git a/app/my-notebooks/actions.ts b/app/my-notebooks/actions.ts index 970a1c0..d0b708e 100644 --- a/app/my-notebooks/actions.ts +++ b/app/my-notebooks/actions.ts @@ -15,22 +15,22 @@ function readCardData(columns: string[], formData: FormData): CardData { const head = String(formData.get("head") ?? "").trim(); const bodyColumns = columns.slice(1); - // 各列につき、`cell:列名:0`, `cell:列名:1`, ... という連番の入力欄を - // 存在する分だけ読み取る(CardFieldsForm側は必ず0番から連番でレンダリングする)。 - // 空文字の値は保存しない - const cells: Record = {}; - for (const column of bodyColumns) { - const values: string[] = []; - let i = 0; - while (formData.has(`cell:${column}:${i}`)) { - const value = String(formData.get(`cell:${column}:${i}`) ?? "").trim(); - if (value !== "") values.push(value); - i += 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; } - if (values.length > 0) cells[column] = values; + rows.push(row); + i += 1; } - return { head, cells }; + return { head, rows: rows.length > 0 ? rows : [{}] }; } // Excelファイルから新しい単語帳を作成する @@ -249,9 +249,12 @@ export async function renameNotebookColumn( await prisma.notebook.update({ where: { id: notebookId }, data: { columns: newColumns } }); } else { await migrateAllCards(notebookId, newColumns, (data) => { - if (!(oldName in data.cells)) return data; - const { [oldName]: value, ...rest } = data.cells; - return { ...data, cells: { ...rest, [newName]: value } }; + const rows = data.rows.map((row) => { + if (!(oldName in row)) return row; + const { [oldName]: value, ...rest } = row; + return { ...rest, [newName]: value }; + }); + return { ...data, rows }; }); } @@ -279,8 +282,10 @@ export async function deleteNotebookColumn(notebookId: string, columnIndex: numb const newColumns = columns.filter((_, index) => index !== columnIndex); await migrateAllCards(notebookId, newColumns, (data) => { - const cells = Object.fromEntries(Object.entries(data.cells).filter(([key]) => key !== name)); - return { ...data, cells }; + const rows = data.rows.map((row) => + Object.fromEntries(Object.entries(row).filter(([key]) => key !== name)), + ); + return { ...data, rows }; }); revalidatePath(`/my-notebooks/${notebookId}`); diff --git a/app/share/[notebookId]/page.tsx b/app/share/[notebookId]/page.tsx index 548259d..54a1d0f 100644 --- a/app/share/[notebookId]/page.tsx +++ b/app/share/[notebookId]/page.tsx @@ -8,6 +8,20 @@ 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; @@ -72,32 +86,40 @@ export default async function SharedNotebookPage(props: PageProps<"/share/[noteb ) : ( - cards.map((card) => ( - - - {card.data.head} - - {bodyColumns.map((column) => { - const values = card.data.cells[column] ?? []; - return ( + 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 && ( - {values.length <= 1 ? ( - (values[0] ?? "") - ) : ( -
      - {values.map((value, i) => ( -
    1. {value}
    2. - ))} -
    - )} + {card.data.head} - ); - })} - - )) + )} + {bodyColumns.map((column, columnIndex) => { + const span = bodySpansByColumn[columnIndex][rowIndex]; + if (span === 0) return null; + return ( + + {row[column] ?? ""} + + ); + })} + + )); + }) )} diff --git a/app/share/[notebookId]/study/PublicStudyDeck.tsx b/app/share/[notebookId]/study/PublicStudyDeck.tsx index 89355b6..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,47 +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 current = units[order[index]]; + const currentCard = current.card; const frontColumn = columns[0] ?? ""; - const bodyColumns = columns.slice(1); - const is3DMode = columns.length === 3; - // 指定した列(見出し語を除く)の値のリスト。3D面では省スペースのため「/」でつなぐ - function bodyValuesFor(column: string | undefined): string[] { - if (!column) return []; - return current?.data.cells[column] ?? []; + // 指定した列の値を取得する。見出し語列は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 faces: [React.ReactNode, React.ReactNode, React.ReactNode] = [ -
    - - {columns[0]} - - - {current?.data.head || "—"} - -
    , -
    - - {columns[1]} - - - {bodyValuesFor(columns[1]).join(" / ") || "—"} - -
    , -
    - - {columns[2]} - - - {bodyValuesFor(columns[2]).join(" / ") || "—"} - -
    , - ]; + // 今のカードでデータが存在する列一覧(見出し語は先頭で固定) + 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); @@ -81,7 +114,7 @@ export default function PublicStudyDeck({ } function shuffle() { - setOrder(shuffleOrder(cards.length)); + setOrder(shuffleOrder(units.length)); setIndex(0); setFlipped(false); } @@ -95,15 +128,17 @@ export default function PublicStudyDeck({
    {is3DMode ? (
    - - クリックして次の面へ回転 + クリックして次の面へ回転({activeColumns.length}角柱)
    ) : ( @@ -118,30 +153,20 @@ export default function PublicStudyDeck({ {frontColumn} - {current.data.head || "—"} + {currentCard.data.head || "—"} ) : bodyColumns.length > 0 ? (
    {bodyColumns.map((column) => { - const values = current.data.cells[column] ?? []; - if (values.length === 0) return null; + const value = current.values[column]; + if (value === undefined) return null; return (

    {column}

    - {values.length === 1 ? ( -

    {values[0]}

    - ) : ( -
      - {values.map((value, i) => ( -
    1. - {value} -
    2. - ))} -
    - )} +

    {value}

    ); })} diff --git a/components/my-notebooks/CardFieldsForm.tsx b/components/my-notebooks/CardFieldsForm.tsx index a7cbfe4..cb02b31 100644 --- a/components/my-notebooks/CardFieldsForm.tsx +++ b/components/my-notebooks/CardFieldsForm.tsx @@ -1,62 +1,179 @@ "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"; -type Slot = { key: number; defaultValue: string }; +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列目)は単一。それ以外の各列は、列ごとに独立して -// 値を1件〜複数件持てる(例: 発音は1件のまま、意味だけ「+値を追加」で増やす)。 -// name="head" / name="cell:列名:連番" という形式でSubmit時に読み取られる +// 見出し語(1列目)は単一。それ以外の列は、Excelの表のように「組」単位で行を増減できる +// うえ、列ごとに隣接するセルを結合できる(結合中は1つの入力欄に1回入力するだけで、 +// 結合された全ての行に同じ値が保存される)。 +// name="head" / name="row:連番:列名" という形式でSubmit時に読み取られる +// (結合されたセルは、行番号ごとに同じ値を持つ隠し入力を追加することで、 +// 読み取り側(actions.tsのreadCardData)を変更せずに対応している) export default function CardFieldsForm({ columns, defaultHead = "", - defaultCells = {}, + defaultRows = [], }: { columns: string[]; defaultHead?: string; - defaultCells?: Record; + defaultRows?: Record[]; }) { const headColumn = columns[0] ?? ""; const bodyColumns = columns.slice(1); - const [cellsState, setCellsState] = useState>(() => { - // 初期スロットのkeyはこの場限りのローカルカウンターで採番する(refはrender中に読めないため) - let key = 0; - const initial: Record = {}; - for (const column of bodyColumns) { - const values = defaultCells[column]; - const list = values && values.length > 0 ? values : [""]; - initial[column] = list.map((value) => ({ key: key++, defaultValue: value })); - } - return initial; + const [rows, setRows] = useState(() => buildInitialRows(defaultRows, bodyColumns)); + // 追加される行のkey採番。初期行の件数から続ける(refはrender中に読めないため) + const nextKey = useRef(rows.length); + + // 結合セル(`列名:先頭行番号` → 要素)の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`; + }); }); - // 初期表示後に追加されるスロットのkey採番。初期スロットの最大key+1から続ける - const nextKey = useRef( - Object.values(cellsState).reduce( - (max, slots) => slots.reduce((m, slot) => Math.max(m, slot.key + 1), max), - 0, - ), - ); - function addValue(column: string) { - setCellsState((state) => ({ - ...state, - [column]: [...(state[column] ?? []), { key: nextKey.current++, defaultValue: "" }], - })); + 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 updateValue(key: number, column: string, value: string) { + setRows((prev) => + prev.map((row) => + row.key === key ? { ...row, values: { ...row.values, [column]: value } } : row, + ), + ); } - function removeValue(column: string, key: number) { - setCellsState((state) => { - const list = state[column] ?? []; - if (list.length <= 1) return state; - return { ...state, [column]: list.filter((slot) => slot.key !== key) }; + // 添字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 (
    @@ -64,41 +181,141 @@ export default function CardFieldsForm({
    - {bodyColumns.map((column) => { - const slots = cellsState[column] ?? []; - return ( -
    - - {slots.map((slot, index) => ( -
    - - -
    - ))} - + {bodyColumns.length > 0 && ( +
    +
    + + + + {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/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 3657819..6d429b8 100644 --- a/lib/card-data.ts +++ b/lib/card-data.ts @@ -1,26 +1,61 @@ // 1枚のカード(見出し語1つ)が持つデータ構造。 -// cells: 列名 → その列の値のリスト。通常1件だが、同じ見出し語で複数の値 -// (多義語の意味、複数ある発音など)を持たせたいときだけ配列を伸ばす。 -// 列同士は完全に独立しており、「この意味とこの発音が対応している」といった -// セル間の対応付けは持たない(列ごとに自由に件数を変えられる) +// rows: 見出し語につく「組」の並び(Excelの1行に相当)。同じ添字の値同士が +// 列をまたいで対応する(例: rows[1].訳 と rows[1].発音 は同じ組=対応する意味・発音)。 +// 見出し語1件で複数の意味を持たせたいときは rows を複数件にする export type CardData = { head: string; - cells: 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を正規化する。 -// 過去に一時的に存在した別形式(single: Record + senses: Record[]) -// との互換性のため、そちらの形式で保存されたデータも cells 形式へ変換して読み込む +// 過去に存在した別形式(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, cells } + // 最新形式: { 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)) { @@ -28,10 +63,10 @@ export function normalizeCardData(raw: unknown): CardData { cells[key] = value.filter((v): v is string => typeof v === "string" && v !== ""); } } - return { head, cells }; + return { head, rows: cellsToRows(cells) }; } - // 旧形式(single + senses)からの変換 + // さらに旧形式(single + senses)からの変換 const cells: Record = {}; const single = data.single && typeof data.single === "object" && !Array.isArray(data.single) @@ -48,5 +83,5 @@ export function normalizeCardData(raw: unknown): CardData { } } - return { head, cells }; + return { head, rows: cellsToRows(cells) }; } diff --git a/lib/excel.ts b/lib/excel.ts index 7c58522..639df92 100644 --- a/lib/excel.ts +++ b/lib/excel.ts @@ -93,7 +93,7 @@ export async function parseExcelWorkbook(buffer: ArrayBuffer): Promise(); @@ -119,25 +119,32 @@ export async function parseExcelWorkbook(buffer: ArrayBuffer): Promise = {}; bodyColumns.forEach((name, index) => { const value = cellValues[index + 1]; - if (value === "") return; - (cells[name] ??= []).push(value); + if (value !== "") cardRow[name] = value; }); + if (Object.keys(cardRow).length > 0) { + cards[groupIndex].rows.push(cardRow); + } + } + + // 見出し語のみで本文列が1件も無かったカードには、空の組を1件持たせておく + // (CardFieldsForm・表示側は常に1件以上のrowsがある前提のため) + for (const card of cards) { + if (card.rows.length === 0) card.rows.push({}); } - if (rows.length === 0) { + if (cards.length === 0) { throw new ExcelParseError("2行目以降にデータが見つかりませんでした。"); } - return { columns, rows }; + return { columns, rows: cards }; } From 4a31d1d08a10f9d17ddd5b88824ef3d2667761ce Mon Sep 17 00:00:00 2001 From: Tatsu723 Date: Sat, 19 Sep 2026 22:46:23 +0900 Subject: [PATCH 3/4] =?UTF-8?q?excel=E3=83=95=E3=82=A1=E3=82=A4=E3=83=AB?= =?UTF-8?q?=E3=81=AA=E3=81=97=E3=81=A7=E5=8D=98=E8=AA=9E=E5=B8=B3=E3=82=92?= =?UTF-8?q?=E4=BD=9C=E6=88=90=E3=81=A7=E3=81=8D=E3=82=8B=E3=82=88=E3=81=86?= =?UTF-8?q?=E3=81=AB=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/my-notebooks/[notebookId]/page.tsx | 4 +- app/my-notebooks/actions.ts | 71 +++++-- app/my-notebooks/page.tsx | 31 ++- .../my-notebooks/CreateBlankNotebookForm.tsx | 67 ++++++ components/my-notebooks/ImportCardsForm.tsx | 190 ++++++++++++++++++ components/my-notebooks/ImportForm.tsx | 155 -------------- 6 files changed, 324 insertions(+), 194 deletions(-) create mode 100644 components/my-notebooks/CreateBlankNotebookForm.tsx create mode 100644 components/my-notebooks/ImportCardsForm.tsx delete mode 100644 components/my-notebooks/ImportForm.tsx diff --git a/app/my-notebooks/[notebookId]/page.tsx b/app/my-notebooks/[notebookId]/page.tsx index 0c6a30b..f7b85ea 100644 --- a/app/my-notebooks/[notebookId]/page.tsx +++ b/app/my-notebooks/[notebookId]/page.tsx @@ -6,6 +6,7 @@ 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 { normalizeCardData } from "@/lib/card-data"; @@ -83,7 +84,8 @@ export default async function NotebookPage(props: PageProps<"/my-notebooks/[note )}
    -
    +
    +
    diff --git a/app/my-notebooks/actions.ts b/app/my-notebooks/actions.ts index d0b708e..c8c67d0 100644 --- a/app/my-notebooks/actions.ts +++ b/app/my-notebooks/actions.ts @@ -33,25 +33,27 @@ function readCardData(columns: string[], formData: FormData): CardData { 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; @@ -64,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}`); } 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/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} -

      - )} - -
      - -
      -
      - ); -} From aa022cad964aff7ccc7cfed8c951db72a7876c73 Mon Sep 17 00:00:00 2001 From: Tatsu723 Date: Sun, 20 Sep 2026 08:36:11 +0900 Subject: [PATCH 4/4] =?UTF-8?q?fix:=20format=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/my-notebooks/[notebookId]/CardRow.tsx | 6 +++++- app/my-notebooks/actions.ts | 5 ++++- app/share/[notebookId]/page.tsx | 5 ++++- components/my-notebooks/CardFieldsForm.tsx | 18 ++++++++++++++---- 4 files changed, 27 insertions(+), 7 deletions(-) diff --git a/app/my-notebooks/[notebookId]/CardRow.tsx b/app/my-notebooks/[notebookId]/CardRow.tsx index 914ade1..5f10c74 100644 --- a/app/my-notebooks/[notebookId]/CardRow.tsx +++ b/app/my-notebooks/[notebookId]/CardRow.tsx @@ -205,7 +205,11 @@ export default function CardRow({ {/* action={formAction} に渡すことで、Server Actionの結果がuseActionStateのstateに反映される */}
      {/* defaultHead/defaultRowsで現在の値を初期表示し、そこから編集する */} - +

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