article

Tuesday, November 12, 2024

Nextjs Pagination and Search with Create and Upload Image | Postgresql Prisma

Nextjs Pagination and Search with Create and Upload Image | Postgresql Prisma Next.js

Install requirements
Zod
Zod is a TypeScript-first schema declaration and validation library.
https://www.npmjs.com/package/zod

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 String @id @default(cuid())
title String
image 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

app\post\page.tsx
//app\post\page.tsx
import PostTable from "@/components/table";
import { getDataPages } from "@/lib/data";
import Pagination from "@/components/pagination";
import Link from "next/link";
import { Suspense } from "react";
import { Spinner } from "@/components/spinner";
import Search from "@/components/search";

const Posts = async ({
  searchParams,
}: {
  searchParams?: {
    query?: string;
    page?: string;
  };
}) => {
  
  const query = searchParams?.query || "";
  const currentPage = Number(searchParams?.page) || 1;

  const totalPages = await getDataPages(query);

  console.log(searchParams);
  console.log(query);
  console.log(currentPage);

  return (
    <div className="max-w-screen-lg mx-auto py-14">
      <h1 className="text-4xl font-bold">Nextjs Pagination and Search with Create and Upload Image | Postgresql Prisma</h1>
        <Link
          href="/create"
          className="py-3 px-6 bg-green-700 hover:bg-green-800 text-white"
        >
          New Post
        </Link>
      <div className="flex items-end justify-between m-12">
        <h1 className="text-4xl font-bold">Latest Post</h1>
        <div><Search /></div>
      </div>
      <Suspense key={query + currentPage} fallback={<Spinner />}>
        <PostTable query={query} currentPage={currentPage} />
      </Suspense>
      <div className="flex justify-center mt-4">
        <Pagination totalPages={totalPages} />
      </div>
    </div>
  );
};

export default Posts;
components\table.tsx
//components\table.tsx
import Image from "next/image";
import { formatDate } from "@/lib/utils";
import { getPosts } from "@/lib/data";

const PostTable = async ({
  query,
  currentPage,
}: {
  query: string;
  currentPage: number;
}) => {
  const posts = await getPosts(query, currentPage);

  return (
      <div className="grid md:grid-cols-3 gap-5 mt-10">
        {posts.map((item) => (
          <div key={item.id} className="max-w-sm border border-gray-200 rounded-md shadow">
            <div className="relative aspect-video">
              <Image
                src={`http://localhost:3000/assets/${item.image}`}
                alt={item.title}
                fill
                priority
                sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
                className="rounded-t-md object-cover"
              />
            </div>
            <div className="p-5">
              <h1 className="text-2xl font-bold text-gray-900 truncate">
                {item.title}
              </h1>
            </div>
            <div className="flex items-center justify-between">
              <div className="py-3 text-sm text-white bg-blue-700 rounded-bl-md w-full text-center">{formatDate(item.createdAt.toString())}</div>
            </div>
          </div>
        ))}
      </div>
  );
};

export default PostTable;
components\button.tsx
//components\button.tsx
"use client";

import { useFormStatus } from "react-dom";
import { clsx } from "clsx";

export const SubmitButton = ({ label }: { label: string }) => {
  const { pending } = useFormStatus();
  return (
    <button
      className={clsx(
        "bg-blue-700 text-white w-full font-medium py-2.5 px-6 text-base rounded-sm hover:bg-blue-600",
        {
          "opacity-50 cursor-progress": pending,
        }
      )}
      type="submit"
      disabled={pending}
    >
      {label === "upload" ? (
        <>{pending ? "Uploading..." : "Upload"}</>
      ) : (
        <>{pending ? "Updating..." : "Update"}</>
      )}
    </button>
  );
};
components\upload-form.tsx
//components\upload-form.tsx
"use client";

import React from "react";
import { CreateData } from "@/lib/actions";
import { useFormState } from "react-dom";
import { SubmitButton } from "@/components/button";

const UploadForm = () => {
  const [state, formAction] = useFormState(CreateData, null);

  return (
    <form action={formAction}>  
      {/* Alert */}
      {state?.message ? (
        <div
          className="p-4 mb-4 text-sm text-green-800 rounded-lg bg-green-50"
          role="alert"
        >
          <div className="font-medium">{state?.message}</div>
        </div>
      ) : null}
      <div className="mb-4 pt-2">
        <input
          type="text"
          name="title"
          className="py-2 px-4 rounded-sm border border-gray-400 w-full"
          placeholder="Title..."
        />
        <div aria-live="polite" aria-atomic="true">
          <p className="text-sm text-red-500 mt-2">{state?.error?.title}</p>
        </div>
      </div>
      <div className="mb-4 pt-2">
        <input
          type="file"
          name="image"
          className="file:py-2 file:px-4 file:mr-4 file:rounded-sm file:border-0 file:bg-gray-200 hover:file:bg-gray-300 file:cursor-pointer border border-gray-400 w-full"
        />
        <div aria-live="polite" aria-atomic="true">
          <p className="text-sm text-red-500 mt-2">{state?.error?.image}</p>
        </div>
      </div>
      <div className="mb-4 pt-4">
        <SubmitButton label="upload" />
      </div>
    </form>
  );
};

