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 לדיפלוי
פיצ'רים נדרשים¶
- ניהול פוסטים - יצירה, עריכה, מחיקה ופרסום
- אותנטיקציה - רישום, התחברות, התנתקות
- תגובות - הוספת תגובות לפוסטים
- דשבורד ניהול - סטטיסטיקות וניהול תוכן
- SEO - מטא-דאטה דינמי, Open Graph
- דיפלוי - פריסה לוורסל
שלב 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[]
}
מבנה תיקיות מומלץ¶
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 (מומלץ לייצור)¶
קריטריונים להערכה¶
| קריטריון | ניקוד |
|---|---|
| מבנה פרויקט מאורגן | 10 |
| אותנטיקציה עובדת (רישום, התחברות, התנתקות) | 15 |
| CRUD מלא לפוסטים | 20 |
| מערכת תגובות | 10 |
| דשבורד עם סטטיסטיקות | 10 |
| מטא-דאטה דינמי ו-SEO | 10 |
| עיצוב נאה עם Tailwind | 10 |
| טיפול בשגיאות וולידציה | 5 |
| דיפלוי לוורסל | 5 |
| קוד נקי ומתועד | 5 |
| סה"כ | 100 |
בונוס¶
- הוספת עורך rich text (למשל Tiptap)
- חיפוש פוסטים
- פגינציה
- תמונות פרופיל עם upload
- RSS feed
- Dark mode
- אנימציות עם Framer Motion