قسمت بیست و پنجم - پیادهسازی کامل آپلود تصویر با Cloudinary در Next.js
پیاده سازی کامل آپلود تصویر به همراه api های مورد نظر در پروژه با استفاده از سرویس cloudinary
پیادهسازی کامل آپلود تصویر با Cloudinary در Next.js
در قسمت قبل درباره این صحبت کردیم که چرا برای مدیریت فایلهای رسانهای بهتر است از یک سرویس بیرونی مثل Cloudinary استفاده کنیم.
در آن بخش، بیشتر روی معماری و منطق کلی تمرکز داشتیم:
- چرا فایل را داخل پروژه نگه نداریم
- چرا فایل را داخل دیتابیس ذخیره نکنیم
- چرا باید فقط
URLیا اطلاعات رسانه را ذخیره کنیم - و چطور این قابلیت به پنل ادمین ما یک قدم واقعی به سمت CMS شدن میدهد
حالا وقت پیادهسازی کامل است.
در این قسمت میخواهیم:
- Cloudinary را در پروژه تنظیم کنیم
- یک helper سمت سرور بسازیم
- یک API امن برای آپلود تصویر ایجاد کنیم
- اعتبارسنجی فایل انجام دهیم
- خروجی آپلود را به فرم پروژه وصل کنیم
- فیلد
imageUrlرا در دیتابیس ذخیره کنیم - و همین الگو را برای مقالات هم قابل استفاده کنیم
هدف این قسمت
در پایان این آموزش، باید این جریان کامل را داشته باشید:
- ادمین در فرم پروژه یا مقاله یک تصویر انتخاب میکند
- فایل به endpoint داخلی شما ارسال میشود
- endpoint فایل را بررسی میکند
- فایل به Cloudinary آپلود میشود
secure_urlبرگردانده میشود- URL در فرم ذخیره میشود
- هنگام ثبت پروژه یا مقاله، همان 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 متنی نیست.
حالا شما یک سیستم مدیریت محتوای واقعیتر دارید که میتواند رسانه را هم مدیریت کند.