إطار Next.js

بناء تطبيق Next.js كامل - الجزء الأول

55 دقيقة الدرس 39 من 40

بناء تطبيق Next.js كامل - الجزء الأول

في هذه السلسلة الشاملة المكونة من جزأين، سنبني تطبيق Next.js كامل وجاهز للإنتاج من الصفر. يركز الجزء الأول على تخطيط المشروع، والإعداد الأولي، والمصادقة، وتصميم قاعدة البيانات، وتنفيذ الميزات الأساسية.

نظرة عامة على المشروع: TaskFlow - تطبيق إدارة المشاريع

سنبني TaskFlow، وهو تطبيق حديث لإدارة المشاريع بالميزات التالية:

  • المصادقة: تسجيل المستخدم، وتسجيل الدخول، وإدارة الجلسات
  • المشاريع: إنشاء وإدارة وأرشفة المشاريع
  • المهام: إنشاء المهام، والتعيين، وتتبع الحالة، والأولويات
  • الفرق: التعاون الجماعي وإدارة الأعضاء
  • التعليقات: تعليقات المهام في الوقت الفعلي والمناقشات
  • لوحة المعلومات: التحليلات ونظرة عامة على المشروع
  • الإشعارات: تحديثات في الوقت الفعلي لأنشطة الفريق

الخطوة 1: تخطيط المشروع والبنية

مجموعة التقنيات:
  • الإطار: Next.js 15 مع App Router
  • اللغة: TypeScript
  • قاعدة البيانات: PostgreSQL مع Prisma ORM
  • المصادقة: NextAuth.js v5 (Auth.js)
  • التنسيق: Tailwind CSS + shadcn/ui
  • إدارة الحالة: Zustand لحالة العميل
  • الوقت الفعلي: Pusher للتحديثات المباشرة
  • النشر: Vercel
هيكل المشروع:
taskflow/
├── app/
│   ├── (auth)/
│   │   ├── login/
│   │   └── register/
│   ├── (dashboard)/
│   │   ├── dashboard/
│   │   ├── projects/
│   │   │   ├── [id]/
│   │   │   └── new/
│   │   ├── tasks/
│   │   └── teams/
│   ├── api/
│   │   ├── auth/
│   │   ├── projects/
│   │   ├── tasks/
│   │   └── notifications/
│   └── layout.tsx
├── components/
│   ├── ui/           # مكونات shadcn
│   ├── layout/       # مكونات التخطيط
│   ├── projects/     # مكونات خاصة بالمشروع
│   └── tasks/        # مكونات خاصة بالمهام
├── lib/
│   ├── auth.ts       # تكوين المصادقة
│   ├── db.ts         # عميل Prisma
│   ├── utils.ts      # وظائف المساعدة
│   └── validations/  # مخططات Zod
├── prisma/
│   └── schema.prisma
└── types/
    └── index.ts

الخطوة 2: الإعداد الأولي للمشروع

إنشاء مشروع Next.js:
npx create-next-app@latest taskflow --typescript --tailwind --app --use-npm
cd taskflow
تثبيت التبعيات:
# التبعيات الأساسية
npm install prisma @prisma/client
npm install next-auth@beta
npm install zod react-hook-form @hookform/resolvers
npm install zustand
npm install pusher pusher-js
npm install date-fns
npm install lucide-react

# مكونات واجهة المستخدم
npx shadcn-ui@latest init
npx shadcn-ui@latest add button input label card dialog dropdown-menu
متغيرات البيئة (.env.local):
# قاعدة البيانات
DATABASE_URL="postgresql://user:password@localhost:5432/taskflow"

# المصادقة
NEXTAUTH_URL="http://localhost:3000"
NEXTAUTH_SECRET="your-secret-key-here"

# OAuth (اختياري)
GOOGLE_CLIENT_ID="your-google-client-id"
GOOGLE_CLIENT_SECRET="your-google-client-secret"

