0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

[ Next.js15 + Ruby on Rails8 ] フルスタックBlogアプリ開発(CRUD編)

Last updated at Posted at 2025-01-28

概要

 今回はフロントエンドに Next.js、バックエンドに Ruby on Rails を用いたフルスタックの Blog アプリを作成します。

 今回の記事では、基本的な CRUD 操作のみを行いますが、次回以降の記事では、認証機能(ログイン・ログアウト)やバリデーション機能の追加を行います。

 また、この記事の対象者としては、各種環境構築が終了している方を想定しています。環境構築がまだの方は、各自環境設定から宜しくお願いいたします。
 

開発手順

1. バックエンド構築

  • プロジェクト作成
  • controller 作成
  • model 作成
  • マイグレーション
  • ルーティング設定
  • アクション追加
  • API テスト
  • ポート番号変更

2. フロントエンド構築

  • プロジェクト作成
  • ブログ取得
  • ブログ表示
  • 新規ブログ投稿
  • ブログ詳細表示
  • ブログ編集
  • ブログ削除
     

実装

1. バックエンド編

➀ プロジェクト作成

 まずは適当なディレクトリを作成し、そこに移動します。

ターミナル
mkdir blog
cd blog

 
 次に、blog ディレクトリに rails のひな型を作成します。
 今回はフロントエンドを Next.js で書くため --api オプションを付け、API モードでひな型を作成します。
 また、テストファイルは今回無しで進めるので、-T オプションも付けておきます。

ターミナル
rails new backend --api -T

 
 作成が完了したら、backend ディレクトリに移動します。

ターミナル
cd backend

 

➁ controller 作成

 続いて、コントローラーを作成します。
 コントローラー名は複数形にしましょう。
 また、Api : : Blogsとすることで、Api ディレクトリの中に Blogs のコントローラーを作成します。

ターミナル
rails g controller Api::Blogs index show create update destroy

 

➂ model 作成

 続いて、モデルを作成します。
 モデル名は単数形にしましょう。
 また、今回は3つのカラムを作成します。

ターミナル
rails g model Blog title:string author:string content:text

 

➃ マイグレーション

 ③で作成したモデルをマイグレーションします。

ターミナル
rails db:migrate

 

➄ ルーティング設定

 ここでは、リクエストに対して適切なコントローラーのアクションに振り分けます。
 今回はフロントエンドを Next.js で書くため、views に関するアクションは追加しません。

blog / backend / config / routes.rb
Rails.application.routes.draw do
  namespace :api do
    # resources :blogsとすることで BlogsController を参照
    # api/blogsというURLをリクエストしたとき(HTTPメソッドはGET)、indexアクションが実行
    # (以下略)
    # api/blogs/:id GET    -> showアクション
    # api/blogs      POST   -> createアクション
    # api/blogs/:id  PUT    -> updateアクション
    # api/blogs/:id  DELETE -> destroyアクション
    resources :blogs, only: [:index, :show, :create, :update, :destroy]
  end
end

 

➅ アクション追加

 続いて、アクションの詳しい挙動を定義します。

