{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"avatar-upload","type":"registry:ui","title":"Avatar Upload","description":"A profile photo or logo with its upload: pick or drop a picture, crop it in a dialog, get a square file back.","dependencies":["lucide-react"],"registryDependencies":["https://dashral.com/r/utils.json","https://dashral.com/r/lib-files.json","https://dashral.com/r/lib-locale.json","https://dashral.com/r/button.json","https://dashral.com/r/dialog.json","https://dashral.com/r/field.json","https://dashral.com/r/image-cropper.json"],"files":[{"path":"registry/dashral/avatar-upload.tsx","type":"registry:file","target":"components/dashral/avatar-upload.tsx","content":"\"use client\"\n\nimport * as React from \"react\"\nimport { CameraIcon, UserIcon } from \"lucide-react\"\n\nimport { accepts, formatBytes } from \"./lib/files\"\nimport { useLocale } from \"./lib/locale\"\nimport { cn } from \"./lib/utils\"\nimport { Button } from \"./button\"\nimport { Dialog, DialogClose } from \"./dialog\"\nimport { FieldWrapper, splitFieldProps, useFieldControl, type FieldClassNames, type FieldControlProps, type FieldOwnProps } from \"./field\"\nimport { cropImage, ImageCropper, type CropArea } from \"./image-cropper\"\n\ntype AvatarUploadProps = FieldOwnProps & {\n  /** The picture now: a URL (say, from your server) or the File cropped last. null for none. */\n  value?: File | string | null\n  defaultValue?: File | string | null\n  /** The cropped picture as a File, ready to upload, or null when it's removed. */\n  onValueChange?: (file: File | null) => void\n  /** Like the `accept` attribute. */\n  accept?: string\n  /** Largest picture someone can pick, in bytes (before cropping). */\n  maxSize?: number\n  /** A circle for a person, a square for a logo. The file is square either way. */\n  shape?: \"circle\" | \"square\"\n  /** In the picture's place when there's none, e.g. initials. A person icon by default. */\n  fallback?: React.ReactNode\n  /** Width and height of the file, in pixels. */\n  outputSize?: number\n  /** \"image/jpeg\" (the default), \"image/png\" to keep a logo's transparency, or \"image/webp\". */\n  outputType?: string\n  /** The crop dialog's title and the line under it. */\n  cropTitle?: React.ReactNode\n  cropDescription?: React.ReactNode\n  disabled?: boolean\n  required?: boolean\n  id?: string\n  /** Sends the cropped file with a form, as a file input of this name would. */\n  name?: string\n  className?: string\n  \"aria-invalid\"?: React.AriaAttributes[\"aria-invalid\"]\n  classNames?: FieldClassNames & { preview?: string; actions?: string }\n}\n\n/**\n * A profile photo or logo with its upload in one tag: pick a picture (or drop it on the preview), crop it in a dialog\n * and get a square File back, sized and compressed, to upload wherever onValueChange sends it.\n */\nfunction AvatarUpload(props: AvatarUploadProps) {\n  const [field, { required: _required, ...control }] = splitFieldProps(props)\n  return (\n    <FieldWrapper {...field} group>\n      <AvatarUploadControl {...control} />\n    </FieldWrapper>\n  )\n}\n\n/** Whether it's a File; `File` isn't there on every server that renders the page. */\nconst isFile = (value: unknown): value is File => typeof File !== \"undefined\" && value instanceof File\n\n/** A File's object URL for as long as it's shown, or the URL itself. */\nfunction usePictureUrl(value: File | string | null) {\n  const [fileUrl, setFileUrl] = React.useState<string | null>(null)\n  React.useEffect(() => {\n    if (!isFile(value)) return setFileUrl(null)\n    const next = URL.createObjectURL(value)\n    setFileUrl(next)\n    return () => URL.revokeObjectURL(next)\n  }, [value])\n  return isFile(value) ? fileUrl : value || null\n}\n\nfunction AvatarUploadControl({\n  value,\n  defaultValue = null,\n  onValueChange,\n  accept = \"image/*\",\n  maxSize,\n  shape = \"circle\",\n  fallback,\n  outputSize = 512,\n  outputType = \"image/jpeg\",\n  cropTitle,\n  cropDescription,\n  name,\n  className,\n  classNames,\n  ...rest\n}: Omit<AvatarUploadProps, keyof FieldOwnProps | \"required\">) {\n  const { id, disabled, ...aria } = useFieldControl<FieldControlProps>({ id: rest.id, disabled: rest.disabled, \"aria-invalid\": rest[\"aria-invalid\"] })\n  const { t, locale } = useLocale()\n  const [internal, setInternal] = React.useState<File | string | null>(defaultValue)\n  const current = value === undefined ? internal : value\n  const url = usePictureUrl(current)\n\n  const pickerRef = React.useRef<HTMLInputElement>(null)\n  const formRef = React.useRef<HTMLInputElement>(null)\n  const [error, setError] = React.useState<string | null>(null)\n  const [dragging, setDragging] = React.useState(false)\n  // The picture being cropped, its crop, and whether the dialog is open (it stays mounted while it closes).\n  const [picked, setPicked] = React.useState<{ file: File; url: string } | null>(null)\n  const [open, setOpen] = React.useState(false)\n  const [saving, setSaving] = React.useState(false)\n  const [cropError, setCropError] = React.useState(false)\n  // Set once the picture in the dialog has loaded; Apply waits for it.\n  const [area, setArea] = React.useState<CropArea | null>(null)\n  const errorId = React.useId()\n\n  React.useEffect(() => {\n    if (!picked) return\n    return () => URL.revokeObjectURL(picked.url)\n  }, [picked])\n\n  // The form's copy of the file, so a plain form submit (or a server action) gets the cropped picture.\n  React.useEffect(() => {\n    const input = formRef.current\n    if (!input || typeof DataTransfer === \"undefined\") return\n    const transfer = new DataTransfer()\n    if (isFile(current)) transfer.items.add(current)\n    input.files = transfer.files\n  }, [current, name])\n\n  function set(next: File | null) {\n    if (value === undefined) setInternal(next)\n    onValueChange?.(next)\n  }\n\n  function pick(list: FileList | null) {\n    const file = list?.[0]\n    if (!file || disabled) return\n    if (!file.type.startsWith(\"image/\") || !accepts(file, accept)) return setError(t(\"fileType\", { name: file.name }))\n    if (maxSize !== undefined && file.size > maxSize) return setError(t(\"fileSize\", { name: file.name, size: formatBytes(maxSize, locale) }))\n    setError(null)\n    setArea(null)\n    setCropError(false)\n    setPicked({ file, url: URL.createObjectURL(file) })\n    setOpen(true)\n  }\n\n  async function apply() {\n    if (!picked || !area || saving) return\n    setSaving(true)\n    setCropError(false)\n    try {\n      const blob = await cropImage(picked.url, area, { width: outputSize, type: outputType })\n      const extension = blob.type.split(\"/\")[1]?.replace(\"jpeg\", \"jpg\") ?? \"jpg\"\n      const base = picked.file.name.replace(/\\.[^.]+$/, \"\") || \"avatar\"\n      set(new File([blob], `${base}.${extension}`, { type: blob.type }))\n      setOpen(false)\n    } catch {\n      // A picture the browser can show but not draw (too big for a canvas, say): say so and keep the dialog open.\n      setCropError(true)\n    } finally {\n      setSaving(false)\n    }\n  }\n\n  const radius = shape === \"circle\" ? \"rounded-full\" : \"rounded-lg\"\n  // A circle is someone's photo; a square is a logo or another image.\n  const words =\n    shape === \"circle\"\n      ? { upload: t(\"uploadPhoto\"), change: t(\"changePhoto\"), remove: t(\"removePhoto\"), title: t(\"cropPhoto\"), hint: t(\"cropPhotoHint\") }\n      : { upload: t(\"uploadImage\"), change: t(\"changeImage\"), remove: t(\"removeImage\"), title: t(\"cropImage\"), hint: t(\"cropImageHint\") }\n\n  return (\n    <div\n      data-slot=\"avatar-upload\"\n      role=\"group\"\n      aria-labelledby={aria[\"aria-labelledby\"]}\n      aria-describedby={aria[\"aria-describedby\"]}\n      className={cn(\"grid gap-2\", className)}\n    >\n      <div className=\"flex items-center gap-4\">\n        {/* The preview is a big target for the pointer and for a dropped file; keyboards and screen readers use the\n            button beside it, which says what it does. */}\n        <button\n          type=\"button\"\n          tabIndex={-1}\n          aria-hidden\n          disabled={disabled}\n          aria-invalid={aria[\"aria-invalid\"]}\n          onClick={() => pickerRef.current?.click()}\n          onDragOver={(event) => {\n            event.preventDefault()\n            if (!disabled) setDragging(true)\n          }}\n          onDragLeave={() => setDragging(false)}\n          onDrop={(event) => {\n            event.preventDefault()\n            setDragging(false)\n            pick(event.dataTransfer.files)\n          }}\n          data-dragging={dragging || undefined}\n          className={cn(\n            // Its insides ignore the pointer, so dragging over them doesn't count as leaving it.\n            \"group/preview relative isolate flex size-16 *:pointer-events-none shrink-0 items-center justify-center overflow-hidden border bg-muted text-muted-foreground outline-none\",\n            \"transition-[box-shadow,border-color] duration-(--duration-fast) ease-standard disabled:pointer-events-none disabled:opacity-50\",\n            \"data-dragging:border-primary data-dragging:ring-4 data-dragging:ring-primary/20 aria-invalid:border-destructive/60\",\n            radius,\n            classNames?.preview\n          )}\n        >\n          <span aria-hidden className=\"flex items-center justify-center text-lg font-medium\">\n            {fallback ?? <UserIcon className=\"size-6\" />}\n          </span>\n          <Pictures url={url} />\n          {/* A camera over the picture on hover, focus or while a file is dragged over it. */}\n          <span\n            aria-hidden\n            className={cn(\n              \"absolute inset-0 z-10 flex items-center justify-center bg-black/45 text-white opacity-0 transition-opacity duration-(--duration-fast) ease-standard\",\n              \"group-hover/preview:opacity-100 group-data-dragging/preview:opacity-100\"\n            )}\n          >\n            <CameraIcon className=\"size-5\" />\n          </span>\n        </button>\n        <div className={cn(\"flex flex-wrap items-center gap-2\", classNames?.actions)}>\n          <Button\n            type=\"button\"\n            id={id}\n            variant=\"outline\"\n            size=\"sm\"\n            disabled={disabled}\n            aria-invalid={aria[\"aria-invalid\"] ?? (error ? true : undefined)}\n            aria-describedby={error ? errorId : undefined}\n            onClick={() => pickerRef.current?.click()}\n          >\n            {url ? words.change : words.upload}\n          </Button>\n          {url && (\n            <Button type=\"button\" variant=\"ghost\" size=\"sm\" disabled={disabled} onClick={() => set(null)} className=\"text-muted-foreground\">\n              {words.remove}\n            </Button>\n          )}\n        </div>\n      </div>\n      <input\n        ref={pickerRef}\n        type=\"file\"\n        accept={accept}\n        disabled={disabled}\n        className=\"sr-only\"\n        tabIndex={-1}\n        aria-hidden\n        onChange={(event) => {\n          pick(event.target.files)\n          event.target.value = \"\"\n        }}\n      />\n      {name && <input ref={formRef} type=\"file\" name={name} className=\"sr-only\" tabIndex={-1} aria-hidden />}\n      {error && (\n        <p id={errorId} role=\"alert\" className=\"animate-in text-xs text-destructive-text duration-(--duration-standard) ease-enter fade-in-0\">\n          {error}\n        </p>\n      )}\n      <Dialog\n        open={open}\n        // Not closed halfway through saving, so a crop can't land after it was cancelled.\n        onOpenChange={(next) => !saving && setOpen(next)}\n        size=\"sm\"\n        title={cropTitle ?? words.title}\n        description={cropDescription ?? words.hint}\n        footer={\n          <>\n            <DialogClose asChild>\n              <Button type=\"button\" variant=\"outline\">\n                {t(\"cancel\")}\n              </Button>\n            </DialogClose>\n            <Button type=\"button\" loading={saving} disabled={!area} onClick={apply}>\n              {t(\"apply\")}\n            </Button>\n          </>\n        }\n      >\n        {picked && (\n          <div className=\"grid gap-2\">\n            <ImageCropper src={picked.url} shape={shape === \"circle\" ? \"circle\" : \"rect\"} onCropChange={setArea} height={280} />\n            {cropError && (\n              <p role=\"alert\" className=\"text-xs text-destructive-text\">\n                {t(\"cropFailed\")}\n              </p>\n            )}\n          </div>\n        )}\n      </Dialog>\n    </div>\n  )\n}\n\n/**\n * The picture in the preview. A new one fades in over the old, and a removed one fades out, so the preview never\n * blinks empty in between.\n */\nfunction Pictures({ url }: { url: string | null }) {\n  const [layers, setLayers] = React.useState<{ url: string; leaving: boolean }[]>(url ? [{ url, leaving: false }] : [])\n  const [shown, setShown] = React.useState(url)\n  if (shown !== url) {\n    setShown(url)\n    setLayers((all) => [...all.filter((layer) => layer.url !== url).map((layer) => ({ ...layer, leaving: true })), ...(url ? [{ url, leaving: false }] : [])])\n  }\n  const top = layers.at(-1)\n  return layers.map((layer) => {\n    // A leaving picture under a new one stays put until the new one has faded in; with nothing over it, it fades out.\n    const fading = layer.leaving && layer === top\n    return (\n      <Picture\n        key={layer.url}\n        url={layer.url}\n        leaving={fading}\n        // Faded out: gone. Faded in: the pictures under it can go.\n        onDone={() => setLayers((all) => all.filter((item) => (layer.leaving ? item !== layer : !item.leaving)))}\n      />\n    )\n  })\n}\n\nfunction Picture({ url, leaving, onDone }: { url: string; leaving: boolean; onDone: () => void }) {\n  const [loaded, setLoaded] = React.useState(false)\n  return (\n    <img\n      src={url}\n      alt=\"\"\n      draggable={false}\n      onLoad={() => setLoaded(true)}\n      // A picture that won't load stays hidden over the fallback, and lets the one under it go.\n      onError={onDone}\n      onAnimationEnd={onDone}\n      className={cn(\n        \"absolute inset-0 size-full object-cover\",\n        !loaded && \"opacity-0\",\n        loaded && !leaving && \"animate-in fade-in-0 duration-(--duration-standard) ease-enter\",\n        leaving && \"animate-out fade-out-0 fill-mode-forwards duration-(--duration-standard) ease-exit\"\n      )}\n    />\n  )\n}\n\nexport { AvatarUpload, type AvatarUploadProps }\n"}],"cssVars":{"theme":{"color-destructive-foreground":"var(--destructive-foreground)","color-destructive-text":"var(--destructive-text)","ease-standard":"var(--ease-standard)","ease-enter":"var(--ease-enter)","ease-exit":"var(--ease-exit)","ease-spring":"var(--ease-spring)"},"light":{"destructive-foreground":"oklch(0.985 0 0)","destructive-text":"#bd2800","duration-fast":"160ms","duration-standard":"240ms","duration-considered":"480ms","ease-standard":"cubic-bezier(0.2, 0, 0, 1)","ease-enter":"cubic-bezier(0, 0, 0, 1)","ease-exit":"cubic-bezier(0.3, 0, 1, 1)","ease-spring":"linear(0, 0.071 4%, 0.234 8%, 0.433 12%, 0.626 17%, 0.791 21%, 0.917 25%, 1.004 29%, 1.055 33%, 1.079 38%, 1.083 42%, 1.075 46%, 1.06 50%, 1.043 54%, 1.027 58%, 1.015 62%, 1.005 67%, 0.998 71%, 0.995 75%, 0.993 79%, 0.993 83%, 0.994 88%, 0.995 92%, 0.997 96%, 1)"},"dark":{"destructive-foreground":"oklch(0.2 0 0)","destructive-text":"#ff8a5c"}}}