# Pusher
NEXT_PUBLIC_PUSHER_KEY="your-pusher-key"
PUSHER_SECRET="your-pusher-secret"
PUSHER_APP_ID="your-app-id"
NEXT_PUBLIC_PUSHER_CLUSTER="mt1"

الخطوة 3: تصميم مخطط قاعدة البيانات

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

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

model User {
  id            String    @id @default(cuid())
  name          String?
  email         String    @unique
  emailVerified DateTime?
  image         String?
  password      String?
  role          Role      @default(USER)
  createdAt     DateTime  @default(now())
  updatedAt     DateTime  @updatedAt

  accounts      Account[]
  sessions      Session[]
  projects      ProjectMember[]
  tasks         Task[]
  comments      Comment[]
  notifications Notification[]
}

enum Role {
  USER
  ADMIN
}

model Account {
  id                String  @id @default(cuid())
  userId            String
  type              String
  provider          String
  providerAccountId String
  refresh_token     String?
  access_token      String?
  expires_at        Int?
  token_type        String?
  scope             String?
  id_token          String?
  session_state     String?

  user User @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@unique([provider, providerAccountId])
}

model Session {
  id           String   @id @default(cuid())
  sessionToken String   @unique
  userId       String
  expires      DateTime
  user         User     @relation(fields: [userId], references: [id], onDelete: Cascade)
}

model VerificationToken {
  identifier String
  token      String   @unique
  expires    DateTime

  @@unique([identifier, token])
}

model Project {
  id          String   @id @default(cuid())
  name        String
  description String?
  slug        String   @unique
  color       String   @default("#3B82F6")
  archived    Boolean  @default(false)
  createdAt   DateTime @default(now())
  updatedAt   DateTime @updatedAt

  members ProjectMember[]
  tasks   Task[]
}

model ProjectMember {
  id        String      @id @default(cuid())
  role      MemberRole  @default(MEMBER)
  joinedAt  DateTime    @default(now())

  user      User        @relation(fields: [userId], references: [id], onDelete: Cascade)
  userId    String
  project   Project     @relation(fields: [projectId], references: [id], onDelete: Cascade)
  projectId String

  @@unique([userId, projectId])
}

enum MemberRole {
  OWNER
  ADMIN
  MEMBER
}

model Task {
  id          String       @id @default(cuid())
  title       String
  description String?
  status      TaskStatus   @default(TODO)
  priority    TaskPriority @default(MEDIUM)
  dueDate     DateTime?
  createdAt   DateTime     @default(now())
  updatedAt   DateTime     @updatedAt

  project     Project      @relation(fields: [projectId], references: [id], onDelete: Cascade)
  projectId   String
  assignee    User?        @relation(fields: [assigneeId], references: [id])
  assigneeId  String?

  comments    Comment[]
}

enum TaskStatus {
  TODO
  IN_PROGRESS
  IN_REVIEW
  DONE
}

enum TaskPriority {
  LOW
  MEDIUM
  HIGH
  URGENT
}

model Comment {
  id        String   @id @default(cuid())
  content   String
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  task      Task     @relation(fields: [taskId], references: [id], onDelete: Cascade)
  taskId    String
  author    User     @relation(fields: [authorId], references: [id], onDelete: Cascade)
  authorId  String
}

model Notification {
  id        String   @id @default(cuid())
  type      String
  title     String
  message   String
  read      Boolean  @default(false)
  link      String?
  createdAt DateTime @default(now())

  user      User     @relation(fields: [userId], references: [id], onDelete: Cascade)
  userId    String
}
تهيئة قاعدة البيانات:
npx prisma generate
npx prisma db push

الخطوة 4: إعداد المصادقة

lib/auth.ts:
import NextAuth from 'next-auth';
import Credentials from 'next-auth/providers/credentials';
import Google from 'next-auth/providers/google';
import { PrismaAdapter } from '@auth/prisma-adapter';
import { prisma } from '@/lib/db';
import bcrypt from 'bcryptjs';
import { z } from 'zod';