blog / backend / app / controllers / api / blogs_controller.rb
class Api::BlogsController < ApplicationController
  # 一覧表示
  def index
    # Blog.all の Blog は、model の名前
    blogs = Blog.all
    # render でクライアント側にレスポンスを返す
    # json: でJSON形式に変換
    render json: blogs
  end
  
  # 詳細表示
  def show
    # params[:id]で、URLのid部分を取り出す
    # Blog.find -> idを基に、特定のデータをdbから取り出す
    blog = Blog.find(params[:id])
    render json: blog
  end

  # 新規投稿
  def create
    # blog_params -> ストロングパラメーター(コード下部で定義)
    blog = Blog.new(blog_params)
    # if blog.save -> blog 変数が保存されたとき
    if blog.save
      # status: -> ステータスコードを定義
      render json: blog, status: :created
    else
      render json: blog.errors, status: :unprocessable_entity
    end
  end

  # 更新
  def update
    blog = Blog.find(params[:id])
    # if blog.update -> blog 変数が更新されたとき
    if blog.update(blog_params)
      render json: blog, status: :ok
    else
      render json: blog.errors, status: :unprocessable_entity
    end
  end

  # 削除
  def destroy
    blog = Blog.find(params[:id])
    blog.destroy
  end

  # ストロングパラメーターのメソッド(blog_params)は private 以下に書く
  private
  def blog_params
    # params -> クライアントからのリクエストが入ったオブジェクト
    # require の引数には、リクエストのキーを入れる(デフォルトでは、モデル名)
    # permit の引数には、サーバー側で利用するリクエストのデータを指定
    params.require(:blog).permit(:title, :author, :content)
  end
end

 

➆ API テスト

 次に、ローカルサーバーを立てて、実際に API が正しく動くのかをテストします。
 まずは、ローカルサーバーを立ち上げます。

ターミナル
rails s

 
 また、テストには Postman を使用します。
 手順は以下の通りです。(インストールがまだの方は、先にインストールをお願いします。)

スクリーンショット 2025-01-28 113146.png
 
 
 まずは、データを追加してみましょう。POST メソッドを選択し、エンドポイント(ルーティング設定時に自動作成されたルート)を設定します。
 そして、Body -> raw -> JSON を選択し、追加するデータを JSON 形式で定義します。
 この時、コントローラーのストロングパラメーターで定義したカラムのみデータとして定義します。
 最後に、Send ボタンを押すと画面右側、あるいは画面下部に送信されたデータが表示されます。
 これで、ポストできたことが分かりました! API もしっかり叩けているみたいです。

スクリーンショット 2025-01-28 114836.png
 
 
 続いては、このポストしたデータを取得します。
 GET メソッドに変更し、エンドポイントはそのままで Send ボタンを押すと、しっかりデータを取得できています。

スクリーンショット 2025-01-28 115150.png
 
 
 更新も行っておきましょう。
 PUT メソッドに変更し、エンドポイントには id を指定しましょう。
 POST の API を叩いた時と同様に、変更後のデータを入力し、Send ボタンを押すと、データが更新されました。
 念のため、先ほどと同じ手順で GET の API を叩いてみてください。取得したデータは更新されているはずです。

スクリーンショット 2025-01-28 115608.png
 
 
 同じ要領で、show アクション(詳細表示)や、destroy アクション(削除)もテストしてみてください。
 エラーが吐かれなければ、バックエンドの構築は終了です、と言いたいところですが、フロントエンドの開発を行う前にポート番号を変更しておきます。
 

⑧ ポート番号変更

 フロントエンド構築を行う前に、ポート番号を変更します。
 なぜなら、フロントエンド側でサーバーを立てた際、ポート番号が被ってしまうからです。
 そのため、バックエンド側のポート番号を 3001 に変更しておきましょう。
 まずは、gem "rack-cors" のコメントアウトを外します。

blog / backend / Gemfile
# Use Rack CORS for handling Cross-Origin Resource Sharing (CORS), making cross-origin Ajax possible
gem "rack-cors"

 
 その後、以下のコマンドを実行します。

ターミナル
bundle i

 
 続いて、Rails.application. ~ 以下のコメントアウトを外し、origins の値を "localhost:3000" に変更します。

blog / backend / config / initializers / cors.rb
# Be sure to restart your server when you modify this file.

# Avoid CORS issues when API is called from the frontend app.
# Handle Cross-Origin Resource Sharing (CORS) in order to accept cross-origin Ajax requests.

# Read more: https://github.com/cyu/rack-cors

Rails.application.config.middleware.insert_before 0, Rack::Cors do
 allow do
   origins "localhost:3000"

   resource "*",
     headers: :any,
     methods: [:get, :post, :put, :patch, :delete, :options, :head]
 end
