mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-04-25 00:36:31 +02:00
refactor: restructure instrumentation client initialization and enhance UI components for better performance and user experience
This commit is contained in:
parent
cba7923503
commit
d70e292fbe
18 changed files with 227 additions and 264 deletions
|
|
@ -1,10 +1,29 @@
|
|||
"use client";
|
||||
|
||||
import { CTAHomepage } from "@/components/homepage/cta";
|
||||
import { FeaturesBentoGrid } from "@/components/homepage/features-bento-grid";
|
||||
import { FeaturesCards } from "@/components/homepage/features-card";
|
||||
import dynamic from "next/dynamic";
|
||||
import { HeroSection } from "@/components/homepage/hero-section";
|
||||
import ExternalIntegrations from "@/components/homepage/integrations";
|
||||
|
||||
const FeaturesCards = dynamic(
|
||||
() => import("@/components/homepage/features-card").then((m) => ({ default: m.FeaturesCards })),
|
||||
{ ssr: false }
|
||||
);
|
||||
|
||||
const FeaturesBentoGrid = dynamic(
|
||||
() =>
|
||||
import("@/components/homepage/features-bento-grid").then((m) => ({
|
||||
default: m.FeaturesBentoGrid,
|
||||
})),
|
||||
{ ssr: false }
|
||||
);
|
||||
|
||||
const ExternalIntegrations = dynamic(() => import("@/components/homepage/integrations"), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
const CTAHomepage = dynamic(
|
||||
() => import("@/components/homepage/cta").then((m) => ({ default: m.CTAHomepage })),
|
||||
{ ssr: false }
|
||||
);
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -105,11 +105,19 @@ export function DocumentsFilters({
|
|||
</div>
|
||||
) : (
|
||||
filteredTypes.map((value: DocumentTypeEnum, i) => (
|
||||
<button
|
||||
type="button"
|
||||
<div
|
||||
role="option"
|
||||
aria-selected={activeTypes.includes(value)}
|
||||
tabIndex={0}
|
||||
key={value}
|
||||
className="flex w-full items-center gap-2.5 py-2 px-3 rounded-md hover:bg-neutral-200 dark:hover:bg-neutral-700 transition-colors cursor-pointer text-left"
|
||||
onClick={() => onToggleType(value, !activeTypes.includes(value))}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onToggleType(value, !activeTypes.includes(value));
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Icon */}
|
||||
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-muted/50 text-foreground/80">
|
||||
|
|
@ -132,7 +140,7 @@ export function DocumentsFilters({
|
|||
onCheckedChange={(checked: boolean) => onToggleType(value, !!checked)}
|
||||
className="h-4 w-4 shrink-0 rounded border-muted-foreground/30 data-[state=checked]:bg-primary data-[state=checked]:border-primary"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -108,6 +108,9 @@ export default function RootLayout({
|
|||
// Locale state is managed by LocaleContext and persisted in localStorage
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<head>
|
||||
<link rel="preconnect" href="https://api.github.com" />
|
||||
</head>
|
||||
<body className={cn(roboto.className, "bg-white dark:bg-black antialiased h-full w-full ")}>
|
||||
<PostHogProvider>
|
||||
<LocaleProvider>
|
||||
|
|
|
|||
|
|
@ -101,7 +101,6 @@ const ThreadContent: FC = () => {
|
|||
>
|
||||
<ThreadPrimitive.Viewport
|
||||
turnAnchor="top"
|
||||
autoScroll
|
||||
className={cn(
|
||||
"aui-thread-viewport relative flex flex-1 min-h-0 flex-col overflow-y-auto px-4 pt-4 transition-[padding] duration-300 ease-out",
|
||||
showGutter && "lg:pr-30"
|
||||
|
|
|
|||
|
|
@ -1,16 +1,8 @@
|
|||
"use client";
|
||||
|
||||
import { IconBrandGithub } from "@tabler/icons-react";
|
||||
import { StarIcon } from "lucide-react";
|
||||
import type { HTMLMotionProps, UseInViewOptions } from "motion/react";
|
||||
import {
|
||||
AnimatePresence,
|
||||
motion,
|
||||
useInView,
|
||||
useMotionValue,
|
||||
useSpring,
|
||||
useTransform,
|
||||
} from "motion/react";
|
||||
import { motion, useInView, useMotionValue, useSpring } from "motion/react";
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
|
|
@ -53,122 +45,6 @@ function useIsInView<T extends HTMLElement = HTMLElement>(
|
|||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -317,18 +193,42 @@ 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 (
|
||||
<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;
|
||||
for (const c of chars) {
|
||||
if (c >= "0" && c <= "9") totalDigits++;
|
||||
|
|
@ -407,13 +307,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);
|
||||
|
||||
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(() => {
|
||||
setHasMounted(true);
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
const abortController = new AbortController();
|
||||
|
|
@ -424,7 +324,6 @@ function NavbarGitHubStars({
|
|||
.then((data) => {
|
||||
if (data && typeof data.stargazers_count === "number") {
|
||||
setStars(data.stargazers_count);
|
||||
fillRaw.set(1);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
|
|
@ -434,7 +333,7 @@ function NavbarGitHubStars({
|
|||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
return () => abortController.abort();
|
||||
}, [username, repo, fillRaw]);
|
||||
}, [username, repo]);
|
||||
|
||||
return (
|
||||
<a
|
||||
|
|
@ -442,37 +341,20 @@ function NavbarGitHubStars({
|
|||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={cn(
|
||||
"group flex items-center gap-2 rounded-full px-3 py-1.5 transition-colors",
|
||||
"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",
|
||||
"hover:bg-neutral-100 dark:hover:bg-neutral-900",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<IconBrandGithub className="h-5 w-5 text-neutral-600 dark:text-neutral-300 shrink-0" />
|
||||
<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">
|
||||
<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" />
|
||||
<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">
|
||||
<AnimatedStarCount
|
||||
value={isLoading ? 10000 : stars}
|
||||
itemSize={ITEM_SIZE}
|
||||
isRolling={isLoading}
|
||||
isRolling={hasMounted && 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"
|
||||
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>
|
||||
</a>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -32,11 +32,24 @@ 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;
|
||||
}
|
||||
|
||||
export function HeroSection() {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const parentRef = useRef<HTMLDivElement>(null);
|
||||
const heroVariant = useFeatureFlagVariantKey("notebooklm_superpowers_flag");
|
||||
const isNotebookLMVariant = heroVariant === "superpowers";
|
||||
const isDesktop = useIsDesktop();
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -44,42 +57,46 @@ export function HeroSection() {
|
|||
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 />
|
||||
<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,
|
||||
}}
|
||||
/>
|
||||
{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,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<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">
|
||||
{isNotebookLMVariant ? (
|
||||
|
|
|
|||
|
|
@ -4,7 +4,11 @@ import { AnimatePresence, motion } from "motion/react";
|
|||
import { useCallback, useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
function ExpandedGifOverlay({
|
||||
function isVideoSrc(src: string) {
|
||||
return /\.(mp4|webm|ogg)(\?|$)/i.test(src);
|
||||
}
|
||||
|
||||
function ExpandedMediaOverlay({
|
||||
src,
|
||||
alt,
|
||||
onClose,
|
||||
|
|
@ -21,6 +25,31 @@ function ExpandedGifOverlay({
|
|||
return () => document.removeEventListener("keydown", handleKey);
|
||||
}, [onClose]);
|
||||
|
||||
const mediaElement = isVideoSrc(src) ? (
|
||||
<motion.video
|
||||
initial={{ scale: 0.85, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.85, opacity: 0 }}
|
||||
transition={{ duration: 0.25, ease: "easeOut" }}
|
||||
src={src}
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
playsInline
|
||||
className="max-h-[90vh] max-w-[90vw] cursor-pointer rounded-2xl shadow-2xl"
|
||||
/>
|
||||
) : (
|
||||
<motion.img
|
||||
initial={{ scale: 0.85, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.85, opacity: 0 }}
|
||||
transition={{ duration: 0.25, ease: "easeOut" }}
|
||||
src={src}
|
||||
alt={alt}
|
||||
className="max-h-[90vh] max-w-[90vw] cursor-pointer rounded-2xl shadow-2xl"
|
||||
/>
|
||||
);
|
||||
|
||||
return createPortal(
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
|
|
@ -30,25 +59,22 @@ function ExpandedGifOverlay({
|
|||
className="fixed inset-0 z-100 flex items-center justify-center bg-black/70 p-4 backdrop-blur-sm sm:p-8"
|
||||
onClick={onClose}
|
||||
>
|
||||
<motion.img
|
||||
initial={{ scale: 0.85, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.85, opacity: 0 }}
|
||||
transition={{ duration: 0.25, ease: "easeOut" }}
|
||||
src={src}
|
||||
alt={alt}
|
||||
className="max-h-[90vh] max-w-[90vw] cursor-pointer rounded-2xl shadow-2xl"
|
||||
/>
|
||||
{mediaElement}
|
||||
</motion.div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
||||
function useExpandedGif() {
|
||||
function useExpandedMedia() {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const open = useCallback(() => setExpanded(true), []);
|
||||
const close = useCallback(() => setExpanded(false), []);
|
||||
return { expanded, open, close };
|
||||
}
|
||||
|
||||
export { ExpandedGifOverlay, useExpandedGif };
|
||||
/** @deprecated Use ExpandedMediaOverlay instead */
|
||||
const ExpandedGifOverlay = ExpandedMediaOverlay;
|
||||
/** @deprecated Use useExpandedMedia instead */
|
||||
const useExpandedGif = useExpandedMedia;
|
||||
|
||||
export { ExpandedMediaOverlay, useExpandedMedia, ExpandedGifOverlay, useExpandedGif };
|
||||
|
|
|
|||
|
|
@ -9,59 +9,57 @@ const carouselItems = [
|
|||
title: "Connect & Sync",
|
||||
description:
|
||||
"Connect data sources like Notion, Drive and Gmail. Automatically sync to keep them updated.",
|
||||
src: "/homepage/hero_tutorial/ConnectorFlowGif.gif",
|
||||
src: "/homepage/hero_tutorial/ConnectorFlowGif.mp4",
|
||||
},
|
||||
{
|
||||
title: "Upload Documents",
|
||||
description: "Upload documents directly, from images to massive PDFs.",
|
||||
src: "/homepage/hero_tutorial/DocUploadGif.gif",
|
||||
src: "/homepage/hero_tutorial/DocUploadGif.mp4",
|
||||
},
|
||||
{
|
||||
title: "Search & Citation",
|
||||
description: "Ask questions and get cited responses from your knowledge base.",
|
||||
src: "/homepage/hero_tutorial/BSNCGif.gif",
|
||||
src: "/homepage/hero_tutorial/BSNCGif.mp4",
|
||||
},
|
||||
{
|
||||
title: "Targeted Document Q&A",
|
||||
description: "Mention specific documents in chat for targeted answers.",
|
||||
src: "/homepage/hero_tutorial/BQnaGif_compressed.gif",
|
||||
src: "/homepage/hero_tutorial/BQnaGif_compressed.mp4",
|
||||
},
|
||||
{
|
||||
title: "Produce Reports Instantly",
|
||||
description: "Generate reports from your sources in many formats.",
|
||||
src: "/homepage/hero_tutorial/ReportGenGif_compressed.gif",
|
||||
src: "/homepage/hero_tutorial/ReportGenGif_compressed.mp4",
|
||||
},
|
||||
{
|
||||
title: "Create Podcasts",
|
||||
description: "Turn anything into a podcast in under 20 seconds.",
|
||||
src: "/homepage/hero_tutorial/PodcastGenGif.gif",
|
||||
src: "/homepage/hero_tutorial/PodcastGenGif.mp4",
|
||||
},
|
||||
{
|
||||
title: "Image Generation",
|
||||
description: "Generate high-quality images easily from your conversations.",
|
||||
src: "/homepage/hero_tutorial/ImageGenGif.gif",
|
||||
src: "/homepage/hero_tutorial/ImageGenGif.mp4",
|
||||
},
|
||||
{
|
||||
title: "Collaborative AI Chat",
|
||||
description: "Collaborate on AI-powered conversations in realtime with your team.",
|
||||
src: "/homepage/hero_realtime/RealTimeChatGif.gif",
|
||||
src: "/homepage/hero_realtime/RealTimeChatGif.mp4",
|
||||
},
|
||||
{
|
||||
title: "Realtime Comments",
|
||||
description: "Add comments and tag teammates on any message.",
|
||||
src: "/homepage/hero_realtime/RealTimeCommentsFlow.gif",
|
||||
src: "/homepage/hero_realtime/RealTimeCommentsFlow.mp4",
|
||||
},
|
||||
];
|
||||
|
||||
function HeroCarouselCard({
|
||||
index,
|
||||
title,
|
||||
description,
|
||||
src,
|
||||
isActive,
|
||||
onExpandedChange,
|
||||
}: {
|
||||
index: number;
|
||||
title: string;
|
||||
description: string;
|
||||
src: string;
|
||||
|
|
@ -69,53 +67,50 @@ function HeroCarouselCard({
|
|||
onExpandedChange?: (expanded: boolean) => void;
|
||||
}) {
|
||||
const { expanded, open, close } = useExpandedGif();
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const [frozenFrame, setFrozenFrame] = useState<string | null>(null);
|
||||
const [hasLoaded, setHasLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
onExpandedChange?.(expanded);
|
||||
}, [expanded, onExpandedChange]);
|
||||
const imgRef = useRef<HTMLImageElement>(null);
|
||||
const [frozenFrame, setFrozenFrame] = useState<string | null>(null);
|
||||
const [playKey, setPlayKey] = useState(0);
|
||||
|
||||
const captureFrame = useCallback((img: HTMLImageElement) => {
|
||||
const captureFrame = useCallback((video: HTMLVideoElement) => {
|
||||
try {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = img.naturalWidth;
|
||||
canvas.height = img.naturalHeight;
|
||||
canvas.getContext("2d")?.drawImage(img, 0, 0);
|
||||
setFrozenFrame(canvas.toDataURL());
|
||||
canvas.width = video.videoWidth;
|
||||
canvas.height = video.videoHeight;
|
||||
canvas.getContext("2d")?.drawImage(video, 0, 0);
|
||||
setFrozenFrame(canvas.toDataURL("image/jpeg", 0.85));
|
||||
} catch {
|
||||
/* cross-origin or other issue */
|
||||
/* tainted canvas */
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (isActive) {
|
||||
setPlayKey((k) => k + 1);
|
||||
setFrozenFrame(null);
|
||||
setHasLoaded(false);
|
||||
if (video) {
|
||||
video.currentTime = 0;
|
||||
video.play().catch(() => {});
|
||||
}
|
||||
} else {
|
||||
const img = imgRef.current;
|
||||
if (img && img.complete && img.naturalWidth > 0) {
|
||||
captureFrame(img);
|
||||
if (video) {
|
||||
if (video.readyState >= 2) captureFrame(video);
|
||||
video.pause();
|
||||
}
|
||||
}
|
||||
}, [isActive, captureFrame]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive && !frozenFrame) {
|
||||
const img = new Image();
|
||||
img.onload = () => captureFrame(img);
|
||||
img.src = src;
|
||||
}
|
||||
}, [isActive, frozenFrame, src, captureFrame]);
|
||||
const handleCanPlay = useCallback(() => {
|
||||
setHasLoaded(true);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="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">
|
||||
{/* <span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-neutral-900 text-xs font-semibold text-white sm:h-8 sm:w-8 sm:text-sm dark:bg-white dark:text-neutral-900">
|
||||
{index + 1}
|
||||
</span> */}
|
||||
<div className="min-w-0">
|
||||
<h3 className="truncate text-base font-semibold text-neutral-900 sm:text-xl dark:text-white">
|
||||
{title}
|
||||
|
|
@ -130,13 +125,28 @@ function HeroCarouselCard({
|
|||
onClick={isActive ? open : undefined}
|
||||
>
|
||||
{isActive ? (
|
||||
<img
|
||||
ref={imgRef}
|
||||
key={`gif_${index}_${playKey}`}
|
||||
src={src}
|
||||
alt={title}
|
||||
className="w-full rounded-lg sm:rounded-xl"
|
||||
/>
|
||||
<div className="relative">
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={src}
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
playsInline
|
||||
onCanPlay={handleCanPlay}
|
||||
className="w-full rounded-lg sm:rounded-xl"
|
||||
/>
|
||||
{!hasLoaded && frozenFrame && (
|
||||
<img
|
||||
src={frozenFrame}
|
||||
alt={title}
|
||||
className="absolute inset-0 w-full rounded-lg sm:rounded-xl"
|
||||
/>
|
||||
)}
|
||||
{!hasLoaded && !frozenFrame && (
|
||||
<div className="aspect-video w-full animate-pulse rounded-lg bg-neutral-100 sm:rounded-xl dark:bg-neutral-800" />
|
||||
)}
|
||||
</div>
|
||||
) : frozenFrame ? (
|
||||
<img src={frozenFrame} alt={title} className="w-full rounded-lg sm:rounded-xl" />
|
||||
) : (
|
||||
|
|
@ -284,7 +294,6 @@ function HeroCarousel() {
|
|||
transition={{ duration: 0.7, ease: [0.32, 0.72, 0, 1] }}
|
||||
>
|
||||
<HeroCarouselCard
|
||||
index={i}
|
||||
title={item.title}
|
||||
description={item.description}
|
||||
src={item.src}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,13 @@
|
|||
import posthog from "posthog-js";
|
||||
|
||||
if (process.env.NEXT_PUBLIC_POSTHOG_KEY) {
|
||||
function initPostHog() {
|
||||
if (!process.env.NEXT_PUBLIC_POSTHOG_KEY) return;
|
||||
|
||||
posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY, {
|
||||
// Use reverse proxy to bypass ad blockers
|
||||
api_host: "/ingest",
|
||||
// Required for toolbar and other UI features to work correctly
|
||||
ui_host: "https://us.posthog.com",
|
||||
defaults: "2025-11-30",
|
||||
// Disable automatic pageview capture, as we capture manually with PostHogProvider
|
||||
// This ensures proper pageview tracking with Next.js client-side navigation
|
||||
capture_pageview: "history_change",
|
||||
// Enable session recording
|
||||
capture_pageleave: true,
|
||||
before_send: (event) => {
|
||||
if (event.properties) {
|
||||
|
|
@ -21,17 +18,20 @@ if (process.env.NEXT_PUBLIC_POSTHOG_KEY) {
|
|||
}
|
||||
return event;
|
||||
},
|
||||
loaded: (posthog) => {
|
||||
// Expose PostHog to window for console access and toolbar
|
||||
loaded: (ph) => {
|
||||
if (typeof window !== "undefined") {
|
||||
window.posthog = posthog;
|
||||
window.posthog = ph;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Always expose posthog to window for debugging/toolbar access
|
||||
// This allows testing feature flags even without POSTHOG_KEY configured
|
||||
if (typeof window !== "undefined") {
|
||||
window.posthog = posthog;
|
||||
|
||||
if ("requestIdleCallback" in window) {
|
||||
requestIdleCallback(initPostHog);
|
||||
} else {
|
||||
setTimeout(initPostHog, 3500);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
BIN
surfsense_web/public/homepage/hero_realtime/RealTimeChatGif.mp4
Normal file
BIN
surfsense_web/public/homepage/hero_realtime/RealTimeChatGif.mp4
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
surfsense_web/public/homepage/hero_tutorial/BSNCGif.mp4
Normal file
BIN
surfsense_web/public/homepage/hero_tutorial/BSNCGif.mp4
Normal file
Binary file not shown.
BIN
surfsense_web/public/homepage/hero_tutorial/ConnectorFlowGif.mp4
Normal file
BIN
surfsense_web/public/homepage/hero_tutorial/ConnectorFlowGif.mp4
Normal file
Binary file not shown.
BIN
surfsense_web/public/homepage/hero_tutorial/DocUploadGif.mp4
Normal file
BIN
surfsense_web/public/homepage/hero_tutorial/DocUploadGif.mp4
Normal file
Binary file not shown.
BIN
surfsense_web/public/homepage/hero_tutorial/ImageGenGif.mp4
Normal file
BIN
surfsense_web/public/homepage/hero_tutorial/ImageGenGif.mp4
Normal file
Binary file not shown.
BIN
surfsense_web/public/homepage/hero_tutorial/PodcastGenGif.mp4
Normal file
BIN
surfsense_web/public/homepage/hero_tutorial/PodcastGenGif.mp4
Normal file
Binary file not shown.
Binary file not shown.
Loading…
Add table
Add a link
Reference in a new issue