🐙
【開発ログ】下書き保存と画像アップロード
いよいよ学習カード登録の実装を始めました。
今回は カード登録機能 と 下書き保存の仕組み についてまとめます。
前回
- ダッシュボードの学習情報取得(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フロー
登録処理の流れ
-
登録ボタンクリック
フロントから以下を送信:-
jaSurface(日本語) -
koSurface(韓国語) -
imageUrl(画像URL) -
storagePath(Firebase Storageパス) -
contentType(MIMEタイプ)
-
-
画像保存
Imageテーブルに必ずレコードを作成。 -
Word作成 / 更新
-
params.id === "new"→ 新規作成 - それ以外 → 既存レコードを更新
-
-
status判定
- 日本語 + 韓国語 両方あり →
published - どちらか欠ける →
draft
- 日本語 + 韓国語 両方あり →
-
応答
保存完了後、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 の場合に空文字で保存するための安全対策。
③ コンポーネント作成
登録の流れ:
-
写真選択
ファイルを選択 or 撮影 → Firebase Storage にアップロード → URL取得 & プレビュー表示 -
単語入力
日本語 / 韓国語の表記を入力 -
保存
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