end

 
 最後にローカルサーバーを立ちあげます。-p 3001 はポート番号を 3001 番に変更するコマンドです。

ターミナル
rails s -p 3001

 
 これで、バックエンド構築は終了です。お疲れさまでした。
 

2. フロントエンド編

➀ プロジェクト作成

 まずはターミナルを分割して( api 側のサーバーは立ち上げたまま)、backend ディレクトリから、blog ディレクトリに移動します。

ターミナル
cd ..

 
 バックエンド編と同様に、blog ディレクトリに Next.js のひな型を作成します。
 設定の選択を促されますが、すべてデフォルトのままで構いません。

ターミナル
npx create-next-app frontend

 
 作成が完了したら、frontend ディレクトリに移動します。

ターミナル
cd frontend

 

➁ ブログ取得

 フロントエンド側で、API を叩いくことでデータの取得を行います。
 エンドポイントの URL が localhost:3001 になっていることに気を付けましょう。

blog / frontend / src / app / page.tsx
export default async function Home() {
 const getAllBlogs = async () => {
   const res = await fetch("http://localhost:3001/api/blogs", {
     // SSRでデータを取りに行く
     cache: "no-store"
   });
   // JSON形式に変換
   return res.json();
 };
 const blogs = await getAllBlogs();
 console.log("取得したデータは、", blogs);
}

 
 ローカルサーバーを立ち上げて、コンソールへの出力を確認しましょう。
 以下のようになっていれば、しっかりとデータの取得ができています!次は、この取得したデータを表示させましょう。

ターミナル
npm run dev

スクリーンショット 2025-01-28 120420.png
 

➂ ブログ表示

 次は、先ほど取得したデータを表示させます。まずその前に、blogs 変数の型定義をしておきましょう。以下のディレクトリ構成で、types ファイルを作成します。

blog / frontend / src / types / types.ts
// ( blog / backend / db / schema.rb )を参照
// schema.rb に id は定義されていないが、デフォルトで定義されるため、型宣言する必要あり
export interface Blog {
  id: number;
  title: string;
  author: string;
  content: string;
  created_at: Date;
  updated_at: Date;
};

 
また、CSSファイルも編集しておきましょう。

blog / frontend / src / app / globals.css
@tailwind base;
@tailwind components;
@tailwind utilities;

 
 あとは、blog を表示する部分をコンポーネント化し、クライアントコンポーネントとサーバーコンポーネントをそれぞれ独立させます。とりあえず、ひな型だけ作っておきましょう。

blog / frontend / src / components / BlogList.tsx
export default function BlogList() {
  return (
    <></>
  );
}

 
 最後に、ページの遷移先のディレクトリを作成しましょう。
 新規投稿ページ、編集ページ、投稿詳細ページの、計3ページ作成します。
 編集ページと投稿詳細ページは動的ルーティングを実現するために、[id] ディレクトリを作成します。

blog / frontend / src / app / manage / create / page.tsx
export default function CreateBlog() {
 return (
   <></>
 );
}
blog / frontend / src / app / manage / edit / [id] / page.tsx
export default function EditBlog() {
 return (
   <></>
 );
}
blog / frontend / src / app / manage / [id] / page.tsx
export default function SpecificBlog() {
 return (
   <></>
 );
}

 
 お待たせしました。では、実際に表示させましょう。

blog / frontend / src / app / page.tsx
import BlogList from "@/components/BlogList";
import { Blog } from "@/types/types";
import Link from "next/link";

