【麹帳】アップデート

【麹帳】アップデート

投稿日: 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);
  }
};

感じたこと

コンポーネント設計重要!!!

これです。今回多分最初から上手く行っていて、麹調味料/レシピ、詳細画面/一覧画面といいねできる場面ありましたが、全部一つのコンポーネントで使いまわしています。

こういったロジックの修正があった際にも一つのコンポーネントの修正で済んだのですごく楽に感じました。

おわりに

管理画面での追加機能もあるので、また投稿します。

シェア!

Threads
user
吉本茜
山口在住/二児の母
Loading...
記事一覧に戻る
Threads
0