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

ناحیهٔ رهاکردن، کارتِ هر فایل با برچسبِ نوع، پیشرفت و درصد، لغو حینِ بارگذاری، خطا با تلاشِ دوباره، وارد کردن از لینک، قالبِ نمونه و ثبت پس از اتمام.

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

این بلاک از ۹ آیتم دیگ استفاده می‌کند: buttoncardcontrol-sizefile-uploadiconsinputlabelpersiantoast

پکیج‌منیجر پیش‌فرض Node.jsنصب سریع با لینک سخت و دیسک مشترکYarn نسخهٔ ۲ به بالا (Berry)رانتایم و پکیج‌منیجر Bun
npx digdesign@latest add https://docs.digdesign.ir/r/button.json https://docs.digdesign.ir/r/card.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/input.json https://docs.digdesign.ir/r/label.json https://docs.digdesign.ir/r/persian.json https://docs.digdesign.ir/r/toast.json
pnpm dlx digdesign@latest add https://docs.digdesign.ir/r/button.json https://docs.digdesign.ir/r/card.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/input.json https://docs.digdesign.ir/r/label.json https://docs.digdesign.ir/r/persian.json https://docs.digdesign.ir/r/toast.json
yarn dlx digdesign@latest add https://docs.digdesign.ir/r/button.json https://docs.digdesign.ir/r/card.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/input.json https://docs.digdesign.ir/r/label.json https://docs.digdesign.ir/r/persian.json https://docs.digdesign.ir/r/toast.json
bunx --bun digdesign@latest add https://docs.digdesign.ir/r/button.json https://docs.digdesign.ir/r/card.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/input.json https://docs.digdesign.ir/r/label.json https://docs.digdesign.ir/r/persian.json https://docs.digdesign.ir/r/toast.json

۲. کد صفحه

کد را در app/<route>/page.tsx بگذارید و متن‌ها، داده و هندلرها را با محصول خودتان عوض کنید.

upload-files.tsx
"use client"

import * as React from "react"

import { DownloadIcon, GlobeIcon } from "@/components/icons"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
import { ControlSizeProvider } from "@/components/ui/control-size"
import { FileTypeIcon, FileUpload, type FileUploadHandler } from "@/components/ui/file-upload"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { toast } from "@/components/ui/toast"
import { toFaDigits } from "@/lib/persian"

type UploadState = "uploading" | "done" | "error"

/**
 * بارگذاری شبیه‌سازی‌شده؛ در پروژهٔ واقعی همان fetch یا XHR با onProgress است.
 * فایلی که اسمش «ناقص» دارد وسطِ راه خطا می‌دهد تا حالتِ «تلاشِ دوباره» دیده شود.
 */
const simulate: FileUploadHandler = (file, { onProgress, signal }) =>
  new Promise<void>((resolve, reject) => {
    let progress = 0
    const fails = file.name.includes("ناقص")
    const timer = setInterval(() => {
      progress += 6 + Math.random() * 12
      if (fails && progress >= 50) {
        clearInterval(timer)
        reject(new Error("اتصال قطع شد."))
        return
      }
      onProgress(Math.min(progress, 100))
      if (progress >= 100) {
        clearInterval(timer)
        resolve()
      }
    }, 220)
    signal.addEventListener("abort", () => clearInterval(timer))
  })

/** نامِ فایل از انتهای مسیرِ لینک؛ اگر نبود، یک نامِ عمومی. */
function nameFromUrl(url: URL) {
  const last = decodeURIComponent(url.pathname.split("/").filter(Boolean).pop() ?? "")
  return last.includes(".") ? last : `${url.hostname}.pdf`
}

/**
 * صفحهٔ بارگذاری فایل: ناحیهٔ رهاکردن، فهرستِ کارتی فایل‌ها با پیشرفت و وضعیت،
 * وارد کردن از لینک، دانلودِ قالبِ نمونه و دکمه‌های انصراف و ثبت. «ثبت» فقط وقتی
 * فعال است که دست‌کم یک فایل کامل بارگذاری شده و هیچ فایلی در راه نیست.
 */
