"use client";
import { ChevronDown } from "lucide-react";
import { AnimatePresence, motion, useAnimation } from "motion/react";
import { useCallback, useMemo, useState } from "react";
export type AppData = {
id: number;
name: string;
icon: string;
};
export type AppDownloadStackProps = {
apps?: AppData[];
title?: string;
selectedApps?: number[];
onChange?: (selected: number[]) => void;
onDownload?: (selected: number[]) => void;
isExpanded?: boolean;
onExpandChange?: (expanded: boolean) => void;
className?: string;
};
const DOWNLOAD_DURATION_MS = 3000;
const RESET_DELAY_MS = 1000;
const ROTATION_MULTIPLIER = 8;
const TRANSLATION_MULTIPLIER = 3;
const BASE_Z_INDEX = 40;
const Z_INDEX_STEP = 10;
const HOVER_X_MULTIPLIER = 10;
const HOVER_Y_MULTIPLIER = 10;
const FLOAT_AMPLITUDE = 5;
const FLOAT_DURATION = 2;
const FLOAT_DELAY_MULTIPLIER = 0.2;
const STAGGER_DELAY_MULTIPLIER = 0.1;
const TRANSITION_DURATION = 0.3;
const CHECKMARK_TRANSITION_DURATION = 0.3;
const defaultApps: AppData[] = [
{
id: 1,
name: "GitHub",
icon: "https://parsefiles.back4app.com/JPaQcFfEEQ1ePBxbf6wvzkPMEqKYHhPYv8boI1Rc/9c9721583ecba33e59ebcebdca2248fd_Mmr12FRh5V.png",
},
{
id: 2,
name: "Canary",
icon: "https://parsefiles.back4app.com/JPaQcFfEEQ1ePBxbf6wvzkPMEqKYHhPYv8boI1Rc/b47f43e02f04563447fa90d4ff6c8943_9KzW5GTggQ.png",
},
{
id: 3,
name: "Figma",
icon: "https://parsefiles.back4app.com/JPaQcFfEEQ1ePBxbf6wvzkPMEqKYHhPYv8boI1Rc/f0b9cdefa67b57eeb080278c2f6984cc_sCqUJBg6Qq.png",
},
{
id: 4,
name: "Arc",
icon: "https://parsefiles.back4app.com/JPaQcFfEEQ1ePBxbf6wvzkPMEqKYHhPYv8boI1Rc/178c7b02003c933e6b5afe98bbee595b_low_res_Arc_Browser.png",
},
];
export default function AppDownloadStack({
apps = defaultApps,
title = "Starter Mac",
selectedApps: controlledSelected,
onChange,
onDownload,
isExpanded: controlledExpanded,
onExpandChange,
className = "",
}: AppDownloadStackProps) {
const [internalExpanded, setInternalExpanded] = useState(false);
const [internalSelected, setInternalSelected] = useState<number[]>([]);
const [isDownloading, setIsDownloading] = useState(false);
const [downloadComplete, setDownloadComplete] = useState(false);
const shineControls = useAnimation();
const isExpanded =
controlledExpanded !== undefined ? controlledExpanded : internalExpanded;
const selected = controlledSelected ?? internalSelected;
const setExpanded = (val: boolean) => {
if (onExpandChange) {
onExpandChange(val);
} else {
setInternalExpanded(val);
}
};
const toggleApp = useCallback(
(id: number) => {
const newSelected = selected.includes(id)
? selected.filter((appId) => appId !== id)
: [...selected, id];
if (onChange) {
onChange(newSelected);
} else {
setInternalSelected(newSelected);
}
},
[selected, onChange]
);
const handleDownload = useCallback(() => {
setIsDownloading(true);
if (onDownload) {
onDownload(selected);
}
shineControls.start({
x: ["0%", "100%"],
transition: {
duration: 1,
repeat: Number.POSITIVE_INFINITY,
ease: "linear",
},
});
setTimeout(() => {
shineControls.stop();
setDownloadComplete(true);
setTimeout(() => {
if (onExpandChange) {
onExpandChange(false);
} else {
setInternalExpanded(false);
}
if (onChange) {
onChange([]);
} else {
setInternalSelected([]);
}
setIsDownloading(false);
setDownloadComplete(false);
}, RESET_DELAY_MS);
}, DOWNLOAD_DURATION_MS);
}, [shineControls, selected, onDownload, onChange, onExpandChange]);
const stackVariants = useMemo(
() => ({
initial: (i: number) => ({
rotate:
i % 2 === 0
? -ROTATION_MULTIPLIER * (i + 1)
: ROTATION_MULTIPLIER * (i + 1),
x:
i % 2 === 0
? -TRANSLATION_MULTIPLIER * (i + 1)
: TRANSLATION_MULTIPLIER * (i + 1),
y: 0,
zIndex: BASE_Z_INDEX - i * Z_INDEX_STEP,
}),
hover: (i: number) => ({
rotate: 0,
x: i * HOVER_X_MULTIPLIER,
y: -i * HOVER_Y_MULTIPLIER,
zIndex: BASE_Z_INDEX - i * Z_INDEX_STEP,
}),
float: (i: number) => ({
y: [0, -FLOAT_AMPLITUDE, 0],
transition: {
y: {
repeat: Number.POSITIVE_INFINITY,
duration: FLOAT_DURATION,
ease: "easeInOut",
delay: i * FLOAT_DELAY_MULTIPLIER,
},
},
}),
}),
[]
);
return (
<div
className={`flex h-auto flex-col items-center justify-center ${className}`}
>
<motion.div className="flex flex-col items-center justify-center" layout>
<AnimatePresence mode="wait">
{!(isExpanded || isDownloading) && (
<motion.button
aria-label="Expand app selection"
className="group relative isolate flex h-16 w-16 cursor-pointer items-center justify-center"
key="initial-stack"
layout
onClick={() => setExpanded(true)}
whileHover="hover"
>
{apps.map((app, index) => (
/* biome-ignore lint/performance/noImgElement: Using motion.img for animated app icons */
<motion.img
alt={`${app.name} Logo`}
animate={["initial", "float"]}
className="absolute inset-0 rounded-xl border-none"
custom={index}
height={64}
initial="initial"
key={app.id}
layoutId={`app-icon-${app.id}`}
src={app.icon}
transition={{ duration: TRANSITION_DURATION }}
variants={stackVariants}
whileHover="hover"
width={64}
/>
))}
</motion.button>
)}
{isExpanded && !isDownloading && (
<motion.div
animate={{ opacity: 1, scale: 1 }}
className="flex flex-col items-center gap-2"
exit={{ opacity: 0, scale: 0.8 }}
initial={{ opacity: 0, scale: 0.8 }}
key="app-selector"
layout
>
<button
className="flex w-full cursor-pointer items-center justify-between px-0.5"
onClick={() => setExpanded(false)}
type="button"
>
<p className="my-0 font-medium leading-0">{title}</p>
<div className="flex items-center gap-1">
<p className="my-0 font-medium leading-0">
{selected.length}
</p>
<ChevronDown className="text-mauve-11" size={16} />
</div>
</button>
<motion.ul className="grid grid-cols-2 gap-3">
{apps.map((app, index) => (
<motion.li
animate={{ opacity: 1, y: 0 }}
className="relative flex h-[80px] w-[80px]"
initial={{ opacity: 0, y: 20 }}
key={app.id}
transition={{ delay: index * STAGGER_DELAY_MULTIPLIER }}
>
<div
className={`pointer-events-none absolute top-2 right-2 flex h-4 w-4 items-center justify-center rounded-full border border-solid ${
selected.includes(app.id)
? "border-blue-500 bg-blue-500"
: "border-white/60"
}`}
>
{selected.includes(app.id) && (
<motion.svg
animate={{ pathLength: 1 }}
className="z-1 h-3 w-3"
fill="none"
initial={{ pathLength: 0 }}
stroke="white"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
transition={{
duration: CHECKMARK_TRANSITION_DURATION,
}}
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<title>Checkmark</title>
<motion.path d="M5 13l4 4L19 7" />
</motion.svg>
)}
</div>
<button
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 ${
selected.includes(app.id)
? "border-blue-500 bg-blue-500/10"
: ""
}`}
onClick={() => toggleApp(app.id)}
type="button"
>
{/* biome-ignore lint/performance/noImgElement: Using img for app icon without Next.js Image optimizations */}
<img
alt={app.name}
className="rounded-lg"
height={40}
src={app.icon}
width={40}
/>
<span className="font-medium text-foreground text-xs">
{app.name}
</span>
</button>
</motion.li>
))}
</motion.ul>
<button
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"
disabled={selected.length === 0}
onClick={handleDownload}
type="button"
>
Download Selected
</button>
</motion.div>
)}
{isDownloading && (
<motion.div
animate={{ opacity: 1, scale: 1 }}
className="flex flex-col items-center gap-4"
exit={{ opacity: 0, scale: 0.8 }}
initial={{ opacity: 0, scale: 0.8 }}
key="downloading"
layout
>
<div className="relative flex h-16 w-16 items-center justify-center">
<motion.div
animate={shineControls}
className="absolute inset-0 rounded-xl bg-blue-500/20"
style={{ x: 0 }}
/>
{apps.map((app, index) => (
/* biome-ignore lint/performance/noImgElement: Using motion.img for animated app icons */
<motion.img
alt={`${app.name} Logo`}
animate={["initial", "float"]}
className="absolute inset-0 rounded-xl border-none"
custom={index}
height={64}
initial="initial"
key={app.id}
layoutId={`app-icon-${app.id}`}
src={app.icon}
transition={{ duration: TRANSITION_DURATION }}
variants={stackVariants}
width={64}
/>
))}
</div>
<span className="font-semibold text-blue-500">
Downloading...
</span>
</motion.div>
)}
{downloadComplete && (
<motion.div
animate={{ opacity: 1, scale: 1 }}
className="flex flex-col items-center gap-4"
exit={{ opacity: 0, scale: 0.8 }}
initial={{ opacity: 0, scale: 0.8 }}
key="download-complete"
layout
>
<span className="font-semibold text-green-500">
Download Complete!
</span>
</motion.div>
)}
</AnimatePresence>
</motion.div>
</div>
);
}