MATIN MOLKARA
SERIES: آموزش Next js — PART 25 OF 28

قسمت بیست و پنجم - پیاده‌سازی کامل آپلود تصویر با Cloudinary در Next.js

پیاده سازی کامل آپلود تصویر به همراه api های مورد نظر در پروژه با استفاده از سرویس cloudinary

پیاده‌سازی کامل آپلود تصویر با Cloudinary در Next.js

در قسمت قبل درباره این صحبت کردیم که چرا برای مدیریت فایل‌های رسانه‌ای بهتر است از یک سرویس بیرونی مثل Cloudinary استفاده کنیم.

در آن بخش، بیشتر روی معماری و منطق کلی تمرکز داشتیم:

  • چرا فایل را داخل پروژه نگه نداریم
  • چرا فایل را داخل دیتابیس ذخیره نکنیم
  • چرا باید فقط URL یا اطلاعات رسانه را ذخیره کنیم
  • و چطور این قابلیت به پنل ادمین ما یک قدم واقعی به سمت CMS شدن می‌دهد

حالا وقت پیاده‌سازی کامل است.

در این قسمت می‌خواهیم:

  • Cloudinary را در پروژه تنظیم کنیم
  • یک helper سمت سرور بسازیم
  • یک API امن برای آپلود تصویر ایجاد کنیم
  • اعتبارسنجی فایل انجام دهیم
  • خروجی آپلود را به فرم پروژه وصل کنیم
  • فیلد imageUrl را در دیتابیس ذخیره کنیم
  • و همین الگو را برای مقالات هم قابل استفاده کنیم

هدف این قسمت

در پایان این آموزش، باید این جریان کامل را داشته باشید:

  1. ادمین در فرم پروژه یا مقاله یک تصویر انتخاب می‌کند
  2. فایل به endpoint داخلی شما ارسال می‌شود
  3. endpoint فایل را بررسی می‌کند
  4. فایل به Cloudinary آپلود می‌شود
  5. secure_url برگردانده می‌شود
  6. URL در فرم ذخیره می‌شود
  7. هنگام ثبت پروژه یا مقاله، همان URL در دیتابیس ذخیره می‌شود

یعنی در نهایت، پروژه یا پست شما به یک تصویر واقعی متصل خواهد بود.


چیزی که قرار است بسازیم

در این قسمت این فایل‌ها یا بخش‌ها را تکمیل می‌کنیم:

lib/cloudinary.ts
lib/validations/upload.ts
app/api/uploads/image/route.ts
components/admin/image-upload.tsx
components/admin/project-form.tsx
components/admin/post-form.tsx
lib/validations/project.ts
lib/validations/post.ts

و اگر هنوز در مدل دیتابیس خودتان این فیلدها را ندارید، باید آن‌ها را هم اضافه کنید:

  • برای پروژه: imageUrl
  • برای پست: coverImage

مرحله اول: تنظیم envهای Cloudinary

فایل .env یا .env.local خودتان را باز کنید و مقادیر Cloudinary را اضافه کنید:

CLOUDINARY_CLOUD_NAME=your_cloud_name
CLOUDINARY_API_KEY=your_api_key
CLOUDINARY_API_SECRET=your_api_secret

نکته

این مقادیر فقط باید در سمت سرور استفاده شوند.
مخصوصاً CLOUDINARY_API_SECRET نباید در هیچ Client Component یا کدی که به مرورگر ارسال می‌شود استفاده شود.


مرحله دوم: نصب پکیج Cloudinary

اگر هنوز نصب نکرده‌اید:

npm install cloudinary

مرحله سوم: ساخت helper برای Cloudinary

فایل زیر را بسازید:

lib/cloudinary.ts

محتوای آن:

import { v2 as cloudinary } from 'cloudinary'

const cloudName = process.env.CLOUDINARY_CLOUD_NAME
const apiKey = process.env.CLOUDINARY_API_KEY
const apiSecret = process.env.CLOUDINARY_API_SECRET

if (!cloudName || !apiKey || !apiSecret) {
  throw new Error('Cloudinary environment variables are missing')
}

cloudinary.config({
  cloud_name: cloudName,
  api_key: apiKey,
  api_secret: apiSecret,
})

export { cloudinary }

این فایل چه کاری می‌کند؟

  • envها را می‌خواند
  • Cloudinary را تنظیم می‌کند
  • یک instance قابل استفاده در کل پروژه export می‌کند

مرحله چهارم: اعتبارسنجی فایل آپلود

