mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-15 18:25:18 +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
|
|
@ -79,6 +79,7 @@ import {
|
||||||
import type { Document } from "@/contracts/types/document.types";
|
import type { Document } from "@/contracts/types/document.types";
|
||||||
import { useBatchCommentsPreload } from "@/hooks/use-comments";
|
import { useBatchCommentsPreload } from "@/hooks/use-comments";
|
||||||
import { useCommentsElectric } from "@/hooks/use-comments-electric";
|
import { useCommentsElectric } from "@/hooks/use-comments-electric";
|
||||||
|
import { SLIDEOUT_PANEL_OPENED_EVENT } from "@/components/layout/ui/sidebar/SidebarSlideOutPanel";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
/** Placeholder texts that cycle in new chats when input is empty */
|
/** Placeholder texts that cycle in new chats when input is empty */
|
||||||
|
|
@ -316,6 +317,16 @@ const Composer: FC = () => {
|
||||||
}
|
}
|
||||||
}, [isThreadEmpty]);
|
}, [isThreadEmpty]);
|
||||||
|
|
||||||
|
// Close document picker when a slide-out panel (inbox, shared/private chats) opens
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = () => {
|
||||||
|
setShowDocumentPopover(false);
|
||||||
|
setMentionQuery("");
|
||||||
|
};
|
||||||
|
window.addEventListener(SLIDEOUT_PANEL_OPENED_EVENT, handler);
|
||||||
|
return () => window.removeEventListener(SLIDEOUT_PANEL_OPENED_EVENT, handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Sync editor text with assistant-ui composer runtime
|
// Sync editor text with assistant-ui composer runtime
|
||||||
const handleEditorChange = useCallback(
|
const handleEditorChange = useCallback(
|
||||||
(text: string) => {
|
(text: string) => {
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,16 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { IconBrandGithub } from "@tabler/icons-react";
|
import { IconBrandGithub } from "@tabler/icons-react";
|
||||||
|
import { StarIcon } from "lucide-react";
|
||||||
import type { HTMLMotionProps, UseInViewOptions } from "motion/react";
|
import type { HTMLMotionProps, UseInViewOptions } from "motion/react";
|
||||||
import { motion, useInView, useMotionValue, useSpring } from "motion/react";
|
import {
|
||||||
|
AnimatePresence,
|
||||||
|
motion,
|
||||||
|
useInView,
|
||||||
|
useMotionValue,
|
||||||
|
useSpring,
|
||||||
|
useTransform,
|
||||||
|
} from "motion/react";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
|
@ -45,6 +53,122 @@ function useIsInView<T extends HTMLElement = HTMLElement>(
|
||||||
return { ref: localRef, isInView };
|
return { ref: localRef, isInView };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Particles (for star burst effect on completion)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
type ParticlesContextType = { animate: boolean; isInView: boolean };
|
||||||
|
const [ParticlesProvider, useParticles] =
|
||||||
|
getStrictContext<ParticlesContextType>("ParticlesContext");
|
||||||
|
|
||||||
|
function Particles({
|
||||||
|
ref,
|
||||||
|
animate = true,
|
||||||
|
inView = false,
|
||||||
|
inViewMargin = "0px",
|
||||||
|
inViewOnce = true,
|
||||||
|
children,
|
||||||
|
style,
|
||||||
|
...props
|
||||||
|
}: Omit<HTMLMotionProps<"div">, "children"> & {
|
||||||
|
animate?: boolean;
|
||||||
|
children: React.ReactNode;
|
||||||
|
} & UseIsInViewOptions) {
|
||||||
|
const { ref: localRef, isInView } = useIsInView(ref as React.Ref<HTMLDivElement>, {
|
||||||
|
inView,
|
||||||
|
inViewOnce,
|
||||||
|
inViewMargin,
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<ParticlesProvider value={{ animate, isInView }}>
|
||||||
|
<motion.div ref={localRef} style={{ position: "relative", ...style }} {...props}>
|
||||||
|
{children}
|
||||||
|
</motion.div>
|
||||||
|
</ParticlesProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ParticlesEffect({
|
||||||
|
side = "top",
|
||||||
|
align = "center",
|
||||||
|
count = 6,
|
||||||
|
radius = 30,
|
||||||
|
spread = 360,
|
||||||
|
duration = 0.8,
|
||||||
|
holdDelay = 0.05,
|
||||||
|
sideOffset = 0,
|
||||||
|
alignOffset = 0,
|
||||||
|
delay = 0,
|
||||||
|
transition,
|
||||||
|
style,
|
||||||
|
...props
|
||||||
|
}: Omit<HTMLMotionProps<"div">, "children"> & {
|
||||||
|
side?: "top" | "bottom" | "left" | "right";
|
||||||
|
align?: "start" | "center" | "end";
|
||||||
|
count?: number;
|
||||||
|
radius?: number;
|
||||||
|
spread?: number;
|
||||||
|
duration?: number;
|
||||||
|
holdDelay?: number;
|
||||||
|
sideOffset?: number;
|
||||||
|
alignOffset?: number;
|
||||||
|
delay?: number;
|
||||||
|
}) {
|
||||||
|
const { animate, isInView } = useParticles();
|
||||||
|
const isVertical = side === "top" || side === "bottom";
|
||||||
|
const alignPct = align === "start" ? "0%" : align === "end" ? "100%" : "50%";
|
||||||
|
|
||||||
|
const top = isVertical
|
||||||
|
? side === "top"
|
||||||
|
? `calc(0% - ${sideOffset}px)`
|
||||||
|
: `calc(100% + ${sideOffset}px)`
|
||||||
|
: `calc(${alignPct} + ${alignOffset}px)`;
|
||||||
|
const left = isVertical
|
||||||
|
? `calc(${alignPct} + ${alignOffset}px)`
|
||||||
|
: side === "left"
|
||||||
|
? `calc(0% - ${sideOffset}px)`
|
||||||
|
: `calc(100% + ${sideOffset}px)`;
|
||||||
|
|
||||||
|
const containerStyle: React.CSSProperties = {
|
||||||
|
position: "absolute",
|
||||||
|
top,
|
||||||
|
left,
|
||||||
|
transform: "translate(-50%, -50%)",
|
||||||
|
};
|
||||||
|
const angleStep = (spread * (Math.PI / 180)) / Math.max(1, count - 1);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{animate &&
|
||||||
|
isInView &&
|
||||||
|
[...Array(count)].map((_, i) => {
|
||||||
|
const angle = i * angleStep;
|
||||||
|
const x = Math.cos(angle) * radius;
|
||||||
|
const y = Math.sin(angle) * radius;
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
key={`particle-${angle}`}
|
||||||
|
style={{ ...containerStyle, ...style }}
|
||||||
|
initial={{ scale: 0, opacity: 0 }}
|
||||||
|
animate={{
|
||||||
|
x: `${x}px`,
|
||||||
|
y: `${y}px`,
|
||||||
|
scale: [0, 1, 0],
|
||||||
|
opacity: [0, 1, 0],
|
||||||
|
}}
|
||||||
|
transition={{
|
||||||
|
duration,
|
||||||
|
delay: delay + i * holdDelay,
|
||||||
|
ease: "easeOut",
|
||||||
|
...transition,
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Per-digit scrolling wheel
|
// Per-digit scrolling wheel
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -193,42 +317,18 @@ function AnimatedStarCount({
|
||||||
value,
|
value,
|
||||||
itemSize = 22,
|
itemSize = 22,
|
||||||
isRolling = false,
|
isRolling = false,
|
||||||
animated = true,
|
|
||||||
className,
|
className,
|
||||||
onComplete,
|
onComplete,
|
||||||
}: {
|
}: {
|
||||||
value: number;
|
value: number;
|
||||||
itemSize?: number;
|
itemSize?: number;
|
||||||
isRolling?: boolean;
|
isRolling?: boolean;
|
||||||
animated?: boolean;
|
|
||||||
className?: string;
|
className?: string;
|
||||||
onComplete?: () => void;
|
onComplete?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const formatted = numberFormatter.format(value);
|
const formatted = numberFormatter.format(value);
|
||||||
const chars = formatted.split("");
|
const chars = formatted.split("");
|
||||||
|
|
||||||
if (!animated) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center">
|
|
||||||
{chars.map((char, idx) => (
|
|
||||||
<div
|
|
||||||
key={`static-${idx}-${char}`}
|
|
||||||
className={className}
|
|
||||||
style={{
|
|
||||||
height: itemSize,
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
width: char >= "0" && char <= "9" ? undefined : "0.3em",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{char}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let totalDigits = 0;
|
let totalDigits = 0;
|
||||||
for (const c of chars) {
|
for (const c of chars) {
|
||||||
if (c >= "0" && c <= "9") totalDigits++;
|
if (c >= "0" && c <= "9") totalDigits++;
|
||||||
|
|
@ -307,13 +407,13 @@ function NavbarGitHubStars({
|
||||||
href = "https://github.com/MODSetter/SurfSense",
|
href = "https://github.com/MODSetter/SurfSense",
|
||||||
className,
|
className,
|
||||||
}: NavbarGitHubStarsProps) {
|
}: NavbarGitHubStarsProps) {
|
||||||
const [hasMounted, setHasMounted] = React.useState(false);
|
|
||||||
const [stars, setStars] = React.useState(0);
|
const [stars, setStars] = React.useState(0);
|
||||||
const [isLoading, setIsLoading] = React.useState(true);
|
const [isLoading, setIsLoading] = React.useState(true);
|
||||||
|
const [isCompleted, setIsCompleted] = React.useState(false);
|
||||||
|
|
||||||
React.useEffect(() => {
|
const fillRaw = useMotionValue(0);
|
||||||
setHasMounted(true);
|
const fillSpring = useSpring(fillRaw, { stiffness: 12, damping: 14 });
|
||||||
}, []);
|
const clipPath = useTransform(fillSpring, (v) => `inset(${100 - v * 100}% 0 0 0)`);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
|
|
@ -324,6 +424,7 @@ function NavbarGitHubStars({
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
if (data && typeof data.stargazers_count === "number") {
|
if (data && typeof data.stargazers_count === "number") {
|
||||||
setStars(data.stargazers_count);
|
setStars(data.stargazers_count);
|
||||||
|
fillRaw.set(1);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
|
|
@ -333,7 +434,7 @@ function NavbarGitHubStars({
|
||||||
})
|
})
|
||||||
.finally(() => setIsLoading(false));
|
.finally(() => setIsLoading(false));
|
||||||
return () => abortController.abort();
|
return () => abortController.abort();
|
||||||
}, [username, repo]);
|
}, [username, repo, fillRaw]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
|
|
@ -341,20 +442,37 @@ function NavbarGitHubStars({
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className={cn(
|
className={cn(
|
||||||
"group inline-flex items-center rounded-full border border-neutral-200 bg-white/80 px-3 py-1.5 text-sm backdrop-blur-sm transition-colors dark:border-neutral-800 dark:bg-neutral-950/80",
|
"group flex items-center gap-2 rounded-full px-3 py-1.5 transition-colors",
|
||||||
"hover:bg-neutral-100 dark:hover:bg-neutral-900",
|
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<IconBrandGithub className="h-5 w-5 shrink-0 text-neutral-600 transition-colors dark:text-neutral-300 group-hover:text-neutral-800 dark:group-hover:text-neutral-100" />
|
<IconBrandGithub className="h-5 w-5 text-neutral-600 dark:text-neutral-300 shrink-0" />
|
||||||
<div className="ml-2 flex items-center text-neutral-500 transition-colors dark:text-neutral-400 group-hover:text-neutral-800 dark:group-hover:text-neutral-200">
|
<div className="flex items-center gap-1 rounded-md bg-neutral-100 dark:bg-neutral-800 group-hover:bg-neutral-200 dark:group-hover:bg-neutral-700 px-2 py-0.5 transition-colors">
|
||||||
<AnimatedStarCount
|
<AnimatedStarCount
|
||||||
value={isLoading ? 10000 : stars}
|
value={isLoading ? 10000 : stars}
|
||||||
itemSize={ITEM_SIZE}
|
itemSize={ITEM_SIZE}
|
||||||
isRolling={hasMounted && isLoading}
|
isRolling={isLoading}
|
||||||
animated={hasMounted}
|
|
||||||
className="text-sm font-semibold tabular-nums text-neutral-500 dark:text-neutral-400 group-hover:text-neutral-800 dark:group-hover:text-neutral-200 transition-colors"
|
className="text-sm font-semibold tabular-nums text-neutral-500 dark:text-neutral-400 group-hover:text-neutral-800 dark:group-hover:text-neutral-200 transition-colors"
|
||||||
|
onComplete={() => setIsCompleted(true)}
|
||||||
/>
|
/>
|
||||||
|
<Particles animate={isCompleted}>
|
||||||
|
<div className="relative size-4">
|
||||||
|
<StarIcon
|
||||||
|
aria-hidden="true"
|
||||||
|
className="absolute inset-0 size-4 fill-neutral-400 stroke-neutral-400 dark:fill-neutral-700 dark:stroke-neutral-700 group-hover:fill-neutral-600 group-hover:stroke-neutral-600 dark:group-hover:fill-neutral-300 dark:group-hover:stroke-neutral-300 transition-colors"
|
||||||
|
/>
|
||||||
|
<motion.div className="absolute inset-0" style={{ clipPath }}>
|
||||||
|
<StarIcon
|
||||||
|
aria-hidden="true"
|
||||||
|
className="size-4 fill-neutral-300 stroke-neutral-300 dark:fill-neutral-400 dark:stroke-neutral-400 group-hover:fill-neutral-500 group-hover:stroke-neutral-500 dark:group-hover:fill-neutral-200 dark:group-hover:stroke-neutral-200 transition-colors"
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
<ParticlesEffect
|
||||||
|
delay={0.3}
|
||||||
|
className="size-1 rounded-full bg-neutral-300 dark:bg-neutral-400"
|
||||||
|
/>
|
||||||
|
</Particles>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -179,7 +179,7 @@ function GetStartedButton() {
|
||||||
|
|
||||||
const BackgroundGrids = () => {
|
const BackgroundGrids = () => {
|
||||||
return (
|
return (
|
||||||
<div className="pointer-events-none absolute inset-0 z-0 grid h-full w-full -rotate-45 transform select-none grid-cols-2 gap-10 md:grid-cols-4">
|
<div className="pointer-events-none absolute inset-0 z-0 grid h-screen w-full -rotate-45 transform select-none grid-cols-2 gap-10 md:grid-cols-4">
|
||||||
<div className="relative h-full w-full">
|
<div className="relative h-full w-full">
|
||||||
<GridLineVertical className="left-0" />
|
<GridLineVertical className="left-0" />
|
||||||
<GridLineVertical className="left-auto right-0" />
|
<GridLineVertical className="left-auto right-0" />
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,13 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { AnimatePresence, motion } from "motion/react";
|
import { AnimatePresence, motion } from "motion/react";
|
||||||
|
import { useEffect } from "react";
|
||||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useSidebarContextSafe } from "../../hooks";
|
import { useSidebarContextSafe } from "../../hooks";
|
||||||
|
|
||||||
|
export const SLIDEOUT_PANEL_OPENED_EVENT = "slideout-panel-opened";
|
||||||
|
|
||||||
const SIDEBAR_COLLAPSED_WIDTH = 60;
|
const SIDEBAR_COLLAPSED_WIDTH = 60;
|
||||||
|
|
||||||
interface SidebarSlideOutPanelProps {
|
interface SidebarSlideOutPanelProps {
|
||||||
|
|
@ -36,17 +39,24 @@ export function SidebarSlideOutPanel({
|
||||||
? SIDEBAR_COLLAPSED_WIDTH
|
? SIDEBAR_COLLAPSED_WIDTH
|
||||||
: (sidebarContext?.sidebarWidth ?? 240);
|
: (sidebarContext?.sidebarWidth ?? 240);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
window.dispatchEvent(new Event(SLIDEOUT_PANEL_OPENED_EVENT));
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{open && (
|
{open && (
|
||||||
<>
|
<>
|
||||||
{/* Click-away layer - covers the full container including the sidebar */}
|
{/* Backdrop overlay with blur — only covers the main content area (right of sidebar) */}
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
transition={{ duration: 0.15 }}
|
transition={{ duration: 0.15 }}
|
||||||
className="absolute inset-0 z-[5]"
|
style={{ left: isMobile ? 0 : sidebarWidth }}
|
||||||
|
className="absolute inset-y-0 right-0 z-20 bg-black/30 backdrop-blur-sm"
|
||||||
onClick={() => onOpenChange(false)}
|
onClick={() => onOpenChange(false)}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
|
|
@ -57,7 +67,7 @@ export function SidebarSlideOutPanel({
|
||||||
left: isMobile ? 0 : sidebarWidth,
|
left: isMobile ? 0 : sidebarWidth,
|
||||||
width: isMobile ? "100%" : width,
|
width: isMobile ? "100%" : width,
|
||||||
}}
|
}}
|
||||||
className={cn("absolute z-10 overflow-hidden pointer-events-none", "inset-y-0")}
|
className={cn("absolute z-30 overflow-hidden pointer-events-none", "inset-y-0")}
|
||||||
>
|
>
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ x: "-100%" }}
|
initial={{ x: "-100%" }}
|
||||||
|
|
|
||||||
|
|
@ -58,51 +58,29 @@ function HeroCarouselCard({
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
src,
|
src,
|
||||||
isActive,
|
|
||||||
onExpandedChange,
|
onExpandedChange,
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
src: string;
|
src: string;
|
||||||
isActive: boolean;
|
|
||||||
onExpandedChange?: (expanded: boolean) => void;
|
onExpandedChange?: (expanded: boolean) => void;
|
||||||
}) {
|
}) {
|
||||||
const { expanded, open, close } = useExpandedGif();
|
const { expanded, open, close } = useExpandedGif();
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
const [frozenFrame, setFrozenFrame] = useState<string | null>(null);
|
|
||||||
const [hasLoaded, setHasLoaded] = useState(false);
|
const [hasLoaded, setHasLoaded] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onExpandedChange?.(expanded);
|
onExpandedChange?.(expanded);
|
||||||
}, [expanded, onExpandedChange]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
const video = videoRef.current;
|
const video = videoRef.current;
|
||||||
if (isActive) {
|
if (video) {
|
||||||
setHasLoaded(false);
|
setHasLoaded(false);
|
||||||
if (video) {
|
video.currentTime = 0;
|
||||||
video.currentTime = 0;
|
video.play().catch(() => {});
|
||||||
video.play().catch(() => {});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (video) {
|
|
||||||
if (video.readyState >= 2) captureFrame(video);
|
|
||||||
video.pause();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, [isActive, captureFrame]);
|
}, [src]);
|
||||||
|
|
||||||
const handleCanPlay = useCallback(() => {
|
const handleCanPlay = useCallback(() => {
|
||||||
setHasLoaded(true);
|
setHasLoaded(true);
|
||||||
|
|
@ -119,40 +97,22 @@ function HeroCarouselCard({
|
||||||
<p className="text-sm text-neutral-500 dark:text-neutral-400">{description}</p>
|
<p className="text-sm text-neutral-500 dark:text-neutral-400">{description}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div className="cursor-pointer bg-neutral-50 p-2 sm:p-3 dark:bg-neutral-950" onClick={open}>
|
||||||
className={`bg-neutral-50 p-2 sm:p-3 dark:bg-neutral-950 ${
|
<div className="relative">
|
||||||
isActive ? "cursor-pointer" : "pointer-events-none"
|
<video
|
||||||
}`}
|
ref={videoRef}
|
||||||
onClick={isActive ? open : undefined}
|
src={src}
|
||||||
>
|
autoPlay
|
||||||
{isActive ? (
|
loop
|
||||||
<div className="relative">
|
muted
|
||||||
<video
|
playsInline
|
||||||
ref={videoRef}
|
onCanPlay={handleCanPlay}
|
||||||
src={src}
|
className="w-full rounded-lg sm:rounded-xl"
|
||||||
autoPlay
|
/>
|
||||||
loop
|
{!hasLoaded && (
|
||||||
muted
|
<div className="absolute inset-0 aspect-video w-full animate-pulse rounded-lg bg-neutral-100 sm:rounded-xl dark:bg-neutral-800" />
|
||||||
playsInline
|
)}
|
||||||
onCanPlay={handleCanPlay}
|
</div>
|
||||||
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>
|
</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() {
|
function HeroCarousel() {
|
||||||
const [activeIndex, setActiveIndex] = useState(0);
|
const [activeIndex, setActiveIndex] = useState(0);
|
||||||
const [isGifExpanded, setIsGifExpanded] = useState(false);
|
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");
|
const directionRef = useRef<"forward" | "backward">("forward");
|
||||||
|
|
||||||
|
usePrefetchVideos();
|
||||||
|
|
||||||
const goTo = useCallback(
|
const goTo = useCallback(
|
||||||
(newIndex: number) => {
|
(newIndex: number) => {
|
||||||
directionRef.current = newIndex >= activeIndex ? "forward" : "backward";
|
directionRef.current = newIndex >= activeIndex ? "forward" : "backward";
|
||||||
|
|
@ -188,120 +175,28 @@ function HeroCarousel() {
|
||||||
goTo(activeIndex >= carouselItems.length - 1 ? 0 : activeIndex + 1);
|
goTo(activeIndex >= carouselItems.length - 1 ? 0 : activeIndex + 1);
|
||||||
}, [activeIndex, goTo]);
|
}, [activeIndex, goTo]);
|
||||||
|
|
||||||
useEffect(() => {
|
const item = carouselItems[activeIndex];
|
||||||
const el = containerRef.current;
|
const isForward = directionRef.current === "forward";
|
||||||
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]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full py-4 sm:py-8">
|
<div className="w-full py-4 sm:py-8">
|
||||||
<div ref={containerRef} className="relative mx-auto w-full">
|
<div className="relative mx-auto w-full max-w-[900px]">
|
||||||
<div
|
<AnimatePresence mode="wait" initial={false}>
|
||||||
className="relative z-6 transition-[height] duration-700"
|
<motion.div
|
||||||
style={{ perspective: `${perspective}px`, height: cardHeight }}
|
key={activeIndex}
|
||||||
>
|
initial={{ opacity: 0, x: isForward ? 60 : -60 }}
|
||||||
{containerWidth > 0 &&
|
animate={{ opacity: 1, x: 0 }}
|
||||||
carouselItems.map((item, i) => {
|
exit={{ opacity: 0, x: isForward ? -60 : 60 }}
|
||||||
const style = getCardStyle(i);
|
transition={{ duration: 0.35, ease: [0.32, 0.72, 0, 1] }}
|
||||||
return (
|
>
|
||||||
<motion.div
|
<HeroCarouselCard
|
||||||
key={`carousel_${i}`}
|
title={item.title}
|
||||||
ref={i === activeIndex ? activeCardRef : undefined}
|
description={item.description}
|
||||||
className="absolute top-0"
|
src={item.src}
|
||||||
style={{
|
onExpandedChange={setIsGifExpanded}
|
||||||
left: "50%",
|
/>
|
||||||
width: cardWidth,
|
</motion.div>
|
||||||
transformStyle: "preserve-3d",
|
</AnimatePresence>
|
||||||
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>
|
</div>
|
||||||
|
|
||||||
<div className="relative z-5 mt-6 flex items-center justify-center gap-4">
|
<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