article

Tuesday, July 2, 2024

Nextjs 14 CRUD (Create Read Update and Delete) with Kinde authentication | Postgresql Prisma | TailwindCSS DaisyUI

Nextjs 14 CRUD (Create Read Update and Delete) with Kinde authentication | Postgresql Prisma | TailwindCSS DaisyUI

Prisma is an open-source next-generation ORM. It consists of the following parts: Prisma Client: Auto-generated and type-safe query builder
https://www.prisma.io

Install prisma/client
npm install @prisma/client
https://www.prisma.io/docs/orm/prisma-client/setup-and-configuration/generating-prisma-client

Generate Prisma Client with the following command:
npx prisma generate

Install prisma
npm install prisma --save-dev
https://www.prisma.io/docs/getting-started/quickstart

set up Prisma with the init command of the Prisma CLI:
npx prisma init --datasource-provider sqlite
to npx prisma init --datasource-provider postgres

Model data in the Prisma schema
prisma/schema.prisma
model Post {
id Int @id @default(autoincrement())
title String
body String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
Run a migration to create your database tables with Prisma Migrate
npx prisma migrate dev --name init

.env
DATABASE_URL="postgresql://postgres:admin@localhost:5432/postgresDB?schema=public"

https://www.prisma.io/docs/orm/prisma-client/queries/crud

https://nextjs.org/docs/app/building-your-application/routing/route-handlers

install Kinde NextJS
npm i @kinde-oss/kinde-auth-nextjs
https://www.npmjs.com/package/@kinde-oss/kinde-auth-nextjs
https://kinde.notion.site/Next-js-App-Router-v2-e7a16d8ae38e45b6ad052910075e24ef

https://kinde.com/

app\page.tsx
//app\page.tsx
import prisma from "@/lib/db";

import { Suspense } from "react";
import Image from "next/image";

export default async function Home() {
//export default function Home() {
  await new Promise((resolve) => setTimeout(resolve, 1000));
  const posts = await prisma.post.findMany();

  return (
    <main className="text-center pt-32 px-5">
      <h1 className="text-4xl md:text-5xl font-bold mb-5">
        Our Blog
      </h1>
      <Suspense fallback="Loading...">
        
      <ul>
        {posts.map((post) => (
          <li key={post.id} className="mb-3">
          <div className="card lg:card-side bg-base-100 shadow-xl">
            <figure>
            <Image
              src="https://img.daisyui.com/images/stock/photo-1494232410401-ad00d5433cfa.jpg"
              alt="title"
              width="305"
              height="305"
            />
            </figure>
            <div className="card-body">
              <h2 className="card-title">{post.title}</h2>
              <p>{post.body}</p>
              <div className="card-actions justify-end">
                <button className="btn btn-primary">Details</button>
              </div>
            </div>
          </div>
          </li>
        ))}
      </ul>
      </Suspense>
    </main>
  );
}
app\layout.tsx
//app\layout.tsx
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import Header from "@/components/header";
import Footer from "@/components/footer";
import Container from "@/components/container";

const inter = Inter({ subsets: ["latin"] });

export const metadata: Metadata = {
  title: "My Posts",
  description: "Generated by create next app",
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body>
        <Container>
          <Header />
          {children}
          <Footer />
        </Container>
      </body>
    </html>
  );
}
components\container.tsx
//components\container.tsx
export default function Container({ children }: { children: React.ReactNode }) {
  return (
    <div className="max-w-[1100px] mx-auto bg-white min-h-screen flex flex-col">
      {children}
    </div>
  );
}
components\delete.tsx
//components\delete.tsx
import { deletePost } from "@/actions/actions";

export const DeleteButton = ({ id }: { id: string }) => {
  const DeletedeletePostWithId = deletePost.bind(null, id);
  return (
    <form action={DeletedeletePostWithId}>
      <button className="btn btn-error">
        Delete
      </button>
    </form>
  );
};
components\editform.tsx
//components\editform.tsx
"use client";
 
import { updatePost } from "@/actions/actions";
import { useFormState } from "react-dom";
import type { Post } from "@prisma/client";
 
