بلاک‌ها · پنل مدیریت

سه ستون: پوشه‌ها با شمارش، فهرستِ نامه‌ها با جست‌وجو و تبِ همه/خوانده‌نشده و نقطهٔ خوانده‌نشده، خواندنِ نامه با برچسب، پیوست‌های نوع‌دار و پاسخ با ویرایشگرِ قالب‌دار؛ در موبایل فهرست و خواندن جای هم را می‌گیرند.

۱. نصب کامپوننت‌ها

این بلاک از ۱۱ آیتم دیگ استفاده می‌کند: 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.json
pnpm 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.json
yarn 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.json
bunx --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>
  )
}