לדלג לתוכן

9.10 פרויקטים פרויקט

פרויקט - בלוג ומערכת ניהול תוכן - Full-Stack Blog & CMS

בפרויקט זה נבנה אפליקציית בלוג מלאה עם Next.js App Router, Prisma, ו-Auth.js. המערכת תכלול ניהול פוסטים, אותנטיקציה, תגובות, דשבורד ניהול, ואופטימיזציית SEO.


דרישות הפרויקט

טכנולוגיות

  • Next.js 14+ עם App Router
  • TypeScript
  • Tailwind CSS
  • Prisma עם SQLite (או PostgreSQL)
  • Auth.js (NextAuth.js)
  • Vercel לדיפלוי

פיצ'רים נדרשים

  1. ניהול פוסטים - יצירה, עריכה, מחיקה ופרסום
  2. אותנטיקציה - רישום, התחברות, התנתקות
  3. תגובות - הוספת תגובות לפוסטים
  4. דשבורד ניהול - סטטיסטיקות וניהול תוכן
  5. SEO - מטא-דאטה דינמי, Open Graph
  6. דיפלוי - פריסה לוורסל

שלב 1 - הקמת הפרויקט

יצירת הפרויקט

npx create-next-app@latest blog-cms
cd blog-cms
npm install prisma @prisma/client next-auth@beta bcryptjs
npm install -D @types/bcryptjs
npx prisma init --datasource-provider sqlite
npx auth secret

הגדרת סכמת בסיס נתונים

// prisma/schema.prisma
generator client {
  provider = "prisma-client-js"
}

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

model User {
  id        Int       @id @default(autoincrement())
  name      String
  email     String    @unique
  password  String
  role      String    @default("user") // "user" | "admin"
  image     String?
  createdAt DateTime  @default(now())
  updatedAt DateTime  @updatedAt
  posts     Post[]
  comments  Comment[]
}

model Post {
  id          Int       @id @default(autoincrement())
  title       String
  slug        String    @unique
  content     String
  excerpt     String?
  coverImage  String?
  published   Boolean   @default(false)
  featured    Boolean   @default(false)
  createdAt   DateTime  @default(now())
  updatedAt   DateTime  @updatedAt
  author      User      @relation(fields: [authorId], references: [id])
  authorId    Int
  comments    Comment[]
  tags        Tag[]
}

model Comment {
  id        Int      @id @default(autoincrement())
  content   String
  createdAt DateTime @default(now())
  author    User     @relation(fields: [authorId], references: [id])
  authorId  Int
  post      Post     @relation(fields: [postId], references: [id], onDelete: Cascade)
  postId    Int
}

model Tag {
  id    Int    @id @default(autoincrement())
  name  String @unique
  slug  String @unique
  posts Post[]
}
npx prisma migrate dev --name init

מבנה תיקיות מומלץ

app/
  layout.tsx
  page.tsx                    # דף הבית - פוסטים אחרונים
  not-found.tsx
  error.tsx

  (auth)/
    login/page.tsx
    register/page.tsx
    layout.tsx

  (main)/
    layout.tsx
    blog/
      page.tsx                # רשימת פוסטים
      [slug]/page.tsx         # פוסט בודד
    tags/
      [slug]/page.tsx         # פוסטים לפי תגית

  (dashboard)/
    layout.tsx
    dashboard/
      page.tsx                # סקירה כללית
    dashboard/posts/
      page.tsx                # ניהול פוסטים
      new/page.tsx            # פוסט חדש
      [id]/edit/page.tsx      # עריכת פוסט
    dashboard/comments/
      page.tsx                # ניהול תגובות

  api/
    auth/[...nextauth]/route.ts

  actions/
    auth.ts
    posts.ts
    comments.ts

  components/
    ui/
    layout/
    blog/
    dashboard/

lib/
  prisma.ts
  utils.ts
types/

שלב 2 - אותנטיקציה

הגדרת Auth.js