export default UploadForm;
components\search.tsx
//components\search.tsx
"use client";

import { IoSearch } from "react-icons/io5";
import { useSearchParams, usePathname, useRouter } from "next/navigation";
import { useDebouncedCallback } from "use-debounce"; //npm i use-debounce --save https://www.npmjs.com/package/use-debounce

const Search = () => {
  const searchParams = useSearchParams();
  const pathname = usePathname();
  const { replace } = useRouter();

  const handleSearch = useDebouncedCallback((term: string) => {
    // console.log(term);
    const params = new URLSearchParams(searchParams);
    params.set("page", "1");
    if (term) {
      params.set("query", term);
    } else {
      params.delete("query");
    }
    replace(`${pathname}?${params.toString()}`);
  }, 300);

  return (
    <div className="relative flex flex-1">
      <input
        type="text"
        className="w-full border border-gray-200 py-2 pl-10 text-sm outline-2 rounded-sm"
        placeholder="Search..."
        onChange={(e) => handleSearch(e.target.value)}
        defaultValue={searchParams.get("query")?.toString()}
      />
      <IoSearch className="absolute left-3 top-2 h-5 w-5 text-gray-500" />
    </div>
  );
};

export default Search;
components\Spinner.tsx
//components\Spinner.tsx
export const Spinner = () => {
  return (
    <span className="loading loading-spinner loading-lg">Loading</span>
  );
};
components\pagination.tsx
//components\pagination.tsx
"use client";

import Link from "next/link";
import { HiChevronLeft, HiChevronRight } from "react-icons/hi"; //npm install react-icons --save https://www.npmjs.com/package/react-icons
import { usePathname, useSearchParams } from "next/navigation";
import { generatePagination } from "@/lib/utils";
import clsx from "clsx";

const Pagination = ({ totalPages }: { totalPages: number }) => {
  const pathname = usePathname();
  const searchParams = useSearchParams();
  const currentPage = Number(searchParams.get("page")) || 1;

  const createPageURL = (pageNumber: string | number) => {
    const params = new URLSearchParams(searchParams);
    params.set("page", pageNumber.toString());
    return `${pathname}?${params.toString()}`;
  };

  const allPages = generatePagination(currentPage, totalPages);

  const PaginationNumber = ({
    page,
    href,
    position,
    isActive,
  }: {
    page: number | string;
    href: string;
    position?: "first" | "last" | "middle" | "single";
    isActive: boolean;
  }) => {
    const className = clsx(
      "flex h-10 w-10 items-center justify-center text-sm border",
      {
        "rounded-l-sm": position === "first" || position === "single",
        "rounded-r-sm": position === "last" || position === "single",
        "z-10 bg-blue-100 border-blue-500 text-white": isActive,
        "hover:bg-gray-100": !isActive && position !== "middle",
        "text-gray-300 pointer-events-none": position === "middle",
      }
    );

    return isActive && position === "middle" ? (
      <div className={className}>{page}</div>
    ) : (
      <Link href={href} className={className}>
        {page}
      </Link>
    );
  };
  
  const PaginationArrow = ({
    href,
    direction,
    isDisabled,
  }: {
    href: string;
    direction: "left" | "right";
    isDisabled?: boolean;
  }) => {
    const className = clsx(
      "flex h-10 w-10 items-center justify-center text-sm border",
      {
        "pointer-events-none text-gray-300": isDisabled,
        "hover:bg-gray-100": !isDisabled,
        "mr-2": direction === "left",
        "ml-2": direction === "right",
      }
    );

    const icon =
      direction === "left" ? (
        <HiChevronLeft size={20} />
      ) : (
        <HiChevronRight size={20} />
      );

    return isDisabled ? (
      <div className={className}>{icon}</div>
    ) : (
      <Link href={href} className={className}>
        {icon}
      </Link>
    );
  };

  return (
    <div className="inline-flex">
      <PaginationArrow
        direction="left"
        href={createPageURL(currentPage - 1)}
        isDisabled={currentPage <= 1}
      />
      <div className="flex -space-x-px">
        {allPages.map((page, index) => {
          let position: "first" | "last" | "single" | "middle" | undefined;

          if (index === 0) position = "first";
          if (index === allPages.length - 1) position = "last";
          if (allPages.length === 1) position = "single";
          if (page === "...") position = "middle";

          return (
            <PaginationNumber
              key={index}
              href={createPageURL(page)}
              page={page}
              position={position}
              isActive={currentPage === page} 
            />
          );
        })}
      </div>
      <PaginationArrow
        direction="right"
        href={createPageURL(currentPage + 1)}
        isDisabled={currentPage >= totalPages}
      />
    </div>
  );
};