قبل از اینکه فایل را به Cloudinary بفرستیم، بهتر است خودمان هم چند بررسی ساده انجام دهیم.

فایل زیر را بسازید:

lib/validations/upload.ts

و

export const ALLOWED_IMAGE_TYPES = [
  'image/jpeg',
  'image/png',
  'image/webp',
  'image/jpg',
] as const

export const MAX_IMAGE_SIZE = 5 * 1024 * 1024

export function validateImageFile(file: File) {
  if (!ALLOWED_IMAGE_TYPES.includes(file.type as (typeof ALLOWED_IMAGE_TYPES)[number])) {
    return 'Only JPG, PNG, and WEBP images are allowed.'
  }

  if (file.size > MAX_IMAGE_SIZE) {
    return 'Image size must be 5MB or less.'
  }

  return null
}

این بخش چه چیزی را کنترل می‌کند؟

  • نوع فایل
  • حداکثر حجم فایل

اگر بعداً خواستید، می‌توانید محدودیت‌ها را سخت‌گیرانه‌تر هم بکنید.


مرحله پنجم: ساخت Route Handler برای آپلود تصویر

حالا می‌رسیم به مهم‌ترین بخش.

فایل زیر را بسازید:

app/api/uploads/image/route.ts

کد کامل:

import { NextResponse } from 'next/server'
import { cloudinary } from '@/lib/cloudinary'
import { validateImageFile } from '@/lib/validations/upload'
import { requireAdminFromRequest } from '@/lib/auth'

export async function POST(request: Request) {
  try {
    await requireAdminFromRequest(request)

    const formData = await request.formData()
    const file = formData.get('file')

    if (!file || !(file instanceof File)) {
      return NextResponse.json(
        { error: 'Image file is required.' },
        { status: 400 }
      )
    }

    const validationError = validateImageFile(file)

    if (validationError) {
      return NextResponse.json(
        { error: validationError },
        { status: 400 }
      )
    }

    const bytes = await file.arrayBuffer()
    const buffer = Buffer.from(bytes)

    const uploadResult = await new Promise<{
      secure_url: string
      public_id: string
      width: number
      height: number
      format: string
    }>((resolve, reject) => {
      const stream = cloudinary.uploader.upload_stream(
        {
          folder: 'portfolio/uploads',
          resource_type: 'image',
        },
        (error, result) => {
          if (error || !result) {
            reject(error ?? new Error('Upload failed'))
            return
          }

          resolve({
            secure_url: result.secure_url,
            public_id: result.public_id,
            width: result.width,
            height: result.height,
            format: result.format,
          })
        }
      )

      stream.end(buffer)
    })

    return NextResponse.json(
      {
        message: 'Image uploaded successfully.',
        image: {
          url: uploadResult.secure_url,
          publicId: uploadResult.public_id,
          width: uploadResult.width,
          height: uploadResult.height,
          format: uploadResult.format,
        },
      },
      { status: 201 }
    )
  } catch (error) {
    console.error('Image upload error:', error)

    return NextResponse.json(
      { error: 'Failed to upload image.' },
      { status: 500 }
    )
  }
}

این API دقیقاً چه کاری انجام می‌دهد؟

این endpoint:

  • فقط به ادمین اجازه دسترسی می‌دهد
  • فایل را از formData می‌گیرد
  • بررسی می‌کند که واقعاً فایل وجود دارد
  • فایل را از نظر فرمت و حجم اعتبارسنجی می‌کند
  • فایل را به Cloudinary آپلود می‌کند
  • خروجی نهایی را به‌صورت JSON برمی‌گرداند

خروجی موفق چه شکلی است؟

نمونه پاسخ:

{
  "message": "Image uploaded successfully.",
  "image": {
    "url": "https://res.cloudinary.com/...",
    "publicId": "portfolio/uploads/abc123",
    "width": 1200,
    "height": 630,
    "format": "jpg"
  }
}

اگر requireAdminFromRequest ندارید

اگر در پروژه‌تان قبلاً helper مربوط به محافظت APIها را ساخته‌اید، از همان استفاده کنید.

مثلاً چیزی شبیه این:

import { cookies } from 'next/headers'
import { prisma } from '@/lib/prisma'

export async function requireAdminFromRequest(_request: Request) {
  const cookieStore = await cookies()
  const sessionToken = cookieStore.get('admin_session')?.value

  if (!sessionToken) {
    throw new Error('Unauthorized')
  }

  const session = await prisma.adminSession.findUnique({
    where: { token: sessionToken },
    include: { user: true },
  })

  if (!session || !session.user) {
    throw new Error('Unauthorized')
  }

  return session.user
}

