Merge branch 'dev' into fix/replace-transition-all-with-specific-transitions

This commit is contained in:
Soham Bhattacharjee 2026-04-08 05:38:30 +05:30 committed by GitHub
commit e404b05b11
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
295 changed files with 25773 additions and 10799 deletions

View file

@ -408,6 +408,7 @@ const AudioCommentIllustration = () => (
src="/homepage/comments-audio.webp"
alt="Audio Comment Illustration"
fill
sizes="(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw"
className="object-cover"
/>
</div>

View file

@ -1,39 +1,15 @@
"use client";
import { Download, Monitor } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import dynamic from "next/dynamic";
import Link from "next/link";
import type React from "react";
import { useEffect, useRef, useState } from "react";
import React, { memo, useCallback, useEffect, useRef, useState } from "react";
import Balancer from "react-wrap-balancer";
import { ExpandedMediaOverlay, useExpandedMedia } from "@/components/ui/expanded-gif-overlay";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { AUTH_TYPE, BACKEND_URL } from "@/lib/env-config";
import { trackLoginAttempt } from "@/lib/posthog/events";
import { cn } from "@/lib/utils";
const HeroCarousel = dynamic(
() => import("@/components/ui/hero-carousel").then((m) => ({ default: m.HeroCarousel })),
{
ssr: false,
loading: () => (
<div className="w-full py-4 sm:py-8">
<div className="mx-auto w-full max-w-[900px]">
<div className="overflow-hidden rounded-2xl border border-neutral-200/60 bg-white shadow-xl sm:rounded-3xl dark:border-neutral-700/60 dark:bg-neutral-900">
<div className="flex items-center gap-3 border-b border-neutral-200/60 px-4 py-3 sm:px-6 sm:py-4 dark:border-neutral-700/60">
<div className="min-w-0 flex-1">
<div className="h-5 w-32 animate-pulse rounded bg-neutral-200 dark:bg-neutral-700" />
<div className="mt-2 h-4 w-64 animate-pulse rounded bg-neutral-100 dark:bg-neutral-800" />
</div>
</div>
<div className="bg-neutral-50 p-2 sm:p-3 dark:bg-neutral-950">
<div className="aspect-video w-full animate-pulse rounded-lg bg-neutral-100 sm:rounded-xl dark:bg-neutral-800" />
</div>
</div>
</div>
</div>
),
}
);
// Official Google "G" logo with brand colors
const GoogleLogo = ({ className }: { className?: string }) => (
<svg
className={className}
@ -62,87 +38,117 @@ const GoogleLogo = ({ className }: { className?: string }) => (
</svg>
);
function useIsDesktop(breakpoint = 1024) {
const [isDesktop, setIsDesktop] = useState(false);
useEffect(() => {
const mql = window.matchMedia(`(min-width: ${breakpoint}px)`);
setIsDesktop(mql.matches);
const handler = (e: MediaQueryListEvent) => setIsDesktop(e.matches);
mql.addEventListener("change", handler);
return () => mql.removeEventListener("change", handler);
}, [breakpoint]);
return isDesktop;
}
const TAB_ITEMS = [
{
title: "General Assist",
description: "Launch SurfSense instantly from any application.",
src: "/homepage/hero_tutorial/general_assist.mp4",
featured: true,
},
{
title: "Quick Assist",
description: "Select text anywhere, then ask AI to explain, rewrite, or act on it.",
src: "/homepage/hero_tutorial/quick_assist.mp4",
featured: true,
},
{
title: "Extreme Assist",
description: "Get inline writing suggestions powered by your knowledge base as you type in any app.",
src: "/homepage/hero_tutorial/extreme_assist.mp4",
featured: true,
},
// {
// title: "Connect & Sync",
// description:
// "Connect data sources like Notion, Drive and Gmail. Automatically sync to keep them updated.",
// src: "/homepage/hero_tutorial/ConnectorFlowGif.mp4",
// featured: true,
// },
// {
// title: "Upload Documents",
// description: "Upload documents directly, from images to massive PDFs.",
// src: "/homepage/hero_tutorial/DocUploadGif.mp4",
// featured: true,
// },
{
title: "Video & Presentations",
description: "Create short videos and editable presentations with AI-generated visuals and narration from your sources.",
src: "/homepage/hero_tutorial/video_gen_surf.mp4",
featured: false,
},
{
title: "Search & Citation",
description: "Ask questions and get cited responses from your knowledge base.",
src: "/homepage/hero_tutorial/BSNCGif.mp4",
featured: false,
},
{
title: "Document Q&A",
description: "Mention specific documents in chat for targeted answers.",
src: "/homepage/hero_tutorial/BQnaGif_compressed.mp4",
featured: false,
},
{
title: "Reports",
description: "Generate reports from your sources in many formats.",
src: "/homepage/hero_tutorial/ReportGenGif_compressed.mp4",
featured: false,
},
{
title: "Podcasts",
description: "Turn anything into a podcast in under 20 seconds.",
src: "/homepage/hero_tutorial/PodcastGenGif.mp4",
featured: false,
},
{
title: "Image Generation",
description: "Generate high-quality images easily from your conversations.",
src: "/homepage/hero_tutorial/ImageGenGif.mp4",
featured: false,
},
{
title: "Collaborative Chat",
description: "Collaborate on AI-powered conversations in realtime with your team.",
src: "/homepage/hero_realtime/RealTimeChatGif.mp4",
featured: false,
},
{
title: "Comments",
description: "Add comments and tag teammates on any message.",
src: "/homepage/hero_realtime/RealTimeCommentsFlow.mp4",
featured: false,
},
] as const;
export function HeroSection() {
const containerRef = useRef<HTMLDivElement>(null);
const parentRef = useRef<HTMLDivElement>(null);
const isDesktop = useIsDesktop();
return (
<div
ref={parentRef}
className="relative flex min-h-screen flex-col items-center justify-center overflow-hidden px-4 py-24 md:px-8 md:py-48"
>
<BackgroundGrids />
{isDesktop && (
<>
<CollisionMechanism
parentRef={parentRef}
beamOptions={{
initialX: -400,
translateX: 600,
duration: 7,
repeatDelay: 3,
}}
/>
<CollisionMechanism
parentRef={parentRef}
beamOptions={{
initialX: -200,
translateX: 800,
duration: 4,
repeatDelay: 3,
}}
/>
<CollisionMechanism
parentRef={parentRef}
beamOptions={{
initialX: 200,
translateX: 1200,
duration: 5,
repeatDelay: 3,
}}
/>
<CollisionMechanism
parentRef={parentRef}
beamOptions={{
initialX: 400,
translateX: 1400,
duration: 6,
repeatDelay: 3,
}}
/>
</>
)}
<div className="mx-auto w-full max-w-7xl min-w-0 pt-36">
<div className="mt-4 flex w-full min-w-0 flex-col items-start px-2 md:px-8 xl:px-0">
<h1
className={cn(
"relative mt-4 max-w-7xl text-left text-4xl font-bold tracking-tight text-balance text-neutral-900 sm:text-5xl md:text-6xl xl:text-8xl dark:text-neutral-50"
)}
>
<Balancer>NotebookLM for Teams</Balancer>
</h1>
<div className="mt-4 flex w-full flex-col items-start justify-between gap-4 md:mt-12 md:flex-row md:items-end md:gap-10">
<div>
<h2
className={cn(
"relative mb-8 max-w-2xl text-left text-sm tracking-wide text-neutral-600 antialiased sm:text-base md:text-xl dark:text-neutral-400"
)}
>
An open source, privacy focused alternative to NotebookLM for teams with no data
limits.
</h2>
<h2 className="relative z-50 mx-auto mb-4 mt-8 max-w-4xl text-balance text-center text-3xl font-semibold tracking-tight text-gray-700 md:text-7xl dark:text-neutral-300">
<div className="relative mx-auto inline-block w-max filter-[drop-shadow(0px_1px_3px_rgba(27,37,80,0.14))]">
<div className="text-black [text-shadow:0_0_rgba(0,0,0,0.1)] dark:text-white">
<Balancer>NotebookLM for Teams</Balancer>
<div className="relative mb-4 flex w-full flex-col justify-center gap-y-2 sm:flex-row sm:justify-start sm:space-y-0 sm:space-x-4">
<DownloadButton />
<GetStartedButton />
</div>
</div>
</div>
</h2>
<p className="relative z-50 mx-auto mt-4 max-w-lg px-6 text-center text-sm leading-relaxed text-gray-600 sm:text-base sm:leading-relaxed md:max-w-xl md:text-lg md:leading-relaxed dark:text-gray-200">
Connect any LLM to your internal knowledge sources and chat with it in real time alongside
your team.
</p>
<div className="mb-6 mt-6 flex w-full flex-col items-center justify-center gap-4 px-8 sm:flex-row md:mb-10">
<GetStartedButton />
{/* <ContactSalesButton /> */}
</div>
<div ref={containerRef} className="relative w-full z-51">
<HeroCarousel />
<BrowserWindow />
</div>
</div>
);
@ -158,256 +164,196 @@ function GetStartedButton() {
if (isGoogleAuth) {
return (
<motion.button
<button
type="button"
onClick={handleGoogleLogin}
whileHover="hover"
whileTap={{ scale: 0.98 }}
initial="idle"
className="group relative z-20 flex h-11 w-full cursor-pointer items-center justify-center gap-3 overflow-hidden rounded-xl bg-white px-6 py-2.5 text-sm font-semibold text-neutral-700 shadow-lg ring-1 ring-neutral-200/50 transition-shadow duration-300 hover:shadow-xl sm:w-56 dark:bg-neutral-900 dark:text-neutral-200 dark:ring-neutral-700/50"
variants={{
idle: { scale: 1, y: 0 },
hover: { scale: 1.02, y: -2 },
}}
className="flex h-14 w-full cursor-pointer items-center justify-center gap-3 rounded-lg bg-white text-center text-base font-medium text-neutral-700 shadow-sm ring-1 shadow-black/10 ring-black/10 transition duration-150 active:scale-98 hover:bg-neutral-50 sm:w-56 dark:bg-neutral-900 dark:text-neutral-200 dark:ring-neutral-700/50 dark:hover:bg-neutral-800"
>
{/* Animated gradient background on hover */}
<motion.div
className="absolute inset-0 bg-linear-to-r from-blue-50 via-green-50 to-yellow-50 dark:from-blue-950/30 dark:via-green-950/30 dark:to-yellow-950/30"
variants={{
idle: { opacity: 0 },
hover: { opacity: 1 },
}}
transition={{ duration: 0.3 }}
/>
{/* Google logo with subtle animation */}
<motion.div
className="relative"
variants={{
idle: { rotate: 0 },
hover: { rotate: [0, -8, 8, 0] },
}}
transition={{ duration: 0.4, ease: "easeInOut" }}
>
<GoogleLogo className="h-5 w-5" />
</motion.div>
<span className="relative">Continue with Google</span>
</motion.button>
<GoogleLogo className="h-5 w-5" />
<span>Continue with Google</span>
</button>
);
}
return (
<motion.div whileHover={{ scale: 1.02, y: -2 }} whileTap={{ scale: 0.98 }}>
<Link
href="/login"
className="group relative z-20 flex h-11 w-full cursor-pointer items-center justify-center gap-2 rounded-xl bg-black px-6 py-2.5 text-sm font-semibold text-white shadow-lg transition-shadow duration-300 hover:shadow-xl sm:w-56 dark:bg-white dark:text-black"
>
Get Started
</Link>
</motion.div>
<Link
href="/login"
className="flex h-14 w-full items-center justify-center rounded-lg bg-black text-center text-base font-medium text-white shadow-sm ring-1 shadow-black/10 ring-black/10 transition duration-150 active:scale-98 sm:w-52 dark:bg-white dark:text-black"
>
Get Started
</Link>
);
}
const BackgroundGrids = () => {
function useUserOS() {
const [os, setOs] = useState<"macOS" | "Windows" | "Linux">("macOS");
useEffect(() => {
const ua = navigator.userAgent;
if (/Windows/i.test(ua)) setOs("Windows");
else if (/Linux/i.test(ua)) setOs("Linux");
else setOs("macOS");
}, []);
return os;
}
function DownloadButton() {
const os = useUserOS();
return (
<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">
<GridLineVertical className="left-0" />
<GridLineVertical className="left-auto right-0" />
</div>
<div className="relative h-full w-full">
<GridLineVertical className="left-0" />
<GridLineVertical className="left-auto right-0" />
</div>
<div className="relative h-full w-full bg-linear-to-b from-transparent via-neutral-100 to-transparent dark:via-neutral-800">
<GridLineVertical className="left-0" />
<GridLineVertical className="left-auto right-0" />
</div>
<div className="relative h-full w-full">
<GridLineVertical className="left-0" />
<GridLineVertical className="left-auto right-0" />
</div>
</div>
<a
href={GITHUB_RELEASES_URL}
target="_blank"
rel="noopener noreferrer"
className="flex h-14 w-full items-center justify-center gap-2 rounded-lg border border-neutral-200 bg-white text-center text-base font-medium text-neutral-700 shadow-sm transition duration-150 active:scale-98 hover:bg-neutral-50 sm:w-56 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-200 dark:hover:bg-neutral-800"
>
<Download className="size-4" />
Download for {os}
</a>
);
};
}
const CollisionMechanism = ({
parentRef,
beamOptions = {},
}: {
parentRef: React.RefObject<HTMLDivElement | null>;
beamOptions?: {
initialX?: number;
translateX?: number;
initialY?: number;
translateY?: number;
rotate?: number;
className?: string;
duration?: number;
delay?: number;
repeatDelay?: number;
};
}) => {
const beamRef = useRef<HTMLDivElement>(null);
const [collision, setCollision] = useState<{
detected: boolean;
coordinates: { x: number; y: number } | null;
}>({ detected: false, coordinates: null });
const [beamKey, setBeamKey] = useState(0);
const [cycleCollisionDetected, setCycleCollisionDetected] = useState(false);
useEffect(() => {
const checkCollision = () => {
if (beamRef.current && parentRef.current && !cycleCollisionDetected) {
const beamRect = beamRef.current.getBoundingClientRect();
const parentRect = parentRef.current.getBoundingClientRect();
const rightEdge = parentRect.right;
if (beamRect.right >= rightEdge - 20) {
const relativeX = parentRect.width - 20;
const relativeY = beamRect.top - parentRect.top + beamRect.height / 2;
setCollision({
detected: true,
coordinates: { x: relativeX, y: relativeY },
});
setCycleCollisionDetected(true);
if (beamRef.current) {
beamRef.current.style.opacity = "0";
}
}
}
};
const animationInterval = setInterval(checkCollision, 100);
return () => clearInterval(animationInterval);
}, [cycleCollisionDetected, parentRef]);
useEffect(() => {
if (!collision.detected || !collision.coordinates) return;
const timer1 = setTimeout(() => {
setCollision({ detected: false, coordinates: null });
setCycleCollisionDetected(false);
if (beamRef.current) {
beamRef.current.style.opacity = "1";
}
}, 2000);
const timer2 = setTimeout(() => {
setBeamKey((prevKey) => prevKey + 1);
}, 2000);
return () => {
clearTimeout(timer1);
clearTimeout(timer2);
};
}, [collision]);
const BrowserWindow = () => {
const [selectedIndex, setSelectedIndex] = useState(0);
const selectedItem = TAB_ITEMS[selectedIndex];
const { expanded, open, close } = useExpandedMedia();
return (
<>
<motion.div
key={beamKey}
ref={beamRef}
animate="animate"
initial={{
translateY: beamOptions.initialY || "-200px",
translateX: beamOptions.initialX || "0px",
rotate: beamOptions.rotate || -45,
}}
variants={{
animate: {
translateY: beamOptions.translateY || "800px",
translateX: beamOptions.translateX || "700px",
rotate: beamOptions.rotate || -45,
},
}}
transition={{
duration: beamOptions.duration || 8,
repeat: Infinity,
repeatType: "loop",
ease: "linear",
delay: beamOptions.delay || 0,
repeatDelay: beamOptions.repeatDelay || 0,
}}
className={cn(
"absolute left-96 top-20 m-auto h-14 w-px rounded-full bg-linear-to-t from-orange-500 via-yellow-500 to-transparent will-change-transform",
beamOptions.className
)}
/>
<motion.div className="relative my-4 flex w-full flex-col items-start justify-start overflow-hidden rounded-2xl shadow-2xl md:my-12">
<div className="flex w-full items-center justify-start overflow-hidden bg-gray-200 py-4 pl-4 dark:bg-neutral-800">
<div className="mr-6 flex items-center gap-2">
<div className="size-3 rounded-full bg-red-500" />
<div className="size-3 rounded-full bg-yellow-500" />
<div className="size-3 rounded-full bg-green-500" />
</div>
<div className="no-visible-scrollbar flex min-w-0 shrink flex-row items-center justify-start gap-2 overflow-x-auto mask-l-from-98% py-0.5 pr-2 pl-2 md:pl-4">
{TAB_ITEMS.map((item, index) => (
<React.Fragment key={item.title}>
<button
type="button"
onClick={() => setSelectedIndex(index)}
className={cn(
"flex shrink-0 items-center gap-1.5 rounded-md px-2 py-1 text-xs transition duration-150 hover:bg-white sm:text-sm dark:hover:bg-neutral-950",
selectedIndex === index &&
!item.featured &&
"bg-white shadow ring-1 shadow-black/10 ring-black/10 dark:bg-neutral-900",
selectedIndex === index &&
item.featured &&
"bg-amber-50 shadow ring-1 shadow-amber-200/50 ring-amber-400/60 dark:bg-amber-950/40 dark:shadow-amber-900/30 dark:ring-amber-500/50",
item.featured &&
selectedIndex !== index &&
"hover:bg-amber-50 dark:hover:bg-amber-950/30"
)}
>
{item.title}
{item.featured && (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex shrink-0 items-center justify-center rounded border border-amber-300 bg-amber-100 p-0.5 text-amber-700 dark:border-amber-700 dark:bg-amber-900/50 dark:text-amber-400">
<Monitor className="size-3" />
</span>
</TooltipTrigger>
<TooltipContent side="bottom">Desktop app only</TooltipContent>
</Tooltip>
)}
</button>
{index !== TAB_ITEMS.length - 1 && (
<div className="h-4 w-px shrink-0 rounded-full bg-neutral-300 dark:bg-neutral-700" />
)}
</React.Fragment>
))}
</div>
</div>
<div className="w-full overflow-hidden bg-gray-100/50 px-4 pt-4 perspective-distant dark:bg-neutral-950">
<AnimatePresence mode="wait">
<motion.div
initial={{
opacity: 0,
scale: 0.99,
filter: "blur(10px)",
}}
animate={{
opacity: 1,
scale: 1,
filter: "blur(0px)",
}}
exit={{
opacity: 0,
scale: 0.98,
filter: "blur(10px)",
}}
transition={{
duration: 0.3,
ease: "easeOut",
}}
key={selectedItem.title}
className="relative overflow-hidden rounded-tl-xl rounded-tr-xl bg-white shadow-sm ring-1 shadow-black/10 ring-black/10 will-change-transform dark:bg-neutral-950"
>
<div className="flex items-center gap-3 border-b border-neutral-200/60 px-4 py-3 sm:px-6 sm:py-4 dark:border-neutral-700/60">
<div className="min-w-0">
<h3 className="truncate text-base font-semibold text-neutral-900 sm:text-lg dark:text-white">
{selectedItem.title}
</h3>
<p className="text-sm text-neutral-500 dark:text-neutral-400">
{selectedItem.description}
</p>
</div>
</div>
<button
type="button"
className="cursor-pointer bg-neutral-50 p-2 sm:p-3 dark:bg-neutral-950 w-full"
onClick={open}
>
<TabVideo src={selectedItem.src} />
</button>
</motion.div>
</AnimatePresence>
</div>
</motion.div>
<AnimatePresence>
{collision.detected && collision.coordinates && (
<Explosion
key={`${collision.coordinates.x}-${collision.coordinates.y}`}
className=""
style={{
left: `${collision.coordinates.x + 20}px`,
top: `${collision.coordinates.y}px`,
transform: "translate(-50%, -50%)",
}}
/>
{expanded && (
<ExpandedMediaOverlay src={selectedItem.src} alt={selectedItem.title} onClose={close} />
)}
</AnimatePresence>
</>
);
};
const Explosion = ({ ...props }: React.HTMLProps<HTMLDivElement>) => {
const spans = Array.from({ length: 20 }, (_, index) => ({
id: index,
initialX: 0,
initialY: 0,
directionX: Math.floor(Math.random() * 80 - 40),
directionY: Math.floor(Math.random() * -50 - 10),
}));
const TabVideo = memo(function TabVideo({ src }: { src: string }) {
const videoRef = useRef<HTMLVideoElement>(null);
const [hasLoaded, setHasLoaded] = useState(false);
useEffect(() => {
setHasLoaded(false);
const video = videoRef.current;
if (!video) return;
video.currentTime = 0;
video.play().catch(() => {});
}, [src]);
const handleCanPlay = useCallback(() => {
setHasLoaded(true);
}, []);
return (
<div {...props} className={cn("absolute z-50 h-2 w-2", props.className)}>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: [0, 1, 0] }}
exit={{ opacity: 0 }}
transition={{ duration: 1, ease: "easeOut" }}
className="absolute -inset-x-10 top-0 m-auto h-[4px] w-10 rounded-full bg-linear-to-r from-transparent via-orange-500 to-transparent blur-sm"
></motion.div>
{spans.map((span) => (
<motion.span
key={span.id}
initial={{ x: span.initialX, y: span.initialY, opacity: 1 }}
animate={{ x: span.directionX, y: span.directionY, opacity: 0 }}
transition={{ duration: Math.random() * 1.5 + 0.5, ease: "easeOut" }}
className="absolute h-1 w-1 rounded-full bg-linear-to-b from-orange-500 to-yellow-500"
/>
))}
<div className="relative">
<video
ref={videoRef}
key={src}
src={src}
preload="auto"
loop
muted
playsInline
onCanPlay={handleCanPlay}
className="aspect-video 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>
);
};
});
const GITHUB_RELEASES_URL = "https://github.com/MODSetter/SurfSense/releases/latest";
const GridLineVertical = ({ className, offset }: { className?: string; offset?: string }) => {
return (
<div
style={
{
"--background": "#ffffff",
"--color": "rgba(0, 0, 0, 0.2)",
"--height": "5px",
"--width": "1px",
"--fade-stop": "90%",
"--offset": offset || "150px", //-100px if you want to keep the line inside
"--color-dark": "rgba(255, 255, 255, 0.3)",
maskComposite: "exclude",
} as React.CSSProperties
}
className={cn(
"absolute top-[calc(var(--offset)/2*-1)] h-[calc(100%+var(--offset))] w-(--width)",
"bg-[linear-gradient(to_bottom,var(--color),var(--color)_50%,transparent_0,transparent)]",
"bg-size-[var(--width)_var(--height)]",
"[mask:linear-gradient(to_top,var(--background)_var(--fade-stop),transparent),linear-gradient(to_bottom,var(--background)_var(--fade-stop),transparent),linear-gradient(black,black)]",
"mask-exclude",
"z-30",
"dark:bg-[linear-gradient(to_bottom,var(--color-dark),var(--color-dark)_50%,transparent_0,transparent)]",
className
)}
></div>
);
};

View file

@ -32,7 +32,7 @@ export const Navbar = ({ scrolledBgClassName }: NavbarProps = {}) => {
};
handleScroll();
window.addEventListener("scroll", handleScroll);
window.addEventListener("scroll", handleScroll, { passive: true });
return () => window.removeEventListener("scroll", handleScroll);
}, []);
@ -132,7 +132,7 @@ const MobileNav = ({ navItems, isScrolled, scrolledBgClassName }: any) => {
};
document.addEventListener("mousedown", handleClickOutside);
document.addEventListener("touchstart", handleClickOutside);
document.addEventListener("touchstart", handleClickOutside, { passive: true });
return () => {
document.removeEventListener("mousedown", handleClickOutside);
document.removeEventListener("touchstart", handleClickOutside);
@ -151,6 +151,13 @@ const MobileNav = ({ navItems, isScrolled, scrolledBgClassName }: any) => {
"bg-white/80 backdrop-blur-md border border-white/20 shadow-lg dark:bg-neutral-950/80 dark:border-neutral-800/50")
: "bg-transparent border border-transparent"
)}
className={cn(
"relative mx-auto flex w-full max-w-[calc(100vw-2rem)] flex-col items-center justify-between px-4 py-2 lg:hidden transition-all duration-300",
isScrolled
? (scrolledBgClassName ??
"bg-white/80 backdrop-blur-md border border-white/20 shadow-lg dark:bg-neutral-950/80 dark:border-neutral-800/50")
: "bg-transparent border border-transparent"
)}
>
<div className="flex w-full flex-row items-center justify-between">
<Link

View file

@ -1,6 +1,7 @@
"use client";
import { AnimatePresence, motion } from "motion/react";
import Image from "next/image";
import { ExpandedGifOverlay, useExpandedGif } from "@/components/ui/expanded-gif-overlay";
const useCases = [
@ -81,6 +82,15 @@ function UseCaseCard({
alt={title}
className="w-full rounded-xl object-cover transition-transform duration-500 group-hover:scale-[1.02]"
/>
<div className="relative w-full h-48">
<Image
src={src}
alt={title}
fill
className="rounded-xl object-cover transition-transform duration-500 group-hover:scale-[1.02]"
unoptimized={src.endsWith(".gif")}
/>
</div>
</div>
<div className="px-5 py-4">
<h3 className="text-base font-semibold text-neutral-900 dark:text-white">{title}</h3>

View file

@ -0,0 +1,446 @@
"use client";
import { useRef, useState } from "react";
import { motion, useInView } from "motion/react";
import { IconPointerFilled } from "@tabler/icons-react";
import { Check, X } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils";
const cards = [
{
title: "Unlimited & Self-Hosted",
description:
"No caps on sources, notebooks, or file sizes. Deploy on your own infra and your data never leaves your control.",
skeleton: <UnlimitedSkeleton />,
},
{
title: "100+ LLMs, Zero Lock-in",
description:
"Swap between 100+ LLMs via OpenAI spec and LiteLLM, or run fully private with vLLM, Ollama, and more.",
skeleton: <LLMFlexibilitySkeleton />,
},
{
title: "Real-Time Multiplayer",
description:
"RBAC with Owner, Admin, Editor, and Viewer roles plus real-time chat and comment threads. Built for teams.",
skeleton: <MultiplayerSkeleton />,
},
];
export function WhySurfSense() {
return (
<section className="px-4 py-10 md:px-8 md:py-24 lg:px-16 lg:py-32">
<div className="mx-auto mb-10 max-w-3xl text-center md:mb-16">
<p className="mb-3 text-sm font-semibold uppercase tracking-widest text-brand">
Why SurfSense
</p>
<h2 className="text-balance text-3xl font-bold tracking-tight text-foreground sm:text-4xl lg:text-5xl">
Everything NotebookLM should have been
</h2>
<p className="mx-auto mt-4 max-w-2xl text-base text-muted-foreground">
Open source. No data limits. No vendor lock-in. Built for teams that
care about privacy and flexibility.
</p>
</div>
<div className="mx-auto grid w-full max-w-6xl grid-cols-1 divide-x-0 divide-y divide-border overflow-hidden rounded-2xl shadow-sm ring-1 ring-border md:grid-cols-3 md:divide-x md:divide-y-0">
{cards.map((card) => (
<FeatureCard key={card.title} {...card} />
))}
</div>
<ComparisonStrip />
</section>
);
}
function UnlimitedSkeleton({ className }: { className?: string }) {
const ref = useRef(null);
const isInView = useInView(ref, { once: true, margin: "-50px" });
const items = [
{ label: "Sources", notebookLm: "50-600", surfSense: "Unlimited", icon: "📄" },
{ label: "Notebooks", notebookLm: "100-500", surfSense: "Unlimited", icon: "📓" },
{ label: "File size", notebookLm: "200 MB", surfSense: "No limit", icon: "📦" },
{ label: "Self-host", notebookLm: "No", surfSense: "Yes", icon: "🏠" },
];
return (
<div
ref={ref}
className={cn("flex h-full flex-col justify-center gap-2.5", className)}
>
{items.map((item, index) => (
<motion.div
key={item.label}
initial={{ opacity: 0, x: -16 }}
animate={isInView ? { opacity: 1, x: 0 } : {}}
transition={{ duration: 0.35, delay: index * 0.15 }}
className="flex items-center gap-2 rounded-lg bg-background px-3 py-2 shadow-sm ring-1 ring-border"
>
<span className="text-sm">{item.icon}</span>
<span className="min-w-[60px] text-xs font-medium text-foreground">
{item.label}
</span>
<div className="ml-auto flex items-center gap-2">
<span className="text-[10px] text-muted-foreground line-through">
{item.notebookLm}
</span>
<motion.div
initial={{ scale: 0.8 }}
animate={isInView ? { scale: 1 } : {}}
transition={{
duration: 0.3,
delay: index * 0.15 + 0.2,
type: "spring",
stiffness: 300,
}}
>
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">
{item.surfSense}
</Badge>
</motion.div>
</div>
</motion.div>
))}
</div>
);
}
function LLMFlexibilitySkeleton({ className }: { className?: string }) {
const ref = useRef(null);
const isInView = useInView(ref, { once: true, margin: "-50px" });
const [selected, setSelected] = useState(0);
const models = [
{ name: "GPT-4o", provider: "OpenAI", color: "bg-green-500" },
{ name: "Claude 4", provider: "Anthropic", color: "bg-orange-500" },
{ name: "Gemini 2.5", provider: "Google", color: "bg-blue-500" },
{ name: "Llama 4", provider: "Local", color: "bg-purple-500" },
{ name: "DeepSeek R1", provider: "DeepSeek", color: "bg-cyan-500" },
];
return (
<div
ref={ref}
className={cn(
"flex h-full flex-col items-center justify-center gap-3",
className,
)}
>
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={isInView ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.3 }}
className="flex w-full max-w-[180px] flex-col gap-1.5"
>
{models.map((model, index) => (
<motion.button
key={model.name}
type="button"
onClick={() => setSelected(index)}
initial={{ opacity: 0, x: 12 }}
animate={isInView ? { opacity: 1, x: 0 } : {}}
transition={{ duration: 0.3, delay: 0.1 + index * 0.1 }}
className={cn(
"flex w-full cursor-pointer items-center gap-2 rounded-lg px-2.5 py-1.5 text-left transition-all",
selected === index
? "bg-background shadow-sm ring-1 ring-border"
: "hover:bg-accent",
)}
>
<div className={cn("size-2 shrink-0 rounded-full", model.color)} />
<div className="min-w-0">
<p className="truncate text-xs font-medium text-foreground">
{model.name}
</p>
<p className="text-[10px] text-muted-foreground">
{model.provider}
</p>
</div>
{selected === index && (
<motion.div
layoutId="model-check"
className="ml-auto"
transition={{ type: "spring", stiffness: 400, damping: 25 }}
>
<Check className="size-3 text-brand" />
</motion.div>
)}
</motion.button>
))}
</motion.div>
</div>
);
}
function MultiplayerSkeleton({ className }: { className?: string }) {
const ref = useRef(null);
const isInView = useInView(ref, { once: true, margin: "-50px" });
const collaborators = [
{
id: 1,
name: "Alice",
role: "Editor",
color: "#3b82f6",
path: [
{ x: 15, y: 10 },
{ x: 80, y: 40 },
{ x: 40, y: 80 },
{ x: 15, y: 10 },
],
},
{
id: 2,
name: "Bob",
role: "Viewer",
color: "#10b981",
path: [
{ x: 115, y: 70 },
{ x: 55, y: 20 },
{ x: 95, y: 50 },
{ x: 115, y: 70 },
],
},
];
const codeLines = [
{ indent: 0, width: "60%", color: "bg-chart-4/60" },
{ indent: 1, width: "75%", color: "bg-muted-foreground/20" },
{ indent: 1, width: "50%", color: "bg-chart-1/60" },
{ indent: 2, width: "80%", color: "bg-muted-foreground/20" },
{ indent: 2, width: "45%", color: "bg-chart-2/60" },
{ indent: 1, width: "30%", color: "bg-muted-foreground/20" },
{ indent: 0, width: "20%", color: "bg-chart-4/60" },
];
return (
<div
ref={ref}
className={cn(
"relative flex h-full items-center justify-center overflow-visible",
className,
)}
>
<motion.div
className="relative w-full max-w-[160px] rounded-lg bg-background p-3 shadow-sm ring-1 ring-border"
initial={{ opacity: 0, y: 10 }}
animate={isInView ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.4 }}
>
<div className="mb-2 flex items-center gap-1.5">
<div className="flex gap-1">
<div className="size-1.5 rounded-full bg-red-400" />
<div className="size-1.5 rounded-full bg-yellow-400" />
<div className="size-1.5 rounded-full bg-green-400" />
</div>
<div className="ml-2 h-1.5 w-12 rounded-full bg-muted" />
</div>
{codeLines.map((line, index) => (
<div
key={index}
className="my-1.5 flex items-center"
style={{ paddingLeft: line.indent * 8 }}
>
<div
className={cn("h-1.5 rounded-full", line.color)}
style={{ width: line.width }}
/>
</div>
))}
</motion.div>
{collaborators.map((collaborator, index) => (
<motion.div
key={collaborator.id}
className="absolute"
initial={{ opacity: 0 }}
animate={
isInView
? {
opacity: 1,
x: collaborator.path.map((p) => p.x),
y: collaborator.path.map((p) => p.y),
}
: {}
}
transition={{
opacity: { duration: 0.3, delay: 0.5 + index * 0.2 },
x: {
duration: 6,
delay: 0.5 + index * 0.3,
repeat: Infinity,
ease: "easeInOut",
},
y: {
duration: 6,
delay: 0.5 + index * 0.3,
repeat: Infinity,
ease: "easeInOut",
},
}}
>
<IconPointerFilled
className="size-5 drop-shadow-sm"
style={{ color: collaborator.color }}
/>
<div
className="absolute top-5 left-3 z-50 flex w-max items-center gap-1.5 rounded-full py-1 pr-2.5 pl-1 shadow-sm"
style={{ backgroundColor: collaborator.color }}
>
<div className="flex size-5 items-center justify-center rounded-full bg-white/20 text-[9px] font-bold text-white">
{collaborator.name[0]}
</div>
<span className="shrink-0 text-[10px] font-medium text-white">
{collaborator.name}
</span>
<span className="rounded bg-white/20 px-1 py-px text-[8px] text-white/80">
{collaborator.role}
</span>
</div>
</motion.div>
))}
</div>
);
}
function FeatureCard({
title,
description,
skeleton,
}: {
title: string;
description: string;
skeleton: React.ReactNode;
}) {
return (
<div className="flex h-full flex-col justify-between bg-card p-10 first:rounded-l-2xl last:rounded-r-2xl">
<div className="h-60 w-full overflow-visible rounded-md">{skeleton}</div>
<div className="mt-4">
<h3 className="text-base font-bold tracking-tight text-card-foreground">
{title}
</h3>
<p className="mt-2 text-sm leading-relaxed tracking-tight text-muted-foreground">
{description}
</p>
</div>
</div>
);
}
const comparisonRows: {
feature: string;
notebookLm: string | boolean;
surfSense: string | boolean;
}[] = [
{
feature: "Sources per Notebook",
notebookLm: "50-600",
surfSense: "Unlimited",
},
{
feature: "LLM Support",
notebookLm: "Gemini only",
surfSense: "100+ LLMs",
},
{
feature: "Self-Hostable",
notebookLm: false,
surfSense: true,
},
{
feature: "Open Source",
notebookLm: false,
surfSense: true,
},
{
feature: "External Connectors",
notebookLm: "Limited",
surfSense: "27+",
},
{
feature: "Desktop App",
notebookLm: false,
surfSense: true,
},
{
feature: "Agentic Architecture",
notebookLm: false,
surfSense: true,
},
];
function ComparisonStrip() {
const ref = useRef(null);
const isInView = useInView(ref, { once: true, margin: "-80px" });
return (
<motion.div
ref={ref}
initial={{ opacity: 0, y: 20 }}
animate={isInView ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.5, delay: 0.1 }}
className="mx-auto mt-12 w-full max-w-4xl overflow-hidden rounded-2xl bg-card shadow-sm ring-1 ring-border"
>
<div className="grid grid-cols-3 px-4 py-3 sm:px-6">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Feature
</span>
<span className="text-center text-xs font-semibold uppercase tracking-wider text-muted-foreground">
NotebookLM
</span>
<span className="text-center text-xs font-semibold uppercase tracking-wider text-muted-foreground">
SurfSense
</span>
</div>
<Separator />
{comparisonRows.map((row, index) => (
<motion.div
key={row.feature}
initial={{ opacity: 0, x: -10 }}
animate={isInView ? { opacity: 1, x: 0 } : {}}
transition={{ duration: 0.3, delay: 0.15 + index * 0.06 }}
>
<div className="grid grid-cols-3 items-center px-4 py-2.5 text-sm sm:px-6">
<span className="font-medium text-card-foreground">
{row.feature}
</span>
<span className="flex justify-center">
{typeof row.notebookLm === "boolean" ? (
row.notebookLm ? (
<Check className="size-4 text-brand" />
) : (
<X className="size-4 text-muted-foreground/40" />
)
) : (
<span className="text-muted-foreground">
{row.notebookLm}
</span>
)}
</span>
<span className="flex justify-center">
{typeof row.surfSense === "boolean" ? (
row.surfSense ? (
<Check className="size-4 text-brand" />
) : (
<X className="size-4 text-muted-foreground/40" />
)
) : (
<Badge variant="secondary">{row.surfSense}</Badge>
)}
</span>
</div>
{index !== comparisonRows.length - 1 && (
<Separator />
)}
</motion.div>
))}
</motion.div>
);
}