export default function UploadFilesBlock() {
  const uploaderRef = React.useRef<HTMLDivElement>(null)
  const [files, setFiles] = React.useState<File[]>([])
  const [states, setStates] = React.useState(() => new Map<File, UploadState>())
  const [link, setLink] = React.useState("")
  const [linkError, setLinkError] = React.useState("")
  const [round, setRound] = React.useState(0)

  const mark = (file: File, state: UploadState | undefined) =>
    setStates((current) => {
      const next = new Map(current)
      if (state) next.set(file, state)
      else next.delete(file)
      return next
    })

  // وضعیتِ هر فایل را کنارِ خودِ FileUpload نگه می‌داریم تا دکمهٔ ثبت بداند کی مجاز است.
  const upload: FileUploadHandler = async (file, context) => {
    mark(file, "uploading")
    try {
      await simulate(file, context)
      if (!context.signal.aborted) mark(file, "done")
    } catch (error) {
      if (!context.signal.aborted) mark(file, "error")
      throw error
    }
  }

  const changeFiles = (next: File[]) => {
    setFiles(next)
    setStates((current) => new Map([...current].filter(([file]) => next.includes(file))))
  }

  const uploading = [...states.values()].filter((state) => state === "uploading").length
  const done = [...states.values()].filter((state) => state === "done").length

  const importLink = (event: React.FormEvent) => {
    event.preventDefault()
    let url: URL
    try {
      url = new URL(link.trim())
      if (!/^https?:$/.test(url.protocol)) throw new Error()
    } catch {
      setLinkError("یک لینکِ کامل با https:// وارد کنید.")
      return
    }
    setLinkError("")
    setLink("")
    // در محصولِ واقعی سرور فایل را از لینک می‌گیرد؛ این‌جا همان مسیرِ انتخابِ فایل را طی می‌کند.
    const input = uploaderRef.current?.querySelector<HTMLInputElement>('input[type="file"]')
    if (!input || typeof DataTransfer === "undefined") return
    const transfer = new DataTransfer()
    transfer.items.add(new File(["نمونه"], nameFromUrl(url), { type: "application/pdf" }))
    input.files = transfer.files
    input.dispatchEvent(new Event("change", { bubbles: true }))
  }

  const reset = () => {
    setFiles([])
    setStates(new Map())
    setRound((value) => value + 1)
  }

  const submit = () => {
    toast.success(`${toFaDigits(done)} فایل ثبت شد.`)
    reset()
  }

  return (
    <ControlSizeProvider size="xl" asChild>
      <main className="mx-auto flex w-full max-w-xl flex-col p-4 sm:p-6">
        <Card>
          <CardHeader>
            <CardTitle asChild className="text-lg">
              <h1>بارگذاری فایل‌ها</h1>
            </CardTitle>
            <CardDescription>مدارک پروژه را اضافه کنید؛ پس از اتمام بارگذاری، ثبت کنید.</CardDescription>
          </CardHeader>

          <CardContent className="flex flex-col gap-6">
            {/* قالبِ نمونه اول می‌آید تا کاربر پیش از انتخابِ فایل، ساختارِ درست را داشته باشد. */}
            <div className="flex items-center gap-3 rounded-xl bg-muted p-3">
              <FileTypeIcon kind="XLSX" className="size-11" />
              <span className="flex min-w-0 flex-1 flex-col gap-0.5">
                <span className="text-sm font-medium">قالب نمونه</span>
                <span className="text-xs text-muted-foreground">برای وارد کردن فهرست، فایل را با همین ستون‌ها پر کنید.</span>
              </span>
              <Button variant="outline" asChild>
                <a href="data:text/csv;charset=utf-8,%EF%BB%BF%D9%86%D8%A7%D9%85,%D8%AA%D9%88%D8%B6%DB%8C%D8%AD" download="قالب-نمونه.csv">
                  <DownloadIcon />
                  دانلود
                </a>
              </Button>
            </div>
            <div ref={uploaderRef}>
              <FileUpload
                key={round}
                name="files"
                multiple
                maxFiles={8}
                maxSize={25 * 1024 * 1024}
                accept=".pdf,.doc,.docx,.xls,.xlsx,.csv,.zip,image/*"
                hint="PDF، ورد، اکسل، ZIP یا تصویر؛ هر فایل تا ۲۵ مگابایت"
                files={files}
                onFilesChange={changeFiles}
                onUpload={upload}
              />
            </div>

            <form onSubmit={importLink} className="flex flex-col gap-2" noValidate>
              <Label htmlFor="upload-link">وارد کردن از لینک</Label>
              <div className="flex gap-2">
                <Input
                  id="upload-link"
                  dir="ltr"
                  inputMode="url"
                  placeholder="https://example.com/file.pdf"
                  value={link}
                  onChange={(event) => setLink(event.target.value)}
                  aria-invalid={linkError ? true : undefined}
                  aria-describedby={linkError ? "upload-link-error" : undefined}
                  startContent={<GlobeIcon />}
                  className="flex-1"
                />
                <Button type="submit" variant="outline" disabled={!link.trim()}>
                  افزودن
                </Button>
              </div>
              {linkError && (
                <p id="upload-link-error" className="text-xs text-destructive" role="alert">
                  {linkError}
                </p>
              )}
            </form>

          </CardContent>

          {/* خطِ جداکننده و فاصلهٔ بیشتر، دکمه‌های پایانی را از گروه‌های محتوا جدا می‌کند. */}
          <CardFooter className="flex-col-reverse items-stretch gap-3 border-t sm:flex-row sm:justify-end">
            <ControlSizeProvider size="xl">
              <Button variant="outline" onClick={reset} disabled={files.length === 0}>
                انصراف
              </Button>
              <Button onClick={submit} disabled={done === 0 || uploading > 0}>
                {uploading > 0 ? "منتظر پایان بارگذاری…" : done > 0 ? `ثبت ${toFaDigits(done)} فایل` : "ثبت فایل‌ها"}
              </Button>
            </ControlSizeProvider>
          </CardFooter>
        </Card>
      </main>
    </ControlSizeProvider>
  )
}