نکته

اگر helper شما در حالت عدم دسترسی throw می‌کند، بهتر است در route handler برای خطای دسترسی پاسخ $401$ برگردانید.

نسخه بهتر route handler با تفکیک خطا را کمی پایین‌تر می‌بینیم.


نسخه بهتر API با مدیریت $401$

اگر می‌خواهید مدیریت خطا دقیق‌تر باشد، این نسخه تمیزتر است:

import { NextResponse } from 'next/server'
import { cloudinary } from '@/lib/cloudinary'
import { validateImageFile } from '@/lib/validations 

دقیق‌تر باشد، این نسخه تمیزتر است:

import { NextResponse } from 'next/server'
import { cloudinary } from '@/lib/cloudinary'
import { validateImageFile } from '@/lib/validations/upload'
import { requireAdminFromRequest } from '@/lib/auth'

export async function POST(request: Request) {
  try {
   Data()
    const file = formData.get('file')

    if (!file || !(file instanceof File)) {
      return NextResponse.json(
        { error: 'Image file is required.' },
        { status: 400 }
      )
    }

    const validationError = validateImageFile(file)

    if (validationError) {
      return NextResponse.json(
        { error: validationError },
        { status: 400 }
      )
    }

    const bytes = await file.arrayBuffer()
    const buffer = Buffer.from(bytes)

    const uploadResult = await new Promise<{
      secure_url: string
      public_id: string
      width: number
      height: number
      format: string
    }>((resolve, reject) => {
      const stream = cloudinary.uploader.upload_stream(
        {
          folder: 'portfolio/uploads',
          resource_type: 'image',
        },
        (error, result) => {
          if (error || !result) {
            reject(error ?? new Error('Upload failed'))
            return
          }

          resolve({
            secure_url: result.secure_url,
            public_id: result.public_id,
            width: result.width,
            height: result.height,
            format: result.format,
          })
        }
      )

      stream.end(buffer)
    })

    return NextResponse.json(
      {
        message: 'Image uploaded successfully.',
        image: {
          url: uploadResult.secure_url,
          publicId: uploadResult.public_id,
          width: uploadResult.width,
          height: uploadResult.height,
          format: uploadResult.format,
        },
      },
      { status: 201 }
    )
  } catch (error) {
    console.error('Image upload error:', error)

    return NextResponse.json(
      { error: 'Failed to upload image.' },
      { status: 500 }
    )
  }
}

مرحله ششم: ساخت کامپوننت reusable برای آپلود تصویر

بهتر است uploader را فقط داخل یک فرم ننویسیم.
یک کامپوننت reusable می‌سازیم تا هم در فرم پروژه و هم در فرم پست استفاده شود.

فایل:

components/adminreact'
.tsx

کد کامل:

'use client'

import { useRef, useState } from 'react'
import Image from 'next/image'

type UploadedImage = {
  url: string
  publicId?: string
  width?: number
  height?: number
  format?: string
}

type ImageUploadProps = {
  label?: string
  value?: string
  onChange: (image: UploadedImage | null) => void
}

export function ImageUpload({
  label = 'Upload Image',
  value,
  onChange,
}: ImageUploadProps) {
  const inputRef = useRef<HTMLInputElement | null>(null)
  const [isUploading, setIsUploading] = useState(false)
  const [error, setError] = useState('')
  const [previewUrl, setPreviewUrl] = useState(value ?? '')

  async function handleFileChange(event: React.ChangeEvent<HTMLInputElement>) {
    const file = event.target.files?.[0]

    if (!file) return

    setError('')
    setIsUploading(true)

    try {
      const formData = new FormData()
      formData.append('file', file)

      const response = await fetch('/api/uploads/image', {
        method: 'POST',
        body: formData,
      })

      const data = await response.json()

      if (!response.ok) {
        throw new Error(data.error || 'Upload failed')
      }

      const uploaded = data.image as UploadedImage

      setPreviewUrl(uploaded.url)
      onChange(uploaded)
    } catch (err) {
      const message =
        err instanceof Error ? err.message : 'Upload failed'

      setError(message)
      onChange(null)
    } finally {
      setIsUploading(false)
    }
  }

  function handleRemove() {
    setPreviewUrl('')
    setError('')
    onChange(null)

    if (inputRef.current) {
      inputRef.current.value = ''
    }
  }

  return (
    <div className="space-y-3">
      <label className="block text-sm font-medium text-white">
        {label}
      </label>

      <input
        ref={inputRef}
        type="file"
        accept="image/png,image/jpeg,image/jpg,image/webp"
        onChange={handleFileChange}
        className="block w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm text-gray-200 file:mr-4 file:rounded-md file:border-0 file:bg-white file:px-3 file:py-2 file:text-sm file:font-medium file:text-black"
      />

      {isUploading && (
        <p className="text-sm text-gray-400">Uploading image...</p>
      )}

      {error && (
        <p className="text-sm text-red-400">{error}</p>
      )}

      {previewUrl && (
        <div className="space-y-3">
          <div className="relative h-48 w-full overflow-hidden rounded-xl border border-white/10 bg-white/5">
            <Image
              src={previewUrl}
              alt="Uploaded preview"
              fill
              className="object-cover"
            />
          </div>

          <button
            type="button"
            onClick={handleRemove}
            className="inline-flex rounded-md border border-red-500/30 bg-red-500/10 px-3 py-2 text-sm text-red-300 transition hover:bg-red-500/20"
          >
            Remove image
          </button>
        </div>
      )}
    </div>
  )
}

