صندوق پیام
پیشنمایش تمامصفحهسه ستون: پوشهها با شمارش، فهرستِ نامهها با جستوجو و تبِ همه/خواندهنشده و نقطهٔ خواندهنشده، خواندنِ نامه با برچسب، پیوستهای نوعدار و پاسخ با ویرایشگرِ قالبدار؛ در موبایل فهرست و خواندن جای هم را میگیرند.
۱. نصب کامپوننتها
این بلاک از ۱۱ آیتم دیگ استفاده میکند: avatarbadgebuttoncontrol-sizefile-uploadiconspersianrich-text-editorsearch-fieldtoasttoggle-group
پکیجمنیجر پیشفرض Node.jsنصب سریع با لینک سخت و دیسک مشترکYarn نسخهٔ ۲ به بالا (Berry)رانتایم و پکیجمنیجر Bun
npx digdesign@latest add https://docs.digdesign.ir/r/avatar.json https://docs.digdesign.ir/r/badge.json https://docs.digdesign.ir/r/button.json https://docs.digdesign.ir/r/control-size.json https://docs.digdesign.ir/r/file-upload.json https://docs.digdesign.ir/r/icons.json https://docs.digdesign.ir/r/persian.json https://docs.digdesign.ir/r/rich-text-editor.json https://docs.digdesign.ir/r/search-field.json https://docs.digdesign.ir/r/toast.json https://docs.digdesign.ir/r/toggle-group.jsonpnpm dlx digdesign@latest add https://docs.digdesign.ir/r/avatar.json https://docs.digdesign.ir/r/badge.json https://docs.digdesign.ir/r/button.json https://docs.digdesign.ir/r/control-size.json https://docs.digdesign.ir/r/file-upload.json https://docs.digdesign.ir/r/icons.json https://docs.digdesign.ir/r/persian.json https://docs.digdesign.ir/r/rich-text-editor.json https://docs.digdesign.ir/r/search-field.json https://docs.digdesign.ir/r/toast.json https://docs.digdesign.ir/r/toggle-group.jsonyarn dlx digdesign@latest add https://docs.digdesign.ir/r/avatar.json https://docs.digdesign.ir/r/badge.json https://docs.digdesign.ir/r/button.json https://docs.digdesign.ir/r/control-size.json https://docs.digdesign.ir/r/file-upload.json https://docs.digdesign.ir/r/icons.json https://docs.digdesign.ir/r/persian.json https://docs.digdesign.ir/r/rich-text-editor.json https://docs.digdesign.ir/r/search-field.json https://docs.digdesign.ir/r/toast.json https://docs.digdesign.ir/r/toggle-group.jsonbunx --bun digdesign@latest add https://docs.digdesign.ir/r/avatar.json https://docs.digdesign.ir/r/badge.json https://docs.digdesign.ir/r/button.json https://docs.digdesign.ir/r/control-size.json https://docs.digdesign.ir/r/file-upload.json https://docs.digdesign.ir/r/icons.json https://docs.digdesign.ir/r/persian.json https://docs.digdesign.ir/r/rich-text-editor.json https://docs.digdesign.ir/r/search-field.json https://docs.digdesign.ir/r/toast.json https://docs.digdesign.ir/r/toggle-group.json۲. کد صفحه
کد را در app/<route>/page.tsx بگذارید و متنها، داده و هندلرها را با محصول خودتان عوض کنید.
inbox.tsx
"use client"
import * as React from "react"
import {
ChevronRightIcon,
DownloadIcon,
EnvelopeIcon,
FileTextIcon,
MoreHorizontalIcon,
PlusIcon,
RefreshIcon,
StarIcon,
TrashIcon,
} from "@/components/icons"
import { Avatar } from "@/components/ui/avatar"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { ControlSizeProvider } from "@/components/ui/control-size"
import { FileTypeIcon } from "@/components/ui/file-upload"
import { RichTextEditor, type RichTextEditorHandle } from "@/components/ui/rich-text-editor"
import { SearchField } from "@/components/ui/search-field"
import { toast } from "@/components/ui/toast"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
import { toFaDigits, toSearchKey } from "@/lib/persian"
import { stockImages } from "@/lib/stock-images"
import { cn } from "@/lib/utils"
type Message = {
id: string
from: string
email: string
photo: keyof typeof stockImages
subject: string
preview: string
body: string[]
time: string
unread?: boolean
starred?: boolean
label?: { text: string; color: "violet" | "info" | "success" | "warning" }
files?: { name: string; kind: string; size: string }[]
}
const FOLDERS = [
{ id: "inbox", label: "صندوقِ ورودی", icon: <EnvelopeIcon />, count: 3 },
{ id: "starred", label: "ستارهدار", icon: <StarIcon />, count: 0 },
{ id: "drafts", label: "پیشنویسها", icon: <FileTextIcon />, count: 1 },
{ id: "trash", label: "سطلِ زباله", icon: <TrashIcon />, count: 0 },
]
const MESSAGES: Message[] = [
{
id: "m1",
from: "مهسا کریمی",
email: "mahsa@sekke.example",
photo: "personWoman1",
subject: "پیشنویسِ قرارداد برای بازبینی",
preview: "سلام، نسخهٔ نهایی قرارداد را پیوست کردم؛ اگر موردی هست تا پنجشنبه بگویید.",
body: ["سلام نیما،", "نسخهٔ نهایی قرارداد را پیوست کردم. بندِ پرداخت را طبقِ جلسهٔ دیروز اصلاح کردهام.", "اگر موردی هست تا پنجشنبه بگو تا برای امضا بفرستیم.", "ممنون، مهسا"],
time: "۱۰:۳۴",
unread: true,
starred: true,
label: { text: "قرارداد", color: "violet" },
files: [
{ name: "قرارداد-نهایی.pdf", kind: "PDF", size: "۵۷۰ کیلوبایت" },
{ name: "پیوست-مالی.xlsx", kind: "XLSX", size: "۸۲ کیلوبایت" },
],
},
{
id: "m2",
from: "آرش نوری",
email: "arash@studio.example",
photo: "personMan2",
subject: "نسخهٔ جدیدِ اپ آمادهٔ تست است",
preview: "بیلدِ ۲٫۳ روی سرورِ تست رفت؛ تغییراتِ صفحهٔ پرداخت را لطفاً امتحان کنید.",
body: ["سلام،", "بیلدِ ۲٫۳ روی سرورِ تست رفت. تغییراتِ صفحهٔ پرداخت و حالتِ تیره را لطفاً امتحان کنید.", "آرش"],
time: "۰۹:۱۲",
unread: true,
label: { text: "محصول", color: "info" },
},
{
id: "m3",
from: "لیلا احمدی",
email: "leila@research.example",
photo: "personWoman2",
subject: "نتیجهٔ مصاحبههای کاربر",
preview: "خلاصهٔ هشت مصاحبه آماده است؛ سه نکتهٔ مهم دربارهٔ آنبوردینگ پیدا کردیم.",
body: ["سلام،", "خلاصهٔ هشت مصاحبه آماده است. سه نکتهٔ مهم دربارهٔ آنبوردینگ پیدا کردیم که در فایل آمده.", "لیلا"],
time: "دیروز",
unread: true,
files: [{ name: "خلاصه-مصاحبهها.docx", kind: "DOCX", size: "۲۴۰ کیلوبایت" }],
},
{
id: "m4",
from: "نیما رضایی",
email: "nima@studio.example",
photo: "personMan1",
subject: "برنامهٔ اسپرینتِ بعد",
preview: "پیشنهادم برای اولویتهای اسپرینتِ آبان را فرستادم؛ نظرت را بنویس.",
body: ["پیشنهادم برای اولویتهای اسپرینتِ آبان را فرستادم. نظرت را تا فردا بنویس."],
time: "۱۲ مهر",
label: { text: "تیم", color: "success" },
},
]
/**
* صندوقِ پیام: پوشهها، فهرستِ نامهها با جستوجو و تبِ «همه / خواندهنشده»، و
* خواندنِ نامه با پیوستهای نوعدار و پاسخ با ویرایشگرِ قالبدار. در صفحهٔ باریک
* فهرست و خواندن جای هم را میگیرند و دکمهٔ بازگشت دارند.
*/
export default function InboxBlock() {
const [folder, setFolder] = React.useState("inbox")
const [filter, setFilter] = React.useState("all")
const [query, setQuery] = React.useState("")
const [messages, setMessages] = React.useState(MESSAGES)
const [openId, setOpenId] = React.useState<string | null>("m1")
const [reply, setReply] = React.useState("")
const editorRef = React.useRef<RichTextEditorHandle>(null)
const key = toSearchKey(query)
const visible = messages.filter(
(message) => (filter === "all" || message.unread) && toSearchKey(message.from + message.subject + message.preview).includes(key)
)
const open = messages.find((message) => message.id === openId)
const openMessage = (id: string) => {
setOpenId(id)
setMessages((list) => list.map((message) => (message.id === id ? { ...message, unread: false } : message)))
}
return (
<ControlSizeProvider size="xl" asChild>
<main className="grid grid-cols-1 min-h-svh bg-background lg:grid-cols-[15rem_22rem_minmax(0,1fr)]">
{/* پوشهها */}
<nav aria-label="پوشهها" className="hidden flex-col gap-4 border-e border-border bg-subtle p-4 lg:flex">
<Button fullWidth>
<PlusIcon />
نامهٔ تازه
</Button>
<div className="flex flex-col gap-1">
{FOLDERS.map((item) => (
<Button
key={item.id}
variant="ghost"
fullWidth
aria-current={folder === item.id ? "page" : undefined}
onClick={() => setFolder(item.id)}
className={cn("justify-start", folder === item.id ? "bg-[color-mix(in_oklab,var(--brand-secondary)_10%,var(--background))] font-semibold text-brand-secondary hover:bg-[color-mix(in_oklab,var(--brand-secondary)_14%,var(--background))] hover:text-brand-secondary" : "font-normal text-muted-foreground")}
>
{item.icon}
{item.label}
{item.count > 0 && <span className="ms-auto text-xs tabular-nums">{toFaDigits(item.count)}</span>}
</Button>
))}
</div>
</nav>
{/* فهرستِ نامهها */}
<section aria-label="نامهها" className={cn("flex min-w-0 flex-col border-e border-border", open && "hidden lg:flex")}>
<div className="flex flex-col gap-3 border-b border-border p-4">
<div className="flex items-center justify-between gap-2">
<h1 className="text-xl font-bold">صندوقِ ورودی</h1>
<Button variant="ghost" size="icon" aria-label="بارگذاری دوباره">
<RefreshIcon />
</Button>
</div>
<SearchField aria-label="جستوجو در نامهها" placeholder="جستوجوی نامه" value={query} onValueChange={setQuery} />
<ToggleGroup type="single" variant="segmented" value={filter} onValueChange={(value) => value && setFilter(value as string)} aria-label="فیلترِ نامهها" className="self-start">
<ToggleGroupItem value="all">همه</ToggleGroupItem>
<ToggleGroupItem value="unread">خواندهنشده</ToggleGroupItem>
</ToggleGroup>
</div>
<ul className="flex flex-col">
{visible.map((message) => (
<li key={message.id}>
<Button
variant="ghost"
fullWidth
onClick={() => openMessage(message.id)}
aria-current={openId === message.id ? "true" : undefined}
className={cn(
"relative h-auto items-start justify-start gap-3 rounded-none border-b border-border px-4 py-4 text-start font-normal whitespace-normal",
// پیامِ باز: تهرنگِ برند با نوارِ شروع؛ از خاکستری هاور و تبها جدا خوانده میشود.
openId === message.id &&
"bg-[color-mix(in_oklab,var(--brand-secondary)_7%,var(--background))] before:absolute before:inset-y-0 before:start-0 before:w-1 before:bg-brand-secondary hover:bg-[color-mix(in_oklab,var(--brand-secondary)_10%,var(--background))]"
)}
>
<Avatar alt={message.from} src={stockImages[message.photo].src} />
<span className="flex min-w-0 flex-1 flex-col gap-1">
<span className="flex items-center justify-between gap-2">
<span className={cn("truncate text-sm", message.unread && "font-bold")}>{message.from}</span>
<span className="shrink-0 text-xs text-muted-foreground">{message.time}</span>
</span>
<span className={cn("truncate text-sm", message.unread ? "font-semibold" : "text-foreground")}>{message.subject}</span>
<span className="line-clamp-2 text-xs leading-5 text-muted-foreground">{message.preview}</span>
</span>
{message.unread && <span aria-label="خواندهنشده" className="mt-1.5 size-2 shrink-0 rounded-full bg-brand-secondary" />}
</Button>
</li>
))}
</ul>
{visible.length === 0 && <p className="p-8 text-center text-sm text-muted-foreground">نامهای پیدا نشد.</p>}
</section>
{/* خواندنِ نامه */}
{open ? (
<article aria-label={open.subject} className="flex min-w-0 flex-col">
<div className="flex items-center gap-2 border-b border-border p-4">
<Button variant="ghost" size="icon" aria-label="بازگشت به فهرست" className="lg:hidden" onClick={() => setOpenId(null)}>
<ChevronRightIcon />
</Button>
<Button variant="outline" onClick={() => toast.success("نامه بایگانی شد.")}>
بایگانی
</Button>
<Button
variant="outline"
size="icon"
aria-label={open.starred ? "برداشتنِ ستاره" : "ستارهدار کردن"}
aria-pressed={!!open.starred}
onClick={() => setMessages((list) => list.map((message) => (message.id === open.id ? { ...message, starred: !message.starred } : message)))}
className={open.starred ? "text-[var(--dig-amber-500)] hover:text-[var(--dig-amber-500)]" : undefined}
>
<StarIcon className={open.starred ? "fill-current" : undefined} />
</Button>
<Button variant="outline" size="icon" aria-label="حذف" className="hover:text-destructive">
<TrashIcon />
</Button>
<Button variant="ghost" size="icon" aria-label="کنشهای بیشتر" className="ms-auto">
<MoreHorizontalIcon />
</Button>
</div>
<div className="flex flex-1 flex-col gap-8 p-6 sm:p-8">
<header className="flex flex-col gap-4">
<h2 className="flex flex-wrap items-center gap-3 text-xl font-bold">
{open.subject}
{open.label && (
<Badge variant="soft" color={open.label.color}>
{open.label.text}
</Badge>
)}
</h2>
<div className="flex items-center gap-3">
<Avatar size="lg" alt={open.from} src={stockImages[open.photo].src} />
<div className="flex min-w-0 flex-1 flex-col text-sm">
<span className="font-semibold">{open.from}</span>
<span className="truncate text-muted-foreground">
<bdi dir="ltr">{open.email}</bdi>
</span>
</div>
<span className="text-sm text-muted-foreground">{open.time}</span>
</div>
</header>
<div className="flex flex-col gap-4 leading-8">
{open.body.map((line) => (
<p key={line}>{line}</p>
))}
</div>
{open.files && (
<ul className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{open.files.map((file) => (
<li key={file.name} className="flex items-center gap-3 rounded-xl border border-border p-3">
<FileTypeIcon kind={file.kind} className="size-10" />
<span className="flex min-w-0 flex-1 flex-col">
<bdi dir="auto" className="truncate text-sm font-medium">
{file.name}
</bdi>
<span className="text-xs text-muted-foreground">{file.size}</span>
</span>
<Button variant="ghost" size="icon" aria-label={`دانلودِ ${file.name}`}>
<DownloadIcon />
</Button>
</li>
))}
</ul>
)}
{/* پاسخ با ویرایشگرِ قالبدار؛ ارسال تا متنی نباشد غیرفعال است. */}
<div className="mt-auto flex flex-col gap-3 border-t border-border pt-8">
<RichTextEditor ref={editorRef} aria-label={`پاسخ به ${open.from}`} placeholder={`پاسخ به ${open.from}…`} minRows={4} onValueChange={(html) => setReply(html.replace(/<[^>]+>/g, "").trim())} />
<div className="flex justify-end">
<Button
disabled={!reply}
onClick={() => {
editorRef.current?.setHTML("")
setReply("")
toast.success(`پاسخ برای ${open.from} فرستاده شد.`)
}}
>
ارسالِ پاسخ
</Button>
</div>
</div>
</div>
</article>
) : (
<div className="hidden items-center justify-center p-8 text-muted-foreground lg:flex">نامهای را برای خواندن انتخاب کنید.</div>
)}
</main>
</ControlSizeProvider>
)
}