{"version":1,"id":"dropzone","framework":"next","kind":"block","files":[{"path":"src/blocks/gfa/dropzone.tsx","content":"\"use client\";\n\nimport { useRef, useState, type DragEvent, type ChangeEvent } from \"react\";\nimport {\n  AnimatePresence,\n  LayoutGroup,\n  motion,\n  useReducedMotion,\n} from \"motion/react\";\nimport { ArrowDown, FileIcon, ImageIcon, Upload, X } from \"lucide-react\";\nimport {\n  EASE_OUT,\n  SPRING_LAYOUT,\n  SPRING_PANEL,\n  SPRING_PRESS,\n  SPRING_SWAP,\n} from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport type DropzoneFile = {\n  id: string;\n  name: string;\n  size: number;\n  type: string;\n  file: File;\n};\n\nexport type DropzoneProps = {\n  accept?: string;\n  multiple?: boolean;\n  maxSizeMb?: number;\n  className?: string;\n  onFilesChange?: (files: DropzoneFile[]) => void;\n};\n\nconst APPLE_SANS =\n  \"font-[system-ui,-apple-system,BlinkMacSystemFont,'SF_Pro_Text','SF_Pro_Display',sans-serif]\";\n\nfunction formatBytes(n: number) {\n  if (n < 1024) return `${n} B`;\n  if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;\n  return `${(n / (1024 * 1024)).toFixed(1)} MB`;\n}\n\nfunction isImage(type: string) {\n  return type.startsWith(\"image/\");\n}\n\nexport function Dropzone({\n  accept = \"image/*,.pdf,.doc,.docx,.txt\",\n  multiple = true,\n  maxSizeMb = 10,\n  className,\n  onFilesChange,\n}: DropzoneProps) {\n  const reduce = useReducedMotion();\n  const inputRef = useRef<HTMLInputElement>(null);\n  const [dragging, setDragging] = useState(false);\n  const [justDropped, setJustDropped] = useState(false);\n  const [files, setFiles] = useState<DropzoneFile[]>([]);\n  const [error, setError] = useState<string | null>(null);\n  const dragDepth = useRef(0);\n\n  const commit = (next: DropzoneFile[]) => {\n    setFiles(next);\n    onFilesChange?.(next);\n  };\n\n  const flashDrop = () => {\n    if (reduce) return;\n    setJustDropped(true);\n    window.setTimeout(() => setJustDropped(false), 520);\n  };\n\n  const ingest = (list: FileList | File[]) => {\n    const incoming = Array.from(list);\n    if (!incoming.length) return;\n\n    const maxBytes = maxSizeMb * 1024 * 1024;\n    const accepted: DropzoneFile[] = [];\n    let rejected: string | null = null;\n\n    for (const file of incoming) {\n      if (file.size > maxBytes) {\n        rejected = `“${file.name}” exceeds ${maxSizeMb} MB.`;\n        continue;\n      }\n      accepted.push({\n        id: `${file.name}-${file.size}-${file.lastModified}-${Math.random().toString(36).slice(2, 7)}`,\n        name: file.name,\n        size: file.size,\n        type: file.type || \"application/octet-stream\",\n        file,\n      });\n    }\n\n    if (!accepted.length && rejected) {\n      setError(rejected);\n      return;\n    }\n\n    setError(rejected);\n    const merged = multiple ? [...files, ...accepted] : accepted.slice(0, 1);\n    const seen = new Set<string>();\n    const unique = merged.filter((f) => {\n      const key = `${f.name}:${f.size}`;\n      if (seen.has(key)) return false;\n      seen.add(key);\n      return true;\n    });\n    if (accepted.length) flashDrop();\n    commit(unique);\n  };\n\n  const onDragEnter = (e: DragEvent) => {\n    e.preventDefault();\n    e.stopPropagation();\n    dragDepth.current += 1;\n    setDragging(true);\n  };\n\n  const onDragLeave = (e: DragEvent) => {\n    e.preventDefault();\n    e.stopPropagation();\n    dragDepth.current = Math.max(0, dragDepth.current - 1);\n    if (dragDepth.current === 0) setDragging(false);\n  };\n\n  const onDragOver = (e: DragEvent) => {\n    e.preventDefault();\n    e.stopPropagation();\n  };\n\n  const onDrop = (e: DragEvent) => {\n    e.preventDefault();\n    e.stopPropagation();\n    dragDepth.current = 0;\n    setDragging(false);\n    if (e.dataTransfer.files?.length) ingest(e.dataTransfer.files);\n  };\n\n  const onPick = (e: ChangeEvent<HTMLInputElement>) => {\n    if (e.target.files?.length) ingest(e.target.files);\n    e.target.value = \"\";\n  };\n\n  const remove = (id: string) => {\n    commit(files.filter((f) => f.id !== id));\n  };\n\n  return (\n    <motion.div\n      data-slot=\"dropzone\"\n      className={cn(\"w-full max-w-[440px]\", APPLE_SANS, className)}\n      initial={reduce ? false : { opacity: 0, y: 16, scale: 0.98 }}\n      animate={{ opacity: 1, y: 0, scale: 1 }}\n      transition={reduce ? { duration: 0 } : SPRING_PANEL}\n    >\n      <motion.div\n        role=\"button\"\n        tabIndex={0}\n        onKeyDown={(e) => {\n          if (e.key === \"Enter\" || e.key === \" \") {\n            e.preventDefault();\n            inputRef.current?.click();\n          }\n        }}\n        onClick={() => inputRef.current?.click()}\n        onDragEnter={onDragEnter}\n        onDragLeave={onDragLeave}\n        onDragOver={onDragOver}\n        onDrop={onDrop}\n        className={cn(\n          \"relative flex cursor-pointer flex-col items-center justify-center gap-3.5 overflow-hidden rounded-[18px] border border-dashed px-6 py-11 text-center transition-[background-color,border-color,box-shadow] duration-200\",\n          \"border-black/15 bg-white shadow-[0_1px_2px_rgba(0,0,0,0.04)]\",\n          \"hover:border-black/28 hover:bg-[#fafafa]\",\n          \"dark:border-white/15 dark:bg-[#1c1c1e] dark:shadow-[0_1px_2px_rgba(0,0,0,0.35)]\",\n          \"dark:hover:border-white/30 dark:hover:bg-[#232326]\",\n          \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1d1d1f]/25 dark:focus-visible:ring-white/30\",\n          dragging &&\n            \"border-[#1d1d1f]/55 bg-[#f5f5f7] shadow-[0_0_0_4px_rgba(29,29,31,0.06),0_8px_28px_rgba(0,0,0,0.08)] dark:border-white/50 dark:bg-[#2c2c2e] dark:shadow-[0_0_0_4px_rgba(255,255,255,0.06)]\",\n        )}\n        aria-label=\"Upload files\"\n        initial={false}\n        animate={\n          reduce\n            ? undefined\n            : { scale: justDropped ? 1.02 : dragging ? 1.015 : 1 }\n        }\n        whileTap={reduce ? undefined : { scale: 0.985 }}\n        transition={SPRING_PRESS}\n      >\n        <input\n          ref={inputRef}\n          type=\"file\"\n          className=\"sr-only\"\n          accept={accept}\n          multiple={multiple}\n          onChange={onPick}\n        />\n\n        <AnimatePresence>\n          {dragging && !reduce ? (\n            <motion.span\n              key=\"sweep\"\n              aria-hidden\n              className=\"pointer-events-none absolute inset-0 bg-[linear-gradient(110deg,transparent_25%,rgba(255,255,255,0.7)_50%,transparent_75%)] dark:bg-[linear-gradient(110deg,transparent_25%,rgba(255,255,255,0.06)_50%,transparent_75%)]\"\n              initial={{ x: \"-120%\", opacity: 0 }}\n              animate={{ x: \"120%\", opacity: 1 }}\n              exit={{ opacity: 0 }}\n              transition={{\n                x: { duration: 1.15, repeat: Infinity, ease: \"linear\" },\n                opacity: { duration: 0.2 },\n              }}\n            />\n          ) : null}\n        </AnimatePresence>\n\n        <AnimatePresence>\n          {(dragging || justDropped) && !reduce ? (\n            <motion.span\n              key=\"ring\"\n              aria-hidden\n              className=\"pointer-events-none absolute inset-3 rounded-[14px] border border-[#1d1d1f]/12 dark:border-white/15\"\n              initial={{ opacity: 0, scale: 0.92 }}\n              animate={{\n                opacity: justDropped ? [0.5, 0] : [0.28, 0.08, 0.28],\n                scale: justDropped ? [0.96, 1.04] : [0.98, 1.02, 0.98],\n              }}\n              exit={{ opacity: 0, scale: 1.04 }}\n              transition={\n                justDropped\n                  ? { duration: 0.45, ease: EASE_OUT }\n                  : { duration: 1.6, repeat: Infinity, ease: \"easeInOut\" }\n              }\n            />\n          ) : null}\n        </AnimatePresence>\n\n        <div className=\"relative\">\n          {!reduce && !dragging ? (\n            <>\n              <motion.span\n                aria-hidden\n                className=\"absolute -top-1 -right-1 size-1.5 rounded-full bg-[#1d1d1f]/30 dark:bg-white/35\"\n                animate={{ y: [0, -5, 0], opacity: [0.3, 0.75, 0.3] }}\n                transition={{ duration: 2.4, repeat: Infinity, ease: \"easeInOut\" }}\n              />\n              <motion.span\n                aria-hidden\n                className=\"absolute -bottom-0.5 -left-1.5 size-1 rounded-full bg-[#1d1d1f]/20 dark:bg-white/25\"\n                animate={{ y: [0, 4, 0], opacity: [0.2, 0.65, 0.2] }}\n                transition={{\n                  duration: 2.8,\n                  repeat: Infinity,\n                  ease: \"easeInOut\",\n                  delay: 0.4,\n                }}\n              />\n            </>\n          ) : null}\n\n          <motion.span\n            className={cn(\n              \"relative z-[1] flex size-12 items-center justify-center rounded-[14px] text-white shadow-[0_1px_2px_rgba(0,0,0,0.12)]\",\n              \"bg-[#1d1d1f] dark:bg-[#f5f5f7] dark:text-[#1d1d1f]\",\n            )}\n            animate={\n              reduce\n                ? undefined\n                : dragging\n                  ? { y: [0, -6, 0], scale: 1.08, rotate: [0, -4, 4, 0] }\n                  : { y: [0, -3, 0], scale: 1, rotate: 0 }\n            }\n            transition={\n              dragging\n                ? {\n                    y: { duration: 0.9, repeat: Infinity, ease: \"easeInOut\" },\n                    rotate: { duration: 0.9, repeat: Infinity },\n                    scale: SPRING_SWAP,\n                  }\n                : {\n                    y: { duration: 2.6, repeat: Infinity, ease: \"easeInOut\" },\n                    scale: SPRING_SWAP,\n                    rotate: SPRING_SWAP,\n                  }\n            }\n          >\n            <AnimatePresence mode=\"wait\" initial={false}>\n              {dragging ? (\n                <motion.span\n                  key=\"down\"\n                  initial={reduce ? false : { opacity: 0, y: -8, scale: 0.8 }}\n                  animate={{ opacity: 1, y: 0, scale: 1 }}\n                  exit={reduce ? undefined : { opacity: 0, y: 8, scale: 0.8 }}\n                  transition={SPRING_SWAP}\n                >\n                  <ArrowDown className=\"size-5\" strokeWidth={2.25} />\n                </motion.span>\n              ) : (\n                <motion.span\n                  key=\"up\"\n                  initial={reduce ? false : { opacity: 0, y: 8, scale: 0.8 }}\n                  animate={{ opacity: 1, y: 0, scale: 1 }}\n                  exit={reduce ? undefined : { opacity: 0, y: -8, scale: 0.8 }}\n                  transition={SPRING_SWAP}\n                >\n                  <Upload className=\"size-5\" strokeWidth={2} />\n                </motion.span>\n              )}\n            </AnimatePresence>\n          </motion.span>\n        </div>\n\n        <div className=\"relative z-[1] min-h-[2.75rem] space-y-1\">\n          <AnimatePresence mode=\"wait\" initial={false}>\n            <motion.p\n              key={dragging ? \"drop\" : \"idle\"}\n              className=\"text-[17px] font-semibold tracking-[-0.02em] text-[#1d1d1f] dark:text-[#f5f5f7]\"\n              initial={reduce ? false : { opacity: 0, y: 6 }}\n              animate={{ opacity: 1, y: 0 }}\n              exit={reduce ? undefined : { opacity: 0, y: -6 }}\n              transition={{ duration: 0.18, ease: EASE_OUT }}\n            >\n              {dragging ? \"Drop files here\" : \"Drag & drop files\"}\n            </motion.p>\n          </AnimatePresence>\n          <motion.p\n            className=\"text-[13px] text-[#6e6e73] dark:text-[#98989d]\"\n            animate={reduce ? undefined : { opacity: dragging ? 0.5 : 1 }}\n            transition={{ duration: 0.2 }}\n          >\n            or{\" \"}\n            <span className=\"font-medium text-[#1d1d1f] underline-offset-2 hover:underline dark:text-[#f5f5f7]\">\n              browse\n            </span>{\" \"}\n            · up to {maxSizeMb} MB\n          </motion.p>\n        </div>\n\n        <p className=\"relative z-[1] text-[11px] font-medium tracking-[0.02em] text-[#8e8e93]\">\n          Images, PDF, DOC, TXT\n        </p>\n      </motion.div>\n\n      <AnimatePresence>\n        {error ? (\n          <motion.p\n            key=\"err\"\n            className=\"mt-2 text-center text-[13px] text-[#ff3b30]\"\n            role=\"alert\"\n            initial={reduce ? false : { opacity: 0, y: -4 }}\n            animate={{ opacity: 1, y: 0 }}\n            exit={reduce ? undefined : { opacity: 0, y: -4 }}\n          >\n            {error}\n          </motion.p>\n        ) : null}\n      </AnimatePresence>\n\n      <LayoutGroup>\n        <AnimatePresence initial={false}>\n          {files.length > 0 ? (\n            <motion.ul\n              layout\n              initial={reduce ? false : { opacity: 0, height: 0 }}\n              animate={{ opacity: 1, height: \"auto\" }}\n              exit={\n                reduce\n                  ? undefined\n                  : { opacity: 0, height: 0, transition: { duration: 0.2 } }\n              }\n              transition={SPRING_LAYOUT}\n              className=\"mt-3 overflow-hidden rounded-[14px] border border-black/8 bg-white dark:border-white/10 dark:bg-[#1c1c1e]\"\n            >\n              <AnimatePresence initial={false} mode=\"popLayout\">\n                {files.map((f, i) => (\n                  <motion.li\n                    key={f.id}\n                    layout\n                    initial={\n                      reduce\n                        ? false\n                        : { opacity: 0, y: 12, scale: 0.98 }\n                    }\n                    animate={{ opacity: 1, y: 0, scale: 1 }}\n                    exit={\n                      reduce\n                        ? undefined\n                        : {\n                            opacity: 0,\n                            x: 20,\n                            scale: 0.98,\n                            transition: { duration: 0.2, ease: EASE_OUT },\n                          }\n                    }\n                    transition={{\n                      ...SPRING_SWAP,\n                      delay: reduce ? 0 : Math.min(i * 0.04, 0.2),\n                    }}\n                    className={cn(\n                      \"relative flex items-center gap-3 overflow-hidden px-3.5 py-2.5\",\n                      i > 0 && \"border-t border-black/[0.06] dark:border-white/[0.08]\",\n                    )}\n                  >\n                    {!reduce ? (\n                      <motion.span\n                        aria-hidden\n                        className=\"pointer-events-none absolute inset-x-0 bottom-0 h-[2px] origin-left bg-[#1d1d1f]/20 dark:bg-white/25\"\n                        initial={{ scaleX: 0 }}\n                        animate={{ scaleX: 1 }}\n                        transition={{\n                          duration: 0.55,\n                          ease: EASE_OUT,\n                          delay: 0.05,\n                        }}\n                      />\n                    ) : null}\n\n                    <motion.span\n                      className=\"flex size-9 shrink-0 items-center justify-center rounded-full bg-[#e8e8ed] text-[#1d1d1f] dark:bg-[#3a3a3c] dark:text-[#f5f5f7]\"\n                      aria-hidden\n                      initial={reduce ? false : { scale: 0.7, rotate: -10 }}\n                      animate={{ scale: 1, rotate: 0 }}\n                      transition={SPRING_SWAP}\n                    >\n                      {isImage(f.type) ? (\n                        <ImageIcon className=\"size-4\" strokeWidth={1.75} />\n                      ) : (\n                        <FileIcon className=\"size-4\" strokeWidth={1.75} />\n                      )}\n                    </motion.span>\n                    <div className=\"min-w-0 flex-1\">\n                      <p className=\"truncate text-[15px] font-medium tracking-tight text-[#1d1d1f] dark:text-[#f5f5f7]\">\n                        {f.name}\n                      </p>\n                      <p className=\"text-[12px] text-[#8e8e93]\">\n                        {formatBytes(f.size)}\n                      </p>\n                    </div>\n                    <motion.button\n                      type=\"button\"\n                      aria-label={`Remove ${f.name}`}\n                      className=\"flex size-7 items-center justify-center rounded-full text-[#8e8e93] transition hover:bg-black/[0.05] hover:text-[#1d1d1f] dark:hover:bg-white/[0.08] dark:hover:text-[#f5f5f7]\"\n                      whileHover={reduce ? undefined : { scale: 1.08 }}\n                      whileTap={reduce ? undefined : { scale: 0.9 }}\n                      onClick={(e) => {\n                        e.stopPropagation();\n                        remove(f.id);\n                      }}\n                    >\n                      <X className=\"size-3.5\" strokeWidth={2.25} />\n                    </motion.button>\n                  </motion.li>\n                ))}\n              </AnimatePresence>\n            </motion.ul>\n          ) : null}\n        </AnimatePresence>\n      </LayoutGroup>\n    </motion.div>\n  );\n}\n"}],"meta":{"studioName":"GFA Development & Design Studio","siteUrl":"https://ui.gkhn.dev"}}