const loginSchema = z.object({
  email: z.string().email(),
  password: z.string().min(6),
});

export const { handlers, auth, signIn, signOut } = NextAuth({
  adapter: PrismaAdapter(prisma),
  session: { strategy: 'jwt' },
  pages: {
    signIn: '/login',
    error: '/login',
  },
  providers: [
    Google({
      clientId: process.env.GOOGLE_CLIENT_ID,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET,
    }),
    Credentials({
      credentials: {
        email: { label: 'البريد الإلكتروني', type: 'email' },
        password: { label: 'كلمة المرور', type: 'password' },
      },
      async authorize(credentials) {
        const validated = loginSchema.safeParse(credentials);

        if (!validated.success) {
          return null;
        }

        const { email, password } = validated.data;

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

        if (!user || !user.password) {
          return null;
        }

        const isValid = await bcrypt.compare(password, user.password);

        if (!isValid) {
          return null;
        }

        return {
          id: user.id,
          email: user.email,
          name: user.name,
          image: user.image,
        };
      },
    }),
  ],
  callbacks: {
    async session({ session, token }) {
      if (token.sub) {
        session.user.id = token.sub;
      }
      return session;
    },
    async jwt({ token, user }) {
      if (user) {
        token.sub = user.id;
      }
      return token;
    },
  },
});
app/api/auth/[...nextauth]/route.ts:
import { handlers } from '@/lib/auth';

export const { GET, POST } = handlers;
app/(auth)/login/page.tsx:
'use client';

import { signIn } from 'next-auth/react';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card } from '@/components/ui/card';

export default function LoginPage() {
  const router = useRouter();
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState('');

  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    setLoading(true);
    setError('');

    const formData = new FormData(e.currentTarget);
    const email = formData.get('email') as string;
    const password = formData.get('password') as string;

    try {
      const result = await signIn('credentials', {
        email,
        password,
        redirect: false,
      });

      if (result?.error) {
        setError('البريد الإلكتروني أو كلمة المرور غير صحيحة');
      } else {
        router.push('/dashboard');
      }
    } catch (error) {
      setError('حدث خطأ');
    } finally {
      setLoading(false);
    }
  };

  return (
    <div className="min-h-screen flex items-center justify-center">
      <Card className="w-full max-w-md p-6">
        <h1 className="text-2xl font-bold mb-6">تسجيل الدخول إلى TaskFlow</h1>

        {error && (
          <div className="bg-red-50 text-red-600 p-3 rounded mb-4">
            {error}
          </div>
        )}

        <form onSubmit={handleSubmit} className="space-y-4">
          <div>
            <Label htmlFor="email">البريد الإلكتروني</Label>
            <Input
              id="email"
              name="email"
              type="email"
              required
              autoComplete="email"
            />
          </div>

          <div>
            <Label htmlFor="password">كلمة المرور</Label>
            <Input
              id="password"
              name="password"
              type="password"
              required
              autoComplete="current-password"
            />
          </div>

          <Button type="submit" className="w-full" disabled={loading}>
            {loading ? 'جاري تسجيل الدخول...' : 'تسجيل الدخول'}
          </Button>
        </form>

        <div className="mt-4">
          <div className="relative">
            <div className="absolute inset-0 flex items-center">
              <span className="w-full border-t" />
            </div>
            <div className="relative flex justify-center text-xs uppercase">
              <span className="bg-white px-2 text-gray-500">
                أو تابع باستخدام
              </span>
            </div>
          </div>

          <Button
            type="button"
            variant="outline"
            className="w-full mt-4"
            onClick={() => signIn('google', { callbackUrl: '/dashboard' })}
          >
            تابع باستخدام Google
          </Button>
        </div>
      </Card>
    </div>
  );
}

الخطوة 5: نماذج البيانات الأساسية ومسارات API

lib/validations/project.ts:
import { z } from 'zod';