این کامپوننت چه مزیتی دارد؟

  • reusable است
  • در چند فرم قابل استفاده است
  • preview دارد
  • وضعیت آپلود را نشان می‌دهد
  • خطا را نمایش می‌دهد
  • حذف تصویر را پشتیبانی می‌کند

مرحله هفتم: به‌روزرسانی schema پروژه

حالا باید schema پروژه را به‌روز کنیم تا imageUrl را بپذیرد.

فایل:

lib/validations/project.ts

نمونه:

import { z } from 'zod'

export const projectSchema = z.object({
  title: z.string().trim().min(1, 'Title is required').max(160),
  slug: z
    .string()
    .trim()
    .min(1, 'Slug is required')
    .regex(/^[a-z0-9-]+$/, 'Slug format is invalid'),
  summary: z.string().trim().min(1, 'Summary is required').max(300),
  content: z.string().trim().min(1, 'Content is required'),
  published: z.boolean(),
  imageUrl: z.string().url('Image URL is invalid').optional().or(z.literal('')),
})

export type ProjectInput = z.infer<typeof projectSchema>

مرحله هشتم: به‌روزرسانی schema پست

فایل:

lib/validations/post.ts
import { z } from 'zod'

export const postSchema = z.object({
  title: z.string().trim().min(1, 'Title is required').max(160),
  slug: z
    .string()
    .trim()
    .min(1, 'Slug is required')
    .regex(/^[a-z0-9-]+$/, 'Slug format is invalid'),
  excerpt: z.string().trim().max(300).optional().or(z.literal('')),
  content: z.string().trim().min(1, 'Content is required'),
  published: z.boolean(),
  coverImage: z.string().url('Cover image URL is invalid').optional().or(z.literal('')),
})

export type PostInput = z.infer<typeof postSchema>

مرحله نهم: به‌روزرسانی مدل Prisma

اگر این فیلدها را هنوز ندارید، مدل‌ها را تغییر دهید.

