├── .gitignore ├── README.md ├── components.json ├── eslint.config.mjs ├── next.config.ts ├── package-lock.json ├── package.json ├── postcss.config.mjs ├── prisma ├── migrations │ ├── 20250204142451_model │ │ └── migration.sql │ ├── 20250205043928_model │ │ └── migration.sql │ ├── 20250205055921_model │ │ └── migration.sql │ ├── 20250205063006_article │ │ └── migration.sql │ ├── 20250205063712_comment │ │ └── migration.sql │ ├── 20250205070256_article │ │ └── migration.sql │ ├── 20250205143026_comment │ │ └── migration.sql │ ├── 20250205143339_comment │ │ └── migration.sql │ ├── 20250205161501_comment │ │ └── migration.sql │ ├── 20250205165041_like │ │ └── migration.sql │ ├── 20250208053517_is_liked │ │ └── migration.sql │ └── migration_lock.toml └── schema.prisma ├── public ├── file.svg ├── globe.svg ├── next.svg ├── vercel.svg └── window.svg ├── src ├── actions │ ├── create-article.ts │ ├── create-comment.ts │ ├── delete-article.ts │ ├── like-toggle.ts │ ├── search.ts │ └── update-article.ts ├── app │ ├── (auth) │ │ ├── sign-in │ │ │ └── [[...sign-in]] │ │ │ │ └── page.tsx │ │ └── sign-up │ │ │ └── [[...sign-up]] │ │ │ └── page.tsx │ ├── (home) │ │ ├── layout.tsx │ │ └── page.tsx │ ├── articles │ │ ├── [id] │ │ │ └── page.tsx │ │ └── page.tsx │ ├── dashboard │ │ ├── articles │ │ │ ├── [id] │ │ │ │ └── edit │ │ │ │ │ └── page.tsx │ │ │ └── create │ │ │ │ └── page.tsx │ │ ├── layout.tsx │ │ ├── loading.tsx │ │ └── page.tsx │ ├── favicon.ico │ ├── globals.css │ └── layout.tsx ├── components │ ├── articles │ │ ├── actions │ │ │ └── like-button.tsx │ │ ├── all-articles-page.tsx │ │ ├── article-detail-page.tsx │ │ ├── article-search-input.tsx │ │ ├── create-articles-page.tsx │ │ └── edit-articles-page.tsx │ ├── comments │ │ ├── comment-form.tsx │ │ └── comment-list.tsx │ ├── dark-mode.tsx │ ├── dashboard │ │ ├── dashboard-page.tsx │ │ ├── recent-articles.tsx │ │ └── sidebar.tsx │ ├── home │ │ ├── blog-footer.tsx │ │ ├── header │ │ │ ├── navbar.tsx │ │ │ └── search-input.tsx │ │ ├── hero-section.tsx │ │ └── top-articles.tsx │ ├── theme-provider.tsx │ └── ui │ │ ├── avatar.tsx │ │ ├── button.tsx │ │ ├── card.tsx │ │ ├── dropdown-menu.tsx │ │ ├── input.tsx │ │ ├── label.tsx │ │ ├── sheet.tsx │ │ ├── skeleton.tsx │ │ └── table.tsx ├── lib │ ├── prisma.ts │ ├── query │ │ └── fetch-articles.ts │ └── utils.ts └── middleware.ts ├── tailwind.config.ts └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.* 7 | .yarn/* 8 | !.yarn/patches 9 | !.yarn/plugins 10 | !.yarn/releases 11 | !.yarn/versions 12 | 13 | # testing 14 | /coverage 15 | 16 | # next.js 17 | /.next/ 18 | /out/ 19 | 20 | # production 21 | /build 22 | 23 | # misc 24 | .DS_Store 25 | *.pem 26 | 27 | # debug 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | .pnpm-debug.log* 32 | 33 | # env files (can opt-in for committing if needed) 34 | .env* 35 | 36 | # vercel 37 | .vercel 38 | 39 | # typescript 40 | *.tsbuildinfo 41 | next-env.d.ts 42 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | # or 10 | yarn dev 11 | # or 12 | pnpm dev 13 | # or 14 | bun dev 15 | ``` 16 | 17 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 18 | 19 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. 20 | 21 | This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. 22 | 23 | ## Learn More 24 | 25 | To learn more about Next.js, take a look at the following resources: 26 | 27 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 28 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 29 | 30 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! 31 | 32 | ## Deploy on Vercel 33 | 34 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 35 | 36 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. 37 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "src/app/globals.css", 9 | "baseColor": "zinc", 10 | "cssVariables": false, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | }, 20 | "iconLibrary": "lucide" 21 | } -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import { dirname } from "path"; 2 | import { fileURLToPath } from "url"; 3 | import { FlatCompat } from "@eslint/eslintrc"; 4 | 5 | const __filename = fileURLToPath(import.meta.url); 6 | const __dirname = dirname(__filename); 7 | 8 | const compat = new FlatCompat({ 9 | baseDirectory: __dirname, 10 | }); 11 | 12 | const eslintConfig = [ 13 | ...compat.extends("next/core-web-vitals", "next/typescript"), 14 | ]; 15 | 16 | export default eslintConfig; 17 | -------------------------------------------------------------------------------- /next.config.ts: -------------------------------------------------------------------------------- 1 | import type { NextConfig } from "next"; 2 | 3 | const nextConfig: NextConfig = { 4 | /* config options here */ 5 | images:{ 6 | remotePatterns:[ 7 | { 8 | protocol: 'https', 9 | hostname: 'images.unsplash.com' 10 | }, 11 | { 12 | protocol: 'https', 13 | hostname: 'res.cloudinary.com' 14 | } 15 | ] 16 | } 17 | }; 18 | 19 | export default nextConfig; 20 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "blogapp", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev --turbopack", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@auth/core": "^0.37.4", 13 | "@auth/prisma-adapter": "^2.7.4", 14 | "@clerk/nextjs": "^6.10.3", 15 | "@prisma/client": "^6.2.1", 16 | "@radix-ui/react-avatar": "^1.1.2", 17 | "@radix-ui/react-dialog": "^1.1.5", 18 | "@radix-ui/react-dropdown-menu": "^2.1.5", 19 | "@radix-ui/react-label": "^2.1.1", 20 | "@radix-ui/react-slot": "^1.1.1", 21 | "class-variance-authority": "^0.7.1", 22 | "cloudinary": "^2.5.1", 23 | "clsx": "^2.1.1", 24 | "framer-motion": "^12.0.6", 25 | "imagekitio-next": "^1.0.1", 26 | "lucide-react": "^0.474.0", 27 | "next": "15.1.6", 28 | "next-auth": "^5.0.0-beta.25", 29 | "next-themes": "^0.4.4", 30 | "react": "^19.0.0", 31 | "react-dom": "^19.0.0", 32 | "react-draft-wysiwyg": "^1.15.0", 33 | "react-quill": "^2.0.0", 34 | "react-quill-new": "^3.3.3", 35 | "svix": "^1.45.1", 36 | "tailwind-merge": "^2.6.0", 37 | "tailwindcss-animate": "^1.0.7", 38 | "zod": "^3.24.1" 39 | }, 40 | "devDependencies": { 41 | "@eslint/eslintrc": "^3", 42 | "@types/node": "^20", 43 | "@types/react": "^19", 44 | "@types/react-dom": "^19", 45 | "eslint": "^9", 46 | "eslint-config-next": "15.1.6", 47 | "postcss": "^8", 48 | "prisma": "^6.2.1", 49 | "tailwindcss": "^3.4.1", 50 | "typescript": "^5" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').Config} */ 2 | const config = { 3 | plugins: { 4 | tailwindcss: {}, 5 | }, 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /prisma/migrations/20250204142451_model/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "User" ( 3 | "id" TEXT NOT NULL, 4 | "clerkUserId" TEXT NOT NULL, 5 | "email" TEXT NOT NULL, 6 | "name" TEXT NOT NULL, 7 | "imageUrl" TEXT, 8 | "role" TEXT, 9 | 10 | CONSTRAINT "User_pkey" PRIMARY KEY ("id") 11 | ); 12 | 13 | -- CreateTable 14 | CREATE TABLE "Articles" ( 15 | "id" TEXT NOT NULL, 16 | "title" TEXT NOT NULL, 17 | "content" TEXT NOT NULL, 18 | "category" TEXT NOT NULL, 19 | "featuredImage" TEXT NOT NULL, 20 | "authorId" TEXT NOT NULL, 21 | 22 | CONSTRAINT "Articles_pkey" PRIMARY KEY ("id") 23 | ); 24 | 25 | -- CreateTable 26 | CREATE TABLE "Comment" ( 27 | "id" TEXT NOT NULL, 28 | "name" TEXT NOT NULL, 29 | "email" TEXT NOT NULL, 30 | "body" TEXT NOT NULL, 31 | "postId" TEXT NOT NULL, 32 | 33 | CONSTRAINT "Comment_pkey" PRIMARY KEY ("id") 34 | ); 35 | 36 | -- CreateIndex 37 | CREATE UNIQUE INDEX "User_clerkUserId_key" ON "User"("clerkUserId"); 38 | 39 | -- CreateIndex 40 | CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); 41 | 42 | -- AddForeignKey 43 | ALTER TABLE "Articles" ADD CONSTRAINT "Articles_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; 44 | 45 | -- AddForeignKey 46 | ALTER TABLE "Comment" ADD CONSTRAINT "Comment_postId_fkey" FOREIGN KEY ("postId") REFERENCES "Articles"("id") ON DELETE CASCADE ON UPDATE CASCADE; 47 | -------------------------------------------------------------------------------- /prisma/migrations/20250205043928_model/migration.sql: -------------------------------------------------------------------------------- 1 | -- DropForeignKey 2 | ALTER TABLE "Articles" DROP CONSTRAINT "Articles_authorId_fkey"; 3 | 4 | -- AddForeignKey 5 | ALTER TABLE "Articles" ADD CONSTRAINT "Articles_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 6 | -------------------------------------------------------------------------------- /prisma/migrations/20250205055921_model/migration.sql: -------------------------------------------------------------------------------- 1 | -- DropForeignKey 2 | ALTER TABLE "Articles" DROP CONSTRAINT "Articles_authorId_fkey"; 3 | 4 | -- DropForeignKey 5 | ALTER TABLE "Comment" DROP CONSTRAINT "Comment_postId_fkey"; 6 | 7 | -- AddForeignKey 8 | ALTER TABLE "Articles" ADD CONSTRAINT "Articles_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("clerkUserId") ON DELETE RESTRICT ON UPDATE CASCADE; 9 | 10 | -- AddForeignKey 11 | ALTER TABLE "Comment" ADD CONSTRAINT "Comment_postId_fkey" FOREIGN KEY ("postId") REFERENCES "Articles"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 12 | -------------------------------------------------------------------------------- /prisma/migrations/20250205063006_article/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "Articles" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP; 3 | -------------------------------------------------------------------------------- /prisma/migrations/20250205063712_comment/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "Comment" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP; 3 | -------------------------------------------------------------------------------- /prisma/migrations/20250205070256_article/migration.sql: -------------------------------------------------------------------------------- 1 | -- DropForeignKey 2 | ALTER TABLE "Articles" DROP CONSTRAINT "Articles_authorId_fkey"; 3 | 4 | -- AddForeignKey 5 | ALTER TABLE "Articles" ADD CONSTRAINT "Articles_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 6 | -------------------------------------------------------------------------------- /prisma/migrations/20250205143026_comment/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - You are about to drop the column `postId` on the `Comment` table. All the data in the column will be lost. 5 | - Added the required column `articleId` to the `Comment` table without a default value. This is not possible if the table is not empty. 6 | 7 | */ 8 | -- DropForeignKey 9 | ALTER TABLE "Comment" DROP CONSTRAINT "Comment_postId_fkey"; 10 | 11 | -- AlterTable 12 | ALTER TABLE "Comment" DROP COLUMN "postId", 13 | ADD COLUMN "articleId" TEXT NOT NULL; 14 | 15 | -- AddForeignKey 16 | ALTER TABLE "Comment" ADD CONSTRAINT "Comment_articleId_fkey" FOREIGN KEY ("articleId") REFERENCES "Articles"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 17 | -------------------------------------------------------------------------------- /prisma/migrations/20250205143339_comment/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - Added the required column `authorId` to the `Comment` table without a default value. This is not possible if the table is not empty. 5 | 6 | */ 7 | -- AlterTable 8 | ALTER TABLE "Comment" ADD COLUMN "authorId" TEXT NOT NULL; 9 | 10 | -- AddForeignKey 11 | ALTER TABLE "Comment" ADD CONSTRAINT "Comment_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 12 | -------------------------------------------------------------------------------- /prisma/migrations/20250205161501_comment/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - You are about to drop the column `email` on the `Comment` table. All the data in the column will be lost. 5 | - You are about to drop the column `name` on the `Comment` table. All the data in the column will be lost. 6 | 7 | */ 8 | -- AlterTable 9 | ALTER TABLE "Comment" DROP COLUMN "email", 10 | DROP COLUMN "name"; 11 | -------------------------------------------------------------------------------- /prisma/migrations/20250205165041_like/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "Like" ( 3 | "id" TEXT NOT NULL, 4 | "userId" TEXT NOT NULL, 5 | "articleId" TEXT NOT NULL, 6 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 7 | 8 | CONSTRAINT "Like_pkey" PRIMARY KEY ("id") 9 | ); 10 | 11 | -- CreateIndex 12 | CREATE UNIQUE INDEX "Like_userId_articleId_key" ON "Like"("userId", "articleId"); 13 | 14 | -- AddForeignKey 15 | ALTER TABLE "Like" ADD CONSTRAINT "Like_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 16 | 17 | -- AddForeignKey 18 | ALTER TABLE "Like" ADD CONSTRAINT "Like_articleId_fkey" FOREIGN KEY ("articleId") REFERENCES "Articles"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 19 | -------------------------------------------------------------------------------- /prisma/migrations/20250208053517_is_liked/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "Like" ADD COLUMN "isLiked" BOOLEAN NOT NULL DEFAULT false; 3 | -------------------------------------------------------------------------------- /prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (e.g., Git) 3 | provider = "postgresql" -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | generator client { 2 | provider = "prisma-client-js" 3 | } 4 | 5 | datasource db { 6 | provider = "postgresql" 7 | url = env("DATABASE_URL") 8 | } 9 | 10 | model User { 11 | id String @id @default(cuid()) 12 | clerkUserId String @unique 13 | email String @unique 14 | name String 15 | imageUrl String? 16 | role String? 17 | articles Articles[] 18 | comments Comment[] 19 | likes Like[] // A user can like multiple articles 20 | } 21 | 22 | model Articles { 23 | id String @id @default(cuid()) 24 | title String 25 | content String 26 | category String 27 | featuredImage String 28 | author User @relation(fields: [authorId], references: [id]) 29 | authorId String 30 | comments Comment[] 31 | likes Like[] // An article can have multiple likes 32 | createdAt DateTime @default(now()) 33 | } 34 | 35 | model Comment { 36 | id String @id @default(cuid()) 37 | body String 38 | articleId String 39 | article Articles @relation(fields: [articleId], references: [id]) 40 | authorId String 41 | author User @relation(fields: [authorId], references: [id]) 42 | createdAt DateTime @default(now()) 43 | } 44 | 45 | model Like { 46 | id String @id @default(cuid()) 47 | isLiked Boolean @default(false) 48 | user User @relation(fields: [userId], references: [id]) 49 | userId String 50 | article Articles @relation(fields: [articleId], references: [id]) 51 | articleId String 52 | createdAt DateTime @default(now()) 53 | 54 | @@unique([userId, articleId]) // Ensures a user can like an article only once 55 | } 56 | -------------------------------------------------------------------------------- /public/file.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/globe.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/window.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/actions/create-article.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | import { prisma } from "@/lib/prisma"; 3 | import { auth } from "@clerk/nextjs/server"; 4 | import { redirect } from "next/navigation"; 5 | import { z } from "zod"; 6 | import { v2 as cloudinary, UploadApiResponse } from "cloudinary"; 7 | import { revalidatePath } from "next/cache"; 8 | 9 | cloudinary.config({ 10 | cloud_name: process.env.CLOUDINARY_CLOUD_NAME, 11 | api_key: process.env.CLOUDINARY_API_KEY, 12 | api_secret: process.env.CLOUDINARY_API_SECRET, 13 | }); 14 | 15 | 16 | const createArticleSchema = z.object({ 17 | title: z.string().min(3).max(100), 18 | category: z.string().min(3).max(50), 19 | content: z.string().min(10), 20 | }); 21 | 22 | type CreateArticleFormState = { 23 | errors: { 24 | title?: string[]; 25 | category?: string[]; 26 | featuredImage?: string[]; 27 | content?: string[]; 28 | formErrors?: string[]; 29 | }; 30 | }; 31 | 32 | export const createArticles = async ( 33 | prevState: CreateArticleFormState, 34 | formData: FormData 35 | ): Promise => { 36 | 37 | const result = createArticleSchema.safeParse({ 38 | title: formData.get("title"), 39 | category: formData.get("category"), 40 | content: formData.get("content"), 41 | }); 42 | 43 | if (!result.success) { 44 | return { 45 | errors: result.error.flatten().fieldErrors, 46 | }; 47 | } 48 | 49 | // ✅ Fix: Get Clerk User ID and check authentication 50 | const { userId } = await auth(); 51 | 52 | if (!userId) { 53 | return { 54 | errors: { 55 | formErrors: ["You have to login first"], 56 | }, 57 | }; 58 | } 59 | 60 | // ✅ Fix: Find the actual user using `clerkUserId` and get their `id` 61 | const existingUser = await prisma.user.findUnique({ 62 | where: { clerkUserId: userId }, 63 | }); 64 | 65 | if (!existingUser) { 66 | return { 67 | errors: { 68 | formErrors: ["User not found. Please register before creating an article."], 69 | }, 70 | }; 71 | } 72 | 73 | // ✅ Fix: Handle image upload properly 74 | const imageFile = formData.get("featuredImage") as File | null; 75 | 76 | if (!imageFile || imageFile?.name === "undefined") { 77 | return { 78 | errors: { 79 | featuredImage: ["Image file is required."], 80 | }, 81 | }; 82 | } 83 | 84 | const arrayBuffer = await imageFile.arrayBuffer(); 85 | const buffer = Buffer.from(arrayBuffer); 86 | 87 | const uploadResult: UploadApiResponse | undefined = await new Promise( 88 | (resolve, reject) => { 89 | const uploadStream = cloudinary.uploader.upload_stream( 90 | { resource_type: "auto" }, // ✅ Fix: Ensure correct file type handling 91 | (error, result) => { 92 | if (error) { 93 | reject(error); 94 | } else { 95 | resolve(result); 96 | } 97 | } 98 | ); 99 | uploadStream.end(buffer); 100 | } 101 | ); 102 | 103 | const imageUrl = uploadResult?.secure_url; 104 | 105 | if (!imageUrl) { 106 | return { 107 | errors: { 108 | featuredImage: ["Failed to upload image. Please try again."], 109 | }, 110 | }; 111 | } 112 | 113 | try { 114 | // ✅ Fix: Use `existingUser.id` instead of `userId` (which is `clerkUserId`) 115 | await prisma.articles.create({ 116 | data: { 117 | title: result.data.title, 118 | category: result.data.category, 119 | content: result.data.content, 120 | featuredImage: imageUrl, 121 | authorId: existingUser.id, // ✅ Correct Foreign Key Usage 122 | }, 123 | }); 124 | } catch (error: unknown) { 125 | if (error instanceof Error) { 126 | return { 127 | errors: { 128 | formErrors: [error.message], 129 | }, 130 | }; 131 | } else { 132 | return { 133 | errors: { 134 | formErrors: ["Some internal server error occurred."], 135 | }, 136 | }; 137 | } 138 | } 139 | 140 | revalidatePath("/dashboard"); 141 | redirect("/dashboard"); 142 | }; 143 | -------------------------------------------------------------------------------- /src/actions/create-comment.ts: -------------------------------------------------------------------------------- 1 | "use server" 2 | import { prisma } from "@/lib/prisma"; 3 | import { auth } from "@clerk/nextjs/server"; 4 | import { revalidatePath } from "next/cache"; 5 | import { z } from "zod"; 6 | 7 | const createCommentSchema = z.object({ 8 | body: z.string().min(1) 9 | }); 10 | 11 | type CreateCommentFormState = { 12 | errors: { 13 | body?: string[]; 14 | formErrors?: string[]; 15 | }; 16 | } 17 | 18 | export const createComments = async (articleId:string,prevState: CreateCommentFormState, formData: FormData): Promise => { 19 | const result = createCommentSchema.safeParse({ 20 | body:formData.get('body') as string 21 | }); 22 | if(!result.success){ 23 | return { 24 | errors: result.error.flatten().fieldErrors 25 | } 26 | } 27 | const {userId} = await auth(); 28 | if(!userId){ 29 | return { 30 | errors:{ 31 | formErrors:['You have to login first'] 32 | } 33 | } 34 | } 35 | const existingUser = await prisma.user.findUnique({ 36 | where:{clerkUserId:userId} 37 | }); 38 | if (!existingUser) { 39 | return { 40 | errors: { 41 | formErrors: ["User not found. Please register before adding comment."], 42 | }, 43 | }; 44 | } 45 | try { 46 | await prisma.comment.create({ 47 | data:{ 48 | body:result.data.body, 49 | authorId:existingUser.id, 50 | articleId:articleId 51 | } 52 | }) 53 | } catch (error:unknown) { 54 | if(error instanceof Error){ 55 | return { 56 | errors:{ 57 | formErrors:[error.message] 58 | } 59 | } 60 | }else{ 61 | return { 62 | errors:{ 63 | formErrors:['Some internal server error while creating comment'] 64 | } 65 | } 66 | } 67 | } 68 | revalidatePath(`/articles/${articleId}`); 69 | return {errors:{}} 70 | } -------------------------------------------------------------------------------- /src/actions/delete-article.ts: -------------------------------------------------------------------------------- 1 | "use server" 2 | 3 | import { prisma } from "@/lib/prisma"; 4 | import { revalidatePath } from "next/cache"; 5 | 6 | export const deleteArticle = async (articleId: string) => { 7 | 8 | await prisma.articles.delete({ 9 | where: { 10 | id: articleId, 11 | }, 12 | }); 13 | revalidatePath("/dashboard"); 14 | } -------------------------------------------------------------------------------- /src/actions/like-toggle.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { prisma } from "@/lib/prisma"; 4 | import { auth } from "@clerk/nextjs/server"; 5 | import { revalidatePath } from "next/cache"; 6 | 7 | export async function toggleLike(articleId : string) { 8 | const { userId } = await auth(); // Clerk's user ID 9 | if (!userId) throw new Error("You must be logged in to like an article"); 10 | 11 | 12 | 13 | // Ensure the user exists in the database 14 | const user = await prisma.user.findUnique({ 15 | where: { clerkUserId: userId }, 16 | }); 17 | 18 | if (!user) { 19 | throw new Error("User does not exist in the database."); 20 | } 21 | 22 | // Check if the user has already liked the article 23 | const existingLike = await prisma.like.findFirst({ 24 | where: { articleId, userId: user.id }, // Use `user.id`, not `clerkUserId` 25 | }); 26 | 27 | if (existingLike) { 28 | // Unlike the article 29 | await prisma.like.delete({ 30 | where: { id: existingLike.id }, 31 | }); 32 | } else { 33 | // Like the article 34 | await prisma.like.create({ 35 | data: { articleId, userId: user.id }, 36 | }); 37 | } 38 | 39 | // Return updated like count 40 | revalidatePath(`/article/${articleId}`) 41 | } 42 | -------------------------------------------------------------------------------- /src/actions/search.ts: -------------------------------------------------------------------------------- 1 | "use server" 2 | 3 | import { redirect } from "next/navigation"; 4 | 5 | export const searchAction = async (formData:FormData) => { 6 | const searchText = formData.get("search"); 7 | if (typeof searchText !== "string" || !searchText) { 8 | redirect("/"); 9 | } 10 | 11 | redirect(`/articles?search=${searchText}`); 12 | } -------------------------------------------------------------------------------- /src/actions/update-article.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | import { prisma } from "@/lib/prisma"; 3 | import { auth } from "@clerk/nextjs/server"; 4 | import { redirect } from "next/navigation"; 5 | import { z } from "zod"; 6 | import { v2 as cloudinary, UploadApiResponse } from "cloudinary"; 7 | import { revalidatePath } from "next/cache"; 8 | 9 | cloudinary.config({ 10 | cloud_name: process.env.CLOUDINARY_CLOUD_NAME, 11 | api_key: process.env.CLOUDINARY_API_KEY, 12 | api_secret: process.env.CLOUDINARY_API_SECRET, 13 | }); 14 | 15 | // ✅ Schema for validating input fields 16 | const updateArticleSchema = z.object({ 17 | title: z.string().min(3).max(100), 18 | category: z.string().min(3).max(50), 19 | content: z.string().min(10), 20 | }); 21 | 22 | type UpdateArticleFormState = { 23 | errors: { 24 | title?: string[]; 25 | category?: string[]; 26 | featuredImage?: string[]; 27 | content?: string[]; 28 | formErrors?: string[]; 29 | }; 30 | }; 31 | 32 | export const updateArticles = async ( 33 | articleId: string, 34 | prevState: UpdateArticleFormState, 35 | formData: FormData, 36 | ): Promise => { 37 | // ✅ Validate input fields 38 | const result = updateArticleSchema.safeParse({ 39 | title: formData.get("title"), 40 | category: formData.get("category"), 41 | content: formData.get("content"), 42 | }); 43 | 44 | if (!result.success) { 45 | return { 46 | errors: result.error.flatten().fieldErrors, 47 | }; 48 | } 49 | 50 | // ✅ Authenticate user 51 | const { userId } = await auth(); 52 | if (!userId) { 53 | return { 54 | errors: { formErrors: ["You must be logged in to update an article."] }, 55 | }; 56 | } 57 | 58 | // ✅ Find the existing article 59 | const existingArticle = await prisma.articles.findUnique({ 60 | where: { id: articleId }, 61 | }); 62 | 63 | if (!existingArticle) { 64 | return { 65 | errors: { formErrors: ["Article not found."] }, 66 | }; 67 | } 68 | 69 | // ✅ Check if the user is the author 70 | const user = await prisma.user.findUnique({ 71 | where: { clerkUserId: userId }, 72 | }); 73 | 74 | if (!user || existingArticle.authorId !== user.id) { 75 | return { 76 | errors: { formErrors: ["You are not authorized to edit this article."] }, 77 | }; 78 | } 79 | 80 | let imageUrl = existingArticle.featuredImage; // Default to the existing image 81 | 82 | // ✅ Check if a new image is provided 83 | const imageFile = formData.get("featuredImage") as File | null; 84 | if (imageFile && imageFile.name !== "undefined") { 85 | try { 86 | const arrayBuffer = await imageFile.arrayBuffer(); 87 | const buffer = Buffer.from(arrayBuffer); 88 | 89 | const uploadResult: UploadApiResponse | undefined = await new Promise( 90 | (resolve, reject) => { 91 | const uploadStream = cloudinary.uploader.upload_stream( 92 | { resource_type: "image" }, 93 | (error, result) => { 94 | if (error) { 95 | reject(error); 96 | } else { 97 | resolve(result); 98 | } 99 | } 100 | ); 101 | uploadStream.end(buffer); 102 | } 103 | ); 104 | 105 | if (uploadResult?.secure_url) { 106 | imageUrl = uploadResult.secure_url; 107 | } else { 108 | return { 109 | errors: { featuredImage: ["Failed to upload image. Please try again."] }, 110 | }; 111 | } 112 | } catch (error) { 113 | if(error instanceof Error){ 114 | return { 115 | errors:{ 116 | formErrors:[error.message] 117 | } 118 | } 119 | }else{ 120 | return { 121 | errors: { formErrors: ["Error uploading image. Please try again."] }, 122 | }; 123 | } 124 | } 125 | } 126 | 127 | // ✅ Update the article in the database 128 | try { 129 | await prisma.articles.update({ 130 | where: { id: articleId }, 131 | data: { 132 | title: result.data.title, 133 | category: result.data.category, 134 | content: result.data.content, 135 | featuredImage: imageUrl, // Updated or existing image 136 | }, 137 | }); 138 | } catch (error:unknown) { 139 | if(error instanceof Error){ 140 | return { 141 | errors:{ 142 | formErrors:[error.message] 143 | } 144 | } 145 | }else{ 146 | return { 147 | errors: { formErrors: ["Failed to update the article. Please try again."] }, 148 | }; 149 | } 150 | } 151 | 152 | revalidatePath("/dashboard"); 153 | redirect("/dashboard"); 154 | }; 155 | -------------------------------------------------------------------------------- /src/app/(auth)/sign-in/[[...sign-in]]/page.tsx: -------------------------------------------------------------------------------- 1 | import { SignIn } from "@clerk/nextjs"; 2 | 3 | function Page() { 4 | return ( 5 |
6 |
7 | 8 |
9 |
10 | ); 11 | } 12 | export default Page; 13 | -------------------------------------------------------------------------------- /src/app/(auth)/sign-up/[[...sign-up]]/page.tsx: -------------------------------------------------------------------------------- 1 | import { SignUp } from "@clerk/nextjs"; 2 | 3 | export default function Page() { 4 | return ( 5 |
6 |
7 | 8 |
9 |
10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /src/app/(home)/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Navbar } from "@/components/home/header/navbar"; 2 | import { prisma } from "@/lib/prisma"; 3 | import { currentUser } from "@clerk/nextjs/server"; 4 | import React from "react"; 5 | 6 | const layout = async ({ children }: { children: React.ReactNode }) => { 7 | 8 | const user = await currentUser(); 9 | if (!user) { 10 | return null; 11 | } 12 | const loggedInUser = await prisma.user.findUnique({ 13 | where: { clerkUserId: user.id }, 14 | }); 15 | if (!loggedInUser) { 16 | await prisma.user.create({ 17 | data: { 18 | name: `${user.fullName} ${user.lastName}`, 19 | clerkUserId: user.id, 20 | email: user.emailAddresses[0].emailAddress, 21 | imageUrl: user.imageUrl, 22 | }, 23 | }); 24 | } 25 | return ( 26 |
27 | 28 | {children} 29 |
30 | ); 31 | }; 32 | 33 | export default layout; 34 | -------------------------------------------------------------------------------- /src/app/(home)/page.tsx: -------------------------------------------------------------------------------- 1 | import { BlogFooter } from "@/components/home/blog-footer"; 2 | import HeroSection from "@/components/home/hero-section"; 3 | import { TopArticles } from "@/components/home/top-articles"; 4 | import { Button } from "@/components/ui/button"; 5 | import Link from "next/link"; 6 | import React, { Suspense } from "react"; 7 | 8 | const page = async () => { 9 | return ( 10 |
11 | 12 |
13 |
14 |
15 |

16 | Featured Articles 17 |

18 |

19 | Discover our most popular and trending content 20 |

21 |
22 | 23 | {/* Top Articles */} 24 | Loading....}> 25 | 26 | 27 | 28 |
29 | 30 | 36 | 37 |
38 |
39 |
40 | 41 |
42 | ); 43 | }; 44 | 45 | export default page; 46 | -------------------------------------------------------------------------------- /src/app/articles/[id]/page.tsx: -------------------------------------------------------------------------------- 1 | import { ArticleDetailPage } from "@/components/articles/article-detail-page"; 2 | import { prisma } from "@/lib/prisma"; 3 | import React from "react"; 4 | 5 | type ArticleDetailPageProps = { 6 | params: Promise<{ id: string }>; 7 | }; 8 | 9 | const page: React.FC = async ({ params }) => { 10 | const id = (await params).id; 11 | const article = await prisma.articles.findUnique({ 12 | where: { 13 | id, 14 | }, 15 | include: { 16 | author: { 17 | select: { 18 | name: true, 19 | email: true, 20 | imageUrl: true, 21 | }, 22 | }, 23 | }, 24 | }); 25 | if (!article) { 26 | return

Article not found.

; 27 | } 28 | return ( 29 |
30 | 31 |
32 | ); 33 | }; 34 | 35 | export default page; 36 | -------------------------------------------------------------------------------- /src/app/articles/page.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | AllArticlesPage, 3 | } from "@/components/articles/all-articles-page"; 4 | import ArticleSearchInput from "@/components/articles/article-search-input"; 5 | import { Button } from "@/components/ui/button"; 6 | import React, { Suspense } from "react"; 7 | import { Card } from "@/components/ui/card"; 8 | import { Skeleton } from "@/components/ui/skeleton"; 9 | import { fetchArticleByQuery } from "@/lib/query/fetch-articles"; 10 | import Link from "next/link"; 11 | 12 | type SearchPageProps = { 13 | searchParams: { search?: string; page?: string }; 14 | }; 15 | 16 | const ITEMS_PER_PAGE = 3; // Number of items per page 17 | 18 | const page: React.FC = async ({ searchParams }) => { 19 | const searchText = searchParams.search || ""; 20 | const currentPage = Number(searchParams.page) || 1; 21 | const skip = (currentPage - 1) * ITEMS_PER_PAGE; 22 | const take = ITEMS_PER_PAGE; 23 | 24 | const { articles, total } = await fetchArticleByQuery(searchText, skip, take); 25 | const totalPages = Math.ceil(total / ITEMS_PER_PAGE); 26 | 27 | 28 | return ( 29 |
30 |
31 | {/* Page Header */} 32 |
33 |

34 | All Articles 35 |

36 | {/* Search Bar */} 37 | 38 | 39 | 40 |
41 | {/* All article page */} 42 | }> 43 | 44 | 45 | {/* */} 46 | {/* Pagination */} 47 |
48 | {/* Prev Button */} 49 | 53 | 56 | 57 | 58 | {/* Page Numbers */} 59 | {Array.from({ length: totalPages }).map((_, index) => ( 60 | 65 | 72 | 73 | ))} 74 | 75 | {/* Next Button */} 76 | 80 | 87 | 88 |
89 |
90 |
91 | ); 92 | }; 93 | 94 | export default page; 95 | 96 | export function AllArticlesPageSkeleton() { 97 | return ( 98 |
99 | {Array.from({ length: 3 }).map((_, index) => ( 100 | 104 |
105 | {/* Article Image Skeleton */} 106 | 107 | 108 | {/* Article Title Skeleton */} 109 | 110 | 111 | {/* Article Category Skeleton */} 112 | 113 | 114 | {/* Author & Metadata Skeleton */} 115 |
116 |
117 | {/* Author Avatar Skeleton */} 118 | 119 | 120 | {/* Author Name Skeleton */} 121 | 122 |
123 | 124 | {/* Date Skeleton */} 125 | 126 |
127 |
128 |
129 | ))} 130 |
131 | ); 132 | } -------------------------------------------------------------------------------- /src/app/dashboard/articles/[id]/edit/page.tsx: -------------------------------------------------------------------------------- 1 | import EditArticlePage from '@/components/articles/edit-articles-page' 2 | import { prisma } from '@/lib/prisma' 3 | 4 | import React from 'react' 5 | type Props = { 6 | params:Promise<{id:string}> 7 | } 8 | const page = async ({params}:Props) => { 9 | const id = (await params).id 10 | const article = await prisma.articles.findUnique({ 11 | where:{ 12 | id 13 | } 14 | }); 15 | if(!article){ 16 | return

Article not found.

17 | } 18 | return ( 19 |
20 | 21 |
22 | ) 23 | } 24 | 25 | export default page -------------------------------------------------------------------------------- /src/app/dashboard/articles/create/page.tsx: -------------------------------------------------------------------------------- 1 | import { CreateArticlePage } from '@/components/articles/create-articles-page' 2 | import React from 'react' 3 | 4 | const page = () => { 5 | return ( 6 |
7 | 8 |
9 | ) 10 | } 11 | 12 | export default page -------------------------------------------------------------------------------- /src/app/dashboard/layout.tsx: -------------------------------------------------------------------------------- 1 | import Sidebar from "@/components/dashboard/sidebar"; 2 | import React, { ReactNode } from "react"; 3 | 4 | const layout = ({ children }: { children: ReactNode }) => { 5 | return ( 6 |
7 |
8 | 9 |
{children}
10 |
11 |
12 | ); 13 | }; 14 | 15 | export default layout; 16 | -------------------------------------------------------------------------------- /src/app/dashboard/loading.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const DashboardLoadingScreen = () => { 4 | return ( 5 |
6 |
7 | {/* Spinner */} 8 |
9 | 10 | {/* Loading Text */} 11 |

Loading Dashboard...

12 |

Please wait while we prepare your dashboard.

13 |
14 |
15 | ); 16 | }; 17 | 18 | export default DashboardLoadingScreen; -------------------------------------------------------------------------------- /src/app/dashboard/page.tsx: -------------------------------------------------------------------------------- 1 | import { BlogDashboard } from '@/components/dashboard/dashboard-page' 2 | import React from 'react' 3 | 4 | const page = () => { 5 | return ( 6 |
7 | 8 |
9 | ) 10 | } 11 | 12 | export default page -------------------------------------------------------------------------------- /src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devxpatel12/blogapp/cf78d19aa7e5465b350ca29c0532de5a8850b6c8/src/app/favicon.ico -------------------------------------------------------------------------------- /src/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | 6 | body { 7 | font-family: Arial, Helvetica, sans-serif; 8 | } 9 | 10 | @layer base { 11 | :root { 12 | --radius: 0.5rem; 13 | } 14 | } 15 | 16 | 17 | -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import { Geist, Geist_Mono } from "next/font/google"; 3 | import "./globals.css"; 4 | import { ClerkProvider } from "@clerk/nextjs"; 5 | import { ThemeProvider } from "@/components/theme-provider"; 6 | 7 | const geistSans = Geist({ 8 | variable: "--font-geist-sans", 9 | subsets: ["latin"], 10 | }); 11 | 12 | const geistMono = Geist_Mono({ 13 | variable: "--font-geist-mono", 14 | subsets: ["latin"], 15 | }); 16 | 17 | export const metadata: Metadata = { 18 | title: "Create Next App", 19 | description: "Generated by create next app", 20 | }; 21 | 22 | export default function RootLayout({ 23 | children, 24 | }: Readonly<{ 25 | children: React.ReactNode; 26 | }>) { 27 | return ( 28 | 29 | 30 | 33 | 39 |
{children}
40 |
41 | 42 | 43 |
44 | ); 45 | } 46 | -------------------------------------------------------------------------------- /src/components/articles/actions/like-button.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Button } from "@/components/ui/button"; 4 | import { Bookmark, Share2, ThumbsUp } from "lucide-react"; 5 | import React, { useOptimistic, useTransition } from "react"; 6 | import { toggleLike } from "@/actions/like-toggle"; 7 | import type { Like } from "@prisma/client"; 8 | 9 | type LikeButtonProps = { 10 | articleId: string; 11 | likes: Like[]; 12 | isLiked: boolean; 13 | }; 14 | 15 | const LikeButton: React.FC = ({ 16 | articleId, 17 | likes, 18 | isLiked, 19 | }) => { 20 | const [optimisticLikes, setOptimisticLikes] = useOptimistic(likes.length); 21 | const [isPending, startTransition] = useTransition(); 22 | 23 | const handleLike = async () => { 24 | 25 | startTransition(async () => { 26 | setOptimisticLikes(isLiked ? optimisticLikes - 1 : optimisticLikes + 1); // Optimistically update UI 27 | await toggleLike(articleId); 28 | }); 29 | }; 30 | 31 | return ( 32 |
33 |
34 | 44 |
45 | 48 | 51 |
52 | ); 53 | }; 54 | 55 | export default LikeButton; 56 | -------------------------------------------------------------------------------- /src/components/articles/all-articles-page.tsx: -------------------------------------------------------------------------------- 1 | import { Card } from "@/components/ui/card"; 2 | import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar"; 3 | import { Search } from "lucide-react"; 4 | import Image from "next/image"; 5 | import { Prisma } from "@prisma/client"; 6 | 7 | type SearchPageProps = { 8 | articles: Prisma.ArticlesGetPayload<{ 9 | include:{ 10 | author:{ 11 | select:{ 12 | name:true, 13 | email:true, 14 | imageUrl:true 15 | } 16 | } 17 | } 18 | }>[]; 19 | }; 20 | 21 | export function AllArticlesPage({ articles }: SearchPageProps) { 22 | 23 | if (articles.length === 0) return ; 24 | 25 | return ( 26 |
27 | {articles.map((article) => ( 28 | 32 |
33 | {/* Image Container */} 34 |
35 | {article.title} 41 |
42 | {/* Article Content */} 43 |

44 | {article.title} 45 |

46 |

{article.category}

47 | 48 | {/* Author & Metadata */} 49 |
50 |
51 | 52 | 53 | {article.author.name} 54 | 55 | 56 | {article.author.name} 57 | 58 |
59 |
60 | {article.createdAt.toDateString()} 61 |
62 |
63 |
64 |
65 | ))} 66 |
67 | ); 68 | } 69 | 70 | export function NoSearchResults() { 71 | return ( 72 |
73 | {/* Icon */} 74 |
75 | 76 |
77 | 78 | {/* Title */} 79 |

80 | No Results Found 81 |

82 | 83 | {/* Description */} 84 |

85 | We could not find any articles matching your search. Try a different 86 | keyword or phrase. 87 |

88 |
89 | ); 90 | } 91 | -------------------------------------------------------------------------------- /src/components/articles/article-detail-page.tsx: -------------------------------------------------------------------------------- 1 | import { Card } from "@/components/ui/card"; 2 | import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar"; 3 | import { MessageCircle } from "lucide-react"; 4 | import { Prisma } from "@prisma/client"; 5 | import CommentForm from "../comments/comment-form"; 6 | import CommentList from "../comments/comment-list"; 7 | import { prisma } from "@/lib/prisma"; 8 | import LikeButton from "./actions/like-button"; 9 | import { auth } from "@clerk/nextjs/server"; 10 | 11 | type ArticleDetailPageProps = { 12 | article: Prisma.ArticlesGetPayload<{ 13 | include: { 14 | author: { 15 | select: { 16 | name: true; 17 | email: true; 18 | imageUrl: true; 19 | }; 20 | }; 21 | }; 22 | }>; 23 | }; 24 | 25 | export async function ArticleDetailPage({ article }: ArticleDetailPageProps) { 26 | const comments = await prisma.comment.findMany({ 27 | where: { 28 | articleId: article.id, 29 | }, 30 | include: { 31 | author: { 32 | select: { 33 | name: true, 34 | email: true, 35 | imageUrl: true, 36 | }, 37 | }, 38 | }, 39 | }); 40 | 41 | const likes = await prisma.like.findMany({where:{articleId:article.id}}); 42 | const {userId} = await auth(); 43 | const user = await prisma.user.findUnique({where:{clerkUserId:userId as string}}); 44 | 45 | const isLiked = likes.some((like) => like.userId === user?.id); 46 | 47 | 48 | 49 | return ( 50 |
51 | {/* Reuse your existing Navbar */} 52 | 53 |
54 |
55 | {/* Article Header */} 56 |
57 |
58 | 59 | {article.category} 60 | 61 |
62 | 63 |