// auth.ts
import NextAuth from "next-auth";
import Credentials from "next-auth/providers/credentials";
import { prisma } from "@/lib/prisma";
import bcrypt from "bcryptjs";

export const { handlers, signIn, signOut, auth } = NextAuth({
  providers: [
    Credentials({
      credentials: {
        email: {},
        password: {},
      },
      async authorize(credentials) {
        if (!credentials?.email || !credentials?.password) return null;

        const user = await prisma.user.findUnique({
          where: { email: credentials.email as string },
        });

        if (!user) return null;

        const valid = await bcrypt.compare(
          credentials.password as string,
          user.password
        );

        if (!valid) return null;

        return {
          id: user.id.toString(),
          name: user.name,
          email: user.email,
          role: user.role,
          image: user.image,
        };
      },
    }),
  ],
  callbacks: {
    jwt({ token, user }) {
      if (user) {
        token.role = (user as any).role;
        token.id = user.id;
      }
      return token;
    },
    session({ session, token }) {
      session.user.id = token.id as string;
      (session.user as any).role = token.role;
      return session;
    },
  },
  pages: { signIn: "/login" },
});

פעולות אותנטיקציה

// app/actions/auth.ts
"use server";

import { prisma } from "@/lib/prisma";
import bcrypt from "bcryptjs";
import { signIn } from "@/auth";

export async function register(formData: FormData) {
  const name = formData.get("name") as string;
  const email = formData.get("email") as string;
  const password = formData.get("password") as string;

  if (!name || !email || !password) {
    return { error: "כל השדות הם חובה" };
  }

  if (password.length < 6) {
    return { error: "הסיסמה חייבת להכיל לפחות 6 תווים" };
  }

  const existing = await prisma.user.findUnique({ where: { email } });
  if (existing) {
    return { error: "משתמש עם אימייל זה כבר קיים" };
  }

  const hashed = await bcrypt.hash(password, 10);
  await prisma.user.create({ data: { name, email, password: hashed } });

  return { success: true };
}

export async function login(formData: FormData) {
  try {
    await signIn("credentials", {
      email: formData.get("email"),
      password: formData.get("password"),
      redirectTo: "/dashboard",
    });
  } catch (error: any) {
    if (error?.type === "CredentialsSignin") {
      return { error: "אימייל או סיסמה שגויים" };
    }
    throw error;
  }
}

שלב 3 - ניהול פוסטים

Server Actions לפוסטים

// app/actions/posts.ts
"use server";

import { auth } from "@/auth";
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";

function slugify(text: string): string {
  return text
    .toLowerCase()
    .replace(/\s+/g, "-")
    .replace(/[^a-z0-9-]/g, "")
    .replace(/-+/g, "-");
}

export async function createPost(formData: FormData) {
  const session = await auth();
  if (!session) throw new Error("לא מחובר");

  const title = formData.get("title") as string;
  const content = formData.get("content") as string;
  const excerpt = formData.get("excerpt") as string;
  const coverImage = formData.get("coverImage") as string;
  const tags = (formData.get("tags") as string)?.split(",").map((t) => t.trim()).filter(Boolean);
  const published = formData.get("published") === "on";

  if (!title || !content) {
    return { error: "כותרת ותוכן הם שדות חובה" };
  }

  const slug = slugify(title) || `post-${Date.now()}`;

  const existingSlug = await prisma.post.findUnique({ where: { slug } });
  const finalSlug = existingSlug ? `${slug}-${Date.now()}` : slug;

  await prisma.post.create({
    data: {
      title,
      content,
      excerpt: excerpt || content.substring(0, 160),
      coverImage: coverImage || null,
      slug: finalSlug,
      published,
      authorId: parseInt(session.user.id),
      tags: {
        connectOrCreate: (tags || []).map((tag) => ({
          where: { slug: slugify(tag) || tag },
          create: { name: tag, slug: slugify(tag) || tag },
        })),
      },
    },
  });

  revalidatePath("/blog");
  revalidatePath("/dashboard/posts");
  redirect("/dashboard/posts");
}

