Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 46 additions & 35 deletions app/my-notebooks/[notebookId]/CardRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -54,7 +68,8 @@ export default function CardRow({
}
}

const senseColumns = columns.slice(1);
// 見出し語を除いた列。表示順そのまま
const bodyColumns = columns.slice(1);

// toggleStarの結果(サーバーの往復)を待たず、クリックした瞬間に★・回数・色を切り替えるための
// 楽観的UI。これが無いと、往復の間だけ古い状態(トグル前の☆)が表示され続けてしまい、
Expand All @@ -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(組)の件数ぶん<tr>を並べる。見出し語セルと操作セルは
// Excelの結合セルのように rowSpan で全組にまたがらせ、本文の各列は列ごとに
// 連続して同じ値が続く区間だけをrowSpanでまとめる(結合・分割)
const rowCount = card.data.rows.length;
const bodySpansByColumn = bodyColumns.map((column) =>
computeRowSpans(card.data.rows.map((row) => row[column] ?? "")),
);

// 1つの見出し語(1枚のカード)が複数の意味を持つ場合、
// <tr>をsenses件数ぶん並べて表現する。見出し語セルと操作セル(編集/削除)は
// rowSpan={senses.length} で縦に結合し、1行目にだけレンダリングする。
// 2行目以降は意味の列だけを持つ行になり、破線の罫線(border-dashed)で
// 「同じ見出し語グループの続き」であることを視覚的に示す
return (
<>
{senses.map((sense, index) => (
<tr
key={index}
className={
index === 0
? "border-t border-black/[.06] dark:border-white/[.1]"
: "border-t border-dashed border-black/[.06] dark:border-white/[.1]"
}
>
{index === 0 && (
{card.data.rows.map((row, rowIndex) => (
<tr key={rowIndex} className="border-t border-black/[.06] dark:border-white/[.1]">
{rowIndex === 0 && (
<td
rowSpan={senses.length}
rowSpan={rowCount}
className="px-4 py-3 align-top text-sm font-medium text-zinc-900 dark:text-zinc-100"
>
{card.data.head}
</td>
)}
{/* 意味側の列は、senseColumns(columnsの2列目以降)の順番通りに1セルずつ描画する */}
{senseColumns.map((column) => (
<td key={column} className="px-4 py-3 text-sm text-zinc-700 dark:text-zinc-300">
{sense[column] ?? ""}
</td>
))}
{index === 0 && (
<td
rowSpan={senses.length}
className="px-4 py-3 text-right align-top whitespace-nowrap"
>
{bodyColumns.map((column, columnIndex) => {
const span = bodySpansByColumn[columnIndex][rowIndex];
if (span === 0) return null;
return (
<td
key={column}
rowSpan={span}
className="px-4 py-3 align-top text-sm text-zinc-700 dark:text-zinc-300"
>
{row[column] ?? ""}
</td>
);
})}
{rowIndex === 0 && (
<td rowSpan={rowCount} className="px-4 py-3 text-right align-top whitespace-nowrap">
{/* handleToggleStarは楽観的UIでoptimisticStarを即座に切り替えてからtoggleStarを呼ぶ。
表示はcard.starredではなくoptimisticStar.starredを見ることで、
サーバーの往復を待たずに★・色が切り替わる。
Expand Down Expand Up @@ -185,19 +197,18 @@ export default function CardRow({
);
}

// 編集モードでは、表示モード時の複数<tr>を1つの<tr>にまとめ、
// colSpan(見出し語1列 + 意味の列数 + 操作列1列)で全カラムぶんを1セルに潰して
// 編集モードでは、colSpan(見出し語1列 + 列数 + 操作列1列)で全カラムぶんを1セルに潰して
// その中にフォームを丸ごと展開する
return (
<tr className="border-t border-black/[.06] dark:border-white/[.1]">
<td colSpan={senseColumns.length + 2} className="px-4 py-3">
<td colSpan={bodyColumns.length + 2} className="px-4 py-3">
{/* action={formAction} に渡すことで、Server Actionの結果がuseActionStateのstateに反映される */}
<form action={formAction} className="flex flex-col items-start gap-3">
{/* defaultHead/defaultSensesで現在の値を初期表示し、そこから編集する */}
{/* defaultHead/defaultRowsで現在の値を初期表示し、そこから編集する */}
<CardFieldsForm
columns={columns}
defaultHead={card.data.head}
defaultSenses={card.data.senses}
defaultRows={card.data.rows}
/>
<div className="flex items-center gap-3">
<SaveButton />
Expand Down
23 changes: 16 additions & 7 deletions app/my-notebooks/[notebookId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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回でも付いている単語があれば「一括リセット」の導線を出す
Expand Down Expand Up @@ -80,7 +84,12 @@ export default async function NotebookPage(props: PageProps<"/my-notebooks/[note
)}
</div>

<div className="mt-8 overflow-x-auto rounded-2xl border border-black/[.08] dark:border-white/[.145]">
<div className="mt-8 flex flex-wrap items-center justify-end gap-4">
<ImportCardsForm notebookId={notebook.id} />
<ColumnsEditor notebookId={notebook.id} columns={columns} />
</div>

<div className="mt-3 overflow-x-auto rounded-2xl border border-black/[.08] dark:border-white/[.145]">
<table className="w-full min-w-max border-collapse text-left">
<thead className="bg-zinc-50 dark:bg-zinc-900">
<tr>
Expand Down Expand Up @@ -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,
Expand Down
7 changes: 4 additions & 3 deletions app/my-notebooks/[notebookId]/review/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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,
Expand Down
Loading
Loading