const UpdateForm = ({ post }: { post: Post }) => {
    const UpdatePostWithId = updatePost.bind(null, post.id);
    const [state, formAction] = useFormState(UpdatePostWithId, null);
 
    return (
    <div className="flex flex-col max-w-[400px] mx-auto gap-2 my-10">
      <form action={formAction}>
        <div className="w-full">
          <label htmlFor="name" className="block text-sm font-medium text-gray-900">
            Title
          </label>
          <input
            type="text"
            name="title"
            id="title"
            className="input input-bordered input-primary w-full max-w-xs"
            placeholder="Title..."
            defaultValue={post.title}
          />
          <div id="name-error" aria-live="polite" aria-atomic="true">
            <p className="mt-2 text-sm text-red-500">{state?.Error?.title}</p>
          </div>
        </div>
        <div className="w-full">
          <label htmlFor="name" className="block text-sm font-medium text-gray-900">
            Description
          </label>
            <textarea
                name="body"
                id="body"
                placeholder="Body content for new post"
                className="textarea textarea-primary textarea-xs w-full max-w-xs"
                rows={6}
                defaultValue={post.body}
                required
            />
          <div id="name-error" aria-live="polite" aria-atomic="true">
            <p className="mt-2 text-sm text-red-500">{state?.Error?.body}</p>
          </div>
        </div>

        <div id="message-error" aria-live="polite" aria-atomic="true">
          <p className="mt-2 text-sm text-red-500">{state?.message}</p>
        </div>
        <button className="btn btn-primary">Update</button>
      </form>
    </div>
  );
};
 
export default UpdateForm;
components\footer.tsx
//components\footer.tsx
export default function Footer() {
  return (
    <footer className="mt-auto text-center text-zinc-400 py-5 px-7">
      <small>All rights reserved.</small>
    </footer>
  );
}
components\form.tsx
//components\form.tsx
import { createPost } from "@/actions/actions";
import React from "react";

export default function Form() {
  return (
    <form
      action={createPost}
      className="flex flex-col max-w-[400px] mx-auto gap-2 my-10"
    >
      <input
        type="text"
        name="title"
        placeholder="Title for new post"
        className="border rounded px-3 h-10"
        required
      />
      <textarea
        name="body"
        placeholder="Body content for new post"
        className="border rounded px-3 py-2"
        rows={6}
        required
      />
      <button className="h-10 bg-blue-500 px-5 rounded text-white">
        Submit
      </button>
    </form>
  );
}
components\header.tsx
//components\header.tsx
"use client";

import Link from "next/link";
import { usePathname } from "next/navigation";

const navLinks = [
  {
    href: "/",
    label: "Home",
  },
  {
    href: "/posts",
    label: "Admin",
  },
];

export default function Header() {
  const pathname = usePathname();

  return (
    <header className="flex justify-between items-center py-4 px-7">
      <Link href="https://www.youtube.com/@cairocoders">
        Cairocoders
      </Link>

      <nav>
        <ul className="flex gap-x-5 text-[14px]">
          {navLinks.map((link) => (
            <li key={link.href}>
              <Link
                className={`${
                  pathname === link.href ? "text-zinc-900" : "text-zinc-400"
                }`}
                href={link.href}
              >
                {link.label}
              </Link>
            </li>
          ))}
        </ul>
      </nav>
    </header>
  );
}
components\posts-list.tsx
//components\posts-list.tsx
import prisma from "@/lib/db";
import Link from "next/link";
import { DeleteButton } from "@/components/delete";

export default async function PostsList() {
  await new Promise((resolve) => setTimeout(resolve, 1000));
  const posts = await prisma.post.findMany();

  return (
        <table className="table table-zebra">
        <thead className="text-sm text-gray-700 uppercase bg-gray-50">
            <tr>
            <th className="py-3 px-6">#</th>
            <th className="py-3 px-6">Title</th>
            <th className="py-3 px-6">Description</th>
            <th className="py-3 px-6 text-center">Actions</th>
            </tr>
        </thead>
        <tbody>
            {posts.map((post) => (
            <tr key={post.id} className="bg-white border-b">
                <td className="py-3 px-6">{post.id}</td>
                <td className="py-3 px-6">{post.title}</td>
                <td className="py-3 px-6">{post.body}</td>
                <td className="flex justify-center gap-1 py-3">
                    <button className="btn btn-info">
                      <Link href={`/posts/${post.id}`}>View</Link>
                    </button>
                    <Link
                        href={`/edit/${post.id}`} 
                        className="btn btn-info"
                        >
                        Edit
                    </Link>
                    <DeleteButton id={post.id} />
                </td>
            </tr>
            ))}
        </tbody>
        </table>
  );
}
lib\db.ts
//lib\db.ts
import { PrismaClient } from "@prisma/client";

const prismaClientSingleton = () => {
  return new PrismaClient();
};

declare const globalThis: {
  prismaGlobal: ReturnType<typeof prismaClientSingleton>;
} & typeof global;

const prisma = globalThis.prismaGlobal ?? prismaClientSingleton();

export default prisma;

if (process.env.NODE_ENV !== "production") globalThis.prismaGlobal = prisma;
posts\page.tsx
//posts\page.tsx
import PostsList from "@/components/posts-list";
import { Suspense } from "react";

import { getKindeServerSession } from "@kinde-oss/kinde-auth-nextjs/server";
import { redirect } from "next/navigation";
import Link from "next/link";
import { LogoutLink } from "@kinde-oss/kinde-auth-nextjs/components";