export default async function Home() {
 const getAllBlogs = async () => {
   const res = await fetch("http://localhost:3001/api/blogs", {
     // SSRでデータを取りに行く
     cache: "no-store"
   });
   // JSON形式に変換
   return res.json();
 };
 // 型指定(複数データを取得するため、配列型にしておく)
 const blogs: Blog[] = await getAllBlogs();
 // console.log("取得したデータは、", blogs);

 return (
   <div className="min-h-screen bg-gray-50">
     <div className="bg-blue-600 text-white py-4 shadow-md">
       <div className="container mx-auto px-6 flex justify-between items-center">
         <span className="text-3xl font-bold">Blog</span>
         <div>
           <Link
             href={"manage/create"}
             className="bg-green-500 text-white py-2 px-4 rounded-lg hover:bg-green-600 transition-all"
           >
             新規投稿
           </Link>
         </div>
       </div>
     </div>
     <div className="container mx-auto px-6 py-8">
       {/* BlogList コンポーネント に blogs を渡す*/}
       <BlogList blogs={blogs} />
     </div>
   </div>
 );
}

 
 今回 HTTP リクエストには、axios を使用し、トースト通知には、react-hot-toast を使用します。
 トースト通知を行うために、layout.tsx を少しいじります。

ターミナル
npm i axios react-hot-toast
blog / frontend / src / app / layout.tsx
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { Toaster } from "react-hot-toast";

const geistSans = Geist({
  variable: "--font-geist-sans",
  subsets: ["latin"],
});

const geistMono = Geist_Mono({
  variable: "--font-geist-mono",
  subsets: ["latin"],
});

export const metadata: Metadata = {
  // 任意のタイトル・説明に変更
  title: "Blog App",
  description: "Generated by using next.js & ruby on rails",
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    // en から ja に
    <html lang="ja">
      <body
        className={`${geistSans.variable} ${geistMono.variable} antialiased`}
      >
        {/* トースター通知をさせるために必要 */}
        <Toaster />
        {children}
      </body>
    </html>
  );
}

 
 ここで、BlogList コンポーネントの中身を作成します。
 プロップスを受け取って、それを map 関数で展開しましょう。

blog / frontend / src / components / BlogList.tsx
// クライアントコンポーネント
"use client";

import { Blog } from "@/types/types";
import axios from "axios";
import Link from "next/link";
import { useRouter } from "next/navigation";
import toast from "react-hot-toast";

// props オブジェクトから、blogs プロパティを分割代入で直接取得
export default function BlogList({ blogs }: { blogs: Blog[] }) {
  // ページ遷移に利用
  const router = useRouter();
  const handleDelete = async ( id: number ) => {
    // ➅ ブログの削除で実装
  };

  return (
    <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6 p-6">
      {blogs.map((blog: Blog) => (
        <div 
          key={blog.id} 
          className="bg-white rounded-lg shadow-lg hover:shadow-xl p-6 transition-all transform hover:scale-105 flex flex-col"
        >
          <div className="mb-4">
            <Link href={`/manage/${blog.id}`}>
              <h2 className="text-2xl font-semibold text-gray-800 mb-2 hover:text-blue-600 transition-colors hover:underline">
                {blog.title}
              </h2>
            </Link>
            <div className="text-sm text-gray-500">{blog.author}</div>
          </div>
          <div className="text-gray-700 text-base mb-4 line-clamp-3">
            {blog.content}
          </div>
          <div className="text-sm text-gray-400 text-right mb-4">
            {new Date(blog.created_at).toLocaleDateString()}
          </div>
          <div className="flex justify-between items-center">
            <Link 
              href={`manage/edit/${blog.id}`} 
              className="text-green-500 hover:text-green-600 hover:underline"
            >
              Edit
            </Link>
            <button 
              className="text-red-500 hover:text-red-600 hover:underline"
              onClick={() => handleDelete(blog.id)}
            >
              Delete
            </button>
          </div>
        </div>
      ))}
    </div>
  );
}

 
 これで、投稿が表示されるはずです。
 ローカルサーバーを立ち上げて、確認してみましょう。

ターミナル
npm run dev

スクリーンショット 2025-01-28 123133.png
 
 上記のようになっていたら OK です。続いては、ブログ記事の投稿を行いましょう。
 