export async function updatePost(id: number, formData: FormData) {
  const session = await auth();
  if (!session) throw new Error("לא מחובר");

  const title = formData.get("title") as string;
  const content = formData.get("content") as string;
  const excerpt = formData.get("excerpt") as string;
  const coverImage = formData.get("coverImage") as string;
  const tags = (formData.get("tags") as string)?.split(",").map((t) => t.trim()).filter(Boolean);
  const published = formData.get("published") === "on";

  await prisma.post.update({
    where: { id },
    data: {
      title,
      content,
      excerpt: excerpt || content.substring(0, 160),
      coverImage: coverImage || null,
      published,
      tags: {
        set: [],
        connectOrCreate: (tags || []).map((tag) => ({
          where: { slug: slugify(tag) || tag },
          create: { name: tag, slug: slugify(tag) || tag },
        })),
      },
    },
  });

  revalidatePath("/blog");
  revalidatePath("/dashboard/posts");
  redirect("/dashboard/posts");
}

export async function deletePost(id: number) {
  const session = await auth();
  if (!session) throw new Error("לא מחובר");

  await prisma.post.delete({ where: { id } });

  revalidatePath("/blog");
  revalidatePath("/dashboard/posts");
}

export async function togglePublish(id: number) {
  const session = await auth();
  if (!session) throw new Error("לא מחובר");

  const post = await prisma.post.findUnique({ where: { id } });
  if (!post) return;

  await prisma.post.update({
    where: { id },
    data: { published: !post.published },
  });

  revalidatePath("/blog");
  revalidatePath("/dashboard/posts");
}

שלב 4 - תגובות

// app/actions/comments.ts
"use server";

import { auth } from "@/auth";
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";

export async function addComment(postId: number, formData: FormData) {
  const session = await auth();
  if (!session) return { error: "יש להתחבר כדי להגיב" };

  const content = formData.get("content") as string;
  if (!content || content.trim().length < 2) {
    return { error: "התגובה חייבת להכיל לפחות 2 תווים" };
  }

  await prisma.comment.create({
    data: {
      content: content.trim(),
      postId,
      authorId: parseInt(session.user.id),
    },
  });

  revalidatePath("/blog");
  return { success: true };
}

export async function deleteComment(id: number) {
  const session = await auth();
  if (!session) throw new Error("לא מחובר");

  const comment = await prisma.comment.findUnique({ where: { id } });
  if (!comment) return;

  // רק המחבר או admin יכולים למחוק
  const isAuthor = comment.authorId === parseInt(session.user.id);
  const isAdmin = (session.user as any).role === "admin";

  if (!isAuthor && !isAdmin) {
    throw new Error("אין הרשאה");
  }

  await prisma.comment.delete({ where: { id } });
  revalidatePath("/blog");
}

שלב 5 - דף הבלוג

// app/blog/page.tsx
import { prisma } from "@/lib/prisma";
import Link from "next/link";
import Image from "next/image";
import type { Metadata } from "next";

export const metadata: Metadata = {
  title: "בלוג",
  description: "כל הפוסטים בבלוג שלנו",
};

