{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "attachment-tray",
  "type": "registry:ui",
  "title": "Attachment Tray",
  "description": "Pending uploads that develop as they finish, retry in place and reorder.",
  "categories": [
    "messaging"
  ],
  "dependencies": [
    "@number-flow/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/icons.json",
    "https://www.stealth.pm/r/motion.json"
  ],
  "files": [
    {
      "path": "components/ui/attachment-tray.tsx",
      "content": "\"use client\";\nimport NumberFlow from \"@number-flow/react\";\nimport { AnimatePresence, Reorder, motion, useDragControls, useReducedMotion } from \"motion/react\";\nimport { useEffect, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { Alert, Image as ImageIcon, Refresh, X } from \"@/lib/icons\";\nimport { ease, spring } from \"@/lib/motion\";\n\nexport type AttachmentStatus = \"uploading\" | \"done\" | \"error\";\n\nexport type Attachment = {\n  id: string;\n  name: string;\n  /** Bytes. */\n  size?: number;\n  /** MIME type. image/* draws a thumbnail tile (a placeholder until preview is set); anything else is a file chip. */\n  type?: string;\n  /** Thumbnail URL, e.g. from URL.createObjectURL(file). */\n  preview?: string;\n  /** 0–1 while uploading. Leave it out for an upload of unknown length. */\n  progress?: number;\n  /** Defaults to \"uploading\" while progress is below 1, else \"done\". */\n  status?: AttachmentStatus;\n  /** Why it failed, in a few words. */\n  error?: string;\n};\n\nconst statusOf = (a: Attachment): AttachmentStatus => a.status ?? (a.progress !== undefined && a.progress < 1 ? \"uploading\" : \"done\");\nconst isImage = (a: Attachment) => (a.type ? a.type.startsWith(\"image/\") : !!a.preview);\n\nfunction split(name: string) {\n  const i = name.lastIndexOf(\".\");\n  return i > 0 && name.length - i <= 6 ? [name.slice(0, i), name.slice(i)] : [name, \"\"];\n}\n\n/** \"2.4 MB\", \"820 KB\". Uses the reader's locale. */\nexport function formatBytes(bytes: number, locale?: string) {\n  const units = [\"byte\", \"kilobyte\", \"megabyte\", \"gigabyte\"] as const;\n  let i = 0;\n  let n = bytes;\n  while (n >= 1000 && i < units.length - 1) {\n    n /= 1000;\n    i++;\n  }\n  return new Intl.NumberFormat(locale, { style: \"unit\", unit: units[i], unitDisplay: \"short\", maximumFractionDigits: n < 10 && i > 1 ? 1 : 0 }).format(n);\n}\n\n/* -------------------------------------------------------------------------------------------------\n * AttachmentTray\n * -----------------------------------------------------------------------------------------------*/\n\nexport type AttachmentTrayProps = Omit<React.ComponentProps<\"div\">, \"children\"> & {\n  items: Attachment[];\n  /** Removes one. Delete or Backspace on a focused item does the same. */\n  onRemove?: (id: string) => void;\n  /** Receives the list in its new order: drag with a mouse or pen, or Alt+Arrow keys. */\n  onReorder?: (items: Attachment[]) => void;\n  /** Shown on failed items, and bound to Enter when one is focused. */\n  onRetry?: (id: string) => void;\n  /** The list's accessible name. */\n  label?: string;\n};\n\n/**\n * Pending attachments above a composer. Thumbnails develop from dim to sharp as they upload,\n * chips roll their percentage, failures offer a retry in place, and items pop in, fold out,\n * and slide aside for one another as they are dragged or moved from the keyboard.\n */\nexport function AttachmentTray({ items, onRemove, onReorder, onRetry, label = \"Attachments\", className, ...rest }: AttachmentTrayProps) {\n  const reduce = !!useReducedMotion();\n  const scroller = useRef<HTMLUListElement>(null);\n  const [active, setActive] = useState<string | null>(null);\n  const [edges, setEdges] = useState({ start: false, end: false });\n  const [dragging, setDragging] = useState<string | null>(null);\n  const [said, setSaid] = useState(\"\");\n\n  // Tell screen reader users what changed on its own: finished, failed. Derived from the last\n  // render's statuses rather than an effect, so it lands in the same commit.\n  const statusKey = items.map((a) => `${a.id}:${statusOf(a)}`).join(\"|\");\n  const [prev, setPrev] = useState(() => ({ key: statusKey, map: new Map(items.map((a) => [a.id, statusOf(a)])), ids: new Set<string>() }));\n  if (prev.key !== statusKey) {\n    const map = new Map(items.map((a) => [a.id, statusOf(a)]));\n    const news: string[] = [];\n    for (const a of items) {\n      const was = prev.map.get(a.id);\n      const now = map.get(a.id);\n      if (was && was !== now && now === \"done\") news.push(`${a.name} uploaded`);\n      if (was !== now && now === \"error\") news.push(`${a.name} failed to upload${a.error ? `: ${a.error}` : \"\"}`);\n    }\n    // Items that arrived since last time pop in with a small stagger.\n    const ids = new Set(items.filter((a) => !prev.map.has(a.id)).map((a) => a.id));\n    setPrev({ key: statusKey, map, ids });\n    if (news.length) setSaid(news.join(\". \"));\n  }\n\n  // Fade the edges only where there is more to scroll to.\n  useEffect(() => {\n    const el = scroller.current;\n    if (!el) return;\n    const measure = () => {\n      const start = el.scrollLeft > 1;\n      const end = el.scrollLeft + el.clientWidth < el.scrollWidth - 1;\n      setEdges((e) => (e.start === start && e.end === end ? e : { start, end }));\n    };\n    const ro = new ResizeObserver(measure);\n    ro.observe(el);\n    Array.from(el.children).forEach((c) => ro.observe(c));\n    el.addEventListener(\"scroll\", measure, { passive: true });\n    return () => {\n      ro.disconnect();\n      el.removeEventListener(\"scroll\", measure);\n    };\n  }, [items.length]);\n\n  // Newly added items scroll into view in the tray (never the page).\n  const lastId = items[items.length - 1]?.id;\n  useEffect(() => {\n    const el = scroller.current;\n    if (!el || !lastId || !prev.ids.has(lastId)) return;\n    el.scrollTo({ left: el.scrollWidth, behavior: reduce ? \"auto\" : \"smooth\" });\n  }, [lastId, prev.ids, reduce]);\n\n  const tabbable = (active && items.some((a) => a.id === active) && active) || items[0]?.id;\n  const focusItem = (id: string | undefined) => {\n    if (!id) return;\n    requestAnimationFrame(() => scroller.current?.querySelector<HTMLElement>(`[data-item=\"${CSS.escape(id)}\"]`)?.focus());\n  };\n\n  const onKeyDown = (e: React.KeyboardEvent, a: Attachment, i: number) => {\n    const move = (to: number) => {\n      if (to < 0 || to >= items.length || !onReorder) return;\n      const next = [...items];\n      next.splice(i, 1);\n      next.splice(to, 0, a);\n      onReorder(next);\n      setSaid(`${a.name} moved to position ${to + 1} of ${items.length}`);\n      focusItem(a.id);\n    };\n    const go = (to: number) => {\n      const t = items[Math.max(0, Math.min(items.length - 1, to))];\n      if (t) scroller.current?.querySelector<HTMLElement>(`[data-item=\"${CSS.escape(t.id)}\"]`)?.focus();\n    };\n    const handled = () => {\n      e.preventDefault();\n      e.stopPropagation();\n    };\n    if (e.key === \"ArrowLeft\" || e.key === \"ArrowRight\") {\n      handled();\n      const d = e.key === \"ArrowLeft\" ? -1 : 1;\n      if (e.altKey) move(i + d);\n      else go(i + d);\n    } else if (e.key === \"Home\" || e.key === \"End\") {\n      handled();\n      if (e.altKey) move(e.key === \"Home\" ? 0 : items.length - 1);\n      else go(e.key === \"Home\" ? 0 : items.length - 1);\n    } else if ((e.key === \"Delete\" || e.key === \"Backspace\") && onRemove) {\n      handled();\n      const neighbour = items[i + 1] ?? items[i - 1];\n      setSaid(`${a.name} removed`);\n      onRemove(a.id);\n      focusItem(neighbour?.id);\n    } else if (e.key === \"Enter\" && statusOf(a) === \"error\" && onRetry) {\n      handled();\n      onRetry(a.id);\n    }\n  };\n\n  const freshIds = [...prev.ids];\n\n  return (\n    <div className={cn(\"relative min-w-0\", className)} {...rest}>\n      <Reorder.Group\n        ref={scroller}\n        as=\"ul\"\n        axis=\"x\"\n        values={items}\n        onReorder={(next) => onReorder?.(next)}\n        aria-label={label}\n        className={cn(\n          \"flex items-center gap-2 overflow-x-auto overscroll-x-contain py-2 pl-2 pr-2 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden\",\n          // The edges fade only where there is more to scroll to. A mask, so it works on any surface.\n          edges.start && edges.end && \"[mask-image:linear-gradient(to_right,transparent,var(--fg)_24px,var(--fg)_calc(100%-24px),transparent)]\",\n          edges.start && !edges.end && \"[mask-image:linear-gradient(to_right,transparent,var(--fg)_24px)]\",\n          !edges.start && edges.end && \"[mask-image:linear-gradient(to_left,transparent,var(--fg)_24px)]\",\n        )}\n      >\n        <AnimatePresence initial={false} mode=\"popLayout\">\n          {items.map((a, i) => (\n            <Item\n              key={a.id}\n              a={a}\n              index={i}\n              count={items.length}\n              enterDelay={reduce ? 0 : Math.min(Math.max(freshIds.indexOf(a.id), 0), 8) * 0.03}\n              reduce={reduce}\n              tabbable={tabbable === a.id}\n              dragging={dragging === a.id}\n              reorderable={!!onReorder && items.length > 1}\n              bounds={scroller}\n              onDragChange={(d) => setDragging(d ? a.id : null)}\n              onFocus={() => setActive(a.id)}\n              onKeyDown={(e) => onKeyDown(e, a, i)}\n              onRemove={\n                onRemove &&\n                (() => {\n                  const neighbour = items[i + 1] ?? items[i - 1];\n                  setSaid(`${a.name} removed`);\n                  onRemove(a.id);\n                  focusItem(neighbour?.id);\n                })\n              }\n              onRetry={onRetry && (() => onRetry(a.id))}\n            />\n          ))}\n        </AnimatePresence>\n      </Reorder.Group>\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {said}\n      </span>\n    </div>\n  );\n}\n\n/* -------------------------------------------------------------------------------------------------\n * Item\n * -----------------------------------------------------------------------------------------------*/\n\ntype ItemProps = {\n  a: Attachment;\n  bounds: React.RefObject<HTMLUListElement | null>;\n  index: number;\n  count: number;\n  enterDelay: number;\n  reduce: boolean;\n  tabbable: boolean;\n  dragging: boolean;\n  reorderable: boolean;\n  onDragChange: (dragging: boolean) => void;\n  onFocus: () => void;\n  onKeyDown: (e: React.KeyboardEvent) => void;\n  onRemove?: () => void;\n  onRetry?: () => void;\n};\n\nfunction Item({ a, bounds, index, count, enterDelay, reduce, tabbable, dragging, reorderable, onDragChange, onFocus, onKeyDown, onRemove, onRetry }: ItemProps) {\n  const controls = useDragControls();\n  const status = statusOf(a);\n  const image = isImage(a);\n  const pct = a.progress !== undefined ? Math.round(a.progress * 100) : undefined;\n  const spoken = [\n    a.name,\n    a.size !== undefined ? formatBytes(a.size) : \"\",\n    status === \"uploading\" ? (pct !== undefined ? `uploading, ${pct}%` : \"uploading\") : status === \"error\" ? `failed${a.error ? `: ${a.error}` : \"\"}` : \"\",\n    `${index + 1} of ${count}`,\n  ]\n    .filter(Boolean)\n    .join(\", \");\n  const hint = [onRemove && \"Delete to remove\", reorderable && \"Alt and arrow keys to move\", status === \"error\" && onRetry && \"Enter to retry\"].filter(Boolean).join(\". \");\n\n  return (\n    <Reorder.Item\n      as=\"li\"\n      value={a}\n      data-item={a.id}\n      data-status={status}\n      data-dragging={dragging || undefined}\n      tabIndex={tabbable ? 0 : -1}\n      aria-label={spoken}\n      aria-description={hint || undefined}\n      dragListener={false}\n      dragControls={controls}\n      // Stays inside the tray, with a little give at the ends.\n      dragConstraints={bounds}\n      dragElastic={0.08}\n      onPointerDown={(e) => {\n        // Mouse and pen pick items up; on touch the tray scrolls instead.\n        if (!reorderable || e.pointerType === \"touch\" || (e.target as HTMLElement).closest(\"button\")) return;\n        controls.start(e);\n      }}\n      onDragStart={() => onDragChange(true)}\n      onDragEnd={() => onDragChange(false)}\n      onFocus={(e) => e.target === e.currentTarget && onFocus()}\n      onKeyDown={(e) => e.target === e.currentTarget && onKeyDown(e)}\n      initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.8, filter: \"blur(4px)\" }}\n      animate={{ opacity: 1, scale: 1, filter: \"blur(0px)\", transition: reduce ? { duration: 0.15 } : { ...spring.pop, delay: enterDelay } }}\n      exit={reduce ? { opacity: 0, transition: { duration: 0.1 } } : { opacity: 0, scale: 0.8, filter: \"blur(3px)\", transition: { duration: 0.14, ease: ease.in } }}\n      whileDrag={reduce ? undefined : { scale: 1.05 }}\n      transition={{ layout: reduce ? { duration: 0 } : spring.soft }}\n      className={cn(\n        \"group/item relative shrink-0 rounded-lg outline-none select-none\",\n        \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n        reorderable && \"pointer-fine:cursor-grab\",\n        dragging && \"z-10 cursor-grabbing shadow-pop\",\n      )}\n    >\n      {image ? <Thumb a={a} status={status} reduce={reduce} onRetry={onRetry} /> : <Chip a={a} status={status} pct={pct} reduce={reduce} onRetry={onRetry} />}\n      {onRemove && (\n        <button\n          type=\"button\"\n          tabIndex={-1}\n          aria-label={`Remove ${a.name}`}\n          onClick={onRemove}\n          className={cn(\n            \"absolute -right-1.5 -top-1.5 grid size-5 place-items-center rounded-full border border-line-2 bg-raised text-fg-2 shadow-[var(--shadow)]\",\n            \"transition-[opacity,scale,color,background-color] duration-150 ease-out hover:bg-hover hover:text-fg active:scale-90\",\n            // On touch it is always there, with a 44px target; with a mouse it waits for hover or focus.\n            \"before:absolute before:-inset-3 before:content-[''] pointer-fine:before:-inset-1\",\n            \"pointer-fine:scale-90 pointer-fine:opacity-0 pointer-fine:group-hover/item:scale-100 pointer-fine:group-hover/item:opacity-100\",\n            \"pointer-fine:group-focus-visible/item:scale-100 pointer-fine:group-focus-visible/item:opacity-100\",\n            dragging && \"invisible\",\n          )}\n        >\n          <X size={10} strokeWidth={2} />\n        </button>\n      )}\n    </Reorder.Item>\n  );\n}\n\nfunction Ring({ progress, reduce, size = 22 }: { progress?: number; reduce: boolean; size?: number }) {\n  const indeterminate = progress === undefined;\n  return (\n    <svg width={size} height={size} viewBox=\"0 0 24 24\" aria-hidden className={cn(indeterminate && \"animate-spin-slow\")}>\n      <circle cx=\"12\" cy=\"12\" r=\"9\" fill=\"none\" stroke=\"currentColor\" strokeOpacity={0.22} strokeWidth=\"2.5\" />\n      <motion.circle\n        cx=\"12\"\n        cy=\"12\"\n        r=\"9\"\n        fill=\"none\"\n        stroke=\"currentColor\"\n        strokeWidth=\"2.5\"\n        strokeLinecap=\"round\"\n        transform=\"rotate(-90 12 12)\"\n        initial={false}\n        animate={{ pathLength: indeterminate ? 0.28 : Math.max(0.02, progress) }}\n        transition={reduce ? { duration: 0 } : { type: \"spring\", stiffness: 140, damping: 24 }}\n      />\n    </svg>\n  );\n}\n\nfunction Thumb({ a, status, reduce, onRetry }: { a: Attachment; status: AttachmentStatus; reduce: boolean; onRetry?: () => void }) {\n  const [loaded, setLoaded] = useState(false);\n  const uploading = status === \"uploading\";\n  const failed = status === \"error\";\n  return (\n    <div className={cn(\"relative size-16 overflow-hidden rounded-lg border bg-fg/[0.05] transition-colors duration-200\", failed ? \"border-danger/50\" : \"border-line-2\")}>\n      {!a.preview && (\n        <span className=\"absolute inset-0 grid place-items-center text-fg-4\">\n          <ImageIcon size={18} />\n        </span>\n      )}\n      {/* eslint-disable-next-line @next/next/no-img-element */}\n      {a.preview && <img\n        src={a.preview}\n        alt=\"\"\n        width={64}\n        height={64}\n        draggable={false}\n        onLoad={() => setLoaded(true)}\n        className={cn(\n          \"size-full object-cover transition-[opacity,filter,scale] duration-500 ease-out-quart\",\n          // It develops as it uploads: dim and soft until the bytes are safe, then sharp.\n          !loaded ? \"opacity-0\" : uploading ? \"scale-[1.04] opacity-50 blur-[1px]\" : failed ? \"opacity-40 grayscale\" : \"opacity-100\",\n        )}\n      />}\n      <AnimatePresence initial={false}>\n        {uploading && (\n          <motion.span\n            key=\"ring\"\n            className=\"absolute inset-0 grid place-items-center\"\n            initial={{ opacity: 0 }}\n            animate={{ opacity: 1 }}\n            exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 1.3, transition: { duration: 0.25, ease: ease.out } }}\n          >\n            <span className=\"grid size-8 place-items-center rounded-full bg-page/70 text-fg\">\n              <Ring progress={a.progress} reduce={reduce} />\n            </span>\n          </motion.span>\n        )}\n        {failed && (\n          <motion.span key=\"error\" className=\"absolute inset-0 grid place-items-center bg-danger-soft\" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}>\n            {onRetry ? (\n              <button\n                type=\"button\"\n                tabIndex={-1}\n                aria-label={`Retry ${a.name}`}\n                onClick={onRetry}\n                className=\"grid size-8 place-items-center rounded-full bg-page/80 text-danger transition-[scale,background-color] duration-150 hover:bg-page active:scale-90\"\n              >\n                <Refresh size={14} />\n              </button>\n            ) : (\n              <span className=\"grid size-8 place-items-center rounded-full bg-page/80 text-danger\">\n                <Alert size={14} />\n              </span>\n            )}\n          </motion.span>\n        )}\n      </AnimatePresence>\n    </div>\n  );\n}\n\nfunction Chip({ a, status, pct, reduce, onRetry }: { a: Attachment; status: AttachmentStatus; pct?: number; reduce: boolean; onRetry?: () => void }) {\n  const [base, ext] = split(a.name);\n  const failed = status === \"error\";\n  const uploading = status === \"uploading\";\n  const swap = {\n    initial: reduce ? { opacity: 0 } : { opacity: 0, scale: 0.6, filter: \"blur(2px)\" },\n    animate: { opacity: 1, scale: 1, filter: \"blur(0px)\" },\n    exit: reduce ? { opacity: 0 } : { opacity: 0, scale: 0.6, filter: \"blur(2px)\" },\n    transition: reduce ? { duration: 0.12 } : spring.pop,\n  };\n  const line = {\n    initial: reduce ? { opacity: 0 } : { opacity: 0, y: 4 },\n    animate: { opacity: 1, y: 0 },\n    exit: reduce ? { opacity: 0, transition: { duration: 0.08 } } : { opacity: 0, y: -4, transition: { duration: 0.1 } },\n    transition: { duration: 0.18, ease: ease.out },\n  };\n\n  return (\n    <div\n      className={cn(\n        \"relative flex h-16 w-[216px] items-center gap-2.5 overflow-hidden rounded-lg border bg-raised pl-2.5 pr-3 transition-colors duration-200\",\n        failed ? \"border-danger/50\" : \"border-line-2\",\n      )}\n    >\n      <span className={cn(\"relative grid size-9 shrink-0 place-items-center rounded-md transition-colors duration-200\", failed ? \"bg-danger-soft text-danger\" : \"bg-fg/[0.06] text-fg-2\")}>\n        <AnimatePresence initial={false} mode=\"popLayout\">\n          <motion.span key={status} className=\"grid place-items-center\" {...swap}>\n            {uploading ? (\n              <Ring progress={a.progress} reduce={reduce} size={20} />\n            ) : failed ? (\n              <Alert />\n            ) : (\n              <span className=\"font-mono text-[9.5px] font-medium uppercase tracking-[0.04em]\">{(ext.slice(1) || \"file\").slice(0, 4)}</span>\n            )}\n          </motion.span>\n        </AnimatePresence>\n      </span>\n\n      <span className=\"flex min-w-0 flex-1 flex-col gap-0.5\">\n        {/* Names truncate in the middle of the stem, so the extension always shows. */}\n        <span className=\"flex min-w-0 text-[12.5px] font-medium leading-4 text-fg\" title={a.name}>\n          <span className=\"truncate\">{base}</span>\n          <span className=\"shrink-0\">{ext}</span>\n        </span>\n        <span className=\"relative grid h-4 text-[11.5px] leading-4\">\n          <AnimatePresence initial={false} mode=\"popLayout\">\n            <motion.span key={status} className=\"col-start-1 row-start-1 flex min-w-0 items-center gap-1 whitespace-nowrap\" {...line}>\n              {uploading ? (\n                <span className=\"text-fg-3 tabular\">\n                  {pct !== undefined ? (\n                    <>\n                      <NumberFlow value={a.progress ?? 0} format={{ style: \"percent\", maximumFractionDigits: 0 }} animated={!reduce} />\n                      {a.size !== undefined && <span suppressHydrationWarning> of {formatBytes(a.size)}</span>}\n                    </>\n                  ) : (\n                    \"Uploading…\"\n                  )}\n                </span>\n              ) : failed ? (\n                <>\n                  <span className=\"truncate text-danger\">{a.error ?? \"Upload failed\"}</span>\n                  {onRetry && (\n                    <button\n                      type=\"button\"\n                      tabIndex={-1}\n                      onClick={onRetry}\n                      className=\"relative shrink-0 font-medium text-fg underline decoration-fg/30 underline-offset-2 transition-[text-decoration-color] duration-150 before:absolute before:-inset-x-1 before:-inset-y-3 before:content-[''] hover:decoration-fg\"\n                    >\n                      Retry\n                    </button>\n                  )}\n                </>\n              ) : (\n                <span className=\"text-fg-3 tabular\" suppressHydrationWarning>\n                  {a.size !== undefined ? formatBytes(a.size) : \"Ready\"}\n                </span>\n              )}\n            </motion.span>\n          </AnimatePresence>\n        </span>\n      </span>\n\n      {/* A hairline of progress along the bottom edge; it drains away once the upload lands. */}\n      <AnimatePresence initial={false}>\n        {uploading && a.progress !== undefined && (\n          <motion.span\n            key=\"bar\"\n            aria-hidden\n            className=\"absolute inset-x-0 bottom-0 h-0.5 origin-left bg-fg\"\n            initial={{ scaleX: a.progress, opacity: 1 }}\n            animate={{ scaleX: a.progress, opacity: 1 }}\n            exit={{ scaleX: 1, opacity: 0, transition: { duration: reduce ? 0.1 : 0.35, ease: ease.out } }}\n            transition={reduce ? { duration: 0 } : { type: \"spring\", stiffness: 140, damping: 24 }}\n          />\n        )}\n      </AnimatePresence>\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/attachment-tray"
}