64 | {article.title} 65 |

66 | 67 |
68 | 69 | 70 | {article.id} 71 | 72 |
73 |

74 | {article.author.name} 75 |

76 |

77 | {article.createdAt.toDateString()} · {12} min read 78 |

79 |
80 |
81 |
82 | 83 | {/* Article Content */} 84 |
88 | 89 | {/* Article Actions */} 90 | 91 | 92 | {/* Comments Section */} 93 | 94 |
95 | 96 |

97 | {comments.length} Comments 98 |

99 |
100 | 101 | {/* Comment Form */} 102 | 103 | 104 | {/* Comments List */} 105 | 106 |
107 |
108 |
109 |
110 | ); 111 | } 112 | -------------------------------------------------------------------------------- /src/components/articles/article-search-input.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Search } from "lucide-react"; 4 | import { Input } from "../ui/input"; 5 | import { useSearchParams } from "next/navigation"; 6 | import { searchAction } from "@/actions/search"; 7 | 8 | const ArticleSearchInput = () => { 9 | const searchParams = useSearchParams(); 10 | const searchText = searchParams.get("search") || ""; 11 | 12 | return ( 13 |
14 |
15 | 16 | 23 |
24 |
25 | ); 26 | }; 27 | 28 | export default ArticleSearchInput; 29 | -------------------------------------------------------------------------------- /src/components/articles/create-articles-page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { FormEvent, startTransition, useActionState, useState } from "react"; 3 | import "react-quill/dist/quill.snow.css"; 4 | import { Button } from "@/components/ui/button"; 5 | import { Input } from "@/components/ui/input"; 6 | import { Label } from "@/components/ui/label"; 7 | import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; 8 | import "react-quill-new/dist/quill.snow.css"; 9 | import { createArticles } from "@/actions/create-article"; 10 | import dynamic from "next/dynamic"; 11 | const ReactQuill = dynamic(() => import("react-quill-new"), { ssr: false }); 12 | 13 | export function CreateArticlePage() { 14 | const [content, setContent] = useState(""); 15 | 16 | const [formState, action, isPending] = useActionState(createArticles, { 17 | errors: {}, 18 | }); 19 | 20 | 21 | const handleSubmit = async (event: FormEvent) => { 22 | event.preventDefault(); 23 | 24 | const formData = new FormData(event.currentTarget); 25 | formData.append("content", content); 26 | 27 | // Wrap the action call in startTransition 28 | startTransition(() => { 29 | action(formData); 30 | }); 31 | }; 32 | 33 | return ( 34 |
35 | 36 | 37 | Create New Article 38 | 39 | 40 |
41 |
42 | 43 | 48 | {formState.errors.title && ( 49 | 50 | {formState.errors.title} 51 | 52 | )} 53 |
54 | 55 |
56 | 57 | 67 | {formState.errors.category && ( 68 | 69 | {formState.errors.category} 70 | 71 | )} 72 |
73 | 74 |
75 | 76 | 82 | {formState.errors.featuredImage && ( 83 | 84 | {formState.errors.featuredImage} 85 | 86 | )} 87 |
88 | 89 |
90 | 91 | 96 | {formState.errors.content && ( 97 | 98 | {formState.errors.content[0]} 99 | 100 | )} 101 |
102 | {formState.errors.formErrors && ( 103 |
104 | 105 | {formState.errors.formErrors} 106 | 107 |
108 | )} 109 |
110 | 113 | 116 |
117 |
118 |
119 |
120 |
121 | ); 122 | } 123 | -------------------------------------------------------------------------------- /src/components/articles/edit-articles-page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { FormEvent, startTransition, useActionState, useState } from "react"; 3 | import ReactQuill from "react-quill-new"; 4 | import "react-quill-new/dist/quill.snow.css"; 5 | import { Button } from "@/components/ui/button"; 6 | import { Input } from "@/components/ui/input"; 7 | import { Label } from "@/components/ui/label"; 8 | import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; 9 | import { Articles } from "@prisma/client"; 10 | import { updateArticles } from "@/actions/update-article"; 11 | import Image from "next/image"; 12 | 13 | type EditPropsPage = { 14 | article: Articles; 15 | }; 16 | const EditArticlePage: React.FC = ({ article }) => { 17 | const [content, setContent] = useState(article.content); 18 | const [formState, action, isPending] = useActionState( 19 | updateArticles.bind(null, article.id), 20 | { errors: {} } 21 | ); 22 | 23 | const handleSubmit = async (event: FormEvent) => { 24 | event.preventDefault(); 25 | 26 | const formData = new FormData(event.currentTarget); 27 | formData.append("content", content); 28 | 29 | // Wrap the action call in startTransition 30 | startTransition(() => { 31 | action(formData); 32 | }); 33 | }; 34 | 35 | return ( 36 |
37 | 38 | 39 | Edit Article 40 | 41 | 42 |
43 |
44 | 45 | 52 | {formState.errors.title && ( 53 | 54 | {formState.errors.title} 55 | 56 | )} 57 |
58 | 59 |
60 | 61 | 73 | {formState.errors.category && ( 74 | 75 | {formState.errors.category} 76 | 77 | )} 78 |
79 | 80 |
81 | 82 | {article.featuredImage && ( 83 |
84 | Current featured 91 |

92 | Current featured image 93 |

94 |
95 | )} 96 | 102 | {formState.errors.featuredImage && ( 103 | 104 | {formState.errors.featuredImage} 105 | 106 | )} 107 |
108 |
109 | 110 | 116 | {formState.errors.content && ( 117 | 118 | {formState.errors.content[0]} 119 | 120 | )} 121 |
122 | 123 |
124 | 127 | 130 |
131 |
132 |
133 |
134 |
135 | ); 136 | }; 137 | export default EditArticlePage; 138 | -------------------------------------------------------------------------------- /src/components/comments/comment-form.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import React, { useActionState } from "react"; 3 | import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar"; 4 | import { Input } from "../ui/input"; 5 | import { Button } from "../ui/button"; 6 | import { createComments } from "@/actions/create-comment"; 7 | 8 | type CommentFormProps = { 9 | articleId:string 10 | } 11 | const CommentForm : React.FC = ({articleId}) => { 12 | const [formState, action, isPending] = useActionState(createComments.bind(null, articleId), { 13 | errors: {}, 14 | }); 15 | return ( 16 |
17 |
18 | 19 | 20 | Y 21 | 22 |
23 | 24 | {formState.errors.body &&

{formState.errors.body}

} 25 |
26 | 29 |
30 | { 31 | formState.errors.formErrors &&
{formState.errors.formErrors[0]}
32 | } 33 |
34 |
35 |
36 | ); 37 | }; 38 | 39 | export default CommentForm; 40 | -------------------------------------------------------------------------------- /src/components/comments/comment-list.tsx: -------------------------------------------------------------------------------- 1 | import type { Prisma } from "@prisma/client"; 2 | import React from "react"; 3 | import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar"; 4 | type CommentListProps = { 5 | comments: Prisma.CommentGetPayload<{ 6 | include: { 7 | author: { 8 | select: { 9 | name: true; 10 | email: true; 11 | imageUrl: true; 12 | }; 13 | }; 14 | }; 15 | }>[]; 16 | }; 17 | const CommentList: React.FC = ({ comments }) => { 18 | return ( 19 |
20 | {comments.map((comment) => ( 21 |
22 | 23 | 24 | {comment.author.name} 25 | 26 |
27 |
28 | 29 | {comment.author.name} 30 | 31 | 32 | {comment.createdAt.toDateString()} 33 | 34 |
35 |

{comment.body}

36 |
37 |
38 | ))} 39 |
40 | ); 41 | }; 42 | 43 | export default CommentList; 44 | -------------------------------------------------------------------------------- /src/components/dark-mode.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import { Moon, Sun } from "lucide-react" 5 | import { useTheme } from "next-themes" 6 | 7 | import { Button } from "@/components/ui/button" 8 | import { 9 | DropdownMenu, 10 | DropdownMenuContent, 11 | DropdownMenuItem, 12 | DropdownMenuTrigger, 13 | } from "@/components/ui/dropdown-menu" 14 | 15 | export function ModeToggle() { 16 | const { setTheme } = useTheme() 17 | 18 | return ( 19 | 20 | 21 | 26 | 27 | 28 | setTheme("light")}> 29 | Light 30 | 31 | setTheme("dark")}> 32 | Dark 33 | 34 | setTheme("system")}> 35 | System 36 | 37 | 38 | 39 | ) 40 | } 41 | -------------------------------------------------------------------------------- /src/components/dashboard/dashboard-page.tsx: -------------------------------------------------------------------------------- 1 | import { FileText, MessageCircle, PlusCircle, Clock } from "lucide-react"; 2 | import { Button } from "@/components/ui/button"; 3 | import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; 4 | import RecentArticles from "./recent-articles"; 5 | import { prisma } from "@/lib/prisma"; 6 | import Link from "next/link"; 7 | 8 | export async function BlogDashboard() { 9 | const [articles, totalComments] = await Promise.all([ 10 | prisma.articles.findMany({ 11 | orderBy: { 12 | createdAt: "desc", 13 | }, 14 | include: { 15 | comments: true, 16 | author: { 17 | select: { 18 | name: true, 19 | email: true, 20 | imageUrl: true, 21 | }, 22 | }, 23 | }, 24 | }), 25 | prisma.comment.count(), 26 | ]); 27 | 28 | return ( 29 |
30 | {/* Header */} 31 |
32 |
33 |

Blog Dashboard

34 |

35 | Manage your content and analytics 36 |

37 |
38 | 39 | 43 | 44 |
45 | 46 | {/* Quick Stats */} 47 |
48 | 49 | 50 | 51 | Total Articles 52 | 53 | 54 | 55 | 56 |
{articles.length}
57 |

58 | +5 from last month 59 |

60 |
61 |
62 | 63 | 64 | 65 | 66 | Total Comments 67 | 68 | 69 | 70 | 71 |
{totalComments}
72 |

73 | 12 awaiting moderation 74 |

75 |
76 |
77 | 78 | 79 | 80 | 81 | Avg. Reading Time 82 | 83 | 84 | 85 | 86 |
4.2m
87 |

88 | +0.8m from last month 89 |

90 |
91 |
92 |
93 | 94 | {/* Recent Articles */} 95 | 96 |
97 | ); 98 | } 99 | -------------------------------------------------------------------------------- /src/components/dashboard/recent-articles.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import React, { useTransition } from "react"; 3 | import { Card, CardContent, CardHeader, CardTitle } from "../ui/card"; 4 | import { Button } from "../ui/button"; 5 | import { 6 | Table, 7 | TableBody, 8 | TableCell, 9 | TableHead, 10 | TableHeader, 11 | TableRow, 12 | } from "../ui/table"; 13 | import Link from "next/link"; 14 | import type { Prisma } from "@prisma/client"; 15 | import { deleteArticle } from "@/actions/delete-article"; 16 | 17 | type RecentArticlesProps = { 18 | articles: Prisma.ArticlesGetPayload<{ 19 | include: { 20 | comments: true; 21 | author: { 22 | select: { 23 | name: true; 24 | email: true; 25 | imageUrl: true; 26 | }; 27 | }; 28 | }; 29 | }>[]; 30 | }; 31 | 32 | const RecentArticles: React.FC = ({ articles }) => { 33 | return ( 34 | 35 | 36 |
37 | Recent Articles 38 | 41 |
42 |
43 | {!articles.length ? ( 44 | No articles found. 45 | ) : ( 46 | 47 | 48 | 49 | 50 | Title 51 | Status 52 | Comments 53 | Date 54 | Actions 55 | 56 | 57 | 58 | {articles.slice(0, 5).map((article) => ( 59 | 60 | {article.title} 61 | 62 | 63 | Published 64 | 65 | 66 | {article.comments.length} 67 | {new Date(article.createdAt).toDateString()} 68 | 69 |
70 | 71 | 72 | 73 | 74 |
75 |
76 |
77 | ))} 78 |
79 |
80 |
81 | )} 82 |
83 | ); 84 | }; 85 | 86 | export default RecentArticles; 87 | 88 | type DeleteButtonProps = { 89 | articleId: string; 90 | }; 91 | 92 | const DeleteButton: React.FC = ({ articleId }) => { 93 | const [isPending, startTransition] = useTransition(); 94 | 95 | return ( 96 |
98 | startTransition(async () => { 99 | await deleteArticle(articleId); 100 | }) 101 | } 102 | > 103 | 106 |
107 | ); 108 | }; 109 | -------------------------------------------------------------------------------- /src/components/dashboard/sidebar.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import React, { useState } from "react"; 3 | import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet"; 4 | import { Button } from "../ui/button"; 5 | import { 6 | BarChart, 7 | FileText, 8 | LayoutDashboard, 9 | MessageCircle, 10 | Settings, 11 | } from "lucide-react"; 12 | import Link from "next/link"; 13 | const Sidebar = () => { 14 | const [isOpen, setIsOpen] = useState(false); 15 | 16 | return ( 17 |
18 | {/* Mobile Sidebar */} 19 | 20 | 21 | 24 | 25 | 26 | setIsOpen(false)} /> 27 | 28 | 29 |
30 | 31 |
32 |
33 | ); 34 | }; 35 | 36 | export default Sidebar; 37 | 38 | function DashboardSidebar({ closeSheet }: { closeSheet?: () => void }) { 39 | return ( 40 |
41 |
42 | 43 | ByteCode 44 | 45 |
46 | 93 |
94 | ); 95 | } 96 | -------------------------------------------------------------------------------- /src/components/home/blog-footer.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@/components/ui/button"; 2 | import { Input } from "@/components/ui/input"; 3 | import { Mail } from "lucide-react"; 4 | import { Linkedin, Github, Twitter } from "lucide-react"; 5 | 6 | export function BlogFooter() { 7 | return ( 8 |
9 |
10 |
11 | {/* Branding Section */} 12 |
13 |