export default async function Page() {

  // auth check
  const { isAuthenticated } = getKindeServerSession();
  if (!(await isAuthenticated())) {
    redirect("/api/auth/login");
  }

  return (
    <main className="text-center pt-16 px-5">
      <h1 className="text-4xl md:text-5xl font-bold mb-5">All posts</h1>
      <span className="btn btn-info">
          <Link href="/create-post">Create post</Link>
      </span>
      <span className="btn btn-error"><LogoutLink>Log out</LogoutLink></span>

      <Suspense fallback="Loading...">
        <PostsList />
      </Suspense>
    </main>
  );
}
posts\[id]\page.tsx
//posts\[id]\page.tsx
import prisma from "@/lib/db";
import { notFound } from "next/navigation";

export default async function Page({ params }: { params: { id: string } }) {
  const post = await prisma.post.findUnique({
    where: {
      id: parseInt(params.id),
    },
  });
  if (!post) {
    notFound();
  }

  return (
    <main className="px-7 pt-24 text-center">
      <h1 className="text-5xl font-semibold mb-7">{post.title}</h1>
      <p className="max-w-[700px] mx-auto">{post.body}</p>
    </main>
  );
}
create-post\page.tsx
//create-post\page.tsx
import Form from "@/components/form";
import { getKindeServerSession } from "@kinde-oss/kinde-auth-nextjs/server";
import { redirect } from "next/navigation";

export default async function Page() {
  // auth check
  const { isAuthenticated } = getKindeServerSession();
  if (!(await isAuthenticated())) {
    redirect("/api/auth/login");
  }
  
  return (
    <main className="text-center pt-16">
      <h1 className="text-4xl md:text-5xl font-bold mb-5">Create post</h1>
      <Form />
    </main>
  );
}
edit\[id]\page.tsx
//edit\[id]\page.tsx
import UpdateForm from "@/components/editform";
import prisma from "@/lib/db";
import { notFound } from "next/navigation";

export default async function Page({ params }: { params: { id: string } }) {
    console.log(params.id);
    const post = await prisma.post.findUnique({
        where: {
        id: parseInt(params.id),
        },
    });
    if (!post) {
        notFound();
    }
 
    return (
    <div className="max-w-md mx-auto w-full mt-5">
        <h1 className="text-2xl text-center mb-2">Update Post</h1>
        <UpdateForm post={post}/>
    </div>
  );
}
api\auth\[kindeAuth]\route.js
//api\auth\[kindeAuth]\route.js
import { handleAuth } from "@kinde-oss/kinde-auth-nextjs/server";
export const GET = handleAuth();
actions\actions.ts
//actions\actions.ts
"use server";

import { z } from "zod"; //npm i zod https://www.npmjs.com/package/zod
import prisma from "@/lib/db";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";

const PostSchema = z.object({
  title: z.string().min(3),
  body: z.string().min(20),
});

export async function createPost(formData: FormData) {

  const title = formData.get("title") as string;
  const body = formData.get("body") as string;

  console.log(title);
  console.log(body);
  // update database
  await prisma.post.create({
    data: {
      title,
      body,
    },
  });

  // revalidate
  revalidatePath("/posts");
}

export const updatePost = async (
  id: string,
  prevSate: any,
  formData: FormData
) => {
  const validatedFields = PostSchema.safeParse(
    Object.fromEntries(formData.entries())
  );
 
  if (!validatedFields.success) {
    return {
      Error: validatedFields.error.flatten().fieldErrors,
    };
  }

  try {
    await prisma.post.update({
      data: {
        title: validatedFields.data.title,
        body: validatedFields.data.body,
      },
      where: { id },
    });
  } catch (error) {
    return { message: "Failed to update" };
  }
  
  revalidatePath("/posts");
  redirect("/posts");
};

export const deletePost = async (id: string) => {
  try {
    await prisma.post.delete({
      where: { id },
    });
  } catch (error) {
    return { message: "Failed to delete post" };
  }
 
  revalidatePath("/posts");
};
prisma\schema.prisma
//prisma\schema.prisma
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model Post {
  id        Int      @id @default(autoincrement())
  title     String
  body      String
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}
.env
//.env
DATABASE_URL="postgresql://postgres:admin@localhost:5432/postgresDB?schema=public" 
.env.local
//.env.local
KINDE_CLIENT_ID=cairocoders
KINDE_CLIENT_SECRET=cairocodersednalan
KINDE_ISSUER_URL=https://cairocoders.kinde.com
KINDE_SITE_URL=http://localhost:3000
KINDE_POST_LOGOUT_REDIRECT_URL=http://localhost:3000
KINDE_POST_LOGIN_REDIRECT_URL=http://localhost:3000/dashboard
run C:\nextjs>npm run dev

Related Post