mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-19 18:45:15 +02:00
Merge branch 'dev' into fix/replace-transition-all-with-specific-transitions
This commit is contained in:
commit
e404b05b11
295 changed files with 25773 additions and 10799 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
446
surfsense_web/components/homepage/why-surfsense.tsx
Normal file
446
surfsense_web/components/homepage/why-surfsense.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue