diff --git a/apps/sim/app/(home)/components/collaboration/collaboration.tsx b/apps/sim/app/(home)/components/collaboration/collaboration.tsx index 383779ac835..e9e760a8525 100644 --- a/apps/sim/app/(home)/components/collaboration/collaboration.tsx +++ b/apps/sim/app/(home)/components/collaboration/collaboration.tsx @@ -303,7 +303,7 @@ export default function Collaboration() { (null) const [revealedRows, setRevealedRows] = useState(0) + const prevTabRef = useRef(activeTab) + const wasExpandedRef = useRef(false) + const expandTransitionRef = useRef<'scale' | 'crossfade'>('scale') const isMothership = activeTab === 0 && isActive const isExpandTab = activeTab >= 1 && activeTab <= 4 && isActive @@ -189,17 +192,37 @@ function WorkspacePreview({ activeTab, isActive }: { activeTab: number; isActive }, [inView, isMothership]) useEffect(() => { + const prevTab = prevTabRef.current + const wasPrevExpanded = wasExpandedRef.current + prevTabRef.current = activeTab + if (!isExpandTab || !showGrid) { if (!isExpandTab) { + wasExpandedRef.current = false setExpandedTab(null) setRevealedRows(0) } return } - setExpandedTab(null) - setRevealedRows(0) - const timer = setTimeout(() => setExpandedTab(activeTab), 300) - return () => clearTimeout(timer) + + const comingFromExpanded = + wasPrevExpanded && prevTab >= 1 && prevTab <= 4 && prevTab !== activeTab + + if (comingFromExpanded) { + expandTransitionRef.current = 'crossfade' + wasExpandedRef.current = true + setRevealedRows(EXPAND_ROW_COUNTS[activeTab] ?? 10) + setExpandedTab(activeTab) + } else { + expandTransitionRef.current = 'scale' + setExpandedTab(null) + setRevealedRows(0) + const timer = setTimeout(() => { + wasExpandedRef.current = true + setExpandedTab(activeTab) + }, 300) + return () => clearTimeout(timer) + } }, [isExpandTab, activeTab, showGrid]) useEffect(() => { @@ -279,23 +302,37 @@ function WorkspacePreview({ activeTab, isActive }: { activeTab: number; isActive )} - {isExpanded && expandTarget && ( - - {expandedTab === 1 && } - {expandedTab === 2 && } - {expandedTab === 3 && } - {expandedTab === 4 && } - - )} + + {isExpanded && expandTarget && ( + + {expandedTab === 1 && } + {expandedTab === 2 && } + {expandedTab === 3 && } + {expandedTab === 4 && } + + )} + ) } diff --git a/apps/sim/app/(home)/components/features/features.tsx b/apps/sim/app/(home)/components/features/features.tsx index b22cbee92db..a70be0c2d0d 100644 --- a/apps/sim/app/(home)/components/features/features.tsx +++ b/apps/sim/app/(home)/components/features/features.tsx @@ -1,18 +1,12 @@ 'use client' import { useRef, useState } from 'react' -import { type MotionValue, motion, useScroll, useTransform } from 'framer-motion' +import { AnimatePresence, type MotionValue, motion, useScroll, useTransform } from 'framer-motion' import Image from 'next/image' import Link from 'next/link' import { Badge, ChevronDown } from '@/components/emcn' import { FeaturesPreview } from '@/app/(home)/components/features/components/features-preview' - -function hexToRgba(hex: string, alpha: number): string { - const r = Number.parseInt(hex.slice(1, 3), 16) - const g = Number.parseInt(hex.slice(3, 5), 16) - const b = Number.parseInt(hex.slice(5, 7), 16) - return `rgba(${r},${g},${b},${alpha})` -} +import { hexToRgba } from '@/lib/core/utils/formatting' const FEATURE_TABS = [ { @@ -168,6 +162,8 @@ function DotGrid({ ) } +const INDICATOR_TRANSITION_MS = 300 + export default function Features() { const sectionRef = useRef(null) const [activeTab, setActiveTab] = useState(0) @@ -259,7 +255,10 @@ export default function Features() { aria-selected={index === activeTab} onClick={() => setActiveTab(index)} className={`relative h-full flex-1 items-center justify-center whitespace-nowrap px-[12px] font-medium font-season text-[#212121] text-[12px] uppercase lg:px-0 lg:text-[14px]${tab.hideOnMobile ? ' hidden lg:flex' : ' flex'}${index > 0 ? ' border-[#E9E9E9] border-l' : ''}`} - style={{ backgroundColor: index === activeTab ? '#FDFDFD' : '#F6F6F6' }} + style={{ + backgroundColor: index === activeTab ? '#FDFDFD' : '#F6F6F6', + transition: `background-color ${INDICATOR_TRANSITION_MS}ms ease`, + }} > {tab.mobileLabel ? ( <> @@ -269,21 +268,25 @@ export default function Features() { ) : ( tab.label )} - {index === activeTab && ( -
- {tab.segments.map(([opacity, width], i) => ( -
- ))} -
- )} +
+ {tab.segments.map(([segOpacity, width], i) => ( +
+ ))} +
))}
@@ -299,38 +302,57 @@ export default function Features() {
-
-
-

- {FEATURE_TABS[activeTab].title} -

-

- {FEATURE_TABS[activeTab].description} -

-
- - {FEATURE_TABS[activeTab].cta} - - - + + +

+ {FEATURE_TABS[activeTab].title} +

+

+ {FEATURE_TABS[activeTab].description} +

+ + + + + - - -
- + {FEATURE_TABS[activeTab].cta} + + + + + + + + +
diff --git a/apps/sim/app/(home)/components/landing-preview/landing-preview.tsx b/apps/sim/app/(home)/components/landing-preview/landing-preview.tsx index 91ef0a2a4d3..b443ab30279 100644 --- a/apps/sim/app/(home)/components/landing-preview/landing-preview.tsx +++ b/apps/sim/app/(home)/components/landing-preview/landing-preview.tsx @@ -96,7 +96,7 @@ export function LandingPreview() { />
-
+

Sim includes {TEMPLATE_WORKFLOWS.length} pre-built workflow templates covering OCR @@ -449,7 +443,7 @@ export default function Templates() {

-
+
setActiveIndex(index)} className={cn( - 'relative w-full text-left', + 'relative w-full overflow-x-clip text-left', isActive ? 'z-10' : cn( @@ -543,7 +537,7 @@ export default function Templates() { className='absolute right-[-8px] bottom-0 left-2 h-2' style={buildBottomWallStyle(depth)} /> -
+
{workflow.name} diff --git a/apps/sim/app/(landing)/blog/[slug]/animated-blocks.tsx b/apps/sim/app/(landing)/blog/[slug]/animated-blocks.tsx new file mode 100644 index 00000000000..2f00ff16e8b --- /dev/null +++ b/apps/sim/app/(landing)/blog/[slug]/animated-blocks.tsx @@ -0,0 +1,187 @@ +'use client' + +import { useEffect, useRef, useState } from 'react' + +const COLORS = ['#2ABBF8', '#FA4EDF', '#FFCC02', '#00F701'] as const + +const ENTER_STAGGER_MS = 60 +const ENTER_DURATION_MS = 300 +const HOLD_MS = 3000 +const EXIT_STAGGER_MS = 120 +const EXIT_DURATION_MS = 500 + +const RE_ENTER_OPACITIES = [1, 0.8, 0.6, 0.9] as const + +function setBlockOpacity(el: HTMLSpanElement | null, opacity: number, animate: boolean) { + if (!el) return + el.style.transition = animate ? `opacity ${ENTER_DURATION_MS}ms ease-out` : 'none' + el.style.opacity = String(opacity) +} + +export function AnimatedColorBlocks() { + const prefersReducedMotion = usePrefersReducedMotion() + const blockRefs = useRef<(HTMLSpanElement | null)[]>([]) + const timers = useRef[]>([]) + const mounted = useRef(true) + + function schedule(fn: () => void, ms: number) { + const id = setTimeout(fn, ms) + timers.current.push(id) + return id + } + + useEffect(() => { + mounted.current = true + timers.current = [] + + if (prefersReducedMotion) { + blockRefs.current.forEach((el) => setBlockOpacity(el, 1, false)) + return () => { + mounted.current = false + timers.current.forEach(clearTimeout) + timers.current = [] + } + } + + blockRefs.current.forEach((el) => setBlockOpacity(el, 0, false)) + + COLORS.forEach((_, i) => { + schedule(() => { + if (!mounted.current) return + setBlockOpacity(blockRefs.current[i], 1, true) + }, i * ENTER_STAGGER_MS) + }) + + const totalEnterMs = COLORS.length * ENTER_STAGGER_MS + ENTER_DURATION_MS + HOLD_MS + schedule(() => { + if (!mounted.current) return + startCycle() + }, totalEnterMs) + + return () => { + mounted.current = false + timers.current.forEach(clearTimeout) + timers.current = [] + } + }, [prefersReducedMotion]) + + function startCycle() { + if (!mounted.current) return + + COLORS.forEach((_, i) => { + schedule(() => { + if (!mounted.current) return + setBlockOpacity(blockRefs.current[i], 0.15, true) + }, i * EXIT_STAGGER_MS) + }) + + const exitTotalMs = COLORS.length * EXIT_STAGGER_MS + EXIT_DURATION_MS + schedule(() => { + if (!mounted.current) return + COLORS.forEach((_, i) => { + schedule(() => { + if (!mounted.current) return + setBlockOpacity(blockRefs.current[i], RE_ENTER_OPACITIES[i], true) + }, i * ENTER_STAGGER_MS) + }) + }, exitTotalMs + 200) + + const cycleDuration = + exitTotalMs + 200 + COLORS.length * ENTER_STAGGER_MS + ENTER_DURATION_MS + HOLD_MS + schedule(() => startCycle(), cycleDuration) + } + + return ( + + ) +} + +export function AnimatedColorBlocksVertical() { + const prefersReducedMotion = usePrefersReducedMotion() + const blockRefs = useRef<(HTMLSpanElement | null)[]>([]) + const timers = useRef[]>([]) + const mounted = useRef(true) + + const verticalColors = [COLORS[0], COLORS[1], COLORS[2]] as const + + function schedule(fn: () => void, ms: number) { + const id = setTimeout(fn, ms) + timers.current.push(id) + return id + } + + useEffect(() => { + mounted.current = true + timers.current = [] + + if (prefersReducedMotion) { + blockRefs.current.forEach((el) => setBlockOpacity(el, 1, false)) + return () => { + mounted.current = false + timers.current.forEach(clearTimeout) + timers.current = [] + } + } + + blockRefs.current.forEach((el) => setBlockOpacity(el, 0, false)) + + const baseDelay = COLORS.length * ENTER_STAGGER_MS + 100 + + verticalColors.forEach((_, i) => { + schedule( + () => { + if (!mounted.current) return + setBlockOpacity(blockRefs.current[i], 1, true) + }, + baseDelay + i * ENTER_STAGGER_MS + ) + }) + + return () => { + mounted.current = false + timers.current.forEach(clearTimeout) + timers.current = [] + } + }, [prefersReducedMotion]) + + return ( + + ) +} + +function usePrefersReducedMotion(): boolean { + const [prefersReduced, setPrefersReduced] = useState(false) + + useEffect(() => { + const mq = window.matchMedia('(prefers-reduced-motion: reduce)') + setPrefersReduced(mq.matches) + + const handler = (e: MediaQueryListEvent) => setPrefersReduced(e.matches) + mq.addEventListener('change', handler) + return () => mq.removeEventListener('change', handler) + }, []) + + return prefersReduced +} diff --git a/apps/sim/app/(landing)/blog/[slug]/article-header.tsx b/apps/sim/app/(landing)/blog/[slug]/article-header.tsx new file mode 100644 index 00000000000..f69fd176915 --- /dev/null +++ b/apps/sim/app/(landing)/blog/[slug]/article-header.tsx @@ -0,0 +1,52 @@ +'use client' + +import { motion, useReducedMotion } from 'framer-motion' + +const EASE_OUT_QUINT = [0.23, 1, 0.32, 1] as const +const STAGGER = 0.15 +const DURATION = 0.35 +const Y_OFFSET = 10 + +const containerVariants = { + hidden: {}, + visible: { + transition: { staggerChildren: STAGGER }, + }, +} + +const itemVariants = { + hidden: { opacity: 0, y: Y_OFFSET }, + visible: { + opacity: 1, + y: 0, + transition: { duration: DURATION, ease: EASE_OUT_QUINT }, + }, +} + +export function ArticleHeaderMotion({ children }: { children: React.ReactNode }) { + const shouldReduceMotion = useReducedMotion() + + return ( + + {children} + + ) +} + +export function ArticleHeaderItem({ + children, + className, +}: { + children: React.ReactNode + className?: string +}) { + return ( + + {children} + + ) +} diff --git a/apps/sim/app/(landing)/blog/[slug]/article-sidebar.tsx b/apps/sim/app/(landing)/blog/[slug]/article-sidebar.tsx new file mode 100644 index 00000000000..f34eabf3a79 --- /dev/null +++ b/apps/sim/app/(landing)/blog/[slug]/article-sidebar.tsx @@ -0,0 +1,15 @@ +import { TableOfContents } from '@/app/(landing)/blog/[slug]/table-of-contents' + +interface ArticleSidebarProps { + headings: { text: string; id: string; level: number }[] +} + +export function ArticleSidebar({ headings }: ArticleSidebarProps) { + if (headings.length === 0) return null + + return ( + + ) +} diff --git a/apps/sim/app/(landing)/blog/[slug]/back-link.tsx b/apps/sim/app/(landing)/blog/[slug]/back-link.tsx index 179223a0b8a..90e4c8d4ab5 100644 --- a/apps/sim/app/(landing)/blog/[slug]/back-link.tsx +++ b/apps/sim/app/(landing)/blog/[slug]/back-link.tsx @@ -1,25 +1,15 @@ -'use client' - -import { useState } from 'react' import { ArrowLeft, ChevronLeft } from 'lucide-react' import Link from 'next/link' export function BackLink() { - const [isHovered, setIsHovered] = useState(false) - return ( setIsHovered(true)} - onMouseLeave={() => setIsHovered(false)} > - {isHovered ? ( - Back to Blog diff --git a/apps/sim/app/(landing)/blog/[slug]/page.tsx b/apps/sim/app/(landing)/blog/[slug]/page.tsx index d5ed263e2b5..4223200f8b8 100644 --- a/apps/sim/app/(landing)/blog/[slug]/page.tsx +++ b/apps/sim/app/(landing)/blog/[slug]/page.tsx @@ -1,13 +1,18 @@ +import { ArrowLeft } from 'lucide-react' import type { Metadata } from 'next' import Image from 'next/image' import Link from 'next/link' -import { Avatar, AvatarFallback, AvatarImage } from '@/components/emcn' import { FAQ } from '@/lib/blog/faq' +import '@/app/(landing)/blog/[slug]/prose-studio.css' import { getAllPostMeta, getPostBySlug, getRelatedPosts } from '@/lib/blog/registry' +import type { Author, BlogMeta } from '@/lib/blog/schema' import { buildArticleJsonLd, buildBreadcrumbJsonLd, buildPostMetadata } from '@/lib/blog/seo' +import { formatDate } from '@/lib/core/utils/formatting' import { getBaseUrl } from '@/lib/core/utils/urls' -import { BackLink } from '@/app/(landing)/blog/[slug]/back-link' -import { ShareButton } from '@/app/(landing)/blog/[slug]/share-button' +import { ArticleHeaderItem, ArticleHeaderMotion } from '@/app/(landing)/blog/[slug]/article-header' +import { ArticleSidebar } from '@/app/(landing)/blog/[slug]/article-sidebar' +import { ShareButtons } from '@/app/(landing)/blog/[slug]/share-button' +import { getPrimaryCategory, getTagCategory, getTagColor } from '@/app/(landing)/blog/tag-colors' export async function generateStaticParams() { const posts = await getAllPostMeta() @@ -24,15 +29,17 @@ export async function generateMetadata({ return buildPostMetadata(post) } -export const revalidate = 86400 - export default async function Page({ params }: { params: Promise<{ slug: string }> }) { const { slug } = await params - const post = await getPostBySlug(slug) + const [post, related] = await Promise.all([getPostBySlug(slug), getRelatedPosts(slug, 3)]) const Article = post.Content const jsonLd = buildArticleJsonLd(post) const breadcrumbLd = buildBreadcrumbJsonLd(post) - const related = await getRelatedPosts(slug, 3) + + const category = getPrimaryCategory(post.tags) + const categoryColor = category.color + const displayAuthors = post.authors && post.authors.length > 0 ? post.authors : [post.author] + const shareUrl = `${getBaseUrl()}/blog/${slug}` return (
@@ -44,127 +51,211 @@ export default async function Page({ params }: { params: Promise<{ slug: string type='application/ld+json' dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbLd) }} /> -
-
- -
-
-
-
- {post.title} -
-
-
-

- {post.title} -

-
-
- {(post.authors || [post.author]).map((a, idx) => ( -
- {a?.avatarUrl ? ( - - - {a.name.slice(0, 2)} - - ) : null} - - {a?.name} - -
- ))} -
- -
-
-
-
-
-
- - -
-
-

- {post.description} -

-
-
-
-
-
-
- {post.faq && post.faq.length > 0 ? : null} -
-
- {related.length > 0 && ( -
-

Related posts

-
- {related.map((p) => ( - -
- {p.title} +
+ +
- )} + + +
+ + {displayAuthors.map((a, idx) => ( + + ))}
) } + +interface ArticleAuthorsProps { + authors: Author[] +} + +function ArticleAuthors({ authors }: ArticleAuthorsProps) { + return ( +
+
+
+
+ {authors.map((a) => ( +
+
+ {a.avatarUrl ? ( + {a.name} + ) : ( + a.name.slice(0, 2).toUpperCase() + )} +
+
+

{a.name}

+ {a.url && ( + + {a.xHandle ? `@${a.xHandle}` : 'Profile'} + + )} +
+
+ ))} +
+
+ ) +} + +interface RelatedArticlesProps { + posts: BlogMeta[] +} + +function RelatedArticles({ posts }: RelatedArticlesProps) { + return ( +
+
+
+
+ {posts.map((p) => { + const color = getTagColor(p.tags[0]) || '#999' + const cat = getPrimaryCategory(p.tags) + return ( + +
+ + {cat.label} + +
+

+ {p.title} +

+

+ {p.description} +

+
+ {formatDate(new Date(p.date))} +
+ + ) + })} +
+
+ ) +} diff --git a/apps/sim/app/(landing)/blog/[slug]/prose-studio.css b/apps/sim/app/(landing)/blog/[slug]/prose-studio.css new file mode 100644 index 00000000000..d0a471eeed2 --- /dev/null +++ b/apps/sim/app/(landing)/blog/[slug]/prose-studio.css @@ -0,0 +1,260 @@ +.prose-studio h2 { + border-bottom: 1px solid #2a2a2a; + padding-bottom: 1.5rem; + font-weight: 500; + font-size: 30px; + color: #ececec; + margin-top: 3rem !important; + margin-bottom: 1.5rem; + letter-spacing: -0.025em; +} + +.prose-studio h3 { + font-weight: 500; + font-size: 24px; + color: #ececec; + margin-top: 1.5rem; + margin-bottom: 0.75rem; + letter-spacing: -0.015em; +} + +.prose-studio p { + margin-bottom: 1.5rem; + line-height: 1.8; + font-size: 1.05rem; + color: #cccccc; +} + +.prose-studio strong { + color: #ececec; + font-weight: 600; +} + +.prose-studio a { + color: #ececec; + text-decoration: underline; + text-decoration-color: #3d3d3d; + text-underline-offset: 3px; + transition: text-decoration-color 0.15s ease; +} + +.prose-studio a:hover { + text-decoration-color: #ececec; +} + +/* Inline code */ +.prose-studio code:not(pre code) { + font-family: var(--font-martian-mono), "JetBrains Mono", monospace; + background: #232323; + color: #2abbf8; + padding: 0.15rem 0.4rem; + border-radius: 2px; + font-size: 0.85em; + border: 1px solid #2a2a2a; +} + +/* Code blocks */ +.prose-studio pre { + background: #111111; + padding: 1.5rem; + overflow-x: auto; + font-size: 0.875rem; + line-height: 1.7; +} + +.prose-studio pre code { + background: transparent; + border: none; + padding: 0; + font-size: inherit; + color: #d4d4d8; +} + +/* Blockquotes */ +.prose-studio blockquote { + padding: 1.5rem 2rem; + margin: 2.5rem 0; + background: #232323; + border-left: 2px solid #fa4edf; + border-radius: 0 2px 2px 0; + font-style: italic; + font-size: 1.125rem; + color: #cccccc; +} + +.prose-studio blockquote p { + margin-bottom: 0; +} + +/* Lists */ +.prose-studio ul, +.prose-studio ol { + color: #cccccc; + margin-bottom: 1.5rem; +} + +.prose-studio li { + color: #cccccc; + margin-bottom: 0.5rem; +} + +.prose-studio li::marker { + color: #666; +} + +/* Horizontal rules */ +.prose-studio hr { + border-color: #2a2a2a; + margin: 3rem 0; +} + +/* Images */ +.prose-studio img { + border-radius: 2px; + border: 1px solid #2a2a2a; +} + +/* Tables */ +.prose-studio table { + border-collapse: collapse; + width: 100%; +} + +.prose-studio th { + color: #ececec; + font-weight: 500; + border-bottom: 1px solid #2a2a2a; + padding: 0.75rem 1rem; + text-align: left; + font-size: 0.875rem; +} + +.prose-studio td { + color: #cccccc; + border-bottom: 1px solid #1f1f1f; + padding: 0.75rem 1rem; + font-size: 0.875rem; +} + +/* ── PrismJS syntax highlighting tokens ── + * Custom theme matching Sim design language. + * Colors aligned with the brand palette: + * - Keywords/imports: #FA4EDF (pink) + * - Strings: #FFCC02 (yellow) + * - Functions/methods: #82aaff (periwinkle) + * - Types/classes/properties: #2ABBF8 (cyan) + * - Numbers/booleans: #00F701 (green) + * - Comments: #666 + * - Operators/punctuation: #999 + * - Plain text: #d4d4d8 + */ + +.token.comment, +.token.prolog, +.token.doctype, +.token.cdata { + color: #666666; + font-style: italic; +} + +.token.punctuation { + color: #999999; +} + +.token.property, +.token.tag, +.token.constant, +.token.symbol, +.token.deleted { + color: #2abbf8; +} + +.token.boolean, +.token.number { + color: #00f701; +} + +.token.selector, +.token.attr-name, +.token.string, +.token.char, +.token.builtin, +.token.inserted { + color: #ffcc02; +} + +.token.operator, +.token.entity, +.token.url, +.language-css .token.string, +.style .token.string { + color: #999999; +} + +.token.atrule, +.token.attr-value, +.token.keyword { + color: #fa4edf; +} + +.token.function, +.token.class-name { + color: #82aaff; +} + +.token.regex, +.token.important, +.token.variable { + color: #2abbf8; +} + +.token.parameter { + color: #d4d4d8; +} + +.token.template-string .token.interpolation { + color: #fa4edf; +} + +.token.template-string .token.string { + color: #ffcc02; +} + +[data-blog-main-content] { + transition: + filter 180ms ease-out, + opacity 180ms ease-out; + will-change: filter, opacity; +} + +body[data-toc-rail-hover="true"] [data-blog-main-content] { + filter: blur(2.5px); + opacity: 0.58; +} + +.nav-label-softenable { + transition: + color 150ms ease-out, + filter 150ms ease-out, + opacity 150ms ease-out; +} + +nav[data-rail-hover] .nav-label-softenable[data-softenable] { + opacity: 0.45; + filter: blur(1.5px); +} + +/* Reduced motion */ +@media (prefers-reduced-motion: reduce) { + [data-blog-main-content] { + transition: none; + } + + .prose-studio a { + transition: none; + } + + .nav-label-softenable { + transition: none; + } +} diff --git a/apps/sim/app/(landing)/blog/[slug]/share-button.tsx b/apps/sim/app/(landing)/blog/[slug]/share-button.tsx index 4950d1ecab1..2e465d5424e 100644 --- a/apps/sim/app/(landing)/blog/[slug]/share-button.tsx +++ b/apps/sim/app/(landing)/blog/[slug]/share-button.tsx @@ -1,20 +1,15 @@ 'use client' import { useState } from 'react' -import { Share2 } from 'lucide-react' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '@/components/emcn' - -interface ShareButtonProps { +import { AnimatePresence, motion } from 'framer-motion' +import { Check, Link2, Linkedin, Twitter } from 'lucide-react' + +interface ShareButtonsProps { url: string title: string } -export function ShareButton({ url, title }: ShareButtonProps) { +export function ShareButtons({ url, title }: ShareButtonsProps) { const [copied, setCopied] = useState(false) const handleCopyLink = async () => { @@ -37,24 +32,59 @@ export function ShareButton({ url, title }: ShareButtonProps) { window.open(linkedInUrl, '_blank', 'noopener,noreferrer') } + const btnClass = + 'flex h-10 w-10 items-center justify-center rounded-[5px] border border-[#2A2A2A] bg-[#232323] text-[#999] transition-[color,border-color] duration-150 ease [@media(hover:hover)]:hover:border-[#2ABBF8] [@media(hover:hover)]:hover:text-[#2ABBF8] active:scale-[0.95]' + return ( - - - - - - - {copied ? 'Copied!' : 'Copy link'} - - Share on X - Share on LinkedIn - - +
+ + + +
) } diff --git a/apps/sim/app/(landing)/blog/[slug]/table-of-contents.tsx b/apps/sim/app/(landing)/blog/[slug]/table-of-contents.tsx new file mode 100644 index 00000000000..d67f9cfef6e --- /dev/null +++ b/apps/sim/app/(landing)/blog/[slug]/table-of-contents.tsx @@ -0,0 +1,835 @@ +'use client' + +import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react' +import clsx from 'clsx' +import { + type MotionValue, + motion, + useMotionValue, + useMotionValueEvent, + useReducedMotion, + useSpring, +} from 'framer-motion' + +interface Heading { + text: string + id: string + level: number +} + +interface TableOfContentsProps { + headings: Heading[] +} + +interface TocSubItem { + id: string + label: string +} + +interface TocItem { + id: string + label: string + showTopBorder: boolean + showBottomBorder: boolean + subItems?: TocSubItem[] +} + +interface TocModel { + items: TocItem[] + parentByHeadingId: Map +} + +interface SectionMetric { + id: string + top: number + lineY: number +} + +interface TocContextValue { + mouseY: MotionValue + scrollCursorY: MotionValue + registerLine: (id: string, node: HTMLDivElement | null) => void + onHoverChange: (id: string | null) => void + onSelect: (id: string) => void +} + +const TocContext = createContext(null) + +function useTocContext(): TocContextValue { + const ctx = useContext(TocContext) + if (!ctx) throw new Error('TocContext must be used within a TocContext.Provider') + return ctx +} + +type NavVariant = 'main' | 'sub' + +const CONTENTS_ID = '__contents__' +const SCROLL_OFFSET = 108 +const INTERSECTION_ROOT_MARGIN = '-16% 0px -66% 0px' + +const SCROLL_SMOOTHING = 0.5 +const DISTANCE_LIMIT = 48 +const POINTER_OUTSIDE = -10_000 + +const INTENSITY: Record = { + main: 0.52, + sub: 0.72, +} as const + +const LINE_WIDTH: Record = { + main: { default: 32, active: 48 }, + sub: { default: 16, active: 36 }, +} as const + +const LINE_SLOT_WIDTH: Record = { + main: 84, + sub: 72, +} as const + +const LINE_RAIL_HOVER_WIDTH = LINE_SLOT_WIDTH.main + 16 + +function clamp(value: number, min: number, max: number) { + return Math.min(Math.max(value, min), max) +} + +function lerp(start: number, end: number, factor: number) { + return start + (end - start) * factor +} + +function transformScale( + distance: number, + initialValue: number, + baseValue: number, + intensity: number +) { + if (Math.abs(distance) > DISTANCE_LIMIT) { + return initialValue + } + + const normalizedDistance = initialValue - Math.abs(distance) / DISTANCE_LIMIT + const scaleFactor = normalizedDistance * normalizedDistance + return baseValue + intensity * scaleFactor +} + +function getElementCenterY(element: HTMLElement) { + const nav = element.closest('nav') + if (!nav) return null + + const navRect = nav.getBoundingClientRect() + const rect = element.getBoundingClientRect() + return rect.top - navRect.top + rect.height / 2 +} + +function buildTocModel(headings: Heading[]): TocModel { + const items: TocItem[] = [] + const parentByHeadingId = new Map() + let currentParentId: string | null = null + + headings.forEach((heading) => { + if (heading.level === 2) { + items.push({ + id: heading.id, + label: heading.text, + showTopBorder: true, + showBottomBorder: true, + subItems: [], + }) + parentByHeadingId.set(heading.id, heading.id) + currentParentId = heading.id + return + } + + if (heading.level === 3 && currentParentId) { + const currentParent = items.find((item) => item.id === currentParentId) + if (!currentParent) return + + currentParent.subItems?.push({ + id: heading.id, + label: heading.text, + }) + parentByHeadingId.set(heading.id, currentParent.id) + return + } + + items.push({ + id: heading.id, + label: heading.text, + showTopBorder: true, + showBottomBorder: true, + subItems: [], + }) + parentByHeadingId.set(heading.id, heading.id) + currentParentId = null + }) + + return { + items: items.map((item, index) => ({ + ...item, + showBottomBorder: index < items.length - 1, + })), + parentByHeadingId, + } +} + +function getSelectedState( + activeId: string, + items: TocItem[], + parentByHeadingId: Map +) { + if (!activeId) { + return { selectedItemId: CONTENTS_ID, selectedSubItemId: '' } + } + + const selectedItemId = parentByHeadingId.get(activeId) ?? activeId + const selectedItem = items.find((item) => item.id === selectedItemId) + + if (!selectedItem) { + return { selectedItemId: CONTENTS_ID, selectedSubItemId: '' } + } + + if (selectedItem.id === activeId) { + return { + selectedItemId, + selectedSubItemId: selectedItem.subItems?.[0]?.id ?? '', + } + } + + return { + selectedItemId, + selectedSubItemId: activeId, + } +} + +function getScrollTarget(id: string) { + if (id === CONTENTS_ID) { + const article = document.querySelector('article') + if (!article) return null + + return Math.max(window.scrollY + article.getBoundingClientRect().top - SCROLL_OFFSET, 0) + } + + const element = document.getElementById(id) + if (!element) return null + + return Math.max(window.scrollY + element.getBoundingClientRect().top - SCROLL_OFFSET, 0) +} + +function getInterpolatedCursorY(sections: SectionMetric[], probeY: number, contentsY: number) { + if (sections.length === 0) return contentsY + + if (probeY <= sections[0].top) { + return contentsY + } + + for (let index = 0; index < sections.length - 1; index++) { + const current = sections[index] + const next = sections[index + 1] + + if (probeY <= next.top) { + const progress = clamp((probeY - current.top) / Math.max(next.top - current.top, 1), 0, 1) + return lerp(current.lineY, next.lineY, progress) + } + } + + return sections[sections.length - 1].lineY +} + +export function TableOfContents({ headings }: TableOfContentsProps) { + const [activeId, setActiveId] = useState('') + const [hoveredId, setHoveredId] = useState(null) + const { items, parentByHeadingId } = buildTocModel(headings) + const { selectedItemId, selectedSubItemId } = getSelectedState(activeId, items, parentByHeadingId) + + const navRef = useRef(null) + const observerRef = useRef(null) + const lineRefs = useRef(new Map()) + const lineCentersRef = useRef(new Map()) + const sectionMetricsRef = useRef([]) + const frameRef = useRef(null) + const isClickScrolling = useRef(false) + const targetCursorYRef = useRef(0) + + const shouldReduceMotion = useReducedMotion() + const mouseY = useMotionValue(POINTER_OUTSIDE) + const scrollCursorY = useSpring(0, { + stiffness: 500, + damping: 40, + mass: 0.8, + }) + + const registerLine = useCallback((id: string, node: HTMLDivElement | null) => { + if (node) { + lineRefs.current.set(id, node) + return + } + + lineRefs.current.delete(id) + }, []) + + const updateCursorTarget = useCallback( + (scrollTop: number) => { + const contentsY = lineCentersRef.current.get(CONTENTS_ID) ?? 10 + const nextY = getInterpolatedCursorY( + sectionMetricsRef.current, + scrollTop + SCROLL_OFFSET, + contentsY + ) + + targetCursorYRef.current = nextY + + if (shouldReduceMotion) { + scrollCursorY.set(nextY) + } + }, + [scrollCursorY, shouldReduceMotion] + ) + + const measureLayout = useCallback(() => { + const nav = navRef.current + if (!nav) return + + const centers = new Map() + + lineRefs.current.forEach((node, id) => { + if (!node) return + + const centerY = getElementCenterY(node) + if (centerY === null) return + + centers.set(id, centerY) + }) + + lineCentersRef.current = centers + sectionMetricsRef.current = headings.flatMap((heading) => { + const lineY = centers.get(heading.id) + const element = document.getElementById(heading.id) + + if (lineY === undefined || !element) return [] + + return [ + { + id: heading.id, + top: window.scrollY + element.getBoundingClientRect().top, + lineY, + }, + ] + }) + + updateCursorTarget(window.scrollY) + }, [headings, updateCursorTarget]) + + const scheduleMeasure = useCallback(() => { + if (frameRef.current !== null) return + + frameRef.current = window.requestAnimationFrame(() => { + frameRef.current = null + measureLayout() + }) + }, [measureLayout]) + + const scrollToId = useCallback((id: string) => { + const targetTop = getScrollTarget(id) + if (targetTop === null) return + + isClickScrolling.current = true + + const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches + window.scrollTo({ + top: targetTop, + behavior: reduced ? 'auto' : 'smooth', + }) + + if (id !== CONTENTS_ID) { + window.history.replaceState(null, '', `#${id}`) + setActiveId(id) + } else { + window.history.replaceState(null, '', window.location.pathname) + setActiveId('') + } + + window.setTimeout( + () => { + isClickScrolling.current = false + }, + reduced ? 50 : 700 + ) + }, []) + + useEffect(() => { + if (headings.length === 0) return + + scheduleMeasure() + }, [headings, scheduleMeasure, selectedItemId]) + + useEffect(() => { + if (headings.length === 0) return + + scheduleMeasure() + + const resizeObserver = new ResizeObserver(scheduleMeasure) + const nav = navRef.current + if (nav) resizeObserver.observe(nav) + + headings.forEach((heading) => { + const element = document.getElementById(heading.id) + if (element) resizeObserver.observe(element) + }) + + window.addEventListener('resize', scheduleMeasure) + + return () => { + if (frameRef.current !== null) { + window.cancelAnimationFrame(frameRef.current) + frameRef.current = null + } + + resizeObserver.disconnect() + window.removeEventListener('resize', scheduleMeasure) + } + }, [headings, scheduleMeasure]) + + useEffect(() => { + if (headings.length === 0) return + + const firstHeading = headings[0] + const onScroll = () => { + if (!isClickScrolling.current) { + const element = document.getElementById(firstHeading.id) + if (element) { + const firstHeadingTop = window.scrollY + element.getBoundingClientRect().top + if (window.scrollY + SCROLL_OFFSET < firstHeadingTop) { + setActiveId('') + } + } + } + + updateCursorTarget(window.scrollY) + } + + onScroll() + window.addEventListener('scroll', onScroll, { passive: true }) + + return () => window.removeEventListener('scroll', onScroll) + }, [headings, updateCursorTarget]) + + useRequestAnimationFrame(() => { + if (shouldReduceMotion) return + + const currentY = scrollCursorY.get() + const smoothY = lerp(currentY, targetCursorYRef.current, SCROLL_SMOOTHING) + + if (Math.abs(smoothY - currentY) > 0.01) { + scrollCursorY.set(smoothY) + } + }) + + useEffect(() => { + observerRef.current?.disconnect() + + if (headings.length === 0) return + + observerRef.current = new IntersectionObserver( + (entries) => { + if (isClickScrolling.current) return + + const visible = entries + .filter((entry) => entry.isIntersecting) + .sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top) + + if (visible.length > 0) { + setActiveId(visible[0].target.id) + } + }, + { + rootMargin: INTERSECTION_ROOT_MARGIN, + threshold: 0, + } + ) + + headings.forEach((heading) => { + const element = document.getElementById(heading.id) + if (element) observerRef.current?.observe(element) + }) + + return () => observerRef.current?.disconnect() + }, [headings]) + + const onPointerMove = useCallback( + (event: React.PointerEvent) => { + const nav = navRef.current + if (!nav) return + + const rect = nav.getBoundingClientRect() + mouseY.set(event.clientY - rect.top) + + const isOverRail = event.clientX - rect.left <= LINE_RAIL_HOVER_WIDTH + if (isOverRail) { + nav.dataset.railHover = 'true' + document.body.dataset.tocRailHover = 'true' + } else { + delete nav.dataset.railHover + document.body.removeAttribute('data-toc-rail-hover') + } + }, + [mouseY] + ) + + const onPointerLeave = useCallback(() => { + mouseY.set(POINTER_OUTSIDE) + setHoveredId(null) + + const nav = navRef.current + if (nav) delete nav.dataset.railHover + document.body.removeAttribute('data-toc-rail-hover') + }, [mouseY]) + + if (headings.length === 0) return null + + const tocCtx: TocContextValue = { + mouseY, + scrollCursorY, + registerLine, + onHoverChange: setHoveredId, + onSelect: scrollToId, + } + + return ( + + + + ) +} + +function TocOverline() { + const { onSelect, registerLine } = useTocContext() + const ref = useRef(null) + + useEffect(() => { + registerLine(CONTENTS_ID, ref.current) + return () => registerLine(CONTENTS_ID, null) + }, [registerLine]) + + return ( + + ) +} + +function NavItem({ + item, + hoveredId, + isSelected, + selectedSubItemId, +}: { + item: TocItem + hoveredId: string | null + isSelected: boolean + selectedSubItemId: string +}) { + const { onHoverChange, onSelect } = useTocContext() + + if (item.subItems && item.subItems.length > 0) { + return ( +
onSelect(item.id)} + onPointerEnter={() => onHoverChange(item.id)} + > +
+ + + {item.showTopBorder ? : null} + {item.showBottomBorder ? : null} +
+ +
+
+
+ {item.subItems.map((subItem) => ( + + ))} +
+
+
+
+ ) + } + + return ( +
onSelect(item.id)} + onPointerEnter={() => onHoverChange(item.id)} + > +
+ + + {item.showTopBorder ? : null} + {item.showBottomBorder ? : null} +
+
+ ) +} + +function SubNavItem({ + item, + active, + hovered, +}: { + item: TocSubItem + active: boolean + hovered: boolean +}) { + const { onHoverChange, onSelect } = useTocContext() + + return ( +
{ + event.stopPropagation() + onSelect(item.id) + }} + onPointerEnter={() => onHoverChange(item.id)} + > + + + +
+ ) +} + +function NavLine({ + active, + hovered, + variant, + lineId, +}: { + active: boolean + hovered: boolean + variant: NavVariant + lineId: string +}) { + const { mouseY, scrollCursorY, registerLine } = useTocContext() + const ref = useRef(null) + const scaleX = useSpring(1, { damping: 45, stiffness: 600 }) + + useEffect(() => { + registerLine(lineId, ref.current) + return () => registerLine(lineId, null) + }, [lineId, registerLine]) + + useProximityY(scaleX, { + intensity: INTENSITY[variant], + mouseY, + ref, + scrollCursorY, + }) + + const widthConfig = LINE_WIDTH[variant] + const width = active || hovered ? widthConfig.active : widthConfig.default + const backgroundColor = active || hovered ? '#ECECEC' : '#3A3A3A' + const slotWidth = LINE_SLOT_WIDTH[variant] + + return ( +
+ +
+ ) +} + +function NavLabel({ + active, + hovered, + label, +}: { + active: boolean + hovered: boolean + label: string +}) { + return ( +

+ {label} +

+ ) +} + +function NavBorder({ + position, + expanded = false, + variant = 'main', +}: { + position: 'top' | 'bottom' + expanded?: boolean + variant?: NavVariant +}) { + const isNarrow = variant === 'sub' || (position === 'bottom' && expanded) + + return ( +
+ ) +} + +function useProximityY( + value: MotionValue, + { + intensity, + mouseY, + ref, + scrollCursorY, + }: { + intensity: number + mouseY: MotionValue + ref: React.RefObject + scrollCursorY: MotionValue + } +) { + const initialValueRef = useRef(null) + + useEffect(() => { + if (initialValueRef.current === null) { + initialValueRef.current = value.get() + } + }, [value]) + + useMotionValueEvent(mouseY, 'change', (latest) => { + const element = ref.current + const initialValue = initialValueRef.current + if (!element || initialValue === null) return + + if (latest <= POINTER_OUTSIDE / 2) { + value.set(initialValue) + return + } + + const centerY = getElementCenterY(element) + if (centerY === null) return + + const distance = latest - centerY + value.set(transformScale(distance, initialValue, 1, intensity)) + }) + + useMotionValueEvent(scrollCursorY, 'change', (latest) => { + const element = ref.current + const initialValue = initialValueRef.current + if (!element || initialValue === null) return + + const centerY = getElementCenterY(element) + if (centerY === null) return + + const distance = latest - centerY + const targetScale = transformScale(distance, initialValue, 1, intensity) + const velocityFactor = Math.min(1, Math.abs(scrollCursorY.getVelocity()) / 300) + value.set(lerp(initialValue, targetScale, velocityFactor)) + }) +} + +function useRequestAnimationFrame(callback: () => void) { + const callbackRef = useRef(callback) + const requestRef = useRef(null) + + useEffect(() => { + callbackRef.current = callback + }, [callback]) + + useEffect(() => { + const animate = () => { + callbackRef.current() + requestRef.current = window.requestAnimationFrame(animate) + } + + requestRef.current = window.requestAnimationFrame(animate) + + return () => { + if (requestRef.current !== null) { + window.cancelAnimationFrame(requestRef.current) + } + } + }, []) +} diff --git a/apps/sim/app/(landing)/blog/authors/[id]/author-with-sidebar.tsx b/apps/sim/app/(landing)/blog/authors/[id]/author-with-sidebar.tsx new file mode 100644 index 00000000000..5d89653615a --- /dev/null +++ b/apps/sim/app/(landing)/blog/authors/[id]/author-with-sidebar.tsx @@ -0,0 +1,48 @@ +'use client' + +import { useCallback, useMemo, useState } from 'react' +import { useRouter } from 'next/navigation' +import { BlogStudioSidebar } from '@/app/(landing)/blog/studio-sidebar-client' + +interface AuthorWithSidebarProps { + allPosts: { tags: string[] }[] + activeTag: string | null + children: React.ReactNode +} + +export function AuthorWithSidebar({ allPosts, activeTag, children }: AuthorWithSidebarProps) { + const router = useRouter() + const [query, setQuery] = useState('') + + const handleChangeQuery = useCallback( + (value: string) => { + setQuery(value) + const trimmed = value.trim() + router.push(trimmed ? `/blog?q=${encodeURIComponent(trimmed)}` : '/blog') + }, + [router] + ) + + const handleSelectTag = useCallback( + (id: string | null) => { + setQuery('') + router.push(id ? `/blog?tag=${encodeURIComponent(id)}` : '/blog') + }, + [router] + ) + + const sidebarPosts = useMemo(() => allPosts.map((p) => ({ tags: p.tags })), [allPosts]) + + return ( +
+ +
{children}
+
+ ) +} diff --git a/apps/sim/app/(landing)/blog/authors/[id]/page.tsx b/apps/sim/app/(landing)/blog/authors/[id]/page.tsx index 9cb330c396e..8049713007e 100644 --- a/apps/sim/app/(landing)/blog/authors/[id]/page.tsx +++ b/apps/sim/app/(landing)/blog/authors/[id]/page.tsx @@ -2,8 +2,17 @@ import type { Metadata } from 'next' import Image from 'next/image' import Link from 'next/link' import { getAllPostMeta } from '@/lib/blog/registry' +import { AuthorWithSidebar } from '@/app/(landing)/blog/authors/[id]/author-with-sidebar' +import { PostGrid } from '@/app/(landing)/blog/post-grid' -export const revalidate = 3600 +function findAuthorById(posts: Awaited>, id: string) { + for (const p of posts) { + if (p.author.id === id) return p.author + const coAuthor = p.authors?.find((a) => a.id === id) + if (coAuthor) return coAuthor + } + return null +} export async function generateMetadata({ params, @@ -11,22 +20,38 @@ export async function generateMetadata({ params: Promise<{ id: string }> }): Promise { const { id } = await params - const posts = (await getAllPostMeta()).filter((p) => p.author.id === id) - const author = posts[0]?.author + const allPosts = await getAllPostMeta() + const author = findAuthorById(allPosts, id) return { title: author?.name ?? 'Author' } } -export default async function AuthorPage({ params }: { params: Promise<{ id: string }> }) { +export default async function AuthorPage({ + params, + searchParams, +}: { + params: Promise<{ id: string }> + searchParams: Promise<{ tag?: string; q?: string }> +}) { const { id } = await params - const posts = (await getAllPostMeta()).filter((p) => p.author.id === id) - const author = posts[0]?.author + const { tag } = await searchParams + const allPosts = await getAllPostMeta() + const posts = allPosts.filter((p) => p.author.id === id || p.authors?.some((a) => a.id === id)) + const author = findAuthorById(allPosts, id) + if (!author) { return ( -
+

Author not found

-
+ + Back to all posts + +
) } + const personJsonLd = { '@context': 'https://schema.org', '@type': 'Person', @@ -35,51 +60,56 @@ export default async function AuthorPage({ params }: { params: Promise<{ id: str sameAs: author.url ? [author.url] : [], image: author.avatarUrl, } + return ( -
-