➃ ブログの新規投稿

 次は、ブログの新規投稿機能を追加します。

frontend / src / app / manage / create / page.tsx
"use client";

import axios from "axios";
import { useRouter } from "next/navigation";
import { FormEvent, useState } from "react";
import toast from "react-hot-toast";

export default function CreateBlog() {
  const [title, setTitle] = useState<string>("");
  const [author, setAuthor] = useState<string>("");
  const [content, setContent] = useState<string>("");
  const router = useRouter();

  const handleSubmit = async (e: FormEvent) => {
    e.preventDefault();
    const loadingToast = toast.loading("投稿中...");
    try {
      // 第1引数にエンドポイント、第2引数に投稿データ
      await axios.post("http://localhost:3001/api/blogs", {
        title: title,
        author: author,
        content: content
      });
      toast.success("投稿に成功しました!", { id: loadingToast });
      // トースト通知を十分に表示したのちに、ルートディレクトリに遷移
      setTimeout(() => {
        toast.dismiss(loadingToast);
        router.push("/");
      }, 1000);
    } catch (error) {
      toast.error(`投稿に失敗しました。 エラー:${error}`,  { id: loadingToast })
    }
  };

  return (
    <>
      <div className="min-h-screen bg-gray-100 py-12 px-4 sm:px-6 lg:px-8">
        <div className="max-w-lg mx-auto bg-white p-8 rounded-lg shadow-lg">
          <h2 className="text-2xl font-semibold text-gray-700 text-center mb-6">ブログ投稿フォーム</h2>
          <form onSubmit={handleSubmit}>
            <div className="space-y-4">
              <div>
                <label htmlFor="title" className="block text-sm font-medium text-gray-600">タイトル</label>
                <input 
                  id="title"
                  type="text"
                  onChange={(e) => setTitle(e.target.value)}
                  className="mt-1 p-2 w-full border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
                  placeholder="タイトルを入力してください"
                />
              </div>
              <div>
                <label htmlFor="author" className="block text-sm font-medium text-gray-600">名前</label>
                <input 
                  id="author"
                  type="text"
                  onChange={(e) => setAuthor(e.target.value)}
                  className="mt-1 p-2 w-full border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
                  placeholder="名前を入力してください"
                />
              </div>
              <div>
                <label htmlFor="content" className="block text-sm font-medium text-gray-600">内容</label>
                <textarea
                  id="content"
                  onChange={(e) => setContent(e.target.value)}
                  className="mt-1 p-2 w-full h-32 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
                  placeholder="ブログ内容を入力してください"
                />
              </div>
              <div className="mt-6 text-center">
                <button
                  type="submit"
                  className="px-6 py-2 text-white bg-blue-600 rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
                >
                  投稿する
                </button>
              </div>
            </div>
          </form>
        </div>
      </div>
    </>
  );
}

 
 これで、ブログの投稿フォームが作成できました。
 ブログの内容を入力して投稿すると、しっかりホーム画面に投稿が追加されましたね!続いては、ブログを詳細表示できるようにします。

スクリーンショット 2025-01-28 145426.png
 
 

➄ ブログの詳細表示

 次は、ブログの詳細を表示するページを作りましょう。
 ブログのタイトルを押すと、詳細画面に遷移するようにします。

blog / frontend / src / app / manage / [id] / page.tsx
"use client";

import { Blog } from "@/types/types";
import { use, useEffect, useState } from "react";

