mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-07-26 23:51:14 +02:00
feat: add react-social-media-embed package and update homepage layout with new components
This commit is contained in:
parent
3f2f327c79
commit
0bf92beb93
29 changed files with 5240 additions and 90 deletions
|
|
@ -106,7 +106,7 @@ export function FooterNew() {
|
|||
];
|
||||
return (
|
||||
<div className="border-t border-neutral-100 dark:border-white/[0.1] px-8 py-20 bg-white dark:bg-neutral-950 w-full relative overflow-hidden">
|
||||
<div className="max-w-7xl mx-auto text-sm text-neutral-500 flex sm:flex-row flex-col justify-between items-start md:px-8">
|
||||
<div className="max-w-7xl mx-auto text-sm text-neutral-600 dark:text-neutral-400 flex sm:flex-row flex-col justify-between items-start md:px-8">
|
||||
<div>
|
||||
<div className="mr-0 md:mr-4 md:flex mb-4">
|
||||
<Logo className="h-6 w-6 rounded-md mr-2" />
|
||||
|
|
|
|||
|
|
@ -77,6 +77,7 @@ function DemoTimeline({
|
|||
<Button
|
||||
variant="ghost"
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
onClick={() => setIsOpen((prev) => !prev)}
|
||||
className="h-auto w-full justify-start gap-1.5 p-0 text-left text-sm font-normal text-muted-foreground transition-colors hover:bg-transparent hover:text-accent-foreground"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -759,9 +759,10 @@ function DownloadButton() {
|
|||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
aria-label="More download options"
|
||||
className="h-auto rounded-l-none rounded-r-lg border border-neutral-200 bg-white px-2.5 text-neutral-500 shadow-sm transition duration-150 hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-400 dark:hover:bg-neutral-800"
|
||||
>
|
||||
<ChevronDown className="size-4" />
|
||||
<ChevronDown className="size-4" aria-hidden />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-64">
|
||||
|
|
@ -869,9 +870,9 @@ const UseCasePane = memo(function UseCasePane({
|
|||
<div className="relative overflow-hidden rounded-tl-xl rounded-tr-xl bg-white shadow-sm ring-1 shadow-black/10 ring-black/10 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">
|
||||
<h2 className="truncate text-base font-semibold text-neutral-900 sm:text-lg dark:text-white">
|
||||
{useCase.title}
|
||||
</h3>
|
||||
</h2>
|
||||
<p className="text-sm text-neutral-500 text-pretty dark:text-neutral-400">
|
||||
{useCase.description}
|
||||
</p>
|
||||
|
|
|
|||
111
surfsense_web/components/homepage/logo-cloud.tsx
Normal file
111
surfsense_web/components/homepage/logo-cloud.tsx
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
"use client";
|
||||
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Reveal } from "@/components/connectors-marketing/reveal";
|
||||
import { MarketingSection } from "@/components/marketing/section";
|
||||
|
||||
/**
|
||||
* Real signups pulled from prod (Google-auth), curated to the most recognizable
|
||||
* names. These are self-serve users, not signed enterprise accounts, so the
|
||||
* heading stays honest ("Used by people at") rather than implying contracts.
|
||||
*
|
||||
* Logos live in `public/logos/` — official marks from Wikimedia Commons /
|
||||
* Wikipedia (universities use their seals). Bosta, Devoteam, and Leverage Edu
|
||||
* have no clean Wikimedia logo, so they fall back to a brand favicon. Each item
|
||||
* also falls back to a text wordmark on image error.
|
||||
*/
|
||||
const COMPANIES: { title: string; file: string }[] = [
|
||||
{ title: "UC Berkeley", file: "berkeley.svg" },
|
||||
{ title: "USC", file: "usc.svg" },
|
||||
{ title: "Texas A&M", file: "tamu.svg" },
|
||||
{ title: "UW–Madison", file: "wisc.svg" },
|
||||
{ title: "Pitt", file: "pitt.svg" },
|
||||
{ title: "Korean Air", file: "koreanair.svg" },
|
||||
{ title: "Iron Mountain", file: "ironmountain.svg" },
|
||||
{ title: "Globant", file: "globant.svg" },
|
||||
{ title: "Devoteam", file: "devoteam.png" },
|
||||
{ title: "VNG", file: "vng.svg" },
|
||||
{ title: "TPBank", file: "tpbank.svg" },
|
||||
{ title: "OpenGov", file: "opengov.png" },
|
||||
{ title: "WeLab", file: "welab.png" },
|
||||
{ title: "Leverage Edu", file: "leverageedu.png" },
|
||||
{ title: "Zopper", file: "zopper.png" },
|
||||
{ title: "Tec de Monterrey", file: "tec.svg" },
|
||||
{ title: "Chulalongkorn", file: "chula.svg" },
|
||||
{ title: "Univ. of Bristol", file: "bristol.svg" },
|
||||
{ title: "Nutresa", file: "nutresa.svg" },
|
||||
{ title: "Bosta", file: "bosta.png" },
|
||||
];
|
||||
|
||||
const LOGOS_PER_SET = 10;
|
||||
const TOTAL_SETS = Math.ceil(COMPANIES.length / LOGOS_PER_SET);
|
||||
|
||||
function LogoItem({ title, file }: { title: string; file: string }) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
if (failed) {
|
||||
return (
|
||||
<span className="text-sm font-semibold text-neutral-500 dark:text-neutral-400">{title}</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
// biome-ignore lint/performance/noImgElement: swapped in/out by the cycling animation, next/image adds no value here
|
||||
<img
|
||||
src={`/logos/${file}`}
|
||||
alt={title}
|
||||
title={title}
|
||||
width={130}
|
||||
height={40}
|
||||
loading="lazy"
|
||||
onError={() => setFailed(true)}
|
||||
// dark mode: dark-on-transparent marks would vanish, so render every logo as a
|
||||
// uniform light silhouette (brightness-0 + invert) instead of relying on its own color
|
||||
className="h-10 w-auto max-w-[130px] object-contain opacity-60 grayscale transition duration-300 hover:opacity-100 hover:grayscale-0 dark:opacity-70 dark:brightness-0 dark:invert dark:hover:opacity-100"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function LogoCloud() {
|
||||
const [currentSet, setCurrentSet] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setCurrentSet((prev) => (prev + 1) % TOTAL_SETS);
|
||||
}, 3000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const startIndex = currentSet * LOGOS_PER_SET;
|
||||
const currentLogos = Array.from(
|
||||
{ length: LOGOS_PER_SET },
|
||||
(_, i) => COMPANIES[(startIndex + i) % COMPANIES.length]
|
||||
);
|
||||
|
||||
return (
|
||||
<MarketingSection>
|
||||
<Reveal>
|
||||
<h2 className="mx-auto max-w-xl text-center text-lg font-medium text-neutral-600 dark:text-neutral-400">
|
||||
Used by people at
|
||||
</h2>
|
||||
</Reveal>
|
||||
<div className="mx-auto mt-10 grid max-w-4xl grid-cols-3 gap-8 sm:grid-cols-5">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{currentLogos.map((logo, index) => (
|
||||
<motion.div
|
||||
key={`${logo.title}-${currentSet}-${index}`}
|
||||
initial={{ x: 40, opacity: 0, filter: "blur(8px)" }}
|
||||
animate={{ x: 0, opacity: 1, filter: "blur(0px)" }}
|
||||
exit={{ x: -40, opacity: 0, filter: "blur(8px)" }}
|
||||
transition={{ duration: 0.4, ease: "easeOut", delay: index * 0.05 }}
|
||||
className="flex items-center justify-center"
|
||||
>
|
||||
<LogoItem title={logo.title} file={logo.file} />
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</MarketingSection>
|
||||
);
|
||||
}
|
||||
|
|
@ -250,17 +250,22 @@ const DesktopNav = ({ navItems, isScrolled, scrolledBgClassName }: DesktopNavPro
|
|||
href="https://discord.gg/ejRNvftDp9"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="SurfSense on Discord"
|
||||
className="hidden rounded-full p-2 hover:bg-gray-100 dark:hover:bg-neutral-800 transition-colors md:flex items-center justify-center"
|
||||
>
|
||||
<IconBrandDiscord className="h-5 w-5 text-neutral-600 dark:text-neutral-300" />
|
||||
<IconBrandDiscord
|
||||
className="h-5 w-5 text-neutral-600 dark:text-neutral-300"
|
||||
aria-hidden
|
||||
/>
|
||||
</Link>
|
||||
<Link
|
||||
href="https://www.reddit.com/r/SurfSense/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="SurfSense on Reddit"
|
||||
className="hidden rounded-full p-2 hover:bg-gray-100 dark:hover:bg-neutral-800 transition-colors md:flex items-center justify-center"
|
||||
>
|
||||
<IconBrandReddit className="h-5 w-5 text-neutral-600 dark:text-neutral-300" />
|
||||
<IconBrandReddit className="h-5 w-5 text-neutral-600 dark:text-neutral-300" aria-hidden />
|
||||
</Link>
|
||||
<NavbarGitHubStars className="hidden md:flex" />
|
||||
<ThemeTogglerComponent />
|
||||
|
|
@ -362,17 +367,25 @@ const MobileNav = ({ navItems, isScrolled, scrolledBgClassName }: MobileNavProps
|
|||
href="https://discord.gg/ejRNvftDp9"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="SurfSense on Discord"
|
||||
className="flex items-center justify-center rounded-lg p-2 hover:bg-gray-100 dark:hover:bg-neutral-800 transition-colors touch-manipulation"
|
||||
>
|
||||
<IconBrandDiscord className="h-5 w-5 text-neutral-600 dark:text-neutral-300" />
|
||||
<IconBrandDiscord
|
||||
className="h-5 w-5 text-neutral-600 dark:text-neutral-300"
|
||||
aria-hidden
|
||||
/>
|
||||
</Link>
|
||||
<Link
|
||||
href="https://www.reddit.com/r/SurfSense/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="SurfSense on Reddit"
|
||||
className="flex items-center justify-center rounded-lg p-2 hover:bg-gray-100 dark:hover:bg-neutral-800 transition-colors touch-manipulation"
|
||||
>
|
||||
<IconBrandReddit className="h-5 w-5 text-neutral-600 dark:text-neutral-300" />
|
||||
<IconBrandReddit
|
||||
className="h-5 w-5 text-neutral-600 dark:text-neutral-300"
|
||||
aria-hidden
|
||||
/>
|
||||
</Link>
|
||||
<NavbarGitHubStars className="rounded-lg" />
|
||||
<ThemeTogglerComponent />
|
||||
|
|
|
|||
298
surfsense_web/components/homepage/social-proof.tsx
Normal file
298
surfsense_web/components/homepage/social-proof.tsx
Normal file
|
|
@ -0,0 +1,298 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
IconBrandLinkedin,
|
||||
IconBrandTiktok,
|
||||
IconBrandX,
|
||||
IconBrandYoutube,
|
||||
} from "@tabler/icons-react";
|
||||
import { Component, type ReactNode, useEffect, useRef, useState } from "react";
|
||||
import { LinkedInEmbed, TikTokEmbed, XEmbed, YouTubeEmbed } from "react-social-media-embed";
|
||||
import { Reveal } from "@/components/connectors-marketing/reveal";
|
||||
|
||||
type Post =
|
||||
| { kind: "youtube"; url: string; title: string; channel: string }
|
||||
| { kind: "x"; url: string }
|
||||
| { kind: "linkedin"; url: string; postUrl: string }
|
||||
| { kind: "tiktok"; url: string };
|
||||
|
||||
/**
|
||||
* Organic SurfSense coverage — real posts embedded live with react-social-media-embed,
|
||||
* grouped into three platform-uniform marquee rows (heights match within a row).
|
||||
*
|
||||
* Note: LinkedIn only renders when the author enabled embedding on the post; if
|
||||
* disabled, the EmbedBoundary swaps in a link card to the original.
|
||||
*
|
||||
* ponytail: three rows, each list duplicated once for a seamless CSS loop. Ceiling:
|
||||
* heavy third-party embeds. Mitigated by lazy-mounting the whole section on scroll
|
||||
* (IntersectionObserver). Upgrade path: per-card virtualization, or swap YouTube
|
||||
* players for thumbnail links.
|
||||
*/
|
||||
const YOUTUBE: Post[] = [
|
||||
{
|
||||
kind: "youtube",
|
||||
url: "https://www.youtube.com/watch?v=i9AJ7PHGSGg",
|
||||
title: "SurfSense vs NotebookLM",
|
||||
channel: "rezasaad plus",
|
||||
},
|
||||
{
|
||||
kind: "youtube",
|
||||
url: "https://www.youtube.com/watch?v=VBOwuD6xVK0",
|
||||
title: "NotebookLM Is Great… Until You See SurfSense",
|
||||
channel: "Thomas AI",
|
||||
},
|
||||
{
|
||||
kind: "youtube",
|
||||
url: "https://www.youtube.com/watch?v=UaekqjhUiJM",
|
||||
title: "NotebookLM vs SurfSense en 6 pruebas reales (sorprendente)",
|
||||
channel: "NextGen IA Hub",
|
||||
},
|
||||
{
|
||||
kind: "youtube",
|
||||
url: "https://www.youtube.com/watch?v=QGjKpZJJ9aw",
|
||||
title: "Gana DINERO configurando “Cerebros de IA” privados con SurfSense",
|
||||
channel: "Creando Con La IA",
|
||||
},
|
||||
{
|
||||
kind: "youtube",
|
||||
url: "https://www.youtube.com/watch?v=cfNAIQtNbKY",
|
||||
title: "¿Adiós NotebookLM? Probé SurfSense y es BRUTAL (IA Gratis)",
|
||||
channel: "NextGen IA Hub",
|
||||
},
|
||||
{
|
||||
kind: "youtube",
|
||||
url: "https://www.youtube.com/watch?v=pIWOKSHhf38",
|
||||
title: "¿Superaron a NotebookLM? SurfSense es Open Source, privada y GRATIS",
|
||||
channel: "academIArtificial",
|
||||
},
|
||||
{
|
||||
kind: "youtube",
|
||||
url: "https://www.youtube.com/watch?v=K5xx-J_mQZ8",
|
||||
title: "¿Mejor que NotebookLM? IA GRATIS con modo local",
|
||||
channel: "Migue Baena IA",
|
||||
},
|
||||
{
|
||||
kind: "youtube",
|
||||
url: "https://www.youtube.com/watch?v=AKxM3RUBFsc",
|
||||
title: "¿Nueva IA GRATIS destroza a NotebookLM de Google? (OPEN SOURCE)",
|
||||
channel: "Inteligencia Artificial Top",
|
||||
},
|
||||
{
|
||||
kind: "youtube",
|
||||
url: "https://www.youtube.com/watch?v=jCAgeaVgPDA",
|
||||
title: "¿Nueva Herramienta IA GRATIS Destroza a NotebookLM? (OPEN SOURCE)",
|
||||
channel: "Joaquín Barberá",
|
||||
},
|
||||
];
|
||||
|
||||
const TWEETS: Post[] = [
|
||||
{ kind: "x", url: "https://x.com/LangChain/status/1853133037019562434" },
|
||||
{ kind: "x", url: "https://x.com/MoureDev/status/1976279289780740448" },
|
||||
{ kind: "x", url: "https://x.com/GithubProjects/status/2004892541590929490" },
|
||||
{ kind: "x", url: "https://x.com/GitHub_Daily/status/1920418408736436438" },
|
||||
{ kind: "x", url: "https://x.com/tom_doerr/status/2066062170173977088" },
|
||||
{ kind: "x", url: "https://x.com/itsharmanjot/status/2066118517905354816" },
|
||||
{ kind: "x", url: "https://x.com/JulianGoldieSEO/status/2011085275133604095" },
|
||||
{ kind: "x", url: "https://x.com/L_go_mrk/status/2066482853232115847" },
|
||||
{ kind: "x", url: "https://x.com/semihdev/status/2006275500952736028" },
|
||||
{ kind: "x", url: "https://x.com/shao__meng/status/1919912860957999494" },
|
||||
{ kind: "x", url: "https://x.com/LangChain/status/1840406184316342561" },
|
||||
];
|
||||
|
||||
const SOCIAL: Post[] = [
|
||||
{
|
||||
kind: "linkedin",
|
||||
url: "https://www.linkedin.com/embed/feed/update/urn:li:ugcPost:7448203908834938881",
|
||||
postUrl:
|
||||
"https://www.linkedin.com/posts/vikas-singh-546643206_most-ai-tools-live-in-your-browser-and-ugcPost-7448203908834938881-gR6y",
|
||||
},
|
||||
{
|
||||
kind: "linkedin",
|
||||
url: "https://www.linkedin.com/embed/feed/update/urn:li:share:7448351685409701889",
|
||||
postUrl:
|
||||
"https://www.linkedin.com/posts/neha-jain-279b80118_ive-been-using-a-lot-of-ai-tools-daily-share-7448351685409701889-JvFP",
|
||||
},
|
||||
{
|
||||
kind: "tiktok",
|
||||
url: "https://www.tiktok.com/@alejavirivera/video/7603064928114625814",
|
||||
},
|
||||
];
|
||||
|
||||
const CARD = "mr-4 shrink-0 overflow-hidden rounded-xl border bg-card";
|
||||
|
||||
const SIZE: Record<Post["kind"], string> = {
|
||||
youtube: "h-[262px] w-[340px]",
|
||||
x: "h-[440px] w-[340px]",
|
||||
linkedin: "h-[540px] w-[340px]",
|
||||
tiktok: "h-[540px] w-[340px]",
|
||||
};
|
||||
|
||||
const META: Record<Post["kind"], { Icon: typeof IconBrandX; label: string }> = {
|
||||
youtube: { Icon: IconBrandYoutube, label: "YouTube" },
|
||||
x: { Icon: IconBrandX, label: "X" },
|
||||
linkedin: { Icon: IconBrandLinkedin, label: "LinkedIn" },
|
||||
tiktok: { Icon: IconBrandTiktok, label: "TikTok" },
|
||||
};
|
||||
|
||||
/**
|
||||
* Some embeds (notably Facebook, and LinkedIn when the author disabled embedding)
|
||||
* throw at render/mount instead of degrading gracefully. This boundary stops one
|
||||
* bad embed from taking down the whole marquee — it swaps in a link card instead.
|
||||
*/
|
||||
class EmbedBoundary extends Component<
|
||||
{ fallback: ReactNode; children: ReactNode },
|
||||
{ failed: boolean }
|
||||
> {
|
||||
state = { failed: false };
|
||||
static getDerivedStateFromError() {
|
||||
return { failed: true };
|
||||
}
|
||||
render() {
|
||||
return this.state.failed ? this.props.fallback : this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
function FallbackCard({ post }: { post: Post }) {
|
||||
const { Icon, label } = META[post.kind];
|
||||
const href = post.kind === "linkedin" ? post.postUrl : post.url;
|
||||
return (
|
||||
<div className={`${CARD} ${SIZE[post.kind]}`}>
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex h-full w-full flex-col items-center justify-center gap-3 p-6 text-center text-sm font-medium text-muted-foreground transition-colors hover:text-brand"
|
||||
>
|
||||
<Icon className="size-8" aria-hidden />
|
||||
<span>View this post on {label}</span>
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Card({ post }: { post: Post }) {
|
||||
switch (post.kind) {
|
||||
case "youtube":
|
||||
return (
|
||||
<div className={`${CARD} ${SIZE.youtube} flex flex-col`}>
|
||||
<YouTubeEmbed url={post.url} width={340} height={191} />
|
||||
<div className="flex flex-1 flex-col gap-1.5 p-3">
|
||||
<div className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
||||
<IconBrandYoutube className="size-4 shrink-0 text-red-500" aria-hidden />
|
||||
<span className="truncate">{post.channel}</span>
|
||||
</div>
|
||||
<a
|
||||
href={post.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="line-clamp-2 text-sm font-medium leading-snug hover:text-brand"
|
||||
>
|
||||
{post.title}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
case "x":
|
||||
return (
|
||||
<div className={`${CARD} ${SIZE.x}`}>
|
||||
<XEmbed url={post.url} width={340} />
|
||||
</div>
|
||||
);
|
||||
case "linkedin":
|
||||
return (
|
||||
<div className={`${CARD} ${SIZE.linkedin}`}>
|
||||
<LinkedInEmbed url={post.url} postUrl={post.postUrl} width={340} height={540} />
|
||||
</div>
|
||||
);
|
||||
case "tiktok":
|
||||
return (
|
||||
<div className={`${CARD} ${SIZE.tiktok}`}>
|
||||
<TikTokEmbed url={post.url} width={340} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function Row({
|
||||
posts,
|
||||
animation,
|
||||
duration,
|
||||
}: {
|
||||
posts: Post[];
|
||||
animation: "ss-marquee-l" | "ss-marquee-r";
|
||||
duration: number;
|
||||
}) {
|
||||
return (
|
||||
<div className="group flex overflow-hidden">
|
||||
{/* Track holds the list twice; the -50% shift wraps seamlessly (margins, not gap). */}
|
||||
<div
|
||||
className="flex w-max shrink-0 group-hover:paused motion-reduce:paused"
|
||||
style={{ animation: `${animation} ${duration}s linear infinite` }}
|
||||
>
|
||||
{[...posts, ...posts].map((post, i) => (
|
||||
<EmbedBoundary
|
||||
key={`${post.kind}-${post.url}-${i}`}
|
||||
fallback={<FallbackCard post={post} />}
|
||||
>
|
||||
<Card post={post} />
|
||||
</EmbedBoundary>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SocialProof() {
|
||||
// Third-party embeds are heavy; only mount them once the section scrolls near view.
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [visible, setVisible] = useState(false);
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const io = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0]?.isIntersecting) {
|
||||
setVisible(true);
|
||||
io.disconnect();
|
||||
}
|
||||
},
|
||||
{ rootMargin: "300px" }
|
||||
);
|
||||
io.observe(el);
|
||||
return () => io.disconnect();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<section className="overflow-hidden py-12 sm:py-16">
|
||||
<Reveal>
|
||||
<div className="mx-auto max-w-2xl px-4 text-center">
|
||||
<h2 className="text-2xl font-bold tracking-tight sm:text-3xl">
|
||||
Loved across the internet
|
||||
</h2>
|
||||
</div>
|
||||
</Reveal>
|
||||
<div
|
||||
ref={ref}
|
||||
className="mt-10 flex min-h-[1360px] flex-col gap-4"
|
||||
style={{
|
||||
maskImage: "linear-gradient(to right, transparent, black 6%, black 94%, transparent)",
|
||||
WebkitMaskImage:
|
||||
"linear-gradient(to right, transparent, black 6%, black 94%, transparent)",
|
||||
}}
|
||||
>
|
||||
{visible ? (
|
||||
<>
|
||||
<Row posts={YOUTUBE} animation="ss-marquee-l" duration={60} />
|
||||
<Row posts={TWEETS} animation="ss-marquee-r" duration={75} />
|
||||
{/* Only 3 social cards — pre-double so one set spans wide viewports (no loop gap). */}
|
||||
<Row posts={[...SOCIAL, ...SOCIAL]} animation="ss-marquee-l" duration={70} />
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
<style>{`
|
||||
@keyframes ss-marquee-l { from { transform: translateX(0); } to { transform: translateX(-50%); } }
|
||||
@keyframes ss-marquee-r { from { transform: translateX(-50%); } to { transform: translateX(0); } }
|
||||
`}</style>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue