{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "file-upload",
  "type": "registry:ui",
  "title": "FileUpload",
  "description": "بارگذاری فایل با کشیدن و رها کردن یا دکمهٔ انتخاب؛ هر فایل وضعیت و پیشرفت خودش را دارد",
  "dependencies": [
    "@kashi-ui/persian@^1.8.0"
  ],
  "registryDependencies": [
    "https://kashi.sepantapouya.com/r/cx.json",
    "https://kashi.sepantapouya.com/r/kashi-provider.json",
    "https://kashi.sepantapouya.com/r/progress.json"
  ],
  "files": [
    {
      "path": "registry/kashi/FileUpload.tsx",
      "type": "registry:ui",
      "target": "components/kashi/FileUpload.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useId, useRef, useState, type DragEvent, type ReactNode } from \"react\";\nimport { getMessages, toFaDigits, type KashiLocale } from \"@kashi-ui/persian\";\nimport { cx } from \"./cx\";\nimport { Progress } from \"./Progress\";\nimport { useKashi } from \"./KashiProvider\";\n\nexport type FileStatus = \"uploading\" | \"done\" | \"error\";\nexport interface FileItem {\n  id: string;\n  /** فایلی که ارسال می‌شود؛ اگر resizeImages کوچکش کرده باشد، همان نسخهٔ کوچک */\n  file: File;\n  status: FileStatus;\n  progress: number;\n  error?: string;\n  /** نشانی پیش‌نمایش (object URL) برای تصویرها */\n  previewUrl?: string;\n  /** توضیح کوتاه، مثل «کوچک شد به ۲۰۴۸×۱۵۳۶» */\n  note?: string;\n}\n\n/** حجم فایل: ۱٫۲ مگابایت؛ با locale=\"en\" ← «1.2 MB» */\nexport function formatBytes(bytes: number, locale: KashiLocale = \"fa\"): string {\n  const en = locale === \"en\";\n  const units = getMessages(locale).fileUpload.units;\n  let v = bytes;\n  let i = 0;\n  while (v >= 1024 && i < units.length - 1) {\n    v /= 1024;\n    i++;\n  }\n  const n = i === 0 ? String(v) : v.toFixed(v < 10 ? 1 : 0).replace(/\\.0$/, \"\");\n  return `${en ? n : toFaDigits(n).replace(\".\", \"٫\")} ${units[i]}`;\n}\n\nexport interface ImageResizeOptions {\n  /** بلندترین ضلع تصویر بعد از کوچک شدن، به پیکسل */\n  maxDimension: number;\n  /** کیفیت JPEG/WebP بین ۰ و ۱ @default 0.85 */\n  quality?: number;\n  /** نوع خروجی؛ پیش‌فرض همان نوع فایل */\n  type?: \"image/jpeg\" | \"image/webp\" | \"image/png\";\n}\n\n/**\n * عکس بزرگ را پیش از ارسال در مرورگر کوچک می‌کند (JPEG، PNG و WebP؛ GIF و SVG دست نمی‌خورند).\n * اگر عکس از maxDimension کوچک‌تر باشد یا خروجی سبک‌تر نشود، null برمی‌گرداند و همان فایل اصلی می‌ماند.\n */\nexport async function shrinkImage(file: File, { maxDimension, quality = 0.85, type }: ImageResizeOptions): Promise<{ file: File; width: number; height: number } | null> {\n  if (!/^image\\/(jpeg|png|webp)$/.test(file.type) || typeof createImageBitmap !== \"function\") return null;\n  let bmp: ImageBitmap;\n  try {\n    bmp = await createImageBitmap(file, { imageOrientation: \"from-image\" } as ImageBitmapOptions);\n  } catch {\n    return null;\n  }\n  const scale = Math.min(1, maxDimension / Math.max(bmp.width, bmp.height));\n  if (scale >= 1) {\n    bmp.close?.();\n    return null;\n  }\n  const width = Math.round(bmp.width * scale), height = Math.round(bmp.height * scale);\n  const out = type ?? file.type;\n  let blob: Blob | null = null;\n  try {\n    if (typeof OffscreenCanvas !== \"undefined\") {\n      const c = new OffscreenCanvas(width, height);\n      const ctx = c.getContext(\"2d\")!;\n      ctx.imageSmoothingQuality = \"high\";\n      ctx.drawImage(bmp, 0, 0, width, height);\n      blob = await c.convertToBlob({ type: out, quality });\n    } else {\n      const c = document.createElement(\"canvas\");\n      c.width = width;\n      c.height = height;\n      const ctx = c.getContext(\"2d\")!;\n      ctx.imageSmoothingQuality = \"high\";\n      ctx.drawImage(bmp, 0, 0, width, height);\n      blob = await new Promise<Blob | null>((r) => c.toBlob(r, out, quality));\n    }\n  } finally {\n    bmp.close?.();\n  }\n  if (!blob || blob.size >= file.size) return null;\n  const name = out === file.type ? file.name : `${file.name.replace(/\\.[^.]+$/, \"\")}.${out === \"image/jpeg\" ? \"jpg\" : out.split(\"/\")[1]}`;\n  return { file: new File([blob], name, { type: out, lastModified: file.lastModified }), width, height };\n}\n\nconst ext = (name: string) => (name.split(\".\").pop() ?? \"\").slice(0, 4).toUpperCase();\n\nfunction accepts(file: File, accept?: string) {\n  if (!accept) return true;\n  return accept.split(\",\").map((a) => a.trim().toLowerCase()).some((a) =>\n    a.startsWith(\".\") ? file.name.toLowerCase().endsWith(a) : a.endsWith(\"/*\") ? file.type.startsWith(a.slice(0, -1)) : file.type === a,\n  );\n}\n\nexport interface FileUploadProps {\n  /** مثل \"image/*,.pdf\" */\n  accept?: string;\n  multiple?: boolean;\n  /** بیشترین حجم هر فایل (بایت) */\n  maxSize?: number;\n  maxFiles?: number;\n  /** آپلود هر فایل؛ onProgress با عدد ۰ تا ۱۰۰. بدون آن فایل‌ها فقط انتخاب می‌شوند */\n  uploader?: (file: File, onProgress: (percent: number) => void) => Promise<void>;\n  onFilesChange?: (files: FileItem[]) => void;\n  /** متن راهنما، مثل «PNG یا PDF، حداکثر ۵ مگابایت» */\n  hint?: ReactNode;\n  disabled?: boolean;\n  /**\n   * نام فیلد در فرم: فایل‌های پذیرفته‌شده (بعد از کوچک شدن) در یک <input type=\"file\" name> واقعی می‌مانند و با ارسال\n   * معمولی فرم، FormData و Server Action فرستاده می‌شوند؛ uploader لازم نیست.\n   */\n  name?: string;\n  /** با name: فرم بدون فایل ارسال نمی‌شود */\n  required?: boolean;\n  /** پیش‌نمایش کوچک برای تصویرها به جای پسوند فایل @default true */\n  preview?: boolean;\n  /** کوچک کردن عکس‌های بزرگ در مرورگر پیش از بررسی حجم و ارسال، مثل { maxDimension: 2048, quality: 0.85 } */\n  resizeImages?: ImageResizeOptions;\n  className?: string;\n  labels?: { browse?: string; drop?: string; remove?: (n: string) => string; tooBig?: (max: string) => string; wrongType?: string; tooMany?: (n: number) => string; done?: string; uploading?: string };\n}\n\nconst UploadIcon = () => (\n  <svg viewBox=\"0 0 20 20\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.7} strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden=\"true\"><path d=\"M10 13V3.5m-4 4 4-4 4 4M4 13v2.5A1.5 1.5 0 0 0 5.5 17h9a1.5 1.5 0 0 0 1.5-1.5V13\" /></svg>\n);\nconst TrashIcon = () => (\n  <svg viewBox=\"0 0 20 20\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.7} strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden=\"true\"><path d=\"M3.5 5.5h13M8 5.5V4h4v1.5M5 5.5l.8 10.2a1.5 1.5 0 0 0 1.5 1.3h5.4a1.5 1.5 0 0 0 1.5-1.3L15 5.5\" /></svg>\n);\n\n/** بارگذاری فایل با کشیدن و رها کردن یا دکمهٔ انتخاب؛ هر فایل وضعیت و پیشرفت خودش را دارد */\nexport function FileUpload({\n  accept, multiple = true, maxSize, maxFiles, uploader, onFilesChange, hint, disabled, name, required, preview = true, resizeImages, className, labels = {},\n}: FileUploadProps) {\n  const id = useId();\n  const inputRef = useRef<HTMLInputElement>(null);\n  const formRef = useRef<HTMLInputElement>(null);\n  const [items, setItems] = useState<FileItem[]>([]);\n  const itemsRef = useRef(items);\n  itemsRef.current = items;\n  const [dragging, setDragging] = useState(false);\n  const [announce, setAnnounce] = useState(\"\");\n  const { messages: M, n: N, locale } = useKashi();\n  const bytes = (b: number) => formatBytes(b, locale);\n  const {\n    browse = M.fileUpload.browse, drop = M.fileUpload.drop, remove = M.common.remove,\n    tooBig = M.fileUpload.tooBig, wrongType = M.fileUpload.wrongType,\n    tooMany = (x: number) => M.fileUpload.tooMany(N(x)), done = M.fileUpload.done, uploading = M.fileUpload.uploading,\n  } = labels;\n\n  const update = (fn: (list: FileItem[]) => FileItem[]) =>\n    setItems((prev) => {\n      const next = fn(prev);\n      onFilesChange?.(next);\n      return next;\n    });\n\n  // فیلد file واقعی فرم همیشه همان فایل‌های پذیرفته‌شدهٔ فهرست را دارد\n  useEffect(() => {\n    const el = formRef.current;\n    if (!el) return;\n    try {\n      const dt = new DataTransfer();\n      for (const it of items) if (it.status !== \"error\") dt.items.add(it.file);\n      el.files = dt.files;\n      if (dt.files.length) inputRef.current?.closest(\".k-dropzone\")?.removeAttribute(\"data-invalid\");\n    } catch { /* مرورگر بدون DataTransfer */ }\n  }, [items]);\n  // نشانی‌های پیش‌نمایش با بسته شدن کامپوننت آزاد می‌شوند\n  useEffect(() => () => itemsRef.current.forEach((it) => it.previewUrl && URL.revokeObjectURL(it.previewUrl)), []);\n\n  const add = (files: FileList | File[]) => {\n    const picked = Array.from(files).slice(0, multiple ? undefined : 1);\n    if (!resizeImages) return accept_(picked.map((file) => ({ file, note: undefined as string | undefined })));\n    // کوچک کردن عکس‌ها ناهمگام است؛ بدون resizeImages همه چیز هم‌زمان می‌ماند\n    Promise.all(picked.map(async (file) => {\n      const small = accepts(file, accept) ? await shrinkImage(file, resizeImages) : null;\n      return small ? { file: small.file, note: M.fileUpload.resized(N(small.width), N(small.height)) } : { file, note: undefined };\n    })).then(accept_);\n  };\n\n  const accept_ = (list: { file: File; note?: string }[]) => {\n    const kept = multiple ? itemsRef.current.filter((x) => x.status !== \"error\").length : 0;\n    const added: FileItem[] = list.map(({ file, note }, i) => {\n      const fid = `${Date.now()}-${i}-${file.name}`;\n      let err: string | undefined;\n      if (!accepts(file, accept)) err = wrongType;\n      else if (maxSize && file.size > maxSize) err = tooBig(bytes(maxSize));\n      else if (maxFiles && kept + i >= maxFiles) err = tooMany(maxFiles);\n      const previewUrl = preview && !err && file.type.startsWith(\"image/\") && typeof URL.createObjectURL === \"function\" ? URL.createObjectURL(file) : undefined;\n      return { id: fid, file, status: err ? \"error\" : uploader ? \"uploading\" : \"done\", progress: err ? 0 : uploader ? 0 : 100, error: err, previewUrl, note };\n    });\n    if (!multiple) itemsRef.current.forEach((it) => it.previewUrl && URL.revokeObjectURL(it.previewUrl));\n    update((prev) => (multiple ? [...prev, ...added] : added));\n    setAnnounce(added.map((a) => `${a.file.name}: ${a.error ?? (uploader ? uploading : done)}`).join(M.common.list));\n    for (const it of added) {\n      if (it.status !== \"uploading\" || !uploader) continue;\n      uploader(it.file, (p) => update((l) => l.map((x) => (x.id === it.id ? { ...x, progress: p } : x))))\n        .then(() => {\n          update((l) => l.map((x) => (x.id === it.id ? { ...x, status: \"done\", progress: 100 } : x)));\n          setAnnounce(`${it.file.name}: ${done}`);\n        })\n        .catch((e: unknown) => {\n          const msg = e instanceof Error ? e.message : M.fileUpload.error;\n          update((l) => l.map((x) => (x.id === it.id ? { ...x, status: \"error\", error: msg } : x)));\n          setAnnounce(`${it.file.name}: ${msg}`);\n        });\n    }\n  };\n\n  const onDrop = (e: DragEvent) => {\n    e.preventDefault();\n    setDragging(false);\n    if (!disabled && e.dataTransfer.files.length) add(e.dataTransfer.files);\n  };\n\n  return (\n    <div className={className}>\n      <div\n        className=\"k-dropzone\"\n        data-dragging={dragging ? \"\" : undefined}\n        data-disabled={disabled ? \"\" : undefined}\n        onDragOver={(e) => {\n          e.preventDefault();\n          if (!disabled) setDragging(true);\n        }}\n        onDragLeave={(e) => {\n          if (!e.currentTarget.contains(e.relatedTarget as Node)) setDragging(false);\n        }}\n        onDrop={onDrop}\n      >\n        <span className=\"k-dropzone__icon\" aria-hidden=\"true\"><UploadIcon /></span>\n        <p className=\"k-dropzone__title\" style={{ margin: 0 }}>\n          <button type=\"button\" className=\"k-dropzone__browse\" disabled={disabled} aria-describedby={hint ? `${id}-hint` : undefined} onClick={() => inputRef.current?.click()}>\n            {browse}\n          </button>{\" \"}\n          <span>{drop}</span>\n        </p>\n        {hint && <p className=\"k-dropzone__hint\" id={`${id}-hint`} style={{ margin: 0 }}>{hint}</p>}\n        <input\n          ref={inputRef}\n          type=\"file\"\n          accept={accept}\n          multiple={multiple}\n          tabIndex={-1}\n          aria-hidden=\"true\"\n          onChange={(e) => {\n            if (e.target.files?.length) add(e.target.files);\n            e.target.value = \"\";\n          }}\n        />\n        {name && (\n          <input\n            ref={formRef}\n            type=\"file\"\n            className=\"k-dropzone__form\"\n            name={name}\n            multiple={multiple}\n            required={required}\n            tabIndex={-1}\n            aria-hidden=\"true\"\n            onInvalid={() => inputRef.current?.closest(\".k-dropzone\")?.setAttribute(\"data-invalid\", \"\")}\n            onChange={(e) => e.preventDefault()}\n          />\n        )}\n      </div>\n      {items.length > 0 && (\n        <ul className=\"k-file-list\" role=\"list\">\n          {items.map((it) => (\n            <li key={it.id} className=\"k-file\" data-status={it.status}>\n              {it.previewUrl ? (\n                <img className=\"k-file__thumb\" src={it.previewUrl} alt=\"\" />\n              ) : (\n                <span className=\"k-file__icon\" aria-hidden=\"true\">{ext(it.file.name)}</span>\n              )}\n              <span className=\"k-file__name\" title={it.file.name}>{it.file.name}</span>\n              <button\n                type=\"button\"\n                className={cx(\"k-btn k-btn--tertiary k-btn--icon k-btn--sm k-file__remove\")}\n                aria-label={remove(it.file.name)}\n                onClick={() => {\n                  if (it.previewUrl) URL.revokeObjectURL(it.previewUrl);\n                  update((l) => l.filter((x) => x.id !== it.id));\n                  setAnnounce(M.common.removed(it.file.name));\n                }}\n              >\n                <TrashIcon />\n              </button>\n              <span className=\"k-file__meta\">\n                {it.error ?? `${bytes(it.file.size)}${it.status === \"uploading\" ? ` · ${M.common.percent(N(Math.round(it.progress)))}` : it.status === \"done\" ? ` · ${done}` : \"\"}${it.note ? ` · ${it.note}` : \"\"}`}\n              </span>\n              {it.status === \"uploading\" && <Progress value={it.progress} size=\"sm\" aria-label={`${uploading} ${it.file.name}`} />}\n            </li>\n          ))}\n        </ul>\n      )}\n      <span className=\"k-sr-only\" aria-live=\"polite\">{announce}</span>\n    </div>\n  );\n}\n"
    }
  ]
}