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} + +
+ )} + {senseColumns.map((column) => ( +
+

+ {column} +

+

{sense[column] || "—"}

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

他に項目がありません

+ )} + + クリックして{flipped ? "表" : "裏"}を見る + + + + {/* handleToggleStar は楽観的UI: optimisticStar を即座に切り替えてから + toggleStar(サーバー更新)を呼ぶ。表示も current.starred ではなく + displayedStar.starred を見るため、通信を待たずに★が切り替わる。 + + ★ボタンはフリップボタンと別要素にし、右上に絶対的に配置。 + ★クリックがフリップに巻き込まれないようにするため。 + + 回数の表示・修正は StarCountEditor に分離。誤クリック時も + 手動で書き換え・0リセットができる。 + + 全件モード(/study)は再検証しても件数・並び順が変わらないため、 + ★の付け外しをその場で反映できる。 */} +
+
+ +
+ + {/* 目アイコンは暗記モード(/study, /review)でこのカードが表示された累計回数。 + ★(左)の右隣に並べる。サイズは★の文字サイズ(text-2xl)に合わせている */} + + + {current.viewCount} + +
+
diff --git a/components/EyeIcon.tsx b/components/EyeIcon.tsx new file mode 100644 index 0000000..e236d30 --- /dev/null +++ b/components/EyeIcon.tsx @@ -0,0 +1,26 @@ +"use client"; + +import { useId } from "react"; + +// 暗記モードでの表示回数を示す、簡略化した「目」アイコン。 +// 単色(currentColor)のシルエットに、maskで虹彩・ハイライトの穴を空けて二層に見せている。 +// idはuseIdでインスタンスごとに一意化し、同じページに複数個並んでもmask参照が衝突しないようにする +export default function EyeIcon({ className }: { className?: string }) { + const maskId = useId(); + + return ( + + ); +} diff --git a/components/StarColorSettings.tsx b/components/StarColorSettings.tsx new file mode 100644 index 0000000..89e31b7 --- /dev/null +++ b/components/StarColorSettings.tsx @@ -0,0 +1,67 @@ +"use client"; + +import { useState } from "react"; + +import { useStarColors } from "./UseStarColors"; +import { STAR_COLOR_LABELS, STAR_COLOR_LEVELS } from "@/lib/star-colors"; + +// ヘッダーから開ける★の色設定パネル。★を付けた回数(1回目/2回目/3回目以降)ごとに +// 色を自由に選べるようにし、選んだ色はStarColorsProvider経由でlocalStorageに保存される +export default function StarColorSettings() { + const [open, setOpen] = useState(false); + const { colors, setColor, resetColors } = useStarColors(); + + return ( +
+ + + {open && ( + <> + {/* パネル外側をクリックしたら閉じる */} + +
+ + )} + + ); +} diff --git a/components/StarCountEditor.tsx b/components/StarCountEditor.tsx new file mode 100644 index 0000000..73689a6 --- /dev/null +++ b/components/StarCountEditor.tsx @@ -0,0 +1,85 @@ +"use client"; + +import { useState } from "react"; + +import { resetStar, setStarCount } from "@/app/my-notebooks/actions"; + +// ★の回数(card.starCount)の横に置く、数字をクリックすると開く修正用ポップオーバー。 +// 誤って★をクリックしてしまった場合に、回数を直接書き換えたり0にリセットしたりできる +export default function StarCountEditor({ + cardId, + notebookId, + starCount, + color, +}: { + cardId: string; + notebookId: string; + starCount: number; + color?: string; +}) { + const [open, setOpen] = useState(false); + + return ( +
+ + + {open && ( + <> + {/* パネル外側をクリックしたら閉じる */} + + +
setOpen(false)} + className="mt-2" + > + +
+
+ + )} + + ); +} diff --git a/components/UseStarColors.ts b/components/UseStarColors.ts new file mode 100644 index 0000000..b232624 --- /dev/null +++ b/components/UseStarColors.ts @@ -0,0 +1,62 @@ +import { useSyncExternalStore } from "react"; + +import { + DEFAULT_STAR_COLORS, + loadStarColors, + saveStarColors, + type StarColorLevel, + type StarColors, +} from "@/lib/star-colors"; + +// ★の色設定を、Reactツリー全体で共有するモジュールスコープの状態として管理する +// (Contextでラップせずに済むよう、useSyncExternalStoreで購読させる) +let colors: StarColors = DEFAULT_STAR_COLORS; +let hydrated = false; +const listeners = new Set<() => void>(); + +// 初回読み取り時にだけlocalStorageから読み込む +// サーバー側(window未定義)では常にDEFAULT_STAR_COLORSのまま=ハイドレーション不一致を起こさない +function ensureHydrated() { + if (hydrated || typeof window === "undefined") return; + hydrated = true; + colors = loadStarColors(); +} + +function subscribe(listener: () => void) { + listeners.add(listener); + return () => listeners.delete(listener); +} + +function getSnapshot(): StarColors { + ensureHydrated(); + return colors; +} + +function getServerSnapshot(): StarColors { + return DEFAULT_STAR_COLORS; +} + +function emitChange() { + for (const listener of listeners) listener(); +} + +function setStarColor(level: StarColorLevel, value: string) { + colors = { ...colors, [level]: value }; + saveStarColors(colors); + emitChange(); +} + +function resetStarColors() { + colors = DEFAULT_STAR_COLORS; + saveStarColors(colors); + emitChange(); +} + +export function useStarColors(): { + colors: StarColors; + setColor: (level: StarColorLevel, value: string) => void; + resetColors: () => void; +} { + const snapshot = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); + return { colors: snapshot, setColor: setStarColor, resetColors: resetStarColors }; +} diff --git a/components/my-notebooks/ResetAllStarsButton.tsx b/components/my-notebooks/ResetAllStarsButton.tsx new file mode 100644 index 0000000..5e43d78 --- /dev/null +++ b/components/my-notebooks/ResetAllStarsButton.tsx @@ -0,0 +1,29 @@ +"use client"; + +import { resetAllStars } from "@/app/my-notebooks/actions"; + +// 単語帳内の★(回数・付け外し状態)を一括でリセットするボタン。 +// 単語帳まるごとに影響する操作なので、削除ボタンと同様に確認ダイアログを挟む +export default function ResetAllStarsButton({ notebookId }: { notebookId: string }) { + return ( +
{ + if ( + !window.confirm( + "この単語帳の★の回数をすべてリセットします。よろしいですか?", + ) + ) { + event.preventDefault(); + } + }} + > + +
+ ); +} diff --git a/lib/star-colors.ts b/lib/star-colors.ts new file mode 100644 index 0000000..2f74dd2 --- /dev/null +++ b/lib/star-colors.ts @@ -0,0 +1,54 @@ +// ★を付けた回数に応じた色分けの設定 +// ユーザーごとに好きな色へ編集できるよう、実際の色はlocalStorageに保存する +// このアプリはログイン機能を持たないため、「ユーザー」=このブラウザ、という単位で扱う + +export type StarColorLevel = "level1" | "level2" | "level3"; + +export type StarColors = Record; + +// デフォルト値: 信号機(1回目=緑、2回目=黄、3回目以降=赤)をイメージしつつ、 +// globals.cssのブランドカラー(coral・tealblue)と彩度・明度を揃えて統一感を持たせている。 +// level3はcoral-700そのものを再利用し、level1はtealblue寄りの寒色相、 +// level2はcoralとlevel1の間を橋渡しする暖色のアンバーにしている +export const DEFAULT_STAR_COLORS: StarColors = { + level1: "#3fb6b8", + level2: "#fbbf24 ", + level3: "#d6503d", +}; + +export const STAR_COLOR_LEVELS: StarColorLevel[] = ["level1", "level2", "level3"]; + +export const STAR_COLOR_LABELS: Record = { + level1: "1回目", + level2: "2回目", + level3: "3回目以降", +}; + +const STORAGE_KEY = "vocabook:starColors"; + +// starCount(★を付けた累計回数)に対応する色を返す +// 一度も★を付けていない(0回)場合はundefined=色分け対象外とする +export function starColorFor(starCount: number, colors: StarColors): string | undefined { + if (starCount <= 0) return undefined; + if (starCount === 1) return colors.level1; + if (starCount === 2) return colors.level2; + return colors.level3; +} + +// localStorageから読み込む。SSR中(window未定義)や壊れた保存値の場合はデフォルト値にフォールバックする +export function loadStarColors(): StarColors { + if (typeof window === "undefined") return DEFAULT_STAR_COLORS; + try { + const raw = window.localStorage.getItem(STORAGE_KEY); + if (!raw) return DEFAULT_STAR_COLORS; + const parsed = JSON.parse(raw) as Partial; + return { ...DEFAULT_STAR_COLORS, ...parsed }; + } catch { + return DEFAULT_STAR_COLORS; + } +} + +export function saveStarColors(colors: StarColors) { + if (typeof window === "undefined") return; + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(colors)); +} diff --git a/prisma/migrations/20260816040931_add_card_star/migration.sql b/prisma/migrations/20260816040931_add_card_star/migration.sql new file mode 100644 index 0000000..f3fe301 --- /dev/null +++ b/prisma/migrations/20260816040931_add_card_star/migration.sql @@ -0,0 +1,20 @@ +-- RedefineTables +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_Card" ( + "id" TEXT NOT NULL PRIMARY KEY, + "notebookId" TEXT NOT NULL, + "data" JSONB NOT NULL, + "position" INTEGER NOT NULL, + "starred" BOOLEAN NOT NULL DEFAULT false, + "starCount" INTEGER NOT NULL DEFAULT 0, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "Card_notebookId_fkey" FOREIGN KEY ("notebookId") REFERENCES "Notebook" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); +INSERT INTO "new_Card" ("createdAt", "data", "id", "notebookId", "position", "updatedAt") SELECT "createdAt", "data", "id", "notebookId", "position", "updatedAt" FROM "Card"; +DROP TABLE "Card"; +ALTER TABLE "new_Card" RENAME TO "Card"; +CREATE INDEX "Card_notebookId_position_idx" ON "Card"("notebookId", "position"); +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; diff --git a/prisma/migrations/20260816070045_add_card_view_count/migration.sql b/prisma/migrations/20260816070045_add_card_view_count/migration.sql new file mode 100644 index 0000000..9d6bb8b --- /dev/null +++ b/prisma/migrations/20260816070045_add_card_view_count/migration.sql @@ -0,0 +1,21 @@ +-- RedefineTables +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_Card" ( + "id" TEXT NOT NULL PRIMARY KEY, + "notebookId" TEXT NOT NULL, + "data" JSONB NOT NULL, + "position" INTEGER NOT NULL, + "starred" BOOLEAN NOT NULL DEFAULT false, + "starCount" INTEGER NOT NULL DEFAULT 0, + "viewCount" INTEGER NOT NULL DEFAULT 0, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "Card_notebookId_fkey" FOREIGN KEY ("notebookId") REFERENCES "Notebook" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); +INSERT INTO "new_Card" ("createdAt", "data", "id", "notebookId", "position", "starCount", "starred", "updatedAt") SELECT "createdAt", "data", "id", "notebookId", "position", "starCount", "starred", "updatedAt" FROM "Card"; +DROP TABLE "Card"; +ALTER TABLE "new_Card" RENAME TO "Card"; +CREATE INDEX "Card_notebookId_position_idx" ON "Card"("notebookId", "position"); +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 0f16569..0fb9e60 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -30,6 +30,12 @@ model Card { data Json // Notebook.columns で先頭に来る列を「表」、次を「裏」として暗記学習モードに使う position Int + // 現在★がついているか(復習抽出の対象になるかどうか) + starred Boolean @default(false) + // ★を付けた累計回数。付け外しを繰り返すほど増え、覚えにくさの目安になる + starCount Int @default(0) + // 暗記学習モード(/study, /review)でこの単語がカードとして表示された累計回数 + viewCount Int @default(0) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt From 1a13678f83316358cde1f338eddcb7818f28318e Mon Sep 17 00:00:00 2001 From: amy-mor <283112394+amy-mor@users.noreply.github.com> Date: Sun, 16 Aug 2026 18:06:27 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=E7=A2=BA=E8=AA=8D=E3=83=A1=E3=83=83?= =?UTF-8?q?=E3=82=BB=E3=83=BC=E3=82=B8=E3=82=92oxfmt=E3=81=AE=E6=95=B4?= =?UTF-8?q?=E5=BD=A2=E3=83=AB=E3=83=BC=E3=83=AB=E3=81=AB=E6=B2=BF=E3=81=A3?= =?UTF-8?q?=E3=81=A6=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/my-notebooks/ResetAllStarsButton.tsx | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/components/my-notebooks/ResetAllStarsButton.tsx b/components/my-notebooks/ResetAllStarsButton.tsx index 5e43d78..72f6acc 100644 --- a/components/my-notebooks/ResetAllStarsButton.tsx +++ b/components/my-notebooks/ResetAllStarsButton.tsx @@ -9,11 +9,7 @@ export default function ResetAllStarsButton({ notebookId }: { notebookId: string
{ - if ( - !window.confirm( - "この単語帳の★の回数をすべてリセットします。よろしいですか?", - ) - ) { + if (!window.confirm("この単語帳の★の回数をすべてリセットします。よろしいですか?")) { event.preventDefault(); } }}