// URLのパラメータ取得(詳しくは前回の記事参照)
export default function SpecificBlog({ params }: { params: Promise<{ id: number }> }) {
  const [blog, setBlog] = useState<Blog>();
  const [loading, setLoading] = useState<boolean>(true);
  // Promiseデータのラッピング
  const wrapParams = use(params);
  const getSpecificBlog = async ( id: number ) => {
    const res = await fetch(`http://localhost:3001/api/blogs/${id}`,{
      // ISRで取得(30秒ごとにページを再生成)
      next: {revalidate: 30}
    });
    return res.json();
  };

  // 初期値の入れ込み
  useEffect(() => {
    const fetchSpecificBlog = async () => {
      const blog = await getSpecificBlog(wrapParams.id);
      setBlog(blog);
      setLoading(false);
    }
    fetchSpecificBlog();
  }, [wrapParams.id]);

  // ローディング画面
  if (loading) {
    return (
      <div className="flex justify-center items-center h-screen">
        <div className="text-xl text-gray-600">Loading...</div>
      </div>
    );
  }

  if (!blog) {
    return (
      <div className="flex justify-center items-center h-screen">
        <div className="text-xl text-red-500">ブログ記事は存在しません。</div>
      </div>
    );
  }

  return (
    <div className="max-w-3xl mx-auto p-6 bg-white shadow-xl rounded-lg my-8 border border-gray-200">
      <h1 className="text-4xl font-bold text-gray-900 mb-6 leading-tight">
        {blog.title}
      </h1>
      <div className="flex items-center justify-between text-sm text-gray-500 mb-8">
        <div className="flex items-center space-x-2">
          <span className="font-medium">{blog.author}</span>
          <span className="text-gray-400">|</span>
          <span>{new Date(blog.created_at).toLocaleDateString()}</span>
        </div>
      </div>
      <p className="text-gray-700 text-lg leading-relaxed">
        {blog.content}
      </p>
    </div>
  );
}

 
 これで、ブログの詳細が表示されました!
 あとは、編集と削除だけです。頑張りましょう!

スクリーンショット 2025-01-28 151105.png
 
 

➅ ブログの編集

 ブログを編集できるようにします。
 詳細表示機能と大差ないので、サクっと作ります!

blog / frontend / src / app / manage / edit / [id] / page.tsx
"use client";

import axios from "axios";
import { useRouter } from "next/navigation";
import { FormEvent, use, useEffect, useState } from "react";
import toast from "react-hot-toast";

export default function EditBlog({ params }: { params: Promise<{ id: number }> }) {
  const [title, setTitle] = useState<string>("");
  const [author, setAuthor] = useState<string>("");
  const [content, setContent] = useState<string>("");
  const wrapParams = use(params);
  const router = useRouter();

  const getSpecficBlog = async ( id: number ) => {
    const res = await fetch(`http://localhost:3001/api/blogs/${id}`);
    return res.json();
  };

  useEffect(() => {
    const fetchSpecificBlog = async () => {
      const blog = await getSpecficBlog(wrapParams.id);
      setTitle(blog.title);
      setAuthor(blog.author);
      setContent(blog.content);
    };
    fetchSpecificBlog();
  }, [wrapParams.id]);

  const handleSubmit = async ( e: FormEvent ) => {
    e.preventDefault();
    const loadingToast = toast.loading("投稿中...");
    try {
      await axios.put(`http://localhost:3001/api/blogs/${wrapParams.id}`, {
        title: title,
        author: author,
        content:content
      });
      toast.success("更新しました!", { id: loadingToast });
      setTimeout(() => {
        toast.dismiss(loadingToast);
        router.push("/");
      }, 1000);
    } catch (error) {
      toast.error(`更新に失敗しました。${error}`, { id: loadingToast });
    }
  };

  return (
    <div className="min-h-screen bg-gray-100 py-12 px-4 sm:px-6 lg:px-8">
      <div className="max-w-lg mx-auto bg-white p-8 rounded-lg shadow-lg">
        <h2 className="text-2xl font-semibold text-gray-700 text-center mb-6">ブログ編集フォーム</h2>
        <form onSubmit={handleSubmit}>
          <div className="space-y-4">
            <div>
              <label htmlFor="title" className="block text-sm font-medium text-gray-600">タイトル</label>
              <input 
                id="title"
                type="text"
                onChange={(e) => setTitle(e.target.value)}
                className="mt-1 p-2 w-full border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
                value={title}
              />
            </div>
            <div>
              <label htmlFor="author" className="block text-sm font-medium text-gray-600">名前</label>
              <input 
                id="author"
                type="text"
                onChange={(e) => setAuthor(e.target.value)}
                className="mt-1 p-2 w-full border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
                value={author}
              />
            </div>
            <div>
              <label htmlFor="content" className="block text-sm font-medium text-gray-600">内容</label>
              <textarea
                id="content"
                onChange={(e) => setContent(e.target.value)}
                className="mt-1 p-2 w-full h-32 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
                value={content}
              />
            </div>
            <div className="mt-6 text-center">
              <button
                type="submit"
                className="px-6 py-2 text-white bg-blue-600 rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
              >
                更新する
              </button>
            </div>
          </div>
        </form>
      </div>
    </div>
  );
}

 
 これで、編集ができるようになりました。
 早速2つ目の投稿を編集してみましょう!

