👌

【開発ログ】学習カードのUIとDB設計(Next.js + Prisma)

に公開

現在、自分の子どものために教育用WEBアプリを作っています。

前回

  • 認証(ログイン)と保護ルートを実装
  • ダッシュボードの簡単なレイアウト & UI を作成

ダッシュボード

今回

  • 学習カード用のダミーデータを作成
  • 最小限のUI(LearningCard)を実装
  • 学習用データベース(Prisma)を設計

① DB 設計(Prisma)

学習カードを作成するため、単語(Word) と 画像(Image) を 1:1 で結びつけるスキーマにしました。音声はTTSで生成する方針のため、DBには持たせません。

// 画像
model Image {
  id          String   @id @default(cuid())
  imageUrl    String                 // Firebase Storage ダウンロードURL
  storagePath String   @unique       // images/{uid}/{cuid}.jpg
  contentType String?
  createdAt   DateTime @default(now())

  // 所有者
  userId String
  user   User     @relation(fields: [userId], references: [id], onDelete: Cascade)

  // Word 側の imageId が参照する(片側ユニーク → 実質 1:1)
  word   Word?
}

// 単語
enum WordStatus {
  draft
  published
}

model Word {
  id         String     @id @default(cuid())
  koSurface  String
  jaSurface  String
  tags       String[]   @default([])
  status     WordStatus @default(draft)
  createdAt  DateTime   @default(now())

  // 代表画像(null可。設定時は一意 → 1:1)
  imageId String?  @unique
  image   Image?   @relation(fields: [imageId], references: [id])

  // 所有者
  userId String
  user   User     @relation(fields: [userId], references: [id])

  // 同一ユーザー内での重複登録を防止
  @@unique([userId, jaSurface])
  @@unique([userId, koSurface])

  // よく使う絞り込み用
  @@index([userId, status, createdAt])
}

設計のポイント

  • Word と Image は 1 対 1
    Word.imageId を @unique にすることで、設定時は 1:1 を保証。画像未設定(null)は許可。
  • ユーザー単位の一意制約
    @@unique([userId, jaSurface]) / @@unique([userId, koSurface]) により、同じユーザー内ではユニーク、ユーザーが違えば重複OK。
  • 音声はDBに保存しない
    まずは Web Speech API の TTS を使用。必要ならキャッシュを別テーブルで追加予定。

② Type とコンポーネント

  • Word エンティティはDBの完全形。
  • UI には必要最小限の LearningCardData を渡す構成に分離。
// types/lesson.ts
export interface LearningCardData {
  id: string;            // = word.id
  ko: string;            // = word.koSurface
  ja: string;            // = word.jaSurface
  imgUrl: string;        // = word.image?.imageUrl ?? placeholder
  tags: string[];
  status: "draft" | "published";
}

学習カード


③ フォルダ構成(現状)

web/
 └── src/
     ├── app/
     │    └── dashboard/
     │         └── page.tsx         // ダッシュボードページ
     │
     ├── components/
     │    └── learning/
     │         ├── LearningCard.tsx // 単一カードコンポーネント
     │         └── LearningList.tsx // カード一覧(ダミーデータを map)
     │
     ├── lib/
     │    └── mock/
     │         └── lessons.ts       // 学習カード用ダミーデータ
     │
     └── types/
          ├── lesson.ts             // Lesson 型定義
          └── word.ts               // Word 型定義

悩んだこと

  • 単語テーブルに持たせる最小情報の切り分け
  • 音声データをDBに保存するか → 初期はTTS採用
  • type と interface の使い分け

学んだこと

  • @@unique([userId, jaSurface]) により、同一ユーザー内での重複登録を抑止できる。
    @unique jaSurface にすると全ユーザーに跨って一意になってしまうので要注意。

  • type と interface の使い分け(私の指針)

    • UIコンポーネントの Props → interface(拡張しやすく、IDE補完が分かりやすい)
    • DB戻り値から派生したユーティリティ型 → type(Union/Tuple等に強い)
type interface
拡張 &(交差型) extends
同名再定義 できない(エラー) マージされる(宣言合体)
Union / Tuple できる ほぼできない
クラス適用 implements 可能だが非推奨気味 implements と相性良

次回やること

  • Prisma + API 経由で実データ読み込み
  • 画像アップロード(Firebase Storage)
  • 公開/下書き切り替え(Word.status "draft" | "published";)
  • LessonCardの機能やデザイン補強

Discussion