export default async function BlogPage() {
  const posts = await prisma.post.findMany({
    where: { published: true },
    include: {
      author: { select: { name: true } },
      tags: true,
      _count: { select: { comments: true } },
    },
    orderBy: { createdAt: "desc" },
  });

  return (
    <div className="max-w-4xl mx-auto p-6">
      <h1 className="text-4xl font-bold mb-8">הבלוג</h1>

      <div className="space-y-8">
        {posts.map((post) => (
          <article key={post.id} className="border rounded-lg overflow-hidden hover:shadow-lg transition">
            {post.coverImage && (
              <div className="relative h-48">
                <Image
                  src={post.coverImage}
                  alt={post.title}
                  fill
                  className="object-cover"
                  sizes="(max-width: 768px) 100vw, 800px"
                />
              </div>
            )}
            <div className="p-6">
              <div className="flex gap-2 mb-2">
                {post.tags.map((tag) => (
                  <Link
                    key={tag.id}
                    href={`/tags/${tag.slug}`}
                    className="text-sm bg-blue-100 text-blue-700 px-2 py-1 rounded"
                  >
                    {tag.name}
                  </Link>
                ))}
              </div>
              <Link href={`/blog/${post.slug}`}>
                <h2 className="text-2xl font-bold hover:text-blue-600">
                  {post.title}
                </h2>
              </Link>
              <p className="text-gray-600 mt-2">{post.excerpt}</p>
              <div className="flex justify-between items-center mt-4 text-sm text-gray-500">
                <span>
                  {post.author.name} | {new Date(post.createdAt).toLocaleDateString("he-IL")}
                </span>
                <span>{post._count.comments} תגובות</span>
              </div>
            </div>
          </article>
        ))}
      </div>
    </div>
  );
}

דף פוסט בודד עם מטא-דאטה דינמי

// app/blog/[slug]/page.tsx
import { prisma } from "@/lib/prisma";
import { notFound } from "next/navigation";
import type { Metadata } from "next";
import CommentSection from "./CommentSection";

interface Props {
  params: { slug: string };
}

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const post = await prisma.post.findUnique({
    where: { slug: params.slug },
    include: { author: { select: { name: true } } },
  });

  if (!post) return { title: "פוסט לא נמצא" };

  return {
    title: post.title,
    description: post.excerpt || post.content.substring(0, 160),
    authors: [{ name: post.author.name }],
    openGraph: {
      title: post.title,
      description: post.excerpt || post.content.substring(0, 160),
      type: "article",
      publishedTime: post.createdAt.toISOString(),
      authors: [post.author.name],
      images: post.coverImage ? [post.coverImage] : [],
    },
  };
}

export default async function PostPage({ params }: Props) {
  const post = await prisma.post.findUnique({
    where: { slug: params.slug, published: true },
    include: {
      author: { select: { name: true, image: true } },
      tags: true,
      comments: {
        include: { author: { select: { name: true } } },
        orderBy: { createdAt: "desc" },
      },
    },
  });

  if (!post) notFound();

  return (
    <article className="max-w-3xl mx-auto p-6">
      <header className="mb-8">
        <h1 className="text-4xl font-bold mb-4">{post.title}</h1>
        <div className="flex items-center gap-4 text-gray-600">
          <span>מאת {post.author.name}</span>
          <span>{new Date(post.createdAt).toLocaleDateString("he-IL")}</span>
        </div>
        <div className="flex gap-2 mt-3">
          {post.tags.map((tag) => (
            <span key={tag.id} className="bg-gray-100 px-3 py-1 rounded text-sm">
              {tag.name}
            </span>
          ))}
        </div>
      </header>

      <div className="prose prose-lg max-w-none">
        {post.content.split("\n").map((paragraph, i) => (
          <p key={i}>{paragraph}</p>
        ))}
      </div>

      <hr className="my-8" />

      <CommentSection postId={post.id} comments={post.comments} />
    </article>
  );
}

שלב 6 - דשבורד ניהול

// app/(dashboard)/dashboard/page.tsx
import { auth } from "@/auth";
import { redirect } from "next/navigation";
import { prisma } from "@/lib/prisma";

