mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-04-25 00:36:31 +02:00
feat: implement slide-out panel event handling in Composer and enhance GitHub stars badge with particle effects for improved visual feedback
This commit is contained in:
parent
469e28958b
commit
07f0179cb2
5 changed files with 249 additions and 215 deletions
|
|
@ -58,51 +58,29 @@ function HeroCarouselCard({
|
|||
title,
|
||||
description,
|
||||
src,
|
||||
isActive,
|
||||
onExpandedChange,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
src: string;
|
||||
isActive: boolean;
|
||||
onExpandedChange?: (expanded: boolean) => void;
|
||||
}) {
|
||||
const { expanded, open, close } = useExpandedGif();
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const [frozenFrame, setFrozenFrame] = useState<string | null>(null);
|
||||
const [hasLoaded, setHasLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
onExpandedChange?.(expanded);
|
||||
}, [expanded, onExpandedChange]);
|
||||
|
||||
const captureFrame = useCallback((video: HTMLVideoElement) => {
|
||||
try {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = video.videoWidth;
|
||||
canvas.height = video.videoHeight;
|
||||
canvas.getContext("2d")?.drawImage(video, 0, 0);
|
||||
setFrozenFrame(canvas.toDataURL("image/jpeg", 0.85));
|
||||
} catch {
|
||||
/* tainted canvas */
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (isActive) {
|
||||
if (video) {
|
||||
setHasLoaded(false);
|
||||
if (video) {
|
||||
video.currentTime = 0;
|
||||
video.play().catch(() => {});
|
||||
}
|
||||
} else {
|
||||
if (video) {
|
||||
if (video.readyState >= 2) captureFrame(video);
|
||||
video.pause();
|
||||
}
|
||||
video.currentTime = 0;
|
||||
video.play().catch(() => {});
|
||||
}
|
||||
}, [isActive, captureFrame]);
|
||||
}, [src]);
|
||||
|
||||
const handleCanPlay = useCallback(() => {
|
||||
setHasLoaded(true);
|
||||
|
|
@ -119,40 +97,22 @@ function HeroCarouselCard({
|
|||
<p className="text-sm text-neutral-500 dark:text-neutral-400">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`bg-neutral-50 p-2 sm:p-3 dark:bg-neutral-950 ${
|
||||
isActive ? "cursor-pointer" : "pointer-events-none"
|
||||
}`}
|
||||
onClick={isActive ? open : undefined}
|
||||
>
|
||||
{isActive ? (
|
||||
<div className="relative">
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={src}
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
playsInline
|
||||
onCanPlay={handleCanPlay}
|
||||
className="w-full rounded-lg sm:rounded-xl"
|
||||
/>
|
||||
{!hasLoaded && frozenFrame && (
|
||||
<img
|
||||
src={frozenFrame}
|
||||
alt={title}
|
||||
className="absolute inset-0 w-full rounded-lg sm:rounded-xl"
|
||||
/>
|
||||
)}
|
||||
{!hasLoaded && !frozenFrame && (
|
||||
<div className="aspect-video w-full animate-pulse rounded-lg bg-neutral-100 sm:rounded-xl dark:bg-neutral-800" />
|
||||
)}
|
||||
</div>
|
||||
) : frozenFrame ? (
|
||||
<img src={frozenFrame} alt={title} className="w-full rounded-lg sm:rounded-xl" />
|
||||
) : (
|
||||
<div className="aspect-video w-full rounded-lg bg-neutral-100 sm:rounded-xl dark:bg-neutral-800" />
|
||||
)}
|
||||
<div className="cursor-pointer bg-neutral-50 p-2 sm:p-3 dark:bg-neutral-950" onClick={open}>
|
||||
<div className="relative">
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={src}
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
playsInline
|
||||
onCanPlay={handleCanPlay}
|
||||
className="w-full rounded-lg sm:rounded-xl"
|
||||
/>
|
||||
{!hasLoaded && (
|
||||
<div className="absolute inset-0 aspect-video w-full animate-pulse rounded-lg bg-neutral-100 sm:rounded-xl dark:bg-neutral-800" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -163,15 +123,42 @@ function HeroCarouselCard({
|
|||
);
|
||||
}
|
||||
|
||||
function usePrefetchVideos() {
|
||||
const videosRef = useRef<HTMLVideoElement[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function prefetch() {
|
||||
for (const item of carouselItems) {
|
||||
if (cancelled) break;
|
||||
await new Promise<void>((resolve) => {
|
||||
const video = document.createElement("video");
|
||||
video.preload = "auto";
|
||||
video.src = item.src;
|
||||
video.oncanplaythrough = () => resolve();
|
||||
video.onerror = () => resolve();
|
||||
setTimeout(resolve, 10000);
|
||||
videosRef.current.push(video);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
prefetch();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
videosRef.current = [];
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
|
||||
function HeroCarousel() {
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const [isGifExpanded, setIsGifExpanded] = useState(false);
|
||||
const [containerWidth, setContainerWidth] = useState(0);
|
||||
const [cardHeight, setCardHeight] = useState(420);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const activeCardRef = useRef<HTMLDivElement>(null);
|
||||
const directionRef = useRef<"forward" | "backward">("forward");
|
||||
|
||||
usePrefetchVideos();
|
||||
|
||||
const goTo = useCallback(
|
||||
(newIndex: number) => {
|
||||
directionRef.current = newIndex >= activeIndex ? "forward" : "backward";
|
||||
|
|
@ -188,120 +175,28 @@ function HeroCarousel() {
|
|||
goTo(activeIndex >= carouselItems.length - 1 ? 0 : activeIndex + 1);
|
||||
}, [activeIndex, goTo]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const update = () => setContainerWidth(el.offsetWidth);
|
||||
update();
|
||||
const observer = new ResizeObserver(update);
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const el = activeCardRef.current;
|
||||
if (!el) return;
|
||||
const update = () => setCardHeight(el.offsetHeight);
|
||||
update();
|
||||
const observer = new ResizeObserver(update);
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, [activeIndex, containerWidth]);
|
||||
|
||||
const cardWidth =
|
||||
containerWidth < 640
|
||||
? containerWidth * 0.85
|
||||
: containerWidth < 1024
|
||||
? Math.min(containerWidth * 0.7, 680)
|
||||
: Math.min(containerWidth * 0.55, 900);
|
||||
|
||||
const baseOffset =
|
||||
containerWidth < 640
|
||||
? containerWidth * 0.2
|
||||
: containerWidth < 1024
|
||||
? containerWidth * 0.15
|
||||
: 150;
|
||||
|
||||
const stackGap = containerWidth < 640 ? 35 : containerWidth < 1024 ? 45 : 55;
|
||||
const perspective = containerWidth < 640 ? 800 : containerWidth < 1024 ? 1000 : 1200;
|
||||
|
||||
const getCardStyle = useCallback(
|
||||
(index: number) => {
|
||||
const diff = index - activeIndex;
|
||||
|
||||
if (diff === 0) {
|
||||
const originX = directionRef.current === "forward" ? 1 : 0;
|
||||
return { x: -cardWidth / 2, rotateY: 0, zIndex: 20, originX, overlayOpacity: 0, blur: 0 };
|
||||
}
|
||||
|
||||
const dist = Math.abs(diff);
|
||||
const isLeft = diff < 0;
|
||||
const offset = baseOffset + (dist - 1) * stackGap;
|
||||
const t = Math.min(1, dist / 3);
|
||||
|
||||
return {
|
||||
x: -cardWidth / 2 + (isLeft ? -offset : offset),
|
||||
rotateY: isLeft ? 90 : -90,
|
||||
zIndex: 20 - dist,
|
||||
originX: isLeft ? 0 : 1,
|
||||
overlayOpacity: t,
|
||||
blur: t * 6,
|
||||
};
|
||||
},
|
||||
[activeIndex, cardWidth, baseOffset, stackGap]
|
||||
);
|
||||
const item = carouselItems[activeIndex];
|
||||
const isForward = directionRef.current === "forward";
|
||||
|
||||
return (
|
||||
<div className="w-full py-4 sm:py-8">
|
||||
<div ref={containerRef} className="relative mx-auto w-full">
|
||||
<div
|
||||
className="relative z-6 transition-[height] duration-700"
|
||||
style={{ perspective: `${perspective}px`, height: cardHeight }}
|
||||
>
|
||||
{containerWidth > 0 &&
|
||||
carouselItems.map((item, i) => {
|
||||
const style = getCardStyle(i);
|
||||
return (
|
||||
<motion.div
|
||||
key={`carousel_${i}`}
|
||||
ref={i === activeIndex ? activeCardRef : undefined}
|
||||
className="absolute top-0"
|
||||
style={{
|
||||
left: "50%",
|
||||
width: cardWidth,
|
||||
transformStyle: "preserve-3d",
|
||||
zIndex: style.zIndex,
|
||||
transformOrigin: `${style.originX * 100}% 50%`,
|
||||
cursor: i !== activeIndex ? "pointer" : undefined,
|
||||
}}
|
||||
onClick={i !== activeIndex && !isGifExpanded ? () => goTo(i) : undefined}
|
||||
animate={{
|
||||
x: style.x,
|
||||
rotateY: style.rotateY,
|
||||
}}
|
||||
transition={{ duration: 0.7, ease: [0.32, 0.72, 0, 1] }}
|
||||
>
|
||||
<motion.div
|
||||
animate={{ filter: `blur(${style.blur}px)` }}
|
||||
transition={{ duration: 0.7, ease: [0.32, 0.72, 0, 1] }}
|
||||
>
|
||||
<HeroCarouselCard
|
||||
title={item.title}
|
||||
description={item.description}
|
||||
src={item.src}
|
||||
isActive={i === activeIndex}
|
||||
onExpandedChange={setIsGifExpanded}
|
||||
/>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
className="pointer-events-none absolute inset-0 rounded-2xl bg-black sm:rounded-3xl"
|
||||
animate={{ opacity: style.overlayOpacity }}
|
||||
transition={{ duration: 0.7, ease: [0.32, 0.72, 0, 1] }}
|
||||
/>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="relative mx-auto w-full max-w-[900px]">
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<motion.div
|
||||
key={activeIndex}
|
||||
initial={{ opacity: 0, x: isForward ? 60 : -60 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: isForward ? -60 : 60 }}
|
||||
transition={{ duration: 0.35, ease: [0.32, 0.72, 0, 1] }}
|
||||
>
|
||||
<HeroCarouselCard
|
||||
title={item.title}
|
||||
description={item.description}
|
||||
src={item.src}
|
||||
onExpandedChange={setIsGifExpanded}
|
||||
/>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<div className="relative z-5 mt-6 flex items-center justify-center gap-4">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue