From 04f2ac18f055988c808d8b8ff8394f39f279a5e7 Mon Sep 17 00:00:00 2001
From: amy-mor <283112394+amy-mor@users.noreply.github.com>
Date: Sun, 16 Aug 2026 17:37:13 +0900
Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=E5=8D=98=E8=AA=9E=E6=9A=97?=
=?UTF-8?q?=E8=A8=98=E9=80=B2=E6=8D=97=E3=81=AE=E8=A8=98=E9=8C=B2=E3=81=AE?=
=?UTF-8?q?=E3=81=9F=E3=82=81=E3=81=AE=E3=83=96=E3=83=83=E3=82=AF=E3=83=9E?=
=?UTF-8?q?=E3=83=BC=E3=82=AF=E6=A9=9F=E8=83=BD=EF=BC=88=E2=98=85=EF=BC=89?=
=?UTF-8?q?=E3=81=A8=E3=80=81=E5=8D=98=E8=AA=9E=E3=82=AB=E3=83=BC=E3=83=89?=
=?UTF-8?q?=E3=82=92=E8=A6=8B=E3=81=9F=E6=95=B0=E3=82=92=E8=A8=98=E9=8C=B2?=
=?UTF-8?q?=E3=81=99=E3=82=8B=E6=A9=9F=E8=83=BD=EF=BC=88=E7=9B=AE=E3=81=AE?=
=?UTF-8?q?=E3=82=A2=E3=82=A4=E3=82=B3=E3=83=B3=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
app/my-notebooks/[notebookId]/CardRow.tsx | 67 ++++++-
app/my-notebooks/[notebookId]/page.tsx | 36 +++-
app/my-notebooks/[notebookId]/review/page.tsx | 49 +++++
.../[notebookId]/study/StudyDeck.tsx | 186 +++++++++++++-----
app/my-notebooks/[notebookId]/study/page.tsx | 3 +
app/my-notebooks/actions.ts | 68 +++++++
app/my-notebooks/page.tsx | 4 +
components/EyeIcon.tsx | 26 +++
components/StarColorSettings.tsx | 67 +++++++
components/StarCountEditor.tsx | 85 ++++++++
components/UseStarColors.ts | 62 ++++++
.../my-notebooks/ResetAllStarsButton.tsx | 29 +++
lib/star-colors.ts | 54 +++++
.../migration.sql | 20 ++
.../migration.sql | 21 ++
prisma/schema.prisma | 6 +
16 files changed, 726 insertions(+), 57 deletions(-)
create mode 100644 app/my-notebooks/[notebookId]/review/page.tsx
create mode 100644 components/EyeIcon.tsx
create mode 100644 components/StarColorSettings.tsx
create mode 100644 components/StarCountEditor.tsx
create mode 100644 components/UseStarColors.ts
create mode 100644 components/my-notebooks/ResetAllStarsButton.tsx
create mode 100644 lib/star-colors.ts
create mode 100644 prisma/migrations/20260816040931_add_card_star/migration.sql
create mode 100644 prisma/migrations/20260816070045_add_card_view_count/migration.sql
diff --git a/app/my-notebooks/[notebookId]/CardRow.tsx b/app/my-notebooks/[notebookId]/CardRow.tsx
index dd97f16..034fdbc 100644
--- a/app/my-notebooks/[notebookId]/CardRow.tsx
+++ b/app/my-notebooks/[notebookId]/CardRow.tsx
@@ -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 = {};
@@ -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);
@@ -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 : [{}];
@@ -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へのリセットができるようにしている */}
+
+
+
+ {/* 目アイコンは暗記モード(/study, /review)でこのカードが表示された累計回数。
+ ★(左)の右隣に並べ、修正対象ではないのでただの表示に留める。
+ サイズは★の文字サイズ(text-sm)に合わせている */}
+
+
+ {card.viewCount}
+
+
{notebook.cards.length > 0 && (
-
- 暗記学習を始める
-
+
+ {starredCount > 0 && (
+
+ ★を復習する({starredCount}語)
+
+ )}
+
+ 暗記学習を始める
+
+ {hasAnyStars && }
+
)}
@@ -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,
+ }}
/>
))
)}
diff --git a/app/my-notebooks/[notebookId]/review/page.tsx b/app/my-notebooks/[notebookId]/review/page.tsx
new file mode 100644
index 0000000..d302250
--- /dev/null
+++ b/app/my-notebooks/[notebookId]/review/page.tsx
@@ -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 (
+
+
+ {notebook.title}
+
+
+ 復習モード(★をつけた単語のみ)
+
+
+
+
+
+
+ );
+}
diff --git a/app/my-notebooks/[notebookId]/study/StudyDeck.tsx b/app/my-notebooks/[notebookId]/study/StudyDeck.tsx
index 824836c..701e26c 100644
--- a/app/my-notebooks/[notebookId]/study/StudyDeck.tsx
+++ b/app/my-notebooks/[notebookId]/study/StudyDeck.tsx
@@ -1,11 +1,22 @@
"use client";
-import { useState } from "react";
+import { useEffect, useOptimistic, useRef, useState } from "react";
import Link from "next/link";
+import { incrementViewCount, toggleStar } from "../../actions";
+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";
-type Card = { id: string; data: CardData };
+type Card = {
+ id: string;
+ data: CardData;
+ starred: boolean;
+ starCount: number;
+ viewCount: number;
+};
// Fisher-Yatesシャッフル: [0, 1, ..., length-1] という「カードの元の並び順(インデックス)」の
// 配列を作り、末尾から先頭に向かって「自分より前(自分を含む)」のランダムな位置と1つずつ
@@ -43,6 +54,39 @@ export default function StudyDeck({
const frontColumn = columns[0];
const senseColumns = columns.slice(1);
+ // toggleStarの結果(サーバーの往復)を待たず、クリックした瞬間に★・回数・色を切り替えるためのUI
+ // idも保持し、往復の間にカードを送り進めても別カードへ誤って適用されないようにする
+ const [optimisticStar, setOptimisticStar] = useOptimistic(
+ { id: current.id, starred: current.starred, starCount: current.starCount },
+ (_state, next: { id: string; starred: boolean; starCount: number }) => next,
+ );
+ const displayedStar =
+ optimisticStar.id === current.id
+ ? optimisticStar
+ : { id: current.id, starred: current.starred, starCount: current.starCount };
+ async function handleToggleStar() {
+ setOptimisticStar(
+ current.starred
+ ? { id: current.id, starred: false, starCount: current.starCount }
+ : { id: current.id, starred: true, starCount: current.starCount + 1 },
+ );
+ await toggleStar(current.id, notebookId);
+ }
+
+ // ★を付けた回数(displayedStar.starCount)に応じた色。0回(未使用)ならundefinedになりニュートラル表示にする
+ const { colors: starColors } = useStarColors();
+ const starColor = starColorFor(displayedStar.starCount, starColors);
+
+ // カードが切り替わる(=暗記モードでこの単語が表示される)たびに、表示回数を1増やす
+ // countedIdRefで直前に数えたカードIDを覚えておき、同じidに対して二重に数えないようにする
+ // (開発時のStrictModeによるeffect二重発火対策も兼ねる)
+ const countedIdRef = useRef(null);
+ useEffect(() => {
+ if (countedIdRef.current === current.id) return;
+ countedIdRef.current = current.id;
+ incrementViewCount(current.id, notebookId).catch(() => {});
+ }, [current.id, notebookId]);
+
// 次のカードへ。indexが末尾を超えないようMath.minでクランプし、
// カードが切り替わったら必ず表向きに戻す
function goNext() {
@@ -69,52 +113,100 @@ export default function StudyDeck({
{index + 1} / {order.length}
- {/* カード自体がクリック領域。クリックするたびに表裏(flipped)をトグルするだけで、
- カードの移動(前へ/次へ/シャッフル)とは独立した操作になっている */}
-