【麹帳】アップデート
投稿日: 2025年02月20日
リリースしたてのアプリ、日々ブラッシュアップしています。
現時点での更新部分をここでアウトプットします。
ここまで必要だなとすぐに気づいて追加しました。
model RecipeComment {
id String @id @default(dbgenerated("uuid_generate_v4()")) @db.Uuid
recipeArticleId String @db.Uuid
userId String @db.Uuid
content String
isRead Boolean @default(false)
createdAt DateTime @default(now()) @map("created_at")
recipeArticle RecipeArticle @relation(fields: [recipeArticleId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
parentId String? @db.Uuid @map("parent_id")
parentComment RecipeComment? @relation("CommentReplies", fields: [parentId], references: [id], onDelete: Cascade)
replies RecipeComment[] @relation("CommentReplies")
@@map("recipe_comments")
}
コメント自体は投稿とのリレーションですが、コメントへの返信も同じテーブル使っていて、親がある場合(=返信である場合)にも対応できるようにしました。
なので、レシピ詳細を開いた時のレスポンスの型は
import { RecipeArticle } from "@prisma/client";
export type Comment = {
id: string;
content: string;
createdDate: Date;
userId: string;
userName: string;
parentComment: {
id: string | null;
content: string | null;
userId: string | null;
userName: string | null;
};
};
export type IndexResponse = {
recipeArticle: RecipeArticle;
maltTitle: string;
postedName: string;
comments: Comment[];
liked: boolean;
saved: boolean;
};
こんな風にしました。
コメントが親コメントを持つんか否かで出力するときに変えます。
そのコメントに親コメントが存在するのかの有無で返信か否かを出力しつつ、というロジックで出力しています。
jsx部分
return (
<>
<div key={comment.id} className="p-2 shadow-sm" id={comment.id}>
<div className="flex justify-between">
<div className="text-[10px] font-bold">{comment.userName}</div>
<div className="text-[10px]">
{isOwnComment && (
<div className="gap-2 flex justify-end">
<button
type="button"
className=""
onClick={() => setEditMode(true)}
>
<FontAwesomeIcon icon={faEdit} />
</button>
<button type="button" className="" onClick={hadleDelete}>
<FontAwesomeIcon icon={faTrash} />
</button>
</div>
)}
{dayjs(comment.createdDate).format("YYYY/M/D")}
</div>
</div>
{editMode ? (
<form ref={formRef} onSubmit={handleEdit} className="relative w-full">
<textarea
value={editedComment}
disabled={isSubmitting}
onChange={e => setEditedComment(e.target.value)}
className="w-full border-[1px] block"
/>
<button
type="submit"
disabled={isSubmitting}
className="absolute right-1 bottom-2 px-1 bg-dark_brown text-white rounded-sm"
>
送信
</button>
</form>
) : (
<div className="flex justify-between flex-col">
{comment.parentComment.id && (
<p className="text-xs text-gray-400">
<span className="font-bold text-sm">
{comment.parentComment.userName}
</span>
さんへの返信
<span className="text-sm line-clamp-2">
{comment.parentComment.content}
</span>
</p>
)}
<div className="whitespace-pre-wrap">{comment.content}</div>
</div>
)}
{session && (
<div className="flex justify-end">
<button
type="button"
onClick={() => setReplyMode(true)}
className=" px-1 bg-dark_brown text-white rounded-sm"
>
返信
</button>
</div>
)}
</div>
{replyMode && (
<form onSubmit={handleReply} className="mt-2">
<textarea
value={replyContent}
onChange={e => setReplyContent(e.target.value)}
placeholder="返信を書く..."
className="w-full border-[1px] block"
/>
<div className="flex justify-end gap-2">
<button
type="submit"
disabled={isSubmitting}
className="mt-2 px-2 bg-dark_brown text-white rounded-sm"
>
送信
</button>
<button
type="button"
onClick={() => setReplyMode(false)}
className="mt-2 px-2 bg-dark_brown text-white rounded-sm"
>
やめる
</button>
</div>
</form>
)}
</>
);
返信のテキストエリアかはステートでモード管理します。
一回クリックするごとにリクエストしていましたが、実際一人の方が数十回いいね押していると明らかな状態が見受けられたので、連打に対応しました。
"use client";
import { api } from "../_utils/api";
import { usePathname } from "next/navigation";
import { KeyedMutator } from "swr";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faHeart as likedHeart } from "@fortawesome/free-solid-svg-icons";
import { faHeart } from "@fortawesome/free-regular-svg-icons";
import { useCallback, useEffect, useState } from "react";
import { PostRequest } from "../_types/Likes/PostRequest";
interface Props<T> {
liked: boolean;
likesCount: number;
articleId: string;
mutate: KeyedMutator<T>;
}
export const Like = <T,>({ liked, likesCount, articleId, mutate }: Props<T>) => {
const pathName = usePathname();
const [isSubmitting, setIsSubmitting] = useState(false);
const [tempLikes, setTempLikes] = useState(0);
const [displayLikes, setDisplayLikes] = useState(likesCount);
const cleanedPath = pathName.includes("malts") ? "malts" : "recipes";
const fetcher = useCallback(async () => {
try {
setIsSubmitting(true);
const body: PostRequest = { likesCount: tempLikes };
await api.post(`/api/${cleanedPath}/${articleId}/like`, body);
mutate();
} catch (error) {
console.error(error);
} finally {
setIsSubmitting(false);
setTempLikes(0);
}
}, [articleId, cleanedPath, mutate, tempLikes]);
useEffect(() => {
if (tempLikes === 0) return;
const timeoutId = setTimeout(fetcher, 500);
return () => clearTimeout(timeoutId);
}, [fetcher, tempLikes]);
const onClick = () => {
if (isSubmitting) return;
setTempLikes(prev => prev + 1);
setDisplayLikes(prev => prev + 1);
};
return (
<div className="flex flex-col items-center relative">
<button type="button" onClick={onClick} disabled={isSubmitting}>
<FontAwesomeIcon
icon={liked ? likedHeart : faHeart}
className="text-dark_brown text-4xl"
/>
</button>
<div>{displayLikes}</div>
</div>
);
};
tempLikesで一回の連打で何回押されたかカウント、displayLikesでハートの下の数値を管理(PropsのlikesCountでDBのデータをdisplayLikesに入れる)
こんな感じのロジックです。
useEffectのクリーンアップ関数とsetTimeoutを利用して連打終了後500ms後のリクエストを実現しました。
実際はいいね押したタイミングで♡が上に上がるアニメーションつけましたが、ここでは割愛しました。
バックエンドは下記のような感じです
import { NextRequest, NextResponse } from "next/server";
import { buildPrisma } from "@/app/_utils/prisma";
import { buildError } from "@/app/api/_utils/buildError";
import { supabase } from "@/app/_utils/supabase";
import { PostRequest } from "@/app/_types/Likes/PostRequest";
interface Props {
params: Promise<{
id: string;
}>;
}
//認証情報あってもなくても良いけど処理が分かれる
export const POST = async (request: NextRequest, { params }: Props) => {
const prisma = await buildPrisma();
const token = request.headers.get("Authorization") ?? "";
try {
const { data } = await supabase.auth.getUser(token);
const { id } = await params;
const { likesCount }: PostRequest = await request.json();
//likesをインクリメント
await prisma.maltArticle.update({
where: {
id,
},
data: {
likes: {
increment: likesCount,
},
},
});
//ログインしてなければreturn
if (!data.user) {
return NextResponse.json(
{
message: "success!",
},
{ status: 200 }
);
}
//ログイン中の人で一回目のいいねならuserActionをcreate
const user = await prisma.user.findUnique({
where: {
supabaseUserId: data.user.id,
},
});
if (!user) {
return NextResponse.json(
{
error: "user is not found!",
},
{ status: 404 }
);
}
const action = await prisma.maltUserAction.findUnique({
where: {
userId_actionType_maltArticleId: {
userId: user.id,
actionType: "LIKE",
maltArticleId: id,
},
},
});
if (action === null) {
await prisma.maltUserAction.create({
data: {
actionType: "LIKE",
userId: user.id,
maltArticleId: id,
},
});
}
return NextResponse.json(
{
message: "success!",
},
{ status: 200 }
);
} catch (e) {
return buildError(e);
}
};
コンポーネント設計重要!!!
これです。今回多分最初から上手く行っていて、麹調味料/レシピ、詳細画面/一覧画面といいねできる場面ありましたが、全部一つのコンポーネントで使いまわしています。
こういったロジックの修正があった際にも一つのコンポーネントの修正で済んだのですごく楽に感じました。
管理画面での追加機能もあるので、また投稿します。