model Project {
  id        String   @id @default(cuid())
  title     String
  slug      String   @unique
  summary   String
  content   String
  published Boolean  @default(false)
  imageUrl  String?
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

model Post {
  id         String   @id @default(cuid())
  title      String
  slug       String   @unique
  excerpt    String?
  content    String
  published  Boolean  @default(false)
  coverImage String?
  createdAt  DateTime @default(now())
  updatedAt  DateTime @updatedAt
}

بعد migration بزنید:

npx prisma migrate dev --name add_project_image_and_post_cover

مرحله دهم: اتصال uploader به فرم پروژه

حالا فرم پروژه را کامل می‌کنیم.

نمونه کامل components/admin/project-form.tsx:

'use client'

import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { ImageUpload } from '@/components/admin/image-upload'

type ProjectFormValues = {
  title: string
  slug: string
  summary: string
  content: string
  published: boolean
  imageUrl: string
}

type ProjectFormProps = {
  mode: 'create' | 'edit'
  projectId?: string
  initialValues?: Partial<ProjectFormValues>
}

export function ProjectForm({
  mode,
  projectId,
  initialValues,
}: ProjectFormProps) {
  const router = useRouter()

  const [title, setTitle] = useState(initialValues?.title ?? '')
  const [slug, setSlug] = useState(initialValues?.slug ?? '')
  const [summary, setSummary] = useState(initialValues?.summary ?? '')
  const [content, setContent] = useState(initialValues?.content ?? '')
  const [published, setPublished] = useState(initialValues?.published ?? false)
  const [imageUrl, setImageUrl] = useState(initialValues?.imageUrl ?? '')
  const [isSubmitting, setIsSubmitting] = useState(false)
  const [error, setError] = useState('')

  async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
    event.preventDefault()
    setError('')
    setIsSubmitting(true)

    try {
      const payload = {
        title,
        slug,
        summary,
        content,
        published,
        imageUrl,
      }

      const endpoint =
        mode === 'create'
          ? '/api/projects'
          : `/api/projects/${projectId}`

      const method = mode === 'create' ? 'POST' : 'PATCH'

      const response = await fetch(endpoint, {
        method,
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify(payload),
      })

      const data = await response.json()

      if (!response.ok) {
        throw new Error(data.error || 'Something went wrong')
      }

      router.push('/admin/projects')
      router.refresh()
    } catch (err) {
      const message =
        err instanceof Error ? err.message : 'Something went wrong'
      setError(message)
    } finally {
      setIsSubmitting(false)
    }
  }

  return (
    <form onSubmit={handleSubmit} className="space-y-6">
      <div className="grid gap-6">
        <div>
          <label className="mb-2 block text-sm font-medium text-white">
            Title
          </label>
          <input
            value={title}
            onChange={(e) => setTitle(e.target.value)}
            className="w-full rounded-lg border border-white/10 bg-white/5 px-4 py-3 text-white outline-none"
          />
        </div>

        <div>
          <label className="mb-2 block text-sm font-medium text-white">
            Slug
          </label>
          <input
            value={slug}
            onChange={(e) => setSlug(e.target.value)}
            className="w-full rounded-lg border border-white/10 bg-white/5 px-4 py-3 text-white outline-none"
          />
        </div>

        <div>
          <label className="mb-2 block text-sm font-medium text-white">
            Summary
          </label>
          <textarea
            value={summary}
            onChange={(e) => setSummary(e.target.value)}
            rows={4}
            className="w-full rounded-lg border border-white/10 bg-white/5 px-4 py-3 text-white outline-none"
          />
        </div>

        <ImageUpload
          label="Project Image"
          value={imageUrl}
          onChange={(image) => setImageUrl(image?.url ?? '')}
        />

        <div>
          <label className="mb-2 block text-sm font-medium text-white">
            Content
          </label>
          <textarea
            value={content}
            onChange={(e) => setContent(e.target.value)}
            rows={8}
            className="w-full rounded-lg border border-white/10 bg-white/5 px-4 py-3 text-white outline-none"
          />
        </div>

        <label className="flex items-center gap-3 text-sm text-gray-300">
          <input
            type="checkbox"
            checked={published}
            onChange={(e) => setPublished(e.target.checked)}
          />
          Published
        </label>
      </div>

      {error && (
        <p className="text-sm text-red-400">{error}</p>
      )}

      <button
        type="submit"
        disabled={isSubmitting}
        className="inline-flex rounded-lg bg-white px-4 py-3 text-sm font-medium text-black transition hover:bg-gray-200 disabled:opacity-60"
      >
        {isSubmitting
          ? 'Saving...'
          : mode === 'create'
          ? 'Create Project'
          : 'Update Project'}
      </button>
    </form>
  )
}

مرحله یازدهم: ذخیره imageUrl در API پروژه

حالا باید route handler پروژه را هم طوری به‌روزرسانی کنیم که imageUrl را ذخیره کند.

نمونه POST /api/projects:

import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { projectSchema } from '@/lib/validations/project'
import { requireAdminFromRequest } from '@/lib/auth'

export async function POST(request: Request) {
  try {
    await requireAdminFromRequest(request)
  } catch {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
  }

  try {
    const body = await request.json()
    const parsed = projectSchema.safeParse(body)

    if (!parsed.success) {
      return NextResponse.json(
        {
          error: parsed.error.flatten(),
        },
        { status: 400 }
      )
    }

    const data = parsed.data

    const existing = await prisma.project.findUnique({
      where: { slug: data.slug },
    })

    if (existing) {
      return NextResponse.json(
        { error: 'Slug already exists.' },
        { status: 409 }
      )
    }

    const project = await prisma.project.create({
      data: {
        title: data.title,
        slug: data.slug,
        summary: data.summary,
        content: data.content,
        published: data.published,
        imageUrl: data.imageUrl || null,
      },
    })

    return NextResponse.json(project, { status: 201 })
  } catch (error) {
    console.error('Create project error:', error)

    return NextResponse.json(
      { error: 'Failed to create project.' },
      { status: 500 }
    )
  }
}