スクリーンショット 2025-01-28 151854.png
 
 

➆ ブログの削除

 最後にブログの削除を行います!

blog / frontend / src / app / page.tsx
"use client";

import { Blog } from "@/types/types";
import axios from "axios";
import Link from "next/link";
import { useRouter } from "next/navigation";
import toast from "react-hot-toast";

// props オブジェクトから、blogs プロパティを分割代入で直接取得
export default function BlogList({ blogs }: { blogs: Blog[] }) {
  const router = useRouter();
  const handleDelete = async ( id: number ) => {
    // 削除確認ダイアログの表示
    const deleteChecker = window.confirm("本当にこの投稿を削除しますか?");
    // 削除キャンセルが選択された場合、処理を中断
    if (!deleteChecker) {
      return;
    }
    try {
      await axios.delete(`http://localhost:3001/api/blogs/${id}`);
      // ページをリロード(削除ボタン押下後、ページをリロードしないと、投稿が削除されないから)
      router.refresh();
    } catch (error) {
      // id に一意の識別子を割り当てることで、トースト通知の重複を防ぐ
      toast.error(`削除に失敗しました。 エラー:${error}`, {id: "a"});
    }
  };

  return (
    <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6 p-6">
      {blogs.map((blog: Blog) => (
        <div 
          key={blog.id} 
          className="bg-white rounded-lg shadow-lg hover:shadow-xl p-6 transition-all transform hover:scale-105 flex flex-col"
        >
          <div className="mb-4">
            <Link href={`/manage/${blog.id}`}>
              <h2 className="text-2xl font-semibold text-gray-800 mb-2 hover:text-blue-600 transition-colors hover:underline">
                {blog.title}
              </h2>
            </Link>
            <div className="text-sm text-gray-500">{blog.author}</div>
          </div>
          <div className="text-gray-700 text-base mb-4 line-clamp-3">
            {blog.content}
          </div>
          <div className="text-sm text-gray-400 text-right mb-4">
            {new Date(blog.created_at).toLocaleDateString()}
          </div>
          <div className="flex justify-between items-center">
            <Link 
              href={`manage/edit/${blog.id}`} 
              className="text-green-500 hover:text-green-600 hover:underline"
            >
              Edit
            </Link>
            <button 
              className="text-red-500 hover:text-red-600 hover:underline"
              onClick={() => handleDelete(blog.id)}
            >
              Delete
            </button>
          </div>
        </div>
      ))}
    </div>
  );
}

 
削除機能も実装することができました!
一番初めに投稿したブログを削除してみましょう!
これで、一通りの CRUD 操作の実装が完了しました。お疲れさまでした。

スクリーンショット 2025-01-28 152437.png
 

最後に

 かなり、記事が長くなってしまいましたが、これにて CRUD 編終了です。
 次は、認証機能の実装をしていきたいと考えているので、是非そちらもご覧ください!

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?