diff --git a/surfsense_web/components/assistant-ui/thread.tsx b/surfsense_web/components/assistant-ui/thread.tsx index b3aff3e2d..763159b17 100644 --- a/surfsense_web/components/assistant-ui/thread.tsx +++ b/surfsense_web/components/assistant-ui/thread.tsx @@ -79,6 +79,7 @@ import { import type { Document } from "@/contracts/types/document.types"; import { useBatchCommentsPreload } from "@/hooks/use-comments"; import { useCommentsElectric } from "@/hooks/use-comments-electric"; +import { SLIDEOUT_PANEL_OPENED_EVENT } from "@/components/layout/ui/sidebar/SidebarSlideOutPanel"; import { cn } from "@/lib/utils"; /** Placeholder texts that cycle in new chats when input is empty */ @@ -316,6 +317,16 @@ const Composer: FC = () => { } }, [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 const handleEditorChange = useCallback( (text: string) => { diff --git a/surfsense_web/components/homepage/github-stars-badge.tsx b/surfsense_web/components/homepage/github-stars-badge.tsx index 27c4ef14f..56abdc464 100644 --- a/surfsense_web/components/homepage/github-stars-badge.tsx +++ b/surfsense_web/components/homepage/github-stars-badge.tsx @@ -1,8 +1,16 @@ "use client"; import { IconBrandGithub } from "@tabler/icons-react"; +import { StarIcon } from "lucide-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 { cn } from "@/lib/utils"; @@ -45,6 +53,122 @@ function useIsInView( return { ref: localRef, isInView }; } +// --------------------------------------------------------------------------- +// Particles (for star burst effect on completion) +// --------------------------------------------------------------------------- +type ParticlesContextType = { animate: boolean; isInView: boolean }; +const [ParticlesProvider, useParticles] = + getStrictContext("ParticlesContext"); + +function Particles({ + ref, + animate = true, + inView = false, + inViewMargin = "0px", + inViewOnce = true, + children, + style, + ...props +}: Omit, "children"> & { + animate?: boolean; + children: React.ReactNode; +} & UseIsInViewOptions) { + const { ref: localRef, isInView } = useIsInView(ref as React.Ref, { + inView, + inViewOnce, + inViewMargin, + }); + return ( + + + {children} + + + ); +} + +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, "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 ( + + {animate && + isInView && + [...Array(count)].map((_, i) => { + const angle = i * angleStep; + const x = Math.cos(angle) * radius; + const y = Math.sin(angle) * radius; + return ( + + ); + })} + + ); +} + // --------------------------------------------------------------------------- // Per-digit scrolling wheel // --------------------------------------------------------------------------- @@ -193,42 +317,18 @@ function AnimatedStarCount({ value, itemSize = 22, isRolling = false, - animated = true, className, onComplete, }: { value: number; itemSize?: number; isRolling?: boolean; - animated?: boolean; className?: string; onComplete?: () => void; }) { const formatted = numberFormatter.format(value); const chars = formatted.split(""); - if (!animated) { - return ( -
- {chars.map((char, idx) => ( -
= "0" && char <= "9" ? undefined : "0.3em", - }} - > - {char} -
- ))} -
- ); - } - let totalDigits = 0; for (const c of chars) { if (c >= "0" && c <= "9") totalDigits++; @@ -307,13 +407,13 @@ function NavbarGitHubStars({ href = "https://github.com/MODSetter/SurfSense", className, }: NavbarGitHubStarsProps) { - const [hasMounted, setHasMounted] = React.useState(false); const [stars, setStars] = React.useState(0); const [isLoading, setIsLoading] = React.useState(true); + const [isCompleted, setIsCompleted] = React.useState(false); - React.useEffect(() => { - setHasMounted(true); - }, []); + const fillRaw = useMotionValue(0); + const fillSpring = useSpring(fillRaw, { stiffness: 12, damping: 14 }); + const clipPath = useTransform(fillSpring, (v) => `inset(${100 - v * 100}% 0 0 0)`); React.useEffect(() => { const abortController = new AbortController(); @@ -324,6 +424,7 @@ function NavbarGitHubStars({ .then((data) => { if (data && typeof data.stargazers_count === "number") { setStars(data.stargazers_count); + fillRaw.set(1); } }) .catch((err) => { @@ -333,7 +434,7 @@ function NavbarGitHubStars({ }) .finally(() => setIsLoading(false)); return () => abortController.abort(); - }, [username, repo]); + }, [username, repo, fillRaw]); return ( - -
+ +
setIsCompleted(true)} /> + +
+
+ +
); diff --git a/surfsense_web/components/homepage/hero-section.tsx b/surfsense_web/components/homepage/hero-section.tsx index dde4bf99b..2d2f1b478 100644 --- a/surfsense_web/components/homepage/hero-section.tsx +++ b/surfsense_web/components/homepage/hero-section.tsx @@ -179,7 +179,7 @@ function GetStartedButton() { const BackgroundGrids = () => { return ( -
+
diff --git a/surfsense_web/components/layout/ui/sidebar/SidebarSlideOutPanel.tsx b/surfsense_web/components/layout/ui/sidebar/SidebarSlideOutPanel.tsx index 5edb6df2c..0f243c6c0 100644 --- a/surfsense_web/components/layout/ui/sidebar/SidebarSlideOutPanel.tsx +++ b/surfsense_web/components/layout/ui/sidebar/SidebarSlideOutPanel.tsx @@ -1,10 +1,13 @@ "use client"; import { AnimatePresence, motion } from "motion/react"; +import { useEffect } from "react"; import { useMediaQuery } from "@/hooks/use-media-query"; import { cn } from "@/lib/utils"; import { useSidebarContextSafe } from "../../hooks"; +export const SLIDEOUT_PANEL_OPENED_EVENT = "slideout-panel-opened"; + const SIDEBAR_COLLAPSED_WIDTH = 60; interface SidebarSlideOutPanelProps { @@ -36,17 +39,24 @@ export function SidebarSlideOutPanel({ ? SIDEBAR_COLLAPSED_WIDTH : (sidebarContext?.sidebarWidth ?? 240); + useEffect(() => { + if (open) { + window.dispatchEvent(new Event(SLIDEOUT_PANEL_OPENED_EVENT)); + } + }, [open]); + return ( {open && ( <> - {/* Click-away layer - covers the full container including the sidebar */} + {/* Backdrop overlay with blur — only covers the main content area (right of sidebar) */} onOpenChange(false)} aria-hidden="true" /> @@ -57,7 +67,7 @@ export function SidebarSlideOutPanel({ left: isMobile ? 0 : sidebarWidth, 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")} > void; }) { const { expanded, open, close } = useExpandedGif(); const videoRef = useRef(null); - const [frozenFrame, setFrozenFrame] = useState(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({

{description}

-
- {isActive ? ( -
-