نمونه PATCH /api/projects/[id]:

import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { projectSchema } from '@/lib/validations/project'
import { requireAdminFromRequest } from '@/lib/auth'

type RouteContext = {
  params: Promise<{Admin: string }>
}

export async function PATCH(request: Request, context: RouteContext) {
  try {
    await requireAdminFromRequest(request)
  } catch {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
  }

  try {
    const { id } = await context.params
    const body = await request.json()
    const parsed = projectSchema.safeParse(body)

    if (!parsed.success) {
      return NextResponse.json(
        { error: parsed.error.flatten() },
        { status: 400 }
      )
    }

    const existingProject = await prisma.project.findUnique({
      where: { id },
    })

    if (!existingProject) {
      return NextResponse.json(
        { error: 'Project not found.' },
        { status: 404 }
      )
    }

    const data = parsed.data

    const slugOwner = await prisma.project.findUnique({
      where: { slug: data.slug },
    })

    if (slugOwner && slugOwner.id !== id) {
      return NextResponse.json(
        { error: 'Slug already exists.' },
        { status: 409 }
      )
    }

    const updatedProject = await prisma.project.update({
      where: { id },
      data: {
        title: data.title,
        slug: data.slug,
        summary: data.summary,
        content: data.content,
        published: data.published,
        imageUrl: data.imageUrl || null,
      },
    })

    return NextResponse.json(updatedProject, { status: 200 })
  } catch (error) {
    console.error('Update project error:', error)

    return NextResponse.json(
      { error: 'Failed to update project.' },
      { status: 500 }
    )
  }
}

مرحله دوازدهم: اتصال uploader به فرم پست

برای پست‌ها هم همان الگو را تکرار می‌کنیم.

نمونه components/admin/post-form.tsx:

'use client'

import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { ImageUpload } from '@/components/admin/image-upload'

type PostFormValues = {
  title: string
  slug: string
  excerpt: string
  content: string
  published: boolean
  coverImage: string
}

type PostFormProps = {
  mode: 'create' | 'edit'
  postId?: string
  initialValues?: Partial<PostFormValues>
}

export function PostForm({
  mode,
  postId,
  initialValues,
}: PostFormProps) {
  const router = useRouter()

  const [title, setTitle] = useState(initialValues?.title ?? '')
  const [slug, setSlug] = useState(initialValues?.slug ?? '')
  const [excerpt, setExcerpt] = useState(initialValues?.excerpt ?? '')
  const [content, setContent] = useState(initialValues?.content ?? '')
  const [published, setPublished] = useState(initialValues?.published ?? false)
  const [coverImage, setCoverImage] = useState(initialValues?.coverImage ?? '')
  const [isSubmitting, setIsSubmitting] = useState(false)
  const [error, setError] = useState('')

  async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
    event.preventDefault()
    setError('')
    setIsSubmitting(true)

    try {
      const payload = {
        title,
        slug,
        excerpt,
        content,
        published,
        coverImage,
      }

      const endpoint =
        mode === 'create'
          ? '/api/posts'
          : `/api/posts/${postId}`

      const method = mode === 'create' ? 'POST' : 'PATCH'

      const response = await fetch(endpoint, {
        method,
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify(payload),
      })

      const data = await response.json()

      if (!response.ok) {
        throw new Error(data.error || 'Something went wrong')
      }

      router.push('/admin/posts')
      router.refresh()
    } catch (err) {
      const message =
        err instanceof Error ? err.message : 'Something went wrong'
      setError(message)
    } finally {
      setIsSubmitting(false)
    }
  }

  return (
    <form onSubmit={handleSubmit} className="space-y-6">
      <div className="grid gap-6">
        <div>
          <label className="mb-2 block text-sm font-medium text-white">
            Title
          </label>
          <input
            value={title}
            onChange={(e) => setTitle(e.target.value)}
            className="w-full rounded-lg border border-white/10 bg-white/5 px-4 py-3 text-white outline-none"
          />
        </div>

        <div>
          <label className="mb-2 block text-sm font-medium text-white">
            Slug
          </label>
          <input
            value={slug}
            onChange={(e) => setSlug(e.target.value)}
            className="w-full rounded-lg border border-white/10 bg-white/5 px-4 py-3 text-white outline-none"
          />
        </div>

        <div>
          <label className="mb-2 block text-sm font-medium text-white">
            Excerpt
          </label>
          <textarea
            value={excerpt}
            onChange={(e) => setExcerpt(e.target.value)}
            rows={4}
            className="w-full rounded-lg border border-white/10 bg-white/5 px-4 py-3 text-white outline-none"
          />
        </div>

        <ImageUpload
          label="Cover Image"
          value={coverImage}
          onChange={(image) => setCoverImage(image?.url ?? '')}
        />

        <div>
          <label className="mb-2 block text-sm font-medium text-white">
            Content
          </label>
          <textarea
            value={content}
            onChange={(e) => setContent(e.target.value)}
            rows={8}
            className="w-full rounded-lg border border-white/10 bg-white/5 px-4 py-3 text-white outline-none"
          />
        </div>

        <label className="flex items-center gap-3 text-sm text-gray-300">
          <input
            type="checkbox"
            checked={published}
            onChange={(e) => setPublished(e.target.checked)}
          />
          Published
        </label>
      </div>

      {error && (
        <p className="text-sm text-red-400">{error}</p>
      )}

      <button
        type="submit"
        disabled={isSubmitting}
        className="inline-flex rounded-lg bg-white px-4 py-3 text-sm font-medium text-black transition hover:bg-gray-200 disabled:opacity-60"
      >
        {isSubmitting
          ? 'Saving...'
          : mode === 'create'
          ? 'Create Post'
          : 'Update Post'}
      </button>
    </form>
  )
}

