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..72f6acc --- /dev/null +++ b/components/my-notebooks/ResetAllStarsButton.tsx @@ -0,0 +1,25 @@ +"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