export default Pagination;
app\create\page.tsx
//app\create\page.tsx
import UploadForm from "@/components/upload-form";

const CreatePage = () => {
  return (
    <div className="min-h-screen flex items-center justify-center bg-slate-100">
      <div className="bg-white rounded-sm shadow p-8">
        <h1 className="text-2xl font-bold mb-5">New Post</h1>
        <UploadForm />
      </div>
    </div>
  );
};

export default CreatePage;
lib\actions.ts
//lib\actions.ts
"use server";

import { z } from "zod"; //https://www.npmjs.com/package/zod
import { prisma } from "@/lib/prisma";
import { writeFile } from "fs/promises";
import path from "path";
import { redirect } from 'next/navigation'
import { revalidatePath } from 'next/cache'

const UploadSchema = z.object({
  title: z.string().min(1),
  image: z
    .instanceof(File)
    .refine((file) => file.size > 0, { message: "Image is required" })
    .refine((file) => file.size === 0 || file.type.startsWith("image/"), {
      message: "Only images are allowed",
    })
    .refine((file) => file.size < 4000000, {
      message: "Image must less than 4MB",
    }),
});

export const CreateData = async (prevState: unknown, formData: FormData) => {
    const validatedFields = UploadSchema.safeParse(
      Object.fromEntries(formData.entries())
    );

    if (!validatedFields.success) {
      return {
        error: validatedFields.error.flatten().fieldErrors,
      };
    }

    const file = formData.get("image");
    const { title } = validatedFields.data;

    try {
      const buffer = Buffer.from(await file.arrayBuffer());
      const filename =  file.name.replaceAll(" ", "_");
      console.log(filename);

      await writeFile(
        path.join(process.cwd(), "public/assets/" + filename),
        buffer
      );

      await prisma.post.create({
          data: {
            title,
            image: filename,
          },
      });

      //return { message: "Success" };
    } catch (error) {
      console.log("Error occured ", error);
      return { message: "Failed" };
    }

  revalidatePath("/post");
  redirect("/post");
};
lib\data.ts
//lib\data.ts
import { prisma } from "@/lib/prisma";

const ITEMS_PER_PAGE = 3;

export const getPosts = async (query: string, currentPage: number) => {
  const offset = (currentPage - 1) * ITEMS_PER_PAGE;
  try {
    const posts = await prisma.post.findMany({
      skip: offset,
      take: ITEMS_PER_PAGE,
      where: {
        OR: [
          {
            title: {
              contains: query,
              mode: "insensitive",
            },
          },
        ],
      },
      orderBy: { createdAt: "desc" },
    });
    return posts;
  } catch (error) {
    throw new Error("Failed to fetch contact data");
  }
};

export const getDataPages = async (query: string) => {
  try {
    const posts = await prisma.post.count({
      where: {
        OR: [
          {
            title: {
              contains: query,
              mode: "insensitive",
            },
          },
        ],
      },
    orderBy: { createdAt: "desc" },
    });
    const totalPages = Math.ceil(Number(posts) / ITEMS_PER_PAGE);
    return totalPages;
  } catch (error) {
    throw new Error("Failed to fetch contact data");
  }
};
lib\prisma.ts
//lib\prisma.ts
import { PrismaClient } from "@prisma/client";
 
declare global {
  var prisma: PrismaClient | undefined;
}
 
export const prisma = globalThis.prisma || new PrismaClient();
 
if (process.env.NODE_ENV !== "production") globalThis.prisma = prisma;
lib\utils.ts
//lib\utils.ts
export const formatDate = (dateStr: string) => {
  const date = new Date(dateStr);
  const formatter = new Intl.DateTimeFormat("id-ID", {
    dateStyle: "medium",
  });
  return formatter.format(date);
};

export const generatePagination = (currentPage: number, totalPages: number) => {
  if (totalPages <= 7) {
    return Array.from({ length: totalPages }, (_, i) => i + 1);
  }

  if (currentPage <= 3) {
    return [1, 2, 3, "...", totalPages - 1, totalPages];
  }

  if (currentPage >= totalPages - 2) {
    return [1, 2, 3, "...", totalPages - 2, totalPages - 1, totalPages];
  }

  return [
    1,
    "...",
    currentPage - 1,
    currentPage,
    currentPage + 1,
    "...",
    totalPages,
  ];
};
prisma\schema.prisma
//prisma\schema.prisma
generator client {
  provider = "prisma-client-js"
}

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

model Post {
  id        String   @id @default(cuid())
  title     String
  image     String
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}
.env DATABASE_URL="postgresql://postgres:admin@localhost:5432/postgresDB?schema=public"

run C:\nextjs>npm run dev

Related Post