مرحله سیزدهم: ذخیره coverImage در API پست

نمونه POST /api/posts:

import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { postSchema } from '@/lib/validations/post'
import { requireAdminFromRequest } from '@/lib/auth'

export async function POST(request: Request) {
  try {
    await requireAdminFromRequest(request)
  } catch {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
  }

  try {
    const body = await request.json()
    const parsed = postSchema.safeParse(body)

    if (!parsed.success) {
      return NextResponse.json(
        { error: parsed.error.flatten() },
        { status: 400 }
      )
    }

    const data = parsed.data

    const existing = await prisma.post.findUnique({
      where: { slug: data.slug },
    })

    if (existing) {
      return NextResponse.json(
        { error: 'Slug already exists.' },
        { status: 409 }
      )
    }

    const post = await prisma.post.create({
      data: {
        title: data.title,
        slug: data.slug,
        excerpt: data.excerpt || null,
        content: data.content,
        published: data.published,
        coverImage: data.coverImage || null,
      },
    })

    return NextResponse.json(post, { status: 201 })
  } catch (error) {
    console.error('Create post error:', error)

    return NextResponse.json(
      { error: 'Failed to create post.' },
      { status: 500 }
    )
  }
}

نمونه PATCH /api/posts/[id]:

import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { postSchema } from '@/lib/validations/post'
import { requireAdminFromRequest } from '@/lib/auth'

type RouteContext = {
  params: Promise<{ id: string }>
}

export async function PATCH(request: Request, context: RouteContext) {
  try {
    await requireAdminFromRequest(request)
  } catch {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
  }

  try {
    const { id } = await context.params
    const body = await request.json()
    const parsed = postSchema.safeParse(body)

    if (!parsed.success) {
      return NextResponse.json(
        { error: parsed.error.flatten() },
        { status: 400 }
      )
    }

    const existingPost = await prisma.post.findUnique({
      where: { id },
    })

    if (!existingPost) {
      return NextResponse.json(
        { error: 'Post not found.' },
        { status: 404 }
      )
    }

    const data = parsed.data

    const slugOwner = await prisma.post.findUnique({
      where: { slug: data.slug },
    })

    if (slugOwner && slugOwner.id !== id) {
      return NextResponse.json(
        { error: 'Slug already exists.' },
        { status: 409 }
      )
    }

    const updatedPost = await prisma.post.update({
      where: { id },
      data: {
        title: data.title,
        slug: data.slug,
        excerpt: data.excerpt || null,
        content: data.content,
        published: data.published,
        coverImage: data.coverImage || null,
      },
    })

    return NextResponse.json(updatedPost, { status: 200 })
  } catch (error) {
    console.error('Update post error:', error)

    return NextResponse.json(
      { error: 'Failed to update post.' },
      { status: 500 }
    )
  }
}

نمایش تصویر در جدول یا صفحه جزئیات

بعد از ذخیره URL، می‌توانید تصویر را در UIهای مختلف نمایش دهید.

مثلاً در جدول پروژه‌ها یا پست‌ها:

import Image from 'next/image'

<Image
  src={project.imageUrl}
  alt={project.title}
  width={80}
  height={60}
  className="rounded-md object-cover"
/>

نکته مهم برای Next Image

