diff --git a/.codex-artifacts/dark-docs.png b/.codex-artifacts/dark-docs.png new file mode 100644 index 0000000..c17b1af Binary files /dev/null and b/.codex-artifacts/dark-docs.png differ diff --git a/.codex-artifacts/dark-landing.png b/.codex-artifacts/dark-landing.png new file mode 100644 index 0000000..df33d33 Binary files /dev/null and b/.codex-artifacts/dark-landing.png differ diff --git a/.codex-artifacts/dev-docs-check.png b/.codex-artifacts/dev-docs-check.png new file mode 100644 index 0000000..748fe86 Binary files /dev/null and b/.codex-artifacts/dev-docs-check.png differ diff --git a/.codex-artifacts/dev-root-check.png b/.codex-artifacts/dev-root-check.png new file mode 100644 index 0000000..4e9d602 Binary files /dev/null and b/.codex-artifacts/dev-root-check.png differ diff --git a/.codex-artifacts/docs-desktop-2.png b/.codex-artifacts/docs-desktop-2.png new file mode 100644 index 0000000..c4762fc Binary files /dev/null and b/.codex-artifacts/docs-desktop-2.png differ diff --git a/.codex-artifacts/docs-desktop.png b/.codex-artifacts/docs-desktop.png new file mode 100644 index 0000000..72aa89e Binary files /dev/null and b/.codex-artifacts/docs-desktop.png differ diff --git a/.codex-artifacts/docs-mobile-2.png b/.codex-artifacts/docs-mobile-2.png new file mode 100644 index 0000000..94bbefb Binary files /dev/null and b/.codex-artifacts/docs-mobile-2.png differ diff --git a/.codex-artifacts/docs-mobile.png b/.codex-artifacts/docs-mobile.png new file mode 100644 index 0000000..4f20f40 Binary files /dev/null and b/.codex-artifacts/docs-mobile.png differ diff --git a/.codex-artifacts/landing-desktop-2.png b/.codex-artifacts/landing-desktop-2.png new file mode 100644 index 0000000..35814ba Binary files /dev/null and b/.codex-artifacts/landing-desktop-2.png differ diff --git a/.codex-artifacts/landing-desktop.png b/.codex-artifacts/landing-desktop.png new file mode 100644 index 0000000..35814ba Binary files /dev/null and b/.codex-artifacts/landing-desktop.png differ diff --git a/.codex-artifacts/landing-hero-fix.png b/.codex-artifacts/landing-hero-fix.png new file mode 100644 index 0000000..710d2fa Binary files /dev/null and b/.codex-artifacts/landing-hero-fix.png differ diff --git a/.codex-artifacts/landing-mobile-2.png b/.codex-artifacts/landing-mobile-2.png new file mode 100644 index 0000000..37fff19 Binary files /dev/null and b/.codex-artifacts/landing-mobile-2.png differ diff --git a/.codex-artifacts/landing-mobile-3.png b/.codex-artifacts/landing-mobile-3.png new file mode 100644 index 0000000..37fff19 Binary files /dev/null and b/.codex-artifacts/landing-mobile-3.png differ diff --git a/.codex-artifacts/landing-mobile-4.png b/.codex-artifacts/landing-mobile-4.png new file mode 100644 index 0000000..dace460 Binary files /dev/null and b/.codex-artifacts/landing-mobile-4.png differ diff --git a/.codex-artifacts/landing-mobile.png b/.codex-artifacts/landing-mobile.png new file mode 100644 index 0000000..80e9c05 Binary files /dev/null and b/.codex-artifacts/landing-mobile.png differ diff --git a/.codex-artifacts/twoslash-popup-fix.png b/.codex-artifacts/twoslash-popup-fix.png new file mode 100644 index 0000000..f4e7d25 Binary files /dev/null and b/.codex-artifacts/twoslash-popup-fix.png differ diff --git a/.gitignore b/.gitignore index e612436..7682f43 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .next +dist node_modules .DS_Store .vercel diff --git a/components/CopyRawMdxButton.tsx b/components/CopyRawMdxButton.tsx index b9cf7d3..995ae16 100644 --- a/components/CopyRawMdxButton.tsx +++ b/components/CopyRawMdxButton.tsx @@ -10,21 +10,33 @@ export default function CopyRawMdxButton() { const [errorMessage, setErrorMessage] = React.useState(null); const [prefetched, setPrefetched] = React.useState(null); const [isPrefetching, setIsPrefetching] = React.useState(false); + const sources = React.useMemo( + () => ["/raw/js_api.mdx.txt", "/api/raw-mdx?doc=js_api"], + [] + ); // Prefetch the MDX content early so copy can run synchronously on click (iOS Safari requirement) const prefetch = React.useCallback(async () => { if (prefetched != null || isPrefetching) return; try { setIsPrefetching(true); - const res = await fetch("/api/raw-mdx?doc=js_api"); - if (!res.ok) { - const maybeJson = await res - .json() - .catch(() => ({ message: `HTTP ${res.status}` })); - throw new Error(maybeJson.message || `HTTP ${res.status}`); + let lastError: Error | null = null; + + for (const source of sources) { + const res = await fetch(source); + if (!res.ok) { + const maybeJson = await res + .json() + .catch(() => ({ message: `HTTP ${res.status}` })); + lastError = new Error(maybeJson.message || `HTTP ${res.status}`); + continue; + } + const text = await res.text(); + setPrefetched(text); + return; } - const text = await res.text(); - setPrefetched(text); + + throw lastError ?? new Error("No content source responded"); } catch (e) { // Don't surface prefetch errors loudly; user may still retry // We'll show a message if copy is attempted without available content @@ -32,7 +44,7 @@ export default function CopyRawMdxButton() { } finally { setIsPrefetching(false); } - }, [prefetched, isPrefetching]); + }, [isPrefetching, prefetched, sources]); React.useEffect(() => { // Start prefetch when the button mounts diff --git a/components/LanguageDropdown.tsx b/components/LanguageDropdown.tsx index d2d8496..6ba177c 100644 --- a/components/LanguageDropdown.tsx +++ b/components/LanguageDropdown.tsx @@ -111,9 +111,10 @@ export default function LanguageDropdown() { @@ -121,7 +122,7 @@ export default function LanguageDropdown() { onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} className={cn( - "absolute right-0 mt-2 w-32 overflow-hidden rounded-md border border-gray-200 bg-white py-1 shadow-lg transition-opacity duration-150 dark:border-neutral-700 dark:bg-neutral-900", + "absolute right-0 mt-2 w-32 overflow-hidden rounded-2xl border border-white/10 bg-[#111a22] py-1 shadow-[0_18px_40px_rgba(0,0,0,0.45)] transition-opacity duration-150", isOpen ? "pointer-events-auto opacity-100" : "pointer-events-none opacity-0" )} > @@ -132,8 +133,8 @@ export default function LanguageDropdown() { className={cn( "flex w-full items-center gap-2 px-3 py-2 text-sm text-left transition-colors", id === activeLanguage - ? "bg-gray-100 font-medium text-gray-900 dark:bg-neutral-800 dark:text-gray-100" - : "text-gray-600 hover:bg-gray-50 hover:text-gray-900 dark:text-gray-300 dark:hover:bg-neutral-800 dark:hover:text-gray-100" + ? "bg-white/10 font-medium text-white" + : "text-white/70 hover:bg-white/6 hover:text-white" )} onClick={() => handleSelect(id)} > diff --git a/components/Testimonial.tsx b/components/Testimonial.tsx index db753f2..78f0a88 100644 --- a/components/Testimonial.tsx +++ b/components/Testimonial.tsx @@ -1,126 +1,32 @@ import { useState, useRef, useEffect } from "react"; +import cn from "clsx"; +import { Quote, X, Twitter } from "lucide-react"; -// Define CSS animations with styled component approach +// CSS animations const ModalStyles = () => ( - ); interface TestimonialProps { - id: string; - author: string; - company: string; - avatarSrc: string; - shortQuote: string; - fullQuote: string; - link?: string; - avatarAlt?: string; - tweetLink?: string; + id: string; + author: string; + company: string; + avatarSrc: string; + shortQuote: string; + fullQuote: string; + link?: string; + avatarAlt?: string; + tweetLink?: string; } export default function Testimonial({ - id, - author, - company, - avatarSrc, - shortQuote, - fullQuote, - link, - avatarAlt, - tweetLink, + id, + author, + company, + avatarSrc, + shortQuote, + fullQuote, + link, + avatarAlt, + tweetLink, }: TestimonialProps) { - const [isModalOpen, setIsModalOpen] = useState(false); - const [isExpanded, setIsExpanded] = useState(false); - const [isMobile, setIsMobile] = useState(false); - const modalRef = useRef(null); - - // Check screen size on mount and on resize - useEffect(() => { - const checkScreenSize = () => { - setIsMobile(window.innerWidth < 1024); // Consider mobile if width is less than 1024px - }; + const [isModalOpen, setIsModalOpen] = useState(false); + const [isExpanded, setIsExpanded] = useState(false); + const [isMobile, setIsMobile] = useState(false); + const [isHovered, setIsHovered] = useState(false); + const modalRef = useRef(null); - // Initialize - checkScreenSize(); + useEffect(() => { + const checkScreenSize = () => { + setIsMobile(window.innerWidth < 1024); + }; + checkScreenSize(); + window.addEventListener("resize", checkScreenSize); + return () => window.removeEventListener("resize", checkScreenSize); + }, []); - // Add resize listener - window.addEventListener('resize', checkScreenSize); + const toggleContent = () => { + if (isMobile) { + setIsExpanded(!isExpanded); + } else { + if (isExpanded) { + setIsExpanded(false); + } else { + setIsModalOpen(true); + const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth; + document.body.style.setProperty("--scrollbar-width", `${scrollbarWidth}px`); + document.body.classList.add("modal-open"); + } + } + }; - // Cleanup - return () => { - window.removeEventListener('resize', checkScreenSize); - }; - }, []); + const closeModal = () => { + setIsModalOpen(false); + document.body.classList.remove("modal-open"); + }; - const toggleContent = () => { - if (isMobile) { - setIsExpanded(!isExpanded); - } else { - if (isExpanded) { - setIsExpanded(false); - } else { - setIsModalOpen(true); - // Calculate scrollbar width to prevent page shift - const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth; - document.body.style.setProperty('--scrollbar-width', `${scrollbarWidth}px`); - document.body.classList.add('modal-open'); - } - } + useEffect(() => { + const handleEscape = (e: KeyboardEvent) => { + if (e.key === "Escape") closeModal(); }; + const handleClickOutside = (e: MouseEvent) => { + if (modalRef.current && !modalRef.current.contains(e.target as Node)) { + closeModal(); + } + }; + + if (isModalOpen) { + document.addEventListener("keydown", handleEscape); + document.addEventListener("mousedown", handleClickOutside); + } - const closeModal = () => { - setIsModalOpen(false); - document.body.classList.remove('modal-open'); + return () => { + document.removeEventListener("keydown", handleEscape); + document.removeEventListener("mousedown", handleClickOutside); + if (isModalOpen) document.body.classList.remove("modal-open"); }; + }, [isModalOpen]); - useEffect(() => { - const handleEscape = (e: KeyboardEvent) => { - if (e.key === 'Escape') { - closeModal(); - } - }; + const processQuote = (quote: string): string => { + return quote.replace(/\\n/g, "\n"); + }; - const handleClickOutside = (e: MouseEvent) => { - if (modalRef.current && !modalRef.current.contains(e.target as Node)) { - closeModal(); - } - }; + const showExpand = shortQuote !== fullQuote; - if (isModalOpen) { - document.addEventListener('keydown', handleEscape); - document.addEventListener('mousedown', handleClickOutside); - } + return ( + <> + +
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + > + {/* Hover glow */} +
- return () => { - document.removeEventListener('keydown', handleEscape); - document.removeEventListener('mousedown', handleClickOutside); + {/* Quote icon */} +
+ +
- // Clean up in case component unmounts while modal is open - if (isModalOpen) { - document.body.classList.remove('modal-open'); - } - }; - }, [isModalOpen]); +
+ {!isExpanded ? ( +
+

+ "{processQuote(shortQuote)}" +

+ {showExpand && ( +
+ Read more + + + +
+ )} +
+ ) : ( +
+
+ {processQuote(fullQuote)} +
+
+ Show less + + + +
+
+ )} +
- // Process a quote string to clean up HTML entities - const processQuote = (quote: string): string => { - return quote - .replace(/\\n/g, '\n') - } + {/* Author info */} +
+
+
+ {avatarAlt +
+
+ + {tweetLink && ( + e.stopPropagation()} + > + + + )} +
+
+
- return ( + {/* Modal for desktop */} + {isModalOpen && !isMobile && ( <> - + {/* Backdrop */} +
+ + {/* Modal container */} +
-
- {!isExpanded ? ( -
-
"{processQuote(shortQuote)}"
- {shortQuote !== fullQuote && ( -
- - Read more - - - - -
- )} -
- ) : ( -
-
- {fullQuote && processQuote(fullQuote)} -
-
- - Show less - - - - -
-
- )} -
-
-
- {avatarAlt -
-
{author}
-
- {link ? ( - {company} - ) : (<>{company})} -
-
- {tweetLink && ( - e.stopPropagation()} - > - - - - - )} -
+ {/* Close button */} + + +
+ {/* Quote */} +
+ +
+ {processQuote(fullQuote)} +
-
- {/* Modal (only for larger screens) */} - {isModalOpen && !isMobile && ( - <> -
- +
+
+
- ); -} + )} + + ); +} diff --git a/components/TimelineView/index.tsx b/components/TimelineView/index.tsx index f0df9f9..4c0675f 100644 --- a/components/TimelineView/index.tsx +++ b/components/TimelineView/index.tsx @@ -33,8 +33,8 @@ export default function Timeline({ ...props }: TimelineProps) { }, [updateTrackRange]); useEffect(updateTrackRange, [isHistoryEmpty]); return ( -
-
+
+

History

@@ -56,7 +56,7 @@ export default function Timeline({ ...props }: TimelineProps) {
-
+
diff --git a/components/TwinEditors/ConnectionToggle.tsx b/components/TwinEditors/ConnectionToggle.tsx index 4e3aebc..250603a 100644 --- a/components/TwinEditors/ConnectionToggle.tsx +++ b/components/TwinEditors/ConnectionToggle.tsx @@ -9,8 +9,6 @@ import { useUpdateConnectedHistory } from "@components/landing/store/connection- import { useIsReviewing } from "@components/landing/store/player-state"; import { useTimelineLength } from "@components/landing/store/timeline-history"; import { useCallback, useEffect } from "react"; -import OfflineIcon from "../../public/images/offline.svg"; -import OnlineIcon from "../../public/images/online.svg"; export type ConnectionToggleProps = { className?: string; @@ -57,7 +55,12 @@ export default function ConnectionToggle({ name="connection-toggle" disabled={isReviewing} > - {connected ? : } + diff --git a/components/TwinEditors/index.tsx b/components/TwinEditors/index.tsx index dd919d6..d0f903d 100644 --- a/components/TwinEditors/index.tsx +++ b/components/TwinEditors/index.tsx @@ -51,8 +51,8 @@ function TwinEditors( } }, []); return ( -
-
+
+
-
+
{ - const onResize = () => { - setPageHeight(document.body.clientHeight); - setViewportWidth(window.innerWidth); - setViewportHeight(window.innerHeight); - }; - onResize(); - window.addEventListener("resize", onResize); - return () => window.removeEventListener("resize", onResize); + const [mousePosition, setMousePosition] = useState({ x: 0, y: 0 }); + const [scrollY, setScrollY] = useState(0); + + const handleMouseMove = useCallback((e: MouseEvent) => { + setMousePosition({ + x: (e.clientX / window.innerWidth) * 100, + y: (e.clientY / window.innerHeight) * 100, + }); }, []); - const elements: JSX.Element[] = []; - if (pageHeight > 0 && viewportHeight > 0) { - const limit = Math.floor((pageHeight / viewportWidth) * 100); // number of vw - // Place circles - for (let i = 1; 50 + 100 * (i + 1) + 15 * (i + 1) < limit; i++) { - elements.push( -
- ); - elements.push( -
- ); - break; - } - } + + const handleScroll = useCallback(() => { + setScrollY(window.scrollY); + }, []); + + useEffect(() => { + window.addEventListener("mousemove", handleMouseMove, { passive: true }); + window.addEventListener("scroll", handleScroll, { passive: true }); + + return () => { + window.removeEventListener("mousemove", handleMouseMove); + window.removeEventListener("scroll", handleScroll); + }; + }, [handleMouseMove, handleScroll]); + return (
-
-
- {elements} + {/* Base gradient */} +
+ + {/* Animated gradient orbs */} +
+ +
+ +
+ + {/* Subtle grid pattern */} +
+ + {/* Noise texture overlay */} +
); } diff --git a/components/landing/CustomerWall.tsx b/components/landing/CustomerWall.tsx index c6fa62d..0171986 100644 --- a/components/landing/CustomerWall.tsx +++ b/components/landing/CustomerWall.tsx @@ -1,153 +1,137 @@ -export default function CustomerWall() { - return ( -
- {/* Section Header */} -
-

- Who's Using Loro -

-
- - {/* Main Container */} -
- {/* Customer logos container */} -
- {/* Grid container */} -
+import { useEffect, useRef, useState } from "react"; +import cn from "clsx"; - {/* Latch.bio */} -
- - Latch.bio - -
+const customers = [ + { name: "Latch.bio", logo: "/images/latchbio.svg", link: "https://latch.bio" }, + { name: "Marimo", logo: "/images/marimo.svg", link: "https://marimo.io" }, + { name: "Dora", logo: "/images/dora.png", link: "https://dora.run" }, + { name: "Subset", logo: "/images/subset.png", link: "https://subset.so" }, + { name: "Roomy", logo: "/images/roomy.png", link: "https://roomy.chat/", hasText: true }, + { name: "Nema", logo: "/images/nema.svg", link: "https://nemastudio.app/" }, + { name: "AX Semantics", logo: "/images/ax-semantics.svg", link: "https://ax-semantics.com" }, + { name: "Macro", logo: "/images/macro.png", link: "https://macro.com" }, +]; - {/* Marimo */} -
- - Marimo.io - -
+export default function CustomerWall() { + const [isVisible, setIsVisible] = useState(false); + const [hoveredIndex, setHoveredIndex] = useState(null); + const sectionRef = useRef(null); - {/* Dora */} -
- - Dora.run - -
+ useEffect(() => { + const observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + setIsVisible(true); + observer.unobserve(entry.target); + } + }); + }, + { threshold: 0.2 } + ); - {/* Subset */} -
- - Subset - -
+ if (sectionRef.current) { + observer.observe(sectionRef.current); + } - {/* Roomy */} - + return () => observer.disconnect(); + }, []); - {/* Nema */} -
- - Nema - -
+ return ( +
+ {/* Section Header */} +
+

+ Trusted by Innovative Teams +

+

+ Powering collaboration across industries +

+
- {/* AX Semantics */} -
- - ax-semantics - -
+ {/* Logo Grid */} +
- ); -} + {/* Corner accent on hover */} +
+ + ))} +
+
+
+ ); +} diff --git a/components/landing/Features.module.css b/components/landing/Features.module.css index d3611a4..c2e779d 100644 --- a/components/landing/Features.module.css +++ b/components/landing/Features.module.css @@ -1,20 +1,93 @@ .Card { position: relative; display: flex; - padding: 30px 40px; + padding: 28px; flex-direction: column; align-items: flex-start; - gap: 10px; - border-radius: 40px; - border: 1px solid #98b0ff45; - background: rgba(21, 21, 21, 0.4); - border-radius: 50px; + gap: 0; + border-radius: 24px; + background: rgba(21, 21, 21, 0.5); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + border: 1px solid rgba(255, 255, 255, 0.08); + transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1); + cursor: default; +} + +/* Hover lift effect */ +.Card:hover { + transform: translateY(-4px); + background: rgba(28, 28, 28, 0.7); + border-color: rgba(152, 176, 255, 0.2); + box-shadow: + 0 20px 40px -15px rgba(0, 0, 0, 0.5), + 0 0 0 1px rgba(152, 176, 255, 0.1); +} + +/* Spotlight effect container */ +.Spotlight { + position: absolute; + inset: 0; + border-radius: inherit; + overflow: hidden; +} + +.Spotlight::before { + content: ''; + position: absolute; + width: 200px; + height: 200px; + background: radial-gradient( + circle, + rgba(152, 176, 255, 0.15) 0%, + transparent 70% + ); + transform: translate(-50%, -50%); + pointer-events: none; + opacity: 0; + transition: opacity 0.3s; +} + +.Card:hover .Spotlight::before { + opacity: 1; +} + +/* Animated gradient border on hover */ +.GlowBorder { + background: linear-gradient( + 135deg, + rgba(152, 176, 255, 0.4) 0%, + rgba(66, 255, 164, 0.4) 50%, + rgba(152, 176, 255, 0.4) 100% + ); + background-size: 200% 200%; + animation: border-shift 3s ease infinite; + mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0); + mask-composite: exclude; + -webkit-mask-composite: xor; + padding: 1px; + border-radius: inherit; +} + +@keyframes border-shift { + 0% { background-position: 0% 50%; } + 50% { background-position: 100% 50%; } + 100% { background-position: 0% 50%; } } /* For small screen */ -@media screen and (max-width: 600px) { +@media screen and (max-width: 640px) { .Card { - padding: 20px 22px; - border-radius: 24px; + padding: 22px; + border-radius: 20px; } } + +/* Icon container animation */ +.Card svg { + transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); +} + +.Card:hover svg { + transform: scale(1.1); +} diff --git a/components/landing/Features.tsx b/components/landing/Features.tsx index 5153668..dbe74ed 100644 --- a/components/landing/Features.tsx +++ b/components/landing/Features.tsx @@ -1,49 +1,143 @@ +import { useEffect, useRef, useState } from "react"; import classes from "./Features.module.css"; import FollowOnGitHub from "./FollowOnGitHub"; +import { Zap, Layers, GitBranch, Code2 } from "lucide-react"; +import cn from "clsx"; + +const features = [ + { + icon: Zap, + title: "High Performance", + description: "Optimized for memory, CPU, and loading speed with advanced performance primitives.", + gradient: "from-amber-400 to-orange-500", + }, + { + icon: Layers, + title: "Rich CRDT Types", + description: "Turn JSON-like data into collaborative types effortlessly with Map, List, Tree, and Text.", + gradient: "from-blue-400 to-cyan-500", + }, + { + icon: GitBranch, + title: "Version Control", + description: "Preserve full version history like Git, even during real-time collaboration.", + gradient: "from-emerald-400 to-green-500", + }, + { + icon: Code2, + title: "Intuitive API", + description: "Designed with developer experience in mind. Simple, type-safe, and well-documented.", + gradient: "from-violet-400 to-purple-500", + }, +]; export default function Features() { + const [visibleCards, setVisibleCards] = useState(new Array(features.length).fill(false)); + const sectionRef = useRef(null); + + useEffect(() => { + const observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + // Stagger animation for cards + features.forEach((_, index) => { + setTimeout(() => { + setVisibleCards(prev => { + const newState = [...prev]; + newState[index] = true; + return newState; + }); + }, index * 150); + }); + observer.unobserve(entry.target); + } + }); + }, + { threshold: 0.2, rootMargin: "0px 0px -50px 0px" } + ); + + if (sectionRef.current) { + observer.observe(sectionRef.current); + } + + return () => observer.disconnect(); + }, []); + return ( -
- -

- Loro is a high‑performance CRDT library for local‑first, real‑time collaboration. -

-
-
-

- High Performance -

-

- Optimized for memory, CPU, and loading speed with advanced - performance primitives. -

-
-
-

- Rich CRDT Types Support -

-

- Turn JSON-like data into collaborative types effortlessly -

-
-
-

- Real-Time Collaboration with Version Control -

-

- Preserve full version history like Git, even during real-time - collaboration -

-
-
-

- Simple and Intuitive API -

-

- Designed with developer experience in mind -

-
+
+ + + {/* Section header */} +
+

+ Loro is a high‑performance CRDT library for local‑first, real‑time collaboration. +

+
+ + {/* Features grid */} +
+ {features.map((feature, index) => ( + + ))}
); } + +interface FeatureCardProps { + feature: typeof features[0]; + isVisible: boolean; +} + +function FeatureCard({ feature, isVisible }: FeatureCardProps) { + const Icon = feature.icon; + + return ( +
+ {/* Spotlight effect */} +
+ + {/* Shimmer overlay */} +
+ + {/* Icon container with gradient background */} +
+ +
+ + {/* Title with gradient text */} +

+ {feature.title} +

+ + {/* Description */} +

+ {feature.description} +

+ + {/* Hover glow effect */} +
+
+ ); +} diff --git a/components/landing/FollowOnGitHub.tsx b/components/landing/FollowOnGitHub.tsx index 9c810b3..2c1862a 100644 --- a/components/landing/FollowOnGitHub.tsx +++ b/components/landing/FollowOnGitHub.tsx @@ -1,7 +1,6 @@ import { HTMLAttributes } from "react"; -import classes from "./FollowOnGitHub.module.css"; import cn from "clsx"; -import GitHubIcon from "../../public/images/social-media-github.svg"; +import { Github, Star } from "lucide-react"; export default function FollowOnGitHub( props: HTMLAttributes @@ -9,19 +8,32 @@ export default function FollowOnGitHub( return ( -
- +
+ +
+
+ + Star us on GitHub + +
+ + Star
-
Follow us on GitHub
); } diff --git a/components/landing/Footer.tsx b/components/landing/Footer.tsx index 8761ddc..db5259d 100644 --- a/components/landing/Footer.tsx +++ b/components/landing/Footer.tsx @@ -1,70 +1,97 @@ import React, { SVGProps } from "react"; +import cn from "clsx"; export default function Footer(): JSX.Element { const year = new Date().getFullYear(); + + const socialLinks = [ + { + href: "https://twitter.com/loro_dev", + icon: TwitterIcon, + label: "Twitter", + event: "twitter-click", + }, + { + href: "https://bsky.app/profile/loro.dev", + icon: BlueskyIcon, + label: "Bluesky", + event: "bluesky-click", + }, + { + href: "https://github.com/loro-dev/loro", + icon: GitHubIcon, + label: "GitHub", + event: "github-click", + }, + { + href: "https://discord.gg/tUsBSVfqzf", + icon: DiscordIcon, + label: "Discord", + event: "discord-click", + }, + ]; + return ( -