export default async function DashboardPage() {
  const session = await auth();
  if (!session) redirect("/login");

  const userId = parseInt(session.user.id);

  const [totalPosts, publishedPosts, totalComments, recentPosts] =
    await Promise.all([
      prisma.post.count({ where: { authorId: userId } }),
      prisma.post.count({ where: { authorId: userId, published: true } }),
      prisma.comment.count({
        where: { post: { authorId: userId } },
      }),
      prisma.post.findMany({
        where: { authorId: userId },
        take: 5,
        orderBy: { createdAt: "desc" },
        include: { _count: { select: { comments: true } } },
      }),
    ]);

  return (
    <div>
      <h1 className="text-3xl font-bold mb-6">דשבורד</h1>

      <div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">
        <div className="bg-blue-50 p-6 rounded-lg">
          <div className="text-3xl font-bold">{totalPosts}</div>
          <div className="text-gray-600">סה"כ פוסטים</div>
        </div>
        <div className="bg-green-50 p-6 rounded-lg">
          <div className="text-3xl font-bold">{publishedPosts}</div>
          <div className="text-gray-600">פורסמו</div>
        </div>
        <div className="bg-purple-50 p-6 rounded-lg">
          <div className="text-3xl font-bold">{totalComments}</div>
          <div className="text-gray-600">תגובות</div>
        </div>
      </div>

      <h2 className="text-xl font-bold mb-4">פוסטים אחרונים</h2>
      <div className="space-y-3">
        {recentPosts.map((post) => (
          <div key={post.id} className="flex justify-between items-center border p-3 rounded">
            <div>
              <span className="font-semibold">{post.title}</span>
              <span className={`mr-2 text-sm px-2 py-1 rounded ${
                post.published ? "bg-green-100 text-green-700" : "bg-yellow-100 text-yellow-700"
              }`}>
                {post.published ? "פורסם" : "טיוטה"}
              </span>
            </div>
            <span className="text-sm text-gray-500">
              {post._count.comments} תגובות
            </span>
          </div>
        ))}
      </div>
    </div>
  );
}

שלב 7 - SEO ואופטימיזציה

// app/layout.tsx
import type { Metadata } from "next";
import { Heebo } from "next/font/google";
import "./globals.css";

const heebo = Heebo({ subsets: ["hebrew", "latin"] });

export const metadata: Metadata = {
  title: {
    template: "%s | הבלוג שלי",
    default: "הבלוג שלי - מאמרים על טכנולוגיה",
  },
  description: "בלוג על פיתוח ווב, Next.js, React, וטכנולוגיות מודרניות",
  openGraph: {
    type: "website",
    locale: "he_IL",
    url: process.env.NEXT_PUBLIC_SITE_URL,
    siteName: "הבלוג שלי",
  },
  robots: { index: true, follow: true },
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="he" dir="rtl" className={heebo.className}>
      <body className="min-h-screen">{children}</body>
    </html>
  );
}

שלב 8 - דיפלוי

הכנה לדיפלוי

# ודאו שה-build עובר
npm run build

# העלו ל-GitHub
git add .
git commit -m "Full blog CMS with auth, posts, comments, dashboard"
git push

משתני סביבה בוורסל

DATABASE_URL="file:./dev.db"   # או PostgreSQL URL
AUTH_SECRET="your-secret"
AUTH_URL="https://your-domain.vercel.app"
NEXT_PUBLIC_SITE_URL="https://your-domain.vercel.app"

שדרוג ל-PostgreSQL (מומלץ לייצור)

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}
DATABASE_URL="postgresql://user:pass@host:5432/dbname"

קריטריונים להערכה

קריטריון ניקוד
מבנה פרויקט מאורגן 10
אותנטיקציה עובדת (רישום, התחברות, התנתקות) 15
CRUD מלא לפוסטים 20
מערכת תגובות 10
דשבורד עם סטטיסטיקות 10
מטא-דאטה דינמי ו-SEO 10
עיצוב נאה עם Tailwind 10
טיפול בשגיאות וולידציה 5
דיפלוי לוורסל 5
קוד נקי ומתועד 5
סה"כ 100

בונוס

  • הוספת עורך rich text (למשל Tiptap)
  • חיפוש פוסטים
  • פגינציה
  • תמונות פרופיל עם upload
  • RSS feed
  • Dark mode
  • אנימציות עם Framer Motion