اگر از next/image برای نمایش تصاویر Cloudinary استفاده می‌کنید، باید دامنه را در next.config.ts یا پیکربندی جدید تصاویر اضافه کنید.

مثلاً:

import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  images: {
    remotePatterns: [
      {
        protocol: 'https',
        hostname: 'res.cloudinary.com',
      },
    ],
  },
}

export default nextConfig

اگر بخواهید publicId را هم ذخیره کنید

در حال حاضر ما فقط URL را ذخیره کرده‌ایم، که برای شروع کافی است.
اما اگر بخواهید بعداً حذف تصویر از Cloudinary را هم داشته باشید، بهتر است publicId را هم ذخیره کنید.

مثلاً در مدل پروژه:

imageUrl      String?
imagePublicId String?

و در مدل پست:

coverImage      String?
coverImagePublicId String?

بعد در فرم، علاوه بر URL، publicId را هم نگه می‌دارید و همراه payload می‌فرستید.

این موضوع برای قسمت‌های بعدی، مخصوصاً حذف یا جایگزینی تصویر، بسیار مفید است.


نکات مهم در این پیاده‌سازی

در این آموزش چند تصمیم معماری مهم گرفتیم که ارزش مرور دارند:

1. آپلود فایل را از API موجودیت جدا کردیم

یعنی:

  • /api/uploads/image فقط مسئول آپلود فایل است
  • /api/projects و /api/posts فقط مسئول ذخیره داده نهایی هستند

این جداسازی باعث می‌شود کد شما تمیزتر و قابل نگهداری‌تر شود.

2. فقط URL را ذخیره کردیم

این کار باعث می‌شود:

  • دیتابیس سبک بماند
  • فایل‌ها خارج از دیتابیس مدیریت شوند
  • سیستم ساده‌تر شود

3. uploader را reusable نوشتیم

پس می‌توانیم آن را در فرم‌های مختلف دوباره استفاده کنیم.

4. validation سمت سرور را فراموش نکردیم

حتی اگر کلاینت خوب نوشته شده باشد، باز هم باید سمت سرور فایل را بررسی کنیم.


چک‌لیست این قسمت

اگر این موارد را کامل کرده‌اید، یعنی بخش آپلود تصویر شما واقعاً کار می‌کند:

  • envهای Cloudinary تنظیم شده‌اند
  • پکیج cloudinary نصب شده است
  • helper مربوط به Cloudinary ساخته شده است
  • endpoint /api/uploads/image ساخته شده است
  • endpoint فقط برای ادمین قابل دسترسی است
  • نوع فایل و حجم فایل بررسی می‌شود
  • uploader reusable ساخته شده است
  • فرم پروژه به uploader متصل شده است
  • فرم پست به uploader متصل شده است
  • imageUrl در پروژه ذخیره می‌شود
  • coverImage در پست ذخیره می‌شود
  • تصاویر Cloudinary در UI قابل نمایش هستند

تمرین پیشنهادی

برای اینکه این بخش را عمیق‌تر یاد بگیرید، یکی از این تمرین‌ها را انجام دهید:

تمرین 1

به uploader امکان نمایش نام فایل انتخاب‌شده را اضافه کنید.

تمرین 2

برای پروژه‌ها، به‌جای یک تصویر، پشتیبانی از چند تصویر را اضافه کنید.

تمرین 3

publicId را هم در دیتابیس ذخیره کنید.

تمرین 4

اگر کاربر تصویر جدیدی آپلود کرد، تصویر قبلی را از UI حذف و مقدار جدید را جایگزین کنید.

تمرین 5

برای فرم، پیام‌های خطای field-level بهتر طراحی کنید.


جمع‌بندی

در این قسمت، بخش آپلود تصویر را به‌صورت کامل و عملی به پروژه Next.js خودمان اضافه کردیم.

ما:

  • Cloudinary را تنظیم کردیم
  • helper ساختیم
  • API امن آپلود نوشتیم
  • اعتبارسنجی فایل را اضافه کردیم
  • یک uploader reusable ساختیم
  • فرم پروژه و فرم پست را به آن وصل کردیم
  • و در نهایت URL تصویر را داخل دیتابیس ذخیره کردیم

از اینجا به بعد، پنل ادمین شما فقط یک CRUD متنی نیست.
حالا شما یک سیستم مدیریت محتوای واقعی‌تر دارید که می‌تواند رسانه را هم مدیریت کند.

// 0 comments
#Next.js#Image#uplaod#Cloadinary

نظرات (0)

نظر خود را بنویسید