export const createProjectSchema = z.object({
  name: z.string().min(1, 'اسم المشروع مطلوب').max(100),
  description: z.string().optional(),
  color: z.string().regex(/^#[0-9A-F]{6}$/i).optional(),
});

export const updateProjectSchema = createProjectSchema.partial();

export type CreateProjectInput = z.infer<typeof createProjectSchema>;
export type UpdateProjectInput = z.infer<typeof updateProjectSchema>;
app/api/projects/route.ts:
import { NextRequest, NextResponse } from 'next/server';
import { auth } from '@/lib/auth';
import { prisma } from '@/lib/db';
import { createProjectSchema } from '@/lib/validations/project';

export async function GET(request: NextRequest) {
  const session = await auth();

  if (!session?.user) {
    return NextResponse.json({ error: 'غير مصرح' }, { status: 401 });
  }

  const projects = await prisma.project.findMany({
    where: {
      members: {
        some: {
          userId: session.user.id,
        },
      },
      archived: false,
    },
    include: {
      members: {
        include: {
          user: {
            select: {
              id: true,
              name: true,
              image: true,
            },
          },
        },
      },
      _count: {
        select: {
          tasks: true,
        },
      },
    },
    orderBy: {
      updatedAt: 'desc',
    },
  });

  return NextResponse.json(projects);
}

export async function POST(request: NextRequest) {
  const session = await auth();

  if (!session?.user) {
    return NextResponse.json({ error: 'غير مصرح' }, { status: 401 });
  }

  const body = await request.json();
  const validated = createProjectSchema.safeParse(body);

  if (!validated.success) {
    return NextResponse.json(
      { error: validated.error.errors },
      { status: 400 }
    );
  }

  const { name, description, color } = validated.data;

  // إنشاء المعرف
  const slug = name
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, '-')
    .replace(/^-|-$/g, '');

  const project = await prisma.project.create({
    data: {
      name,
      description,
      color,
      slug: \`${slug}-${Date.now()}\`,
      members: {
        create: {
          userId: session.user.id,
          role: 'OWNER',
        },
      },
    },
    include: {
      members: {
        include: {
          user: true,
        },
      },
    },
  });

  return NextResponse.json(project, { status: 201 });
}
تمرين - نقطة التفتيش للجزء الأول:
  1. قم بإعداد مشروع Next.js جديد باستخدام مجموعة التقنيات المذكورة أعلاه
  2. قم بتكوين مخطط قاعدة البيانات وتشغيل الترحيلات
  3. قم بتنفيذ المصادقة باستخدام بيانات الاعتماد و Google OAuth
  4. أنشئ مسارات API للمشاريع مع نقاط نهاية GET و POST
  5. قم ببناء صفحة تسجيل الدخول مع معالجة الأخطاء المناسبة
  6. اختبر تسجيل المستخدم وتسجيل الدخول وإنشاء المشروع
نصائح التطوير:
  • استخدم متغيرات البيئة لجميع التكوينات الحساسة
  • اختبر تدفقات المصادقة بدقة قبل بناء الميزات
  • حافظ على مخطط قاعدة البيانات الخاص بك طبيعياً ومفهرساً بشكل جيد
  • استخدم أنواع TypeScript المُنشأة من مخططات Prisma
  • قم بتنفيذ معالجة الأخطاء المناسبة من البداية
  • استخدم Prisma Studio (npx prisma studio) لفحص قاعدة البيانات الخاصة بك
ما التالي في الجزء الثاني:

في الجزء الثاني، سنستمر في بناء TaskFlow من خلال تنفيذ:

  • إدارة المهام مع لوحات السحب والإفلات
  • التعاون في الوقت الفعلي مع Pusher
  • الميزات المتقدمة (الإشعارات، البحث، الفلاتر)
  • استراتيجيات الاختبار وتنفيذ الاختبار
  • تقنيات تحسين الأداء
  • النشر في الإنتاج والمراقبة
  • تقوية الأمان وأفضل الممارسات