Skip to content
Open
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
67 changes: 64 additions & 3 deletions app/my-notebooks/[notebookId]/CardRow.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
"use client";

import { useActionState, useState } from "react";
import { useActionState, useOptimistic, useState } from "react";
import { useFormStatus } from "react-dom";

import { deleteCard, updateCard, type FormState } from "../actions";
import { deleteCard, toggleStar, updateCard, type FormState } from "../actions";
import CardFieldsForm from "@/components/my-notebooks/CardFieldsForm";
import EyeIcon from "@/components/EyeIcon";
import StarCountEditor from "@/components/StarCountEditor";
import { useStarColors } from "@/components/UseStarColors";
import { starColorFor } from "@/lib/star-colors";
import type { CardData } from "@/lib/card-data";

const initialState: FormState = {};
Expand All @@ -29,7 +33,7 @@ export default function CardRow({
}: {
notebookId: string;
columns: string[];
card: { id: string; data: CardData };
card: { id: string; data: CardData; starred: boolean; starCount: number; viewCount: number };
}) {
// このカードが「表示モード」か「インライン編集モード」かを切り替えるフラグ
const [editing, setEditing] = useState(false);
Expand All @@ -52,6 +56,26 @@ export default function CardRow({

const senseColumns = columns.slice(1);

// toggleStarの結果(サーバーの往復)を待たず、クリックした瞬間に★・回数・色を切り替えるための
// 楽観的UI。これが無いと、往復の間だけ古い状態(トグル前の☆)が表示され続けてしまい、
// 「一瞬白い星に戻る」ように見えるバグになる
const [optimisticStar, setOptimisticStar] = useOptimistic(
{ starred: card.starred, starCount: card.starCount },
(_state, next: { starred: boolean; starCount: number }) => next,
);
async function handleToggleStar() {
setOptimisticStar(
optimisticStar.starred
? { starred: false, starCount: optimisticStar.starCount }
: { starred: true, starCount: optimisticStar.starCount + 1 },
);
await toggleStar(card.id, notebookId);
}

// ★を付けた回数(optimisticStar.starCount)に応じた色。0回(未使用)ならundefinedになりニュートラル表示にする
const { colors: starColors } = useStarColors();
const starColor = starColorFor(optimisticStar.starCount, starColors);

if (!editing) {
// 意味が0件でも見出し語だけの行を1行表示する(表側の "senses" が空配列にならないようフォールバック)
const senses = card.data.senses.length > 0 ? card.data.senses : [{}];
Expand Down Expand Up @@ -91,6 +115,43 @@ export default function CardRow({
rowSpan={senses.length}
className="px-4 py-3 text-right align-top whitespace-nowrap"
>
{/* handleToggleStarは楽観的UIでoptimisticStarを即座に切り替えてからtoggleStarを呼ぶ。
表示はcard.starredではなくoptimisticStar.starredを見ることで、
サーバーの往復を待たずに★・色が切り替わる。
回数の表示・修正はStarCountEditorに分離し、誤クリック時に
手動での書き換え・0へのリセットができるようにしている */}
<span className="mr-3 inline-flex items-center gap-1">
<form action={handleToggleStar} className="inline">
<button
type="submit"
aria-label={optimisticStar.starred ? "★を外す" : "★をつける"}
style={starColor ? { color: starColor } : undefined}
className={
starColor
? "text-sm transition-opacity hover:opacity-75"
: "text-sm text-zinc-400 transition-colors hover:underline dark:text-zinc-600"
}
>
{optimisticStar.starred ? "★" : "☆"}
</button>
</form>
<StarCountEditor
cardId={card.id}
notebookId={notebookId}
starCount={optimisticStar.starCount}
color={starColor}
/>
{/* 目アイコンは暗記モード(/study, /review)でこのカードが表示された累計回数。
★(左)の右隣に並べ、修正対象ではないのでただの表示に留める。
サイズは★の文字サイズ(text-sm)に合わせている */}
<span
aria-label="暗記モードで表示した回数"
className="inline-flex items-center gap-0.5 text-xs text-zinc-400 dark:text-zinc-600"
>
<EyeIcon className="h-3.5 w-3.5" />
{card.viewCount}
</span>
</span>
<button
type="button"
onClick={() => setEditing(true)}
Expand Down
36 changes: 29 additions & 7 deletions app/my-notebooks/[notebookId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { notFound } from "next/navigation";
import { prisma } from "@/lib/prisma";
import CardRow from "./CardRow";
import CreateCardForm from "./CreateCardForm";
import ResetAllStarsButton from "@/components/my-notebooks/ResetAllStarsButton";
import type { CardData } from "@/lib/card-data";

export default async function NotebookPage(props: PageProps<"/my-notebooks/[notebookId]">) {
Expand All @@ -24,6 +25,10 @@ export default async function NotebookPage(props: PageProps<"/my-notebooks/[note
// 列数・列名はNotebookごとに異なる(Excel由来)ため、テーブルのヘッダーや
// 各行の入力欄は columns をループして動的に組み立てる
const columns = notebook.columns as string[];
// ★がついている単語の件数。1件以上あれば「復習」への導線を出す
const starredCount = notebook.cards.filter((card) => card.starred).length;
// ★の回数が1回でも付いている単語があれば「一括リセット」の導線を出す
const hasAnyStars = notebook.cards.some((card) => card.starCount > 0);

return (
<main className="flex flex-1 flex-col items-center px-6 py-16">
Expand All @@ -45,12 +50,23 @@ export default async function NotebookPage(props: PageProps<"/my-notebooks/[note
</p>
</div>
{notebook.cards.length > 0 && (
<Link
href={`/my-notebooks/${notebook.id}/study`}
className="rounded-full bg-black px-5 py-2 text-sm font-medium text-white transition-colors hover:bg-zinc-800 dark:bg-zinc-50 dark:text-black dark:hover:bg-zinc-200"
>
暗記学習を始める
</Link>
<div className="flex items-center gap-3">
{starredCount > 0 && (
<Link
href={`/my-notebooks/${notebook.id}/review`}
className="rounded-full border border-amber-400 px-5 py-2 text-sm font-medium text-amber-600 transition-colors hover:bg-amber-50 dark:border-amber-400/60 dark:text-amber-400 dark:hover:bg-amber-400/10"
>
★を復習する({starredCount}語)
</Link>
)}
<Link
href={`/my-notebooks/${notebook.id}/study`}
className="rounded-full bg-black px-5 py-2 text-sm font-medium text-white transition-colors hover:bg-zinc-800 dark:bg-zinc-50 dark:text-black dark:hover:bg-zinc-200"
>
暗記学習を始める
</Link>
{hasAnyStars && <ResetAllStarsButton notebookId={notebook.id} />}
</div>
)}
</div>

Expand Down Expand Up @@ -88,7 +104,13 @@ export default async function NotebookPage(props: PageProps<"/my-notebooks/[note
key={card.id}
notebookId={notebook.id}
columns={columns}
card={{ id: card.id, data: card.data as CardData }}
card={{
id: card.id,
data: card.data as CardData,
starred: card.starred,
starCount: card.starCount,
viewCount: card.viewCount,
}}
/>
))
)}
Expand Down
49 changes: 49 additions & 0 deletions app/my-notebooks/[notebookId]/review/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { notFound, redirect } from "next/navigation";

import { prisma } from "@/lib/prisma";
import StudyDeck from "../study/StudyDeck";
import type { CardData } from "@/lib/card-data";

export default async function ReviewPage(props: PageProps<"/my-notebooks/[notebookId]/review">) {
const { notebookId } = await props.params;

// 単語帳と、その中の★がついたカードだけをposition昇順で取得する
const notebook = await prisma.notebook.findUnique({
where: { id: notebookId },
include: { cards: { where: { starred: true }, orderBy: { position: "asc" } } },
});

if (!notebook) {
notFound();
}
// ★のついた単語が1件も無い状態では復習モードが成立しないため、単語帳のトップページへ差し戻す
if (notebook.cards.length === 0) {
redirect(`/my-notebooks/${notebook.id}`);
}

// 表示・フリップ・★の付け外しはすべてクライアント側のStudyDeckが担当するため、
// ここではサーバーでDBから取得したデータをそのまま整形して渡すだけ
const columns = notebook.columns as string[];
const cards = notebook.cards.map((card) => ({
id: card.id,
data: card.data as CardData,
starred: card.starred,
starCount: card.starCount,
viewCount: card.viewCount,
}));

return (
<main className="flex flex-1 flex-col items-center px-6 py-16 text-center">
<h1 className="text-2xl font-semibold tracking-tight text-black dark:text-zinc-50">
{notebook.title}
</h1>
<p className="mt-2 text-sm text-zinc-500 dark:text-zinc-500">
復習モード(★をつけた単語のみ)
</p>

<div className="mt-8 w-full">
<StudyDeck notebookId={notebook.id} columns={columns} cards={cards} />
</div>
</main>
);
}
Loading