🐙

【開発ログ】下書き保存と画像アップロード

に公開

いよいよ学習カード登録の実装を始めました。
今回は カード登録機能下書き保存の仕組み についてまとめます。


前回

  • ダッシュボードの学習情報取得(API経由)
  • タイマー設定とセッション管理

今回

  • 公開 / 下書き切り替え機能
  • Firebase Storage での画像アップロード

① DB定義

WordStatus の定義

enum WordStatus {
  draft     // 下書き保存
  published // 公開
}

Wordテーブル

model Word {
  id        String     @id @default(cuid())
  tags      String[]   @default([])
  createdAt DateTime   @default(now())
  jaSurface String
  koSurface String
  imageId   String?    @unique
  status    WordStatus @default(draft) //✅ デフォルトは下書き保存
  userId    String

  studyEvent StudyEvent[]
  image      Image? @relation(fields: [imageId], references: [id])
  user       User   @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@unique([userId, jaSurface])
  @@unique([userId, koSurface])
  @@index([userId, status, createdAt])
}

② APIフロー

登録処理の流れ

  1. 登録ボタンクリック
    フロントから以下を送信:

    • jaSurface(日本語)
    • koSurface(韓国語)
    • imageUrl(画像URL)
    • storagePath(Firebase Storageパス)
    • contentType(MIMEタイプ)
  2. 画像保存
    Image テーブルに必ずレコードを作成。

  3. Word作成 / 更新

    • params.id === "new" → 新規作成
    • それ以外 → 既存レコードを更新
  4. status判定

    • 日本語 + 韓国語 両方あり → published
    • どちらか欠ける → draft
  5. 応答
    保存完了後、Word + Image の情報を返却。


APIコード(抜粋)

// app/api/words/[id]/route.ts
export async function POST(req: NextRequest, { params }: { params: { id: string } }) {
  const { jaSurface, koSurface, imageUrl, storagePath, contentType } = await req.json();

  // user 認証処理は省略

  // ✅ Image 保存
  const image = await prisma.image.create({
    data: { userId: user.id, imageUrl, storagePath, contentType },
  });

  // ✅ status 判定
  const status = jaSurface && koSurface ? "published" : "draft";

  let word;
  if (params.id === "new") {
    word = await prisma.word.create({
      data: {
        userId: user.id,
        jaSurface: jaSurface ?? "",
        koSurface: koSurface ?? "",
        imageId: image.id,
        status,
      },
      include: { image: true },
    });
  } else {
    word = await prisma.word.update({
      where: { id: params.id, userId: user.id },
      data: {
        jaSurface: jaSurface ?? "",
        koSurface: koSurface ?? "",
        status,
        ...(image?.id ? { imageId: image.id } : {}),
      },
      include: { image: true },
    });
  }

  return NextResponse.json(word);
}

?? "" は null / undefined の場合に空文字で保存するための安全対策。



③ コンポーネント作成

登録の流れ:

  1. 写真選択
    ファイルを選択 or 撮影 → Firebase Storage にアップロード → URL取得 & プレビュー表示

  2. 単語入力
    日本語 / 韓国語の表記を入力

  3. 保存
    APIを呼び出し、Word と Image を一緒に保存

    • 両方入力されていれば公開
    • どちらか欠ければ下書き
type WordForm = {
  jaSurface: string;
  koSurface: string;
  imageFile: File | null;
  preview: string | null;
};

export default function WordEditor({ wordId }: { wordId: string }) {
  const [form, setForm] = useState<WordForm>({
    jaSurface: "",
    koSurface: "",
    imageFile: null,
    preview: null,
  });

  const handleChange = (key: "jaSurface" | "koSurface", value: string) =>
    setForm((prev) => ({ ...prev, [key]: value }));

  const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files?.[0] || null;
    if (!file) return;
    setForm((prev) => ({
      ...prev,
      imageFile: file,
      preview: URL.createObjectURL(file),
    }));
  };

  const handleSave = async () => {
    if (!form.imageFile) {
      alert("画像を選択してください。");
      return;
    }

    // ✅ Firebase Storage アップロード
    const storagePath = `images/${form.imageFile.name}-${Date.now()}`;
    const storageRef = ref(storage, storagePath);
    await uploadBytes(storageRef, form.imageFile, {
      contentType: form.imageFile.type,
    });
    const imageUrl = await getDownloadURL(storageRef);

    // ✅ API 呼び出し
    await fetch(`/api/words/${wordId || "new"}`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        jaSurface: form.jaSurface,
        koSurface: form.koSurface,
        imageUrl,
        storagePath,
        contentType: form.imageFile.type,
      }),
    });
  };
}

悩んだこと / 学んだこと

クライアント → サーバー送信の形

① Typeでまとめて送る(オブジェクト一括)
メリット: 引数1つで済む / 型チェックしやすい
デメリット: フィールドが少ない場合は大げさ

② 個々に送る
メリット: シンプルで学習コスト低い
デメリット: フィールド数が増えると管理がバラける

👉 今回はフィールドが少ないけれどTypeつけるのを習慣化したいので、① Typeでまとめて送る方式 を採用。


次回やること

  • UI / デザインの補強
  • 中間点検

Discussion