قسمت بیست و یکم - ساخت داشبورد مدیریت محتوا در Next.js
داشبورد همان جایی است که ادمین از طریق آن محتوای سایت را مدیریت میکند. اگر سایت عمومی، ویترین پروژهها و نوشتههاست، داشبورد دقیقاً پشت صحنه این ویترین است.
ساخت داشبورد مدیریت محتوا در Next.js
تا اینجای مسیر، بخشهای مهمی از پنل ادمین را آماده کردهایم.
مدل احراز هویت را طراحی کردیم، ورود امن را با bcrypt ساختیم و مسیرهای ادمین را هم محافظت کردیم:
Admin Dashboard
داشبورد همان جایی است که ادمین از طریق آن محتوای سایت را مدیریت میکند.
اگر سایت عمومی، ویترین پروژهها و نوشتههاست، داشبورد دقیقاً پشت صحنه این ویترین است.
در این قسمت میخواهیم ساختار داشبورد مدیریت محتوا را طراحی کنیم و ببینیم یک داشبورد خوب چه بخشهایی باید داشته باشد.
فعلاً تمرکز ما بیشتر روی معماری، تجربه کاربری و ساختار کلی است؛ بعد از این میتوانیم هر بخش را به APIها و فرمهای واقعی وصل کنیم.
داشبورد خوب چه ویژگیهایی دارد؟
قبل از اینکه مستقیم سراغ کدنویسی برویم، بهتر است یک سؤال ساده اما مهم را جواب بدهیم:
یک داشبورد خوب باید چند ویژگی مهم داشته باشد.
1. ساختار مشخص داشته باشد
کاربر ادمین باید خیلی سریع بفهمد:
- الان در کدام بخش است
- از کجا میتواند پروژه جدید بسازد
- از کجا میتواند مقالات را مدیریت کند
- پیامهای تماس را کجا ببیند
- چطور بین بخشها جابهجا شود
اگر ساختار مسیرها و ناوبری مبهم باشد، حتی یک پنل ساده هم آزاردهنده میشود.
2. اطلاعات مهم را خلاصه نشان دهد
داشبورد نباید فقط مجموعهای از لینکها باشد.
باید در همان نگاه اول، اطلاعات کلیدی را نمایش دهد. مثلاً:
- چند پروژه ثبت شده است
- چند مقاله منتشر شده است
- چند پیام تماس دریافت شده است
- آخرین محتوای اضافهشده چه بوده است
یعنی ادمین بدون اینکه وارد چند صفحه مختلف شود، باید یک نمای کلی از وضعیت سیستم داشته باشد.
3. دسترسی سریع به عملیات پرتکرار بدهد
در پنل مدیریت، بعضی کارها بیشتر تکرار میشوند:
- ساخت پروژه جدید
- ویرایش پروژه
- نوشتن مقاله جدید
- بررسی پیامهای تماس
پس داشبورد باید این کارها را سریع و قابل دسترس نگه دارد.
4. خوانا و خلوت باشد
داشبورد خوب الزاماً داشبورد شلوغ نیست.
برعکس، هرچه اطلاعات بهتر دستهبندی شوند، کار با پنل راحتتر میشود.
در پروژهای مثل پرتفولیو شخصی یا CMS سبک، معمولاً این ساختار کافی است:
- overview
- projects
- posts
- messages
- settings
5. با معماری پروژه هماهنگ باشد
از آنجایی که در این پروژه از App Router و Route Handlers استفاده میکنیم، ساختار داشبورد هم بهتر است بر همین اساس طراحی شود.
مثلاً ساختار مسیرها میتواند چیزی شبیه این باشد:
app/
admin/
login/
page.tsx
(dashboard)/
layout.tsx
page.tsx
projects/
page.tsx
posts/
page.tsx
messages/
page.tsx
در این ساختار:
- صفحه login جداست
- همه صفحات مدیریتی داخل layout مشترک قرار میگیرند
- سایدبار و هدر فقط یکبار ساخته میشوند
- ناحیه ادمین یک تجربه یکپارچه پیدا میکند
صفحه overview
اولین صفحهای که معمولاً بعد از ورود ادمین دیده میشود، صفحه اصلی داشبورد یا همان:
Overview
است.
این صفحه قرار نیست همه جزئیات را نمایش دهد.
نقش آن این است که یک نمای سریع و خلاصه از وضعیت کلی سیستم بدهد.
مثلاً در صفحه overview میتوانیم این بخشها را قرار دهیم:
- خوشامدگویی به ادمین
- آمار کلی سایت
- میانبرها برای ساخت محتوا
- آخرین پروژهها
- آخرین مقالات
- آخرین پیامهای تماس
نمونه خیلی ساده:
// app/admin/(dashboard)/page.tsx
import { requireAdmin } from '@/lib/auth/session'
export default async function AdminDashboardPage() {
const user = await requireAdmin()
return (
<section className="space-y-8">
<div>
<h1 className="text-3xl font-semibold tracking-tight text-white">
Dashboard
</h1>
<p className="mt-2 text-sm text-zinc-400">
Welcome back, {user.name ?? user.email}
</p>
</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<div className="rounded-2xl border border-zinc-800 bg-zinc-950 p-5">
<p className="text-sm text-zinc-400">Projects</p>
<p className="mt-3 text-3xl font-semibold text-white">12</p>
</div>
<div className="rounded-2xl border border-zinc-800 bg-zinc-950 p-5">
<p className="text-sm text-zinc-400">Posts</p>
<p className="mt-3 text-3xl font-semibold text-white">8</p>
</div>
<div className="rounded-2xl border border-zinc-800 bg-zinc-950 p-5">
<p className="text-sm text-zinc-400">Messages</p>
<p className="mt-3 text-3xl font-semibold text-white">4</p>
</div>
<div className="rounded-2xl border border-zinc-800 bg-zinc-950 p-5">
<p className="text-sm text-zinc-400">Published</p>
<p className="mt-3 text-3xl font-semibold text-white">15</p>
</div>
</div>
</section>
)
}
این فقط یک نمونه اولیه است، اما ایده اصلی را خوب نشان میدهد:
صفحه overview باید سریع، خوانا و خلاصه باشد.
آمار پروژهها و مقالات
یکی از مهمترین بخشهای داشبورد، نمایش آمار کلی محتواست.
وقتی ادمین وارد پنل میشود، معمولاً دوست دارد سریع بداند:
- چند پروژه ثبت شده
- چند مقاله وجود دارد
- چند مورد منتشر شده
- چند مورد پیشنویس است
- آیا پیام خواندهنشدهای وجود دارد یا نه
این آمار را میتوان با کوئریهای ساده از دیتابیس گرفت.
مثلاً:
const [projectsCount, postsCount, messagesCount] = await Promise.all([
prisma.project.count(),
prisma.post.count(),
prisma.contactMessage.count(),
])
اگر بخواهیم آمار دقیقتر شود، میتوانیم وضعیت انتشار را هم جدا حساب کنیم:
const [publishedProjects, draftProjects] = await Promise.all([
prisma.project.count({
where: { published: true },
}),
prisma.project.count({
where: { published: false },
}),
])
یا برای مقالات:
const [publishedPosts, draftPosts] = await Promise.all([
prisma.post.count({
where: { published: true },
}),
prisma.post.count({
where: { published: false },
}),
])
بعد این دادهها را در قالب کارتهای آماری نمایش میدهیم.
مثلاً یک کامپوننت ساده:
type StatCardProps = {
title: string
value: number
description?: string
}
export function StatCard({ title, value, description }: StatCardProps) {
return (
<div className="rounded-2xl border border-zinc-800 bg-zinc-950 p-5">
<p className="text-sm text-zinc-400">{title}</p>
<p className="mt-3 text-3xl font-semibold text-white">{value}</p>
{description ? (
<p className="mt-2 text-xs text-zinc-500">{description}</p>
) : null}
</div>
)
}
استفاده:
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<StatCard title="Projects" value={projectsCount} />
<StatCard title="Posts" value={postsCount} />
<StatCard title="Messages" value={messagesCount} />
<StatCard title="Published Posts" value={publishedPosts} />
</div>
این بخش کوچک است، اما از نظر تجربه کاربری اهمیت زیادی دارد.
چون داشبورد را از یک صفحه خالی به یک صفحه مدیریتی واقعی تبدیل میکند.
منوی سایدبار
در بیشتر پنلهای مدیریت، سایدبار هسته اصلی ناوبری است.
چون کاربر ادمین معمولاً بین چند بخش ثابت جابهجا میشود و سایدبار این جابهجایی را سریع و قابل پیشبینی میکند.
برای این پروژه، سایدبار میتواند شامل این لینکها باشد:
- Dashboard
- Projects
- Posts
- Messages
- Settings
ساختار ساده:
// components/admin/admin-sidebar.tsx
import Link from 'next/link'
const items = [
{ href: '/admin', label: 'Dashboard' },
{ href: '/admin/projects', label: 'Projects' },
{ href: '/admin/posts', label: 'Posts' },
{ href: '/admin/messages', label: 'Messages' },
{ href: '/admin/settings', label: 'Settings' },
]
export function AdminSidebar() {
return (
<aside className="hidden w-64 shrink-0 border-r border-zinc-800 bg-black/40 p-6 md:block">
<div className="mb-8">
<h2 className="text-lg font-semibold text-white">Admin Panel</h2>
<p className="mt-1 text-sm text-zinc-500">
Content management dashboard
</p>
</div>
<nav className="space-y-2">
{items.map((item) => (
<Link
key={item.href}
href={item.href}
className="block rounded-xl px-3 py-2 text-sm text-zinc-300 transition hover:bg-zinc-900 hover:text-white"
>
{item.label}
</Link>
))}
</nav>
</aside>
)
}
بعد در layout داشبورد:
// app/admin/(dashboard)/layout.tsx
import { ReactNode } from 'react'
import { requireAdmin } from '@/lib/auth/session'
import { AdminSidebar } from '@/components/admin/admin-sidebar'
type AdminDashboardLayoutProps = {
children: ReactNode
}
export default async function AdminDashboardLayout({
children,
}: AdminDashboardLayoutProps) {
await requireAdmin()
return (
<div className="min-h-screen bg-black text-white md:grid md:grid-cols-[256px_1fr]">
<AdminSidebar />
<main className="p-6 md:p-8">{children}</main>
</div>
)
}
این ساختار چند مزیت دارد:
- تمام صفحات ادمین ظاهر یکسان دارند
- ناوبری فقط یکبار تعریف میشود
- توسعه بخشهای بعدی سادهتر میشود
بعداً میتوانیم برای لینک فعال، آیکون، logout button و نسخه موبایل هم این سایدبار را توسعه بدهیم.
جدولها و لیستها
بعد از overview، مهمترین صفحات داشبورد معمولاً صفحههای لیست هستند.
مثلاً:
- لیست پروژهها
- لیست مقالات
- لیست پیامها
در اکثر پنلها این دادهها یا در قالب table نمایش داده میشوند یا در قالب کارت و لیست.
برای محتوای مدیریتی، جدول معمولاً انتخاب مناسبتری است، چون امکان مقایسه و اسکن سریع اطلاعات را میدهد.
مثلاً برای پروژهها، ستونها میتوانند اینها باشند:
- عنوان
- اسلاگ
- وضعیت انتشار
- تاریخ ایجاد
- عملیات
نمونه:
type ProjectRow = {
id: string
title: string
slug: string
published: boolean
createdAt: Date
}
type ProjectsTableProps = {
projects: ProjectRow[]
}
export function ProjectsTable({ projects }: ProjectsTableProps) {
return (
<div className="overflow-hidden rounded-2xl border border-zinc-800">
<table className="min-w-full divide-y divide-zinc-800">
<thead className="bg-zinc-950">
<tr>
<th className="px-4 py-3 text-left text-sm font-medium text-zinc-400">
Title
</th>
<th className="px-4 py-3 text-left text-sm font-medium text-zinc-400">
Slug
</th>
<th className="px-4 py-3 text-left text-sm font-medium text-zinc-400">
Status
</th>
<th className="px-4 py-3 text-left text-sm font-medium text-zinc-400">
Created At
</th>
<th className="px-4 py-3 text-left text-sm font-medium text-zinc-400">
Actions
</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-800 bg-black">
{projects.map((project) => (
<tr key={project.id}>
<td className="px-4 py-3 text-sm text-white">
{project.title}
</td>
<td className="px-4 py-3 text-sm text-zinc-400">
{project.slug}
</td>
<td className="px-4 py-3 text-sm">
<span className="rounded-full border border-zinc-700 px-2.5 py-1 text-xs text-zinc-300">
{project.published ? 'Published' : 'Draft'}
</span>
</td>
<td className="px-4 py-3 text-sm text-zinc-400">
{project.createdAt.toLocaleDateString()}
</td>
<td className="px-4 py-3 text-sm">
<div className="flex gap-3">
<a href={`/admin/projects/${project.id}/edit`} className="text-zinc-300 hover:text-white">
Edit
</a>
<button className="text-red-400 hover:text-red-300">
Delete
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
این جدول را میتوانیم در صفحه /admin/projects استفاده کنیم.
برای مقالات هم منطق مشابه است.
فقط ستونها ممکن است کمی فرق کنند، مثلاً:
- عنوان
- اسلاگ
- وضعیت
- تاریخ انتشار
- عملیات
نکته مهم این است که داشبورد باید فقط نمایشدهنده داده نباشد؛ باید مسیر عملیات را هم روشن کند.
یعنی در کنار هر ردیف، دکمههایی مثل Edit و Delete وجود داشته باشند.
نمایش پیامهای تماس
اگر در سایت فرم تماس داریم، یکی از بخشهای مفید داشبورد میتواند صفحه مدیریت پیامها باشد.
در خیلی از سایتهای شخصی یا پرتفولیو، پیامهای تماس از طریق فرم ارسال میشوند و بعد در دیتابیس ذخیره میشوند. اگر این دادهها فقط در دیتابیس بمانند و هیچ صفحه مدیریتی برایشان نسازیم، عملاً ارزش کاربردیشان کمتر میشود.
برای همین میتوانیم یک صفحه مثل این داشته باشیم:
/admin/messages
و در آن لیستی از پیامها را نمایش دهیم.
مثلاً هر پیام میتواند این فیلدها را داشته باشد:
- نام فرستنده
- ایمیل
- موضوع
- متن پیام
- تاریخ ارسال
- وضعیت خواندهشدن
نمونه نمایش ساده:
type MessageItem = {
id: string
name: string
email: string
subject: string | null
message: string
createdAt: Date
isRead: boolean
}
type MessagesListProps = {
messages: MessageItem[]
}
export function MessagesList({ messages }: MessagesListProps) {
return (
<div className="space-y-4">
{messages.map((item) => (
<article
key={item.id}
className="rounded-2xl border border-zinc-800 bg-zinc-950 p-5"
>
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div>
<h3 className="text-base font-medium text-white">
{item.subject || 'No subject'}
</h3>
<p className="mt-1 text-sm text-zinc-400">
{item.name} - {item.email}
</p>
</div>
<span className="text-xs text-zinc-500">
{item.createdAt.toLocaleDateString()}
</span>
</div>
<p className="mt-4 whitespace-pre-line text-sm leading-7 text-zinc-300">
{item.message}
</p>
<div className="mt-4 flex items-center gap-3">
<span className="rounded-full border border-zinc-700 px-2.5 py-1 text-xs text-zinc-300">
{item.isRead ? 'Read' : 'Unread'}
</span>
<button className="text-sm text-zinc-400 hover:text-white">
Mark as read
</button>
</div>
</article>
))}
</div>
)
}
این بخش برای پروژههای واقعی خیلی مفید است، چون باعث میشود پنل ادمین فقط برای مدیریت پروژه و مقاله نباشد، بلکه به یک مرکز مدیریت واقعی محتوا و ارتباطات تبدیل شود.
اگر بخواهیم میتوانیم برای پیامها امکانات بیشتری هم اضافه کنیم:
- فیلتر پیامهای خواندهنشده
- حذف پیام
- جستوجو بر اساس ایمیل
- پاسخ سریع با لینک
mailto - badge برای تعداد پیامهای خواندهنشده در سایدبار
ساختار پیشنهادی صفحات داشبورد
اگر بخواهی از همین حالا داشبورد را تمیز و قابل توسعه نگه داریم، این ساختار خیلی مناسب است:
app/
admin/
login/
page.tsx
(dashboard)/
layout.tsx
page.tsx
projects/
page.tsx
new/
page.tsx
posts/
page.tsx
new/
page.tsx
messages/
page.tsx
components/
admin/
admin-sidebar.tsx
stat-card.tsx
projects-table.tsx
posts-table.tsx
messages-list.tsx
این فایلبندی چند مزیت مهم دارد:
- کدهای ادمین از بخش عمومی پروژه جدا میشوند
- کامپوننتهای مدیریتی ساختار مشخص پیدا میکنند
- توسعه هر بخش در آینده سادهتر میشود
- خوانایی پروژه بالا میرود
داشبورد را با چه ذهنیتی بسازیم؟
در این مرحله بهتر است داشبورد را با این نگاه بسازیم:
داشبورد جای نمایش نمای کلی است
نه جای تمام جزئیات.
صفحات لیستی جای مدیریت داده هستند
مثل مدیریت پروژهها و مقالات.
فرمهای ساخت و ویرایش باید جدا باشند
مثلاً:
/admin/projects/new
/admin/projects/[id]/edit
/admin/posts/new
/admin/posts/[id]/edit
عملیات مدیریتی باید واضح باشند
مثلاً دکمههای:
- Create
- Edit
- Delete
- Publish
- Unpublish
هر بخش باید با auth یکپارچه باشد
یعنی تمام این صفحات از همان layout محافظتشده استفاده کنند.
جمعبندی
در این قسمت، ساختار کلی داشبورد مدیریت محتوا را طراحی کردیم و دیدیم که یک داشبورد خوب فقط یک صفحه ساده نیست، بلکه مرکز کنترل محتوای سایت است.
یاد گرفتیم که:
- داشبورد باید ساختار روشن و قابل پیشبینی داشته باشد
- صفحه
overviewبرای نمایش نمای کلی سیستم استفاده میشود - آمار پروژهها، مقالات و پیامها باعث میشود پنل کاربردیتر شود
- سایدبار نقش اصلی در ناوبری پنل دارد
- جدولها و لیستها برای مدیریت دادهها ضروری هستند
- پیامهای تماس هم میتوانند بخشی از پنل مدیریت باشند
- بهتر است ناحیه ادمین از نظر فایلبندی و UI ساختار جداگانه داشته باشد