{"$schema":"https://ui.shadcn.com/schema/registry-item.json","author":"Eduardo Calvo <educlopez93@gmail.com>","css":{},"dependencies":["motion","lucide-react"],"description":"A AppDownloadStack component for SmoothUI.","devDependencies":[],"files":[{"content":"\"use client\";\n\nimport { ChevronDown } from \"lucide-react\";\nimport {\n  AnimatePresence,\n  motion,\n  useAnimation,\n  useReducedMotion,\n} from \"motion/react\";\nimport { useCallback, useMemo, useState } from \"react\";\n\nexport interface AppData {\n  icon: string;\n  id: number;\n  name: string;\n}\n\nexport interface AppDownloadStackProps {\n  apps?: AppData[];\n  className?: string;\n  isExpanded?: boolean;\n  onChange?: (selected: number[]) => void;\n  onDownload?: (selected: number[]) => void;\n  onExpandChange?: (expanded: boolean) => void;\n  selectedApps?: number[];\n  title?: string;\n}\n\nconst DOWNLOAD_DURATION_MS = 3000;\nconst RESET_DELAY_MS = 1000;\nconst ROTATION_MULTIPLIER = 8;\nconst TRANSLATION_MULTIPLIER = 3;\nconst BASE_Z_INDEX = 40;\nconst Z_INDEX_STEP = 10;\nconst HOVER_X_MULTIPLIER = 10;\nconst HOVER_Y_MULTIPLIER = 10;\nconst FLOAT_AMPLITUDE = 5;\nconst FLOAT_DURATION = 2;\nconst FLOAT_DELAY_MULTIPLIER = 0.2;\nconst STAGGER_DELAY_MULTIPLIER = 0.1;\nconst TRANSITION_DURATION = 0.3;\nconst CHECKMARK_TRANSITION_DURATION = 0.3;\n\nconst defaultApps: AppData[] = [\n  {\n    icon: \"https://parsefiles.back4app.com/JPaQcFfEEQ1ePBxbf6wvzkPMEqKYHhPYv8boI1Rc/9c9721583ecba33e59ebcebdca2248fd_Mmr12FRh5V.png\",\n    id: 1,\n    name: \"GitHub\",\n  },\n  {\n    icon: \"https://parsefiles.back4app.com/JPaQcFfEEQ1ePBxbf6wvzkPMEqKYHhPYv8boI1Rc/b47f43e02f04563447fa90d4ff6c8943_9KzW5GTggQ.png\",\n    id: 2,\n    name: \"Canary\",\n  },\n  {\n    icon: \"https://parsefiles.back4app.com/JPaQcFfEEQ1ePBxbf6wvzkPMEqKYHhPYv8boI1Rc/f0b9cdefa67b57eeb080278c2f6984cc_sCqUJBg6Qq.png\",\n    id: 3,\n    name: \"Figma\",\n  },\n  {\n    icon: \"https://parsefiles.back4app.com/JPaQcFfEEQ1ePBxbf6wvzkPMEqKYHhPYv8boI1Rc/178c7b02003c933e6b5afe98bbee595b_low_res_Arc_Browser.png\",\n    id: 4,\n    name: \"Arc\",\n  },\n];\n\nexport default function AppDownloadStack({\n  apps = defaultApps,\n  title = \"Starter Mac\",\n  selectedApps: controlledSelected,\n  onChange,\n  onDownload,\n  isExpanded: controlledExpanded,\n  onExpandChange,\n  className = \"\",\n}: AppDownloadStackProps) {\n  const [internalExpanded, setInternalExpanded] = useState(false);\n  const [internalSelected, setInternalSelected] = useState<number[]>([]);\n  const [isDownloading, setIsDownloading] = useState(false);\n  const [downloadComplete, setDownloadComplete] = useState(false);\n  const shineControls = useAnimation();\n  const shouldReduceMotion = useReducedMotion();\n\n  const isExpanded =\n    controlledExpanded === undefined ? internalExpanded : controlledExpanded;\n  const selected = controlledSelected ?? internalSelected;\n\n  const setExpanded = (val: boolean) => {\n    if (onExpandChange) {\n      onExpandChange(val);\n    } else {\n      setInternalExpanded(val);\n    }\n  };\n\n  const toggleApp = useCallback(\n    (id: number) => {\n      const newSelected = selected.includes(id)\n        ? selected.filter((appId) => appId !== id)\n        : [...selected, id];\n      if (onChange) {\n        onChange(newSelected);\n      } else {\n        setInternalSelected(newSelected);\n      }\n    },\n    [selected, onChange]\n  );\n\n  const handleDownload = useCallback(() => {\n    setIsDownloading(true);\n    if (onDownload) {\n      onDownload(selected);\n    }\n    if (!shouldReduceMotion) {\n      shineControls.start({\n        transition: {\n          duration: 1,\n          ease: \"linear\",\n          repeat: Number.POSITIVE_INFINITY,\n        },\n        x: [\"0%\", \"100%\"],\n      });\n    }\n    setTimeout(() => {\n      shineControls.stop();\n      setDownloadComplete(true);\n      setTimeout(() => {\n        if (onExpandChange) {\n          onExpandChange(false);\n        } else {\n          setInternalExpanded(false);\n        }\n        if (onChange) {\n          onChange([]);\n        } else {\n          setInternalSelected([]);\n        }\n        setIsDownloading(false);\n        setDownloadComplete(false);\n      }, RESET_DELAY_MS);\n    }, DOWNLOAD_DURATION_MS);\n  }, [\n    shineControls,\n    selected,\n    onDownload,\n    onChange,\n    onExpandChange,\n    shouldReduceMotion,\n  ]);\n\n  const stackVariants = useMemo(\n    // biome-ignore lint/suspicious/noExplicitAny: Variants type requires flexible return\n    (): Record<string, (i: number) => any> => ({\n      float: (i: number) =>\n        shouldReduceMotion\n          ? { y: 0 }\n          : {\n              transition: {\n                y: {\n                  delay: i * FLOAT_DELAY_MULTIPLIER,\n                  duration: FLOAT_DURATION,\n                  ease: [0.645, 0.045, 0.355, 1],\n                  repeat: Number.POSITIVE_INFINITY,\n                },\n              },\n              y: [0, -FLOAT_AMPLITUDE, 0],\n            },\n      hover: (i: number) =>\n        shouldReduceMotion\n          ? {\n              rotate: 0,\n              x: 0,\n              y: 0,\n              zIndex: BASE_Z_INDEX - i * Z_INDEX_STEP,\n            }\n          : {\n              rotate: 0,\n              x: i * HOVER_X_MULTIPLIER,\n              y: -i * HOVER_Y_MULTIPLIER,\n              zIndex: BASE_Z_INDEX - i * Z_INDEX_STEP,\n            },\n      initial: (i: number) =>\n        shouldReduceMotion\n          ? {\n              rotate: 0,\n              x: 0,\n              y: 0,\n              zIndex: BASE_Z_INDEX - i * Z_INDEX_STEP,\n            }\n          : {\n              rotate:\n                i % 2 === 0\n                  ? -ROTATION_MULTIPLIER * (i + 1)\n                  : ROTATION_MULTIPLIER * (i + 1),\n              x:\n                i % 2 === 0\n                  ? -TRANSLATION_MULTIPLIER * (i + 1)\n                  : TRANSLATION_MULTIPLIER * (i + 1),\n              y: 0,\n              zIndex: BASE_Z_INDEX - i * Z_INDEX_STEP,\n            },\n    }),\n    [shouldReduceMotion]\n  );\n\n  return (\n    <div\n      className={`flex h-auto flex-col items-center justify-center ${className}`}\n    >\n      <motion.div\n        className=\"flex flex-col items-center justify-center\"\n        layout={!shouldReduceMotion}\n      >\n        <AnimatePresence mode=\"wait\">\n          {!(isExpanded || isDownloading) && (\n            <motion.button\n              aria-label=\"Expand app selection\"\n              className=\"group relative isolate flex h-16 w-16 cursor-pointer items-center justify-center\"\n              key=\"initial-stack\"\n              layout={!shouldReduceMotion}\n              onClick={() => setExpanded(true)}\n              whileHover={shouldReduceMotion ? {} : \"hover\"}\n            >\n              {apps.map((app, index) => (\n                <motion.img\n                  alt={`${app.name} Logo`}\n                  animate={\n                    shouldReduceMotion ? \"initial\" : [\"initial\", \"float\"]\n                  }\n                  className=\"absolute inset-0 rounded-xl border-none\"\n                  custom={index}\n                  draggable={false}\n                  height={64}\n                  initial=\"initial\"\n                  key={app.id}\n                  layoutId={\n                    shouldReduceMotion ? undefined : `app-icon-${app.id}`\n                  }\n                  src={app.icon}\n                  transition={\n                    shouldReduceMotion\n                      ? { duration: 0 }\n                      : { duration: TRANSITION_DURATION }\n                  }\n                  variants={stackVariants}\n                  whileHover={shouldReduceMotion ? {} : \"hover\"}\n                  width={64}\n                />\n              ))}\n            </motion.button>\n          )}\n\n          {isExpanded && !isDownloading && (\n            <motion.div\n              animate={\n                shouldReduceMotion ? { opacity: 1 } : { opacity: 1, scale: 1 }\n              }\n              className=\"flex flex-col items-center gap-2\"\n              exit={\n                shouldReduceMotion\n                  ? { opacity: 0, transition: { duration: 0 } }\n                  : { opacity: 0, scale: 0.8 }\n              }\n              initial={\n                shouldReduceMotion ? { opacity: 1 } : { opacity: 0, scale: 0.8 }\n              }\n              key=\"app-selector\"\n              layout={!shouldReduceMotion}\n            >\n              <button\n                className=\"flex w-full cursor-pointer items-center justify-between px-0.5\"\n                onClick={() => setExpanded(false)}\n                type=\"button\"\n              >\n                <p className=\"my-0 font-medium leading-0\">{title}</p>\n                <div className=\"flex items-center gap-1\">\n                  <p className=\"my-0 font-medium leading-0\">\n                    {selected.length}\n                  </p>\n                  <ChevronDown className=\"text-mauve-11\" size={16} />\n                </div>\n              </button>\n              <motion.ul className=\"grid grid-cols-2 gap-3\">\n                {apps.map((app, index) => (\n                  <motion.li\n                    animate={\n                      shouldReduceMotion ? { opacity: 1 } : { opacity: 1, y: 0 }\n                    }\n                    className=\"relative flex h-[80px] w-[80px]\"\n                    initial={\n                      shouldReduceMotion\n                        ? { opacity: 1 }\n                        : { opacity: 0, y: 20 }\n                    }\n                    key={app.id}\n                    transition={\n                      shouldReduceMotion\n                        ? { duration: 0 }\n                        : {\n                            delay: index * STAGGER_DELAY_MULTIPLIER,\n                            duration: 0.25,\n                          }\n                    }\n                  >\n                    <div\n                      className={`pointer-events-none absolute top-2 right-2 flex h-4 w-4 items-center justify-center rounded-full border border-solid ${\n                        selected.includes(app.id)\n                          ? \"border-blue-500 bg-blue-500\"\n                          : \"border-white/60\"\n                      }`}\n                    >\n                      {selected.includes(app.id) && (\n                        <motion.svg\n                          animate={{ pathLength: 1 }}\n                          className=\"z-1 h-3 w-3\"\n                          fill=\"none\"\n                          initial={{ pathLength: 0 }}\n                          stroke=\"white\"\n                          strokeLinecap=\"round\"\n                          strokeLinejoin=\"round\"\n                          strokeWidth=\"2\"\n                          transition={{\n                            duration: CHECKMARK_TRANSITION_DURATION,\n                          }}\n                          viewBox=\"0 0 24 24\"\n                          xmlns=\"http://www.w3.org/2000/svg\"\n                        >\n                          <title>Checkmark</title>\n                          <motion.path d=\"M5 13l4 4L19 7\" />\n                        </motion.svg>\n                      )}\n                    </div>\n                    <button\n                      className={`group flex h-full w-full flex-col items-center justify-center gap-1 rounded-xl border-2 border-transparent bg-background/80 p-2 transition-all duration-200 hover:border-blue-500 ${\n                        selected.includes(app.id)\n                          ? \"border-blue-500 bg-blue-500/10\"\n                          : \"\"\n                      }`}\n                      onClick={() => toggleApp(app.id)}\n                      type=\"button\"\n                    >\n                      <img\n                        alt={app.name}\n                        className=\"rounded-lg\"\n                        draggable={false}\n                        height={40}\n                        src={app.icon}\n                        width={40}\n                      />\n                      <span className=\"font-medium text-foreground text-xs\">\n                        {app.name}\n                      </span>\n                    </button>\n                  </motion.li>\n                ))}\n              </motion.ul>\n              <button\n                className=\"mt-4 w-full rounded-lg bg-blue-500 px-4 py-2 font-semibold text-white shadow transition hover:bg-blue-600 disabled:opacity-50\"\n                disabled={selected.length === 0}\n                onClick={handleDownload}\n                type=\"button\"\n              >\n                Download Selected\n              </button>\n            </motion.div>\n          )}\n\n          {isDownloading && !downloadComplete && (\n            <motion.div\n              animate={\n                shouldReduceMotion ? { opacity: 1 } : { opacity: 1, scale: 1 }\n              }\n              className=\"flex flex-col items-center gap-4\"\n              exit={\n                shouldReduceMotion\n                  ? { opacity: 0, transition: { duration: 0 } }\n                  : { opacity: 0, scale: 0.8 }\n              }\n              initial={\n                shouldReduceMotion ? { opacity: 1 } : { opacity: 0, scale: 0.8 }\n              }\n              key=\"downloading\"\n              layout={!shouldReduceMotion}\n            >\n              <div className=\"relative flex h-16 w-16 items-center justify-center\">\n                <motion.div\n                  animate={shineControls}\n                  className=\"absolute inset-0 rounded-xl bg-blue-500/20\"\n                  style={{ x: 0 }}\n                />\n                {apps.map((app, index) => (\n                  <motion.img\n                    alt={`${app.name} Logo`}\n                    animate={\n                      shouldReduceMotion ? \"initial\" : [\"initial\", \"float\"]\n                    }\n                    className=\"absolute inset-0 rounded-xl border-none\"\n                    custom={index}\n                    draggable={false}\n                    height={64}\n                    initial=\"initial\"\n                    key={app.id}\n                    layoutId={\n                      shouldReduceMotion ? undefined : `app-icon-${app.id}`\n                    }\n                    src={app.icon}\n                    transition={\n                      shouldReduceMotion\n                        ? { duration: 0 }\n                        : { duration: TRANSITION_DURATION }\n                    }\n                    variants={stackVariants}\n                    width={64}\n                  />\n                ))}\n              </div>\n              <span className=\"font-semibold text-blue-500\">\n                Downloading...\n              </span>\n            </motion.div>\n          )}\n\n          {downloadComplete ? (\n            <motion.div\n              animate={\n                shouldReduceMotion ? { opacity: 1 } : { opacity: 1, scale: 1 }\n              }\n              className=\"flex flex-col items-center gap-4\"\n              exit={\n                shouldReduceMotion\n                  ? { opacity: 0, transition: { duration: 0 } }\n                  : { opacity: 0, scale: 0.8 }\n              }\n              initial={\n                shouldReduceMotion ? { opacity: 1 } : { opacity: 0, scale: 0.8 }\n              }\n              key=\"download-complete\"\n              layout={!shouldReduceMotion}\n            >\n              <span className=\"font-semibold text-green-500\">\n                Download Complete!\n              </span>\n            </motion.div>\n          ) : null}\n        </AnimatePresence>\n      </motion.div>\n    </div>\n  );\n}\n","path":"index.tsx","target":"components/smoothui/app-download-stack/index.tsx","type":"registry:ui"}],"name":"app-download-stack","registryDependencies":[],"title":"App Download Stack","type":"registry:ui"}