14 | 15 | Byte 16 | 17 | Code 18 |

19 |

20 | Where ideas meet innovation. Dive into a world of insightful 21 | articles written by passionate thinkers and industry experts. 22 |

23 | 24 |
25 | 28 | 31 | 34 |
35 |
36 | 37 | {/* Quick Links */} 38 |
39 |

Explore

40 | 46 |
47 | 48 | {/* Legal */} 49 |
50 |

Legal

51 | 57 |
58 | 59 | {/* Newsletter */} 60 |
61 |

Stay Updated

62 |
63 |
64 | 69 | 70 |
71 | 77 |
78 |
79 |
80 | 81 | {/* Copyright */} 82 |
83 |

84 | © {new Date().getFullYear()} ByteCode. All rights reserved. 85 |

86 |
87 |
88 |
89 | ); 90 | } -------------------------------------------------------------------------------- /src/components/home/header/navbar.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { useState } from "react"; 3 | import { Button } from "@/components/ui/button"; 4 | import { Input } from "@/components/ui/input"; 5 | import { Search, Menu, X } from "lucide-react"; 6 | import { ModeToggle } from "../../dark-mode"; 7 | import Link from "next/link"; 8 | import { SignedOut, SignInButton, SignUpButton } from "@clerk/nextjs"; 9 | import { SignedIn, UserButton } from "@clerk/nextjs"; 10 | import SearchInput from "./search-input"; 11 | 12 | export function Navbar() { 13 | const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); 14 | 15 | return ( 16 | 163 | ); 164 | } 165 | -------------------------------------------------------------------------------- /src/components/home/header/search-input.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { searchAction } from "@/actions/search"; 3 | import { Input } from "@/components/ui/input"; 4 | import { Search } from "lucide-react"; 5 | import { useSearchParams } from "next/navigation"; 6 | import React from "react"; 7 | 8 | const SearchInput = () => { 9 | const params = useSearchParams(); 10 | 11 | return ( 12 |
13 |
14 | 15 | 22 |
23 |
24 | ); 25 | }; 26 | 27 | export default SearchInput; 28 | -------------------------------------------------------------------------------- /src/components/home/hero-section.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import Image from "next/image"; 3 | import { Button } from "../ui/button"; 4 | import { cn } from "@/lib/utils"; 5 | 6 | const HeroSection = () => { 7 | return ( 8 |
9 | {/* Gradient overlay */} 10 |
11 | 12 |
13 | {/* Content */} 14 |
15 |

16 | Explore the World Through 17 | 18 | {" "} 19 | Words 20 | 21 |

22 | 23 |

24 | Discover insightful articles, thought-provoking stories, and expert 25 | perspectives on technology, lifestyle, and innovation. 26 |

27 | 28 |
29 | 32 | 39 |
40 | 41 | {/* Stats */} 42 |
43 |
44 |
1K+
45 |
Published Articles
46 |
47 |
48 |
50+
49 |
Expert Writers
50 |
51 |
52 |
10M+
53 |
Monthly Readers
54 |
55 |
56 |
57 | 58 |
59 |
67 | Illustration for the blog 73 |
74 |
75 |
76 |
77 | ); 78 | }; 79 | export default HeroSection; 80 | -------------------------------------------------------------------------------- /src/components/home/top-articles.tsx: -------------------------------------------------------------------------------- 1 | import { Card } from "@/components/ui/card"; 2 | import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar"; 3 | import { cn } from "@/lib/utils"; 4 | import Link from "next/link"; 5 | import { prisma } from "@/lib/prisma"; 6 | import Image from "next/image"; 7 | 8 | export async function TopArticles() { 9 | const articles = await prisma.articles.findMany({ 10 | orderBy: { 11 | createdAt: "desc", 12 | }, 13 | include: { 14 | comments: true, 15 | author: { 16 | select: { 17 | name: true, 18 | email: true, 19 | imageUrl: true, 20 | }, 21 | }, 22 | }, 23 | }); 24 | 25 | return ( 26 |
27 | {articles.slice(0, 3).map((article) => ( 28 | 36 |
37 | 38 | {/* Image Container */} 39 |
40 | {article.title} 46 |
47 | 48 | {/* Author Info */} 49 |
50 | 51 | 52 | 53 | {article.author.name.charAt(0)} 54 | 55 | 56 | {article.author.name} 57 |
58 | 59 | {/* Article Title */} 60 |

61 | {article.title} 62 |

63 |

64 | {article.category} 65 |

66 | 67 | {/* Article Meta Info */} 68 |
69 | {new Date(article.createdAt).toDateString()} 70 | {12} min read 71 |
72 | 73 |
74 |
75 | ))} 76 |
77 | ); 78 | } 79 | -------------------------------------------------------------------------------- /src/components/theme-provider.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import { ThemeProvider as NextThemesProvider } from "next-themes" 5 | 6 | export function ThemeProvider({ 7 | children, 8 | ...props 9 | }: React.ComponentProps) { 10 | return {children} 11 | } 12 | -------------------------------------------------------------------------------- /src/components/ui/avatar.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as AvatarPrimitive from "@radix-ui/react-avatar" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Avatar = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, ...props }, ref) => ( 12 | 20 | )) 21 | Avatar.displayName = AvatarPrimitive.Root.displayName 22 | 23 | const AvatarImage = React.forwardRef< 24 | React.ElementRef, 25 | React.ComponentPropsWithoutRef 26 | >(({ className, ...props }, ref) => ( 27 | 32 | )) 33 | AvatarImage.displayName = AvatarPrimitive.Image.displayName 34 | 35 | const AvatarFallback = React.forwardRef< 36 | React.ElementRef, 37 | React.ComponentPropsWithoutRef 38 | >(({ className, ...props }, ref) => ( 39 | 47 | )) 48 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName 49 | 50 | export { Avatar, AvatarImage, AvatarFallback } 51 | -------------------------------------------------------------------------------- /src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-zinc-950 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 dark:focus-visible:ring-zinc-300", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "bg-zinc-900 text-zinc-50 shadow hover:bg-zinc-900/90 dark:bg-zinc-50 dark:text-zinc-900 dark:hover:bg-zinc-50/90", 14 | destructive: 15 | "bg-red-500 text-zinc-50 shadow-sm hover:bg-red-500/90 dark:bg-red-900 dark:text-zinc-50 dark:hover:bg-red-900/90", 16 | outline: 17 | "border border-zinc-200 bg-white shadow-sm hover:bg-zinc-100 hover:text-zinc-900 dark:border-zinc-800 dark:bg-zinc-950 dark:hover:bg-zinc-800 dark:hover:text-zinc-50", 18 | secondary: 19 | "bg-zinc-100 text-zinc-900 shadow-sm hover:bg-zinc-100/80 dark:bg-zinc-800 dark:text-zinc-50 dark:hover:bg-zinc-800/80", 20 | ghost: "hover:bg-zinc-100 hover:text-zinc-900 dark:hover:bg-zinc-800 dark:hover:text-zinc-50", 21 | link: "text-zinc-900 underline-offset-4 hover:underline dark:text-zinc-50", 22 | }, 23 | size: { 24 | default: "h-9 px-4 py-2", 25 | sm: "h-8 rounded-md px-3 text-xs", 26 | lg: "h-10 rounded-md px-8", 27 | icon: "h-9 w-9", 28 | }, 29 | }, 30 | defaultVariants: { 31 | variant: "default", 32 | size: "default", 33 | }, 34 | } 35 | ) 36 | 37 | export interface ButtonProps 38 | extends React.ButtonHTMLAttributes, 39 | VariantProps { 40 | asChild?: boolean 41 | } 42 | 43 | const Button = React.forwardRef( 44 | ({ className, variant, size, asChild = false, ...props }, ref) => { 45 | const Comp = asChild ? Slot : "button" 46 | return ( 47 | 52 | ) 53 | } 54 | ) 55 | Button.displayName = "Button" 56 | 57 | export { Button, buttonVariants } 58 | -------------------------------------------------------------------------------- /src/components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | const Card = React.forwardRef< 6 | HTMLDivElement, 7 | React.HTMLAttributes 8 | >(({ className, ...props }, ref) => ( 9 |
17 | )) 18 | Card.displayName = "Card" 19 | 20 | const CardHeader = React.forwardRef< 21 | HTMLDivElement, 22 | React.HTMLAttributes 23 | >(({ className, ...props }, ref) => ( 24 |
29 | )) 30 | CardHeader.displayName = "CardHeader" 31 | 32 | const CardTitle = React.forwardRef< 33 | HTMLDivElement, 34 | React.HTMLAttributes 35 | >(({ className, ...props }, ref) => ( 36 |
41 | )) 42 | CardTitle.displayName = "CardTitle" 43 | 44 | const CardDescription = React.forwardRef< 45 | HTMLDivElement, 46 | React.HTMLAttributes 47 | >(({ className, ...props }, ref) => ( 48 |
53 | )) 54 | CardDescription.displayName = "CardDescription" 55 | 56 | const CardContent = React.forwardRef< 57 | HTMLDivElement, 58 | React.HTMLAttributes 59 | >(({ className, ...props }, ref) => ( 60 |
61 | )) 62 | CardContent.displayName = "CardContent" 63 | 64 | const CardFooter = React.forwardRef< 65 | HTMLDivElement, 66 | React.HTMLAttributes 67 | >(({ className, ...props }, ref) => ( 68 |
73 | )) 74 | CardFooter.displayName = "CardFooter" 75 | 76 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } 77 | -------------------------------------------------------------------------------- /src/components/ui/dropdown-menu.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" 5 | import { Check, ChevronRight, Circle } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const DropdownMenu = DropdownMenuPrimitive.Root 10 | 11 | const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger 12 | 13 | const DropdownMenuGroup = DropdownMenuPrimitive.Group 14 | 15 | const DropdownMenuPortal = DropdownMenuPrimitive.Portal 16 | 17 | const DropdownMenuSub = DropdownMenuPrimitive.Sub 18 | 19 | const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup 20 | 21 | const DropdownMenuSubTrigger = React.forwardRef< 22 | React.ElementRef, 23 | React.ComponentPropsWithoutRef & { 24 | inset?: boolean 25 | } 26 | >(({ className, inset, children, ...props }, ref) => ( 27 | 36 | {children} 37 | 38 | 39 | )) 40 | DropdownMenuSubTrigger.displayName = 41 | DropdownMenuPrimitive.SubTrigger.displayName 42 | 43 | const DropdownMenuSubContent = React.forwardRef< 44 | React.ElementRef, 45 | React.ComponentPropsWithoutRef 46 | >(({ className, ...props }, ref) => ( 47 | 55 | )) 56 | DropdownMenuSubContent.displayName = 57 | DropdownMenuPrimitive.SubContent.displayName 58 | 59 | const DropdownMenuContent = React.forwardRef< 60 | React.ElementRef, 61 | React.ComponentPropsWithoutRef 62 | >(({ className, sideOffset = 4, ...props }, ref) => ( 63 | 64 | 74 | 75 | )) 76 | DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName 77 | 78 | const DropdownMenuItem = React.forwardRef< 79 | React.ElementRef, 80 | React.ComponentPropsWithoutRef & { 81 | inset?: boolean 82 | } 83 | >(({ className, inset, ...props }, ref) => ( 84 | svg]:size-4 [&>svg]:shrink-0 dark:focus:bg-zinc-800 dark:focus:text-zinc-50", 88 | inset && "pl-8", 89 | className 90 | )} 91 | {...props} 92 | /> 93 | )) 94 | DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName 95 | 96 | const DropdownMenuCheckboxItem = React.forwardRef< 97 | React.ElementRef, 98 | React.ComponentPropsWithoutRef 99 | >(({ className, children, checked, ...props }, ref) => ( 100 | 109 | 110 | 111 | 112 | 113 | 114 | {children} 115 | 116 | )) 117 | DropdownMenuCheckboxItem.displayName = 118 | DropdownMenuPrimitive.CheckboxItem.displayName 119 | 120 | const DropdownMenuRadioItem = React.forwardRef< 121 | React.ElementRef, 122 | React.ComponentPropsWithoutRef 123 | >(({ className, children, ...props }, ref) => ( 124 | 132 | 133 | 134 | 135 | 136 | 137 | {children} 138 | 139 | )) 140 | DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName 141 | 142 | const DropdownMenuLabel = React.forwardRef< 143 | React.ElementRef, 144 | React.ComponentPropsWithoutRef & { 145 | inset?: boolean 146 | } 147 | >(({ className, inset, ...props }, ref) => ( 148 | 157 | )) 158 | DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName 159 | 160 | const DropdownMenuSeparator = React.forwardRef< 161 | React.ElementRef, 162 | React.ComponentPropsWithoutRef 163 | >(({ className, ...props }, ref) => ( 164 | 169 | )) 170 | DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName 171 | 172 | const DropdownMenuShortcut = ({ 173 | className, 174 | ...props 175 | }: React.HTMLAttributes) => { 176 | return ( 177 | 181 | ) 182 | } 183 | DropdownMenuShortcut.displayName = "DropdownMenuShortcut" 184 | 185 | export { 186 | DropdownMenu, 187 | DropdownMenuTrigger, 188 | DropdownMenuContent, 189 | DropdownMenuItem, 190 | DropdownMenuCheckboxItem, 191 | DropdownMenuRadioItem, 192 | DropdownMenuLabel, 193 | DropdownMenuSeparator, 194 | DropdownMenuShortcut, 195 | DropdownMenuGroup, 196 | DropdownMenuPortal, 197 | DropdownMenuSub, 198 | DropdownMenuSubContent, 199 | DropdownMenuSubTrigger, 200 | DropdownMenuRadioGroup, 201 | } 202 | -------------------------------------------------------------------------------- /src/components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | const Input = React.forwardRef>( 6 | ({ className, type, ...props }, ref) => { 7 | return ( 8 | 17 | ) 18 | } 19 | ) 20 | Input.displayName = "Input" 21 | 22 | export { Input } 23 | -------------------------------------------------------------------------------- /src/components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as LabelPrimitive from "@radix-ui/react-label" 5 | import { cva, type VariantProps } from "class-variance-authority" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const labelVariants = cva( 10 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" 11 | ) 12 | 13 | const Label = React.forwardRef< 14 | React.ElementRef, 15 | React.ComponentPropsWithoutRef & 16 | VariantProps 17 | >(({ className, ...props }, ref) => ( 18 | 23 | )) 24 | Label.displayName = LabelPrimitive.Root.displayName 25 | 26 | export { Label } 27 | -------------------------------------------------------------------------------- /src/components/ui/sheet.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as SheetPrimitive from "@radix-ui/react-dialog" 5 | import { cva, type VariantProps } from "class-variance-authority" 6 | import { X } from "lucide-react" 7 | 8 | import { cn } from "@/lib/utils" 9 | 10 | const Sheet = SheetPrimitive.Root 11 | 12 | const SheetTrigger = SheetPrimitive.Trigger 13 | 14 | const SheetClose = SheetPrimitive.Close 15 | 16 | const SheetPortal = SheetPrimitive.Portal 17 | 18 | const SheetOverlay = React.forwardRef< 19 | React.ElementRef, 20 | React.ComponentPropsWithoutRef 21 | >(({ className, ...props }, ref) => ( 22 | 30 | )) 31 | SheetOverlay.displayName = SheetPrimitive.Overlay.displayName 32 | 33 | const sheetVariants = cva( 34 | "fixed z-50 gap-4 bg-white p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out dark:bg-zinc-950", 35 | { 36 | variants: { 37 | side: { 38 | top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top", 39 | bottom: 40 | "inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom", 41 | left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm", 42 | right: 43 | "inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm", 44 | }, 45 | }, 46 | defaultVariants: { 47 | side: "right", 48 | }, 49 | } 50 | ) 51 | 52 | interface SheetContentProps 53 | extends React.ComponentPropsWithoutRef, 54 | VariantProps {} 55 | 56 | const SheetContent = React.forwardRef< 57 | React.ElementRef, 58 | SheetContentProps 59 | >(({ side = "right", className, children, ...props }, ref) => ( 60 | 61 | 62 | 67 | 68 | 69 | Close 70 | 71 | {children} 72 | 73 | 74 | )) 75 | SheetContent.displayName = SheetPrimitive.Content.displayName 76 | 77 | const SheetHeader = ({ 78 | className, 79 | ...props 80 | }: React.HTMLAttributes) => ( 81 |
88 | ) 89 | SheetHeader.displayName = "SheetHeader" 90 | 91 | const SheetFooter = ({ 92 | className, 93 | ...props 94 | }: React.HTMLAttributes) => ( 95 |
102 | ) 103 | SheetFooter.displayName = "SheetFooter" 104 | 105 | const SheetTitle = React.forwardRef< 106 | React.ElementRef, 107 | React.ComponentPropsWithoutRef 108 | >(({ className, ...props }, ref) => ( 109 | 114 | )) 115 | SheetTitle.displayName = SheetPrimitive.Title.displayName 116 | 117 | const SheetDescription = React.forwardRef< 118 | React.ElementRef, 119 | React.ComponentPropsWithoutRef 120 | >(({ className, ...props }, ref) => ( 121 | 126 | )) 127 | SheetDescription.displayName = SheetPrimitive.Description.displayName 128 | 129 | export { 130 | Sheet, 131 | SheetPortal, 132 | SheetOverlay, 133 | SheetTrigger, 134 | SheetClose, 135 | SheetContent, 136 | SheetHeader, 137 | SheetFooter, 138 | SheetTitle, 139 | SheetDescription, 140 | } 141 | -------------------------------------------------------------------------------- /src/components/ui/skeleton.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils" 2 | 3 | function Skeleton({ 4 | className, 5 | ...props 6 | }: React.HTMLAttributes) { 7 | return ( 8 |
12 | ) 13 | } 14 | 15 | export { Skeleton } 16 | -------------------------------------------------------------------------------- /src/components/ui/table.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | const Table = React.forwardRef< 6 | HTMLTableElement, 7 | React.HTMLAttributes 8 | >(({ className, ...props }, ref) => ( 9 |
10 | 15 | 16 | )) 17 | Table.displayName = "Table" 18 | 19 | const TableHeader = React.forwardRef< 20 | HTMLTableSectionElement, 21 | React.HTMLAttributes 22 | >(({ className, ...props }, ref) => ( 23 | 24 | )) 25 | TableHeader.displayName = "TableHeader" 26 | 27 | const TableBody = React.forwardRef< 28 | HTMLTableSectionElement, 29 | React.HTMLAttributes 30 | >(({ className, ...props }, ref) => ( 31 | 36 | )) 37 | TableBody.displayName = "TableBody" 38 | 39 | const TableFooter = React.forwardRef< 40 | HTMLTableSectionElement, 41 | React.HTMLAttributes 42 | >(({ className, ...props }, ref) => ( 43 | tr]:last:border-b-0 dark:bg-zinc-800/50", 47 | className 48 | )} 49 | {...props} 50 | /> 51 | )) 52 | TableFooter.displayName = "TableFooter" 53 | 54 | const TableRow = React.forwardRef< 55 | HTMLTableRowElement, 56 | React.HTMLAttributes 57 | >(({ className, ...props }, ref) => ( 58 | 66 | )) 67 | TableRow.displayName = "TableRow" 68 | 69 | const TableHead = React.forwardRef< 70 | HTMLTableCellElement, 71 | React.ThHTMLAttributes 72 | >(({ className, ...props }, ref) => ( 73 |
[role=checkbox]]:translate-y-[2px] dark:text-zinc-400", 77 | className 78 | )} 79 | {...props} 80 | /> 81 | )) 82 | TableHead.displayName = "TableHead" 83 | 84 | const TableCell = React.forwardRef< 85 | HTMLTableCellElement, 86 | React.TdHTMLAttributes 87 | >(({ className, ...props }, ref) => ( 88 | [role=checkbox]]:translate-y-[2px]", 92 | className 93 | )} 94 | {...props} 95 | /> 96 | )) 97 | TableCell.displayName = "TableCell" 98 | 99 | const TableCaption = React.forwardRef< 100 | HTMLTableCaptionElement, 101 | React.HTMLAttributes 102 | >(({ className, ...props }, ref) => ( 103 |
108 | )) 109 | TableCaption.displayName = "TableCaption" 110 | 111 | export { 112 | Table, 113 | TableHeader, 114 | TableBody, 115 | TableFooter, 116 | TableHead, 117 | TableRow, 118 | TableCell, 119 | TableCaption, 120 | } 121 | -------------------------------------------------------------------------------- /src/lib/prisma.ts: -------------------------------------------------------------------------------- 1 | import {PrismaClient} from '@prisma/client' 2 | export const prisma = new PrismaClient(); 3 | -------------------------------------------------------------------------------- /src/lib/query/fetch-articles.ts: -------------------------------------------------------------------------------- 1 | import { prisma } from "@/lib/prisma"; 2 | 3 | export const fetchArticleByQuery = async (searchText: string, skip: number, take: number) => { 4 | const [articles, total] = await prisma.$transaction([ 5 | prisma.articles.findMany({ 6 | where: { 7 | OR: [ 8 | { title: { contains: searchText, mode: 'insensitive' } }, 9 | { category: { contains: searchText, mode: 'insensitive' } }, 10 | ], 11 | }, 12 | include: { 13 | author: { 14 | select: { name: true, imageUrl: true, email: true }, 15 | }, 16 | }, 17 | skip: skip, 18 | take: take, 19 | }), 20 | prisma.articles.count({ 21 | where: { 22 | OR: [ 23 | { title: { contains: searchText, mode: 'insensitive' } }, 24 | { category: { contains: searchText, mode: 'insensitive' } }, 25 | ], 26 | }, 27 | }), 28 | ]); 29 | 30 | return { articles, total }; 31 | }; -------------------------------------------------------------------------------- /src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from "clsx" 2 | import { twMerge } from "tailwind-merge" 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | -------------------------------------------------------------------------------- /src/middleware.ts: -------------------------------------------------------------------------------- 1 | import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server' 2 | import { NextRequest } from 'next/server'; 3 | 4 | const isProtectedRoute = createRouteMatcher(["/dashboard(.*)","/articles(.*)"]); 5 | 6 | export default clerkMiddleware(async (auth, req: NextRequest) => { 7 | if (isProtectedRoute(req)) { 8 | await auth.protect(); 9 | } 10 | }); 11 | 12 | export const config = { 13 | matcher: [ 14 | // Skip Next.js internals and all static files, unless found in search params 15 | '/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)', 16 | // Always run for API routes 17 | '/(api|trpc)(.*)', 18 | ], 19 | } -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "tailwindcss"; 2 | 3 | export default { 4 | darkMode: ["class"], 5 | content: [ 6 | "./src/pages/**/*.{js,ts,jsx,tsx,mdx}", 7 | "./src/components/**/*.{js,ts,jsx,tsx,mdx}", 8 | "./src/app/**/*.{js,ts,jsx,tsx,mdx}", 9 | ], 10 | theme: { 11 | extend: { 12 | colors: { 13 | background: 'var(--background)', 14 | foreground: 'var(--foreground)' 15 | }, 16 | borderRadius: { 17 | lg: 'var(--radius)', 18 | md: 'calc(var(--radius) - 2px)', 19 | sm: 'calc(var(--radius) - 4px)' 20 | } 21 | } 22 | }, 23 | plugins: [require("tailwindcss-animate")], 24 | } satisfies Config; 25 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2017", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "preserve", 15 | "incremental": true, 16 | "plugins": [ 17 | { 18 | "name": "next" 19 | } 20 | ], 21 | "paths": { 22 | "@/*": ["./src/*"] 23 | } 24 | }, 25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 26 | "exclude": ["node_modules"] 27 | } 28 | --------------------------------------------------------------------------------