test app router blog page

This commit is contained in:
Francesco Sansalvadore committed 2025-05-27 19:27:02 +02:00
1 parent f48308c496
commit 36a7321140
25 files changed
+1289 -1133

No files matched your search

+1 -1
View File
@@ -293,7 +293,7 @@ export const Posts: CollectionConfig = {
versions: {
drafts: {
autosave: {
interval: 100, // We set this interval for optimal live preview
interval: 200, // We set this interval for optimal live preview
},
schedulePublish: true,
},
+1 -1
View File
@@ -1,5 +1,5 @@
# Supabase Design System
Last updated: 2025-04-24T06:00:07.374Z
Last updated: 2025-05-27T16:25:34.367Z
## Overview
The Design System used in Supabase Studio.
+31 -13
View File
@@ -1,5 +1,5 @@
# Supabase UI Library
Last updated: 2025-03-28T12:17:05.978Z
Last updated: 2025-05-27T16:24:55.470Z
## Overview
Library of components for your project. The components integrate with Supabase and are shadcn compatible.
@@ -10,34 +10,44 @@ Library of components for your project. The components integrate with Supabase a
- [FAQ](https://supabase.com/ui/docs/getting-started/faq)
- Frequently asked questions
- [Introduction](https://supabase.com/ui/docs/getting-started/introduction)
- A flexible, open-source UI component library built on shadcn/ui, designed to simplify Supabase-powered projects with pre-built Auth, Storage, and Realtime features.
- A flexible, open-source, React-based UI component library built on shadcn/ui, designed to simplify Supabase-powered projects with pre-built Auth, Storage, and Realtime features.
- [Quick Start](https://supabase.com/ui/docs/getting-started/quickstart)
- Components
- [Next.js](https://supabase.com/ui/docs/nextjs/client)
- Install shadcn/ui and use the components in your project
- [Infinite Query Hook](https://supabase.com/ui/docs/infinite-query-hook)
- React hook for infinite lists, fetching data from Supabase.
- [Supabase Client Libraries](https://supabase.com/ui/docs/nextjs/client)
- Supabase client for Next.js
- [Current User Avatar](https://supabase.com/ui/docs/nextjs/current-user-avatar)
- Supabase Auth-aware avatar
- [Dropzone (File Upload)](https://supabase.com/ui/docs/nextjs/dropzone)
- Displays a control for easier uploading of files directly to Supabase Storage
- [Password-based Auth (Next.js)](https://supabase.com/ui/docs/nextjs/password-based-auth)
- Password-based Auth block for Next.js app
- [Password-based Authentication](https://supabase.com/ui/docs/nextjs/password-based-auth)
- Password-based authentication block for Next.js
- [Realtime Avatar Stack](https://supabase.com/ui/docs/nextjs/realtime-avatar-stack)
- Avatar stack in realtime
- [Realtime Chat](https://supabase.com/ui/docs/nextjs/realtime-chat)
- Real-time chat component for collaborative applications
- [Realtime Cursor](https://supabase.com/ui/docs/nextjs/realtime-cursor)
- Real-time cursor sharing for collaborative applications
- [Supabase Client for React Router](https://supabase.com/ui/docs/react-router/client)
- [Social Authentication](https://supabase.com/ui/docs/nextjs/social-auth)
- Social authentication block for Next.js
- [Supabase Client Libraries](https://supabase.com/ui/docs/react-router/client)
- Supabase client for React Router
- [Current User Avatar](https://supabase.com/ui/docs/react-router/current-user-avatar)
- Supabase Auth-aware avatar
- [Dropzone (File Upload)](https://supabase.com/ui/docs/react-router/dropzone)
- Displays a control for easier uploading of files directly to Supabase Storage
- [Password-based Auth (Next.js)](https://supabase.com/ui/docs/react-router/password-based-auth)
- Password-based Auth block for Next.js app
- [Password-based Authentication](https://supabase.com/ui/docs/react-router/password-based-auth)
- Password-based authentication block for React Router
- [Realtime Avatar Stack](https://supabase.com/ui/docs/react-router/realtime-avatar-stack)
- Avatar stack in realtime
- [Realtime Chat](https://supabase.com/ui/docs/react-router/realtime-chat)
- Real-time chat component for collaborative applications
- [Realtime Cursor](https://supabase.com/ui/docs/react-router/realtime-cursor)
- Real-time cursor sharing for collaborative applications
- [React Single Page Applications](https://supabase.com/ui/docs/react/client)
- [Social Authentication](https://supabase.com/ui/docs/react-router/social-auth)
- Social authentication block for React Router
- [Supabase Client Libraries](https://supabase.com/ui/docs/react/client)
- Supabase client for React Single Page Applications
- [Current User Avatar](https://supabase.com/ui/docs/react/current-user-avatar)
- Supabase Auth-aware avatar
@@ -47,17 +57,25 @@ Library of components for your project. The components integrate with Supabase a
- Password-based authentication block for React Single Page Applications
- [Realtime Avatar Stack](https://supabase.com/ui/docs/react/realtime-avatar-stack)
- Avatar stack in realtime
- [Realtime Chat](https://supabase.com/ui/docs/react/realtime-chat)
- Real-time chat component for collaborative applications
- [Realtime Cursor](https://supabase.com/ui/docs/react/realtime-cursor)
- Real-time cursor sharing for collaborative applications
- [TanStack Start](https://supabase.com/ui/docs/tanstack/client)
- [Social Authentication](https://supabase.com/ui/docs/react/social-auth)
- Social authentication block for React Single Page Applications
- [Supabase Client Libraries](https://supabase.com/ui/docs/tanstack/client)
- Supabase client for TanStack Start
- [Current User Avatar](https://supabase.com/ui/docs/tanstack/current-user-avatar)
- Supabase Auth-aware avatar
- [Dropzone (File Upload)](https://supabase.com/ui/docs/tanstack/dropzone)
- Displays a control for easier uploading of files directly to Supabase Storage
- [TanStack Start](https://supabase.com/ui/docs/tanstack/password-based-auth)
- Supabase client for TanStack Start
- [Password-based Authentication](https://supabase.com/ui/docs/tanstack/password-based-auth)
- Password-based authentication block for TanStack Start
- [Realtime Avatar Stack](https://supabase.com/ui/docs/tanstack/realtime-avatar-stack)
- Avatar stack in realtime
- [Realtime Chat](https://supabase.com/ui/docs/tanstack/realtime-chat)
- Real-time chat component for collaborative applications
- [Realtime Cursor](https://supabase.com/ui/docs/tanstack/realtime-cursor)
- Real-time cursor sharing for collaborative applications
- [Social Authentication](https://supabase.com/ui/docs/tanstack/social-auth)
- Social authentication block for Tanstack Start
+13
View File
@@ -0,0 +1,13 @@
import { draftMode } from 'next/headers'
import { redirect } from 'next/navigation'
export async function GET(request: Request) {
const { searchParams } = new URL(request.url)
const slug = searchParams.get('slug')
// Disable Draft Mode by clearing the cookie
draftMode().disable()
// Redirect to the path from the fetched post
redirect(slug ? `/blog/${slug}` : '/blog')
}
+22
View File
@@ -0,0 +1,22 @@
import { draftMode } from 'next/headers'
import { redirect } from 'next/navigation'
export async function GET(request: Request) {
const { searchParams } = new URL(request.url)
const secret = searchParams.get('secret')
const slug = searchParams.get('slug')
if (secret !== process.env.PREVIEW_SECRET) {
return new Response('Invalid token', { status: 401 })
}
if (!slug) {
return new Response('No slug in the request', { status: 401 })
}
// Enable Draft Mode by setting the cookie
draftMode().enable()
// Redirect to the path from the fetched post
redirect(`/blog/${slug}`)
}
+16
View File
@@ -0,0 +1,16 @@
'use client'
import { RefreshRouteOnSave as PayloadLivePreview } from '@payloadcms/live-preview-react'
import { useRouter } from 'next/navigation'
import React from 'react'
export const RefreshRouteOnSave: React.FC = () => {
const router = useRouter()
return (
<PayloadLivePreview
refresh={() => router.refresh()}
serverURL={process.env.NEXT_PUBLIC_CMS_URL || 'http://localhost:3030'}
/>
)
}
+277
View File
@@ -0,0 +1,277 @@
import { Metadata } from 'next'
import Link from 'next/link'
import { ChevronLeft } from 'lucide-react'
import dayjs from 'dayjs'
import matter from 'gray-matter'
import { draftMode } from 'next/headers'
import { isNotNullOrUndefined } from '~/lib/helpers'
import { mdxSerialize } from '~/lib/mdx/mdxSerialize'
import { getAllPostSlugs, getPostdata, getSortedPosts } from '~/lib/posts'
import { getAllCMSPostSlugs, getCMSPostBySlug, getAllCMSPosts } from '~/lib/cms-posts'
import authors from 'lib/authors.json'
import CTABanner from '~/components/CTABanner'
import LW11Summary from '~/components/LaunchWeek/11/LW11Summary'
import LW12Summary from '~/components/LaunchWeek/12/LWSummary'
import LW13Summary from '~/components/LaunchWeek/13/Releases/LWSummary'
import LW14Summary from '~/components/LaunchWeek/14/Releases/LWSummary'
import BlogLinks from '~/components/LaunchWeek/7/BlogLinks'
import LWXSummary from '~/components/LaunchWeek/X/LWXSummary'
import DefaultLayout from '~/components/Layouts/Default'
import { DraftModeBanner } from '~/components/Blog/DraftModeBanner'
import { BlogContent } from '../components/BlogContent'
import { AuthorList } from '../components/AuthorList'
import { BlogMarkdownProcessor } from '../components/BlogMarkdownProcessor'
import type { BlogData, CMSAuthor, StaticAuthor } from '../types'
type Post = ReturnType<typeof getSortedPosts>[number]
// Generate static params for all blog posts
export async function generateStaticParams() {
// Get paths from static files
const staticPaths = getAllPostSlugs('_blog')
// Get paths from CMS
const cmsPaths = await getAllCMSPostSlugs()
// Combine both path sources
return [...staticPaths, ...cmsPaths].map((path) => ({
slug: path.params.slug,
}))
}
// Generate metadata for the page
export async function generateMetadata({
params,
}: {
params: { slug: string }
}): Promise<Metadata> {
const { isEnabled: isDraftMode } = draftMode()
const slug = params.slug
let blogData: BlogData | null = null
try {
// Try static post first
const postContent = await getPostdata(slug, '_blog')
const parsedContent = matter(postContent) as unknown as { data: BlogData }
blogData = parsedContent.data
} catch {
// Try CMS post if static post not found
const cmsPost = await getCMSPostBySlug(slug, isDraftMode)
if (cmsPost) {
blogData = {
...cmsPost,
image: cmsPost.image || undefined,
thumb: cmsPost.thumb || undefined,
toc: undefined,
}
}
}
if (!blogData) {
return {
title: 'Not Found',
description: 'The page you are looking for does not exist.',
}
}
const imageUrl = blogData.isCMS ? blogData.thumb! : `/images/blog/${blogData.thumb}`
return {
title: blogData.meta_title ?? blogData.title,
description: blogData.meta_description ?? blogData.description,
openGraph: {
title: blogData.meta_title ?? blogData.title,
description: blogData.meta_description ?? blogData.description,
url: `https://supabase.com/blog/${blogData.slug}`,
type: 'article',
...(blogData.video && {
videos: [
{
url: blogData.video,
type: 'application/x-shockwave-flash',
width: 640,
height: 385,
},
],
}),
publishedTime: blogData.date,
authors:
blogData.authors?.map((author) => author.author_url).filter(isNotNullOrUndefined) ?? [],
tags: blogData.tags?.map((tag) => (typeof tag === 'string' ? tag : tag.name)),
images: [
{
url: imageUrl,
alt: `${blogData.title} thumbnail`,
},
],
},
}
}
const generateReadingTime = (text: string | undefined): string => {
if (!text) return '0 min read'
const wordsPerMinute = 200
const numberOfWords = text.split(/\s/g).length
const minutes = Math.ceil(numberOfWords / wordsPerMinute)
return `${minutes} min read`
}
// Main page component
export default async function BlogPostPage({ params }: { params: { slug: string } }) {
const { isEnabled: isDraftMode } = draftMode()
const slug = params.slug
let props: {
prevPost: Post | null
nextPost: Post | null
relatedPosts: (Post & BlogData)[]
blog: BlogData & {
rawContent: string
mdxSource: any
}
isDraftMode: boolean
}
// Try static post first
try {
const postContent = await getPostdata(slug, '_blog')
const parsedContent = matter(postContent) as unknown as { data: BlogData; content: string }
const content = parsedContent.content
const mdxSource = await mdxSerialize(content)
const blogPost = { ...parsedContent.data }
// Get all posts for navigation and related posts
const allStaticPosts = getSortedPosts({ directory: '_blog' })
const allCmsPosts = await getAllCMSPosts()
const allPosts = [...allStaticPosts, ...allCmsPosts].sort(
(a: any, b: any) => new Date(b.date).getTime() - new Date(a.date).getTime()
)
const currentIndex = allPosts.findIndex((post) => post.slug === slug)
const nextPost = currentIndex === allPosts.length - 1 ? null : allPosts[currentIndex + 1]
const prevPost = currentIndex === 0 ? null : allPosts[currentIndex - 1]
const relatedPosts = getSortedPosts({
directory: '_blog',
limit: 3,
tags: mdxSource.scope.tags,
currentPostSlug: slug,
}) as unknown as (BlogData & Post)[]
props = {
prevPost,
nextPost,
relatedPosts,
blog: {
...blogPost,
rawContent: content,
mdxSource,
},
isDraftMode,
}
} catch {
// Try CMS post if static post not found
const cmsPost = await getCMSPostBySlug(slug, isDraftMode)
if (!cmsPost) {
throw new Error('Post not found')
}
const content = cmsPost.content || ''
const mdxSource = await mdxSerialize(content)
props = {
prevPost: null,
nextPost: null,
relatedPosts: [],
blog: {
...cmsPost,
tags: cmsPost.tags || [],
authors: cmsPost.authors || [],
isCMS: true,
rawContent: content,
mdxSource,
image: cmsPost.image || undefined,
thumb: cmsPost.thumb || undefined,
toc: undefined,
},
isDraftMode,
}
}
const { blog: blogMetaData, isDraftMode: isPreview } = props
// For CMS posts, the author info is already included
// For static posts, we need to look up the author in authors.json
const author = blogMetaData.isCMS
? (blogMetaData.authors as CMSAuthor[]) || []
: (blogMetaData.author as string)
?.split(',')
.map((authorId: string) => {
const foundAuthor = authors.find((author) => author.author_id === authorId)
return foundAuthor
? {
author: foundAuthor.author || 'Author',
author_image_url: foundAuthor.author_image_url || null,
author_url: foundAuthor.author_url || '#',
position: foundAuthor.position || '',
}
: null
})
.filter(isNotNullOrUndefined) || []
const imageUrl = blogMetaData.isCMS ? blogMetaData.thumb! : `/images/blog/${blogMetaData.thumb}`
return (
<>
{isPreview && <DraftModeBanner />}
<DefaultLayout className="overflow-x-hidden">
<div className="container mx-auto px-4 py-4 md:py-8 xl:py-10 sm:px-16 xl:px-20">
<div className="grid grid-cols-12 gap-4">
<div className="hidden col-span-12 xl:block lg:col-span-2">
<Link
href="/blog"
className="text-foreground-lighter hover:text-foreground flex cursor-pointer items-center text-sm transition"
>
<ChevronLeft style={{ padding: 0 }} />
Back
</Link>
</div>
<div className="col-span-12 lg:col-span-12 xl:col-span-10">
<div className="mb-6 lg:mb-10 max-w-5xl space-y-8">
<div className="space-y-4">
<Link href="/blog" className="text-brand hidden lg:inline-flex items-center">
Blog
</Link>
<h1 className="text-2xl sm:text-4xl">{blogMetaData.title}</h1>
<div className="text-light flex space-x-3 text-sm">
<p>{dayjs(blogMetaData.date).format('DD MMM YYYY')}</p>
<p>•</p>
<p>{generateReadingTime(blogMetaData.source)}</p>
</div>
<AuthorList authors={author} />
</div>
</div>
<BlogContent
content={props.blog.rawContent}
mdxSource={props.blog.mdxSource}
youtubeHero={blogMetaData.youtubeHero}
thumb={blogMetaData.thumb}
imageUrl={imageUrl}
title={blogMetaData.title}
tags={blogMetaData.tags}
toc_depth={blogMetaData.toc_depth}
prevPost={props.prevPost}
nextPost={props.nextPost}
/>
</div>
</div>
</div>
<CTABanner />
{blogMetaData.launchweek === '7' && <BlogLinks />}
{blogMetaData.launchweek?.toString().toLowerCase() === 'x' && <LWXSummary />}
{blogMetaData.launchweek?.toString().toLowerCase() === '11' && <LW11Summary />}
{blogMetaData.launchweek?.toString().toLowerCase() === '12' && <LW12Summary />}
{blogMetaData.launchweek?.toString().toLowerCase() === '13' && <LW13Summary />}
{blogMetaData.launchweek?.toString().toLowerCase() === '14' && <LW14Summary />}
</DefaultLayout>
</>
)
}
@@ -0,0 +1,57 @@
'use client'
import Image from 'next/image'
import Link from 'next/link'
type Author = {
author: string
author_image_url: string | { url: string } | null
author_url: string
position: string
}
interface AuthorListProps {
authors: Author[]
}
export const AuthorList = ({ authors }: AuthorListProps) => {
if (authors.length === 0) return null
return (
<div className="hidden lg:flex justify-between">
<div className="flex-1 flex flex-col gap-3 pt-2 md:flex-row md:gap-0 lg:gap-3">
{authors.map((author, i: number) => {
const imageUrl = !author.author_image_url
? ''
: typeof author.author_image_url === 'string'
? author.author_image_url
: author.author_image_url.url
return (
<div className="mr-4 w-max" key={i}>
<Link href={author.author_url} target="_blank" className="cursor-pointer">
<div className="flex items-center gap-3">
{imageUrl && (
<div className="w-10">
<Image
src={imageUrl}
className="border-default rounded-full border w-full aspect-square object-cover"
alt={`${author.author} avatar`}
width={40}
height={40}
/>
</div>
)}
<div className="flex flex-col">
<span className="text-foreground mb-0 text-sm">{author.author}</span>
<span className="text-foreground-lighter mb-0 text-xs">{author.position}</span>
</div>
</div>
</Link>
</div>
)
})}
</div>
</div>
)
}
@@ -0,0 +1,105 @@
'use client'
import Image from 'next/image'
import { Badge } from 'ui'
import Link from 'next/link'
import ShareArticleActions from '~/components/Blog/ShareArticleActions'
import { NextCard } from './NextCard'
import { BlogMarkdownProcessor } from './BlogMarkdownProcessor'
import { Tag } from '../types'
interface BlogContentProps {
content: string
mdxSource: any
youtubeHero?: string
thumb?: string
imageUrl?: string
title: string
tags?: Tag[]
toc_depth?: number
prevPost: any
nextPost: any
}
export const BlogContent = ({
content,
mdxSource,
youtubeHero,
thumb,
imageUrl,
title,
tags,
toc_depth,
prevPost,
nextPost,
}: BlogContentProps) => {
return (
<div className="grid grid-cols-12 lg:gap-16 xl:gap-8">
<div className="col-span-12 lg:col-span-7 xl:col-span-7">
<article>
<div className={['prose prose-docs'].join(' ')}>
{youtubeHero ? (
<iframe
className="w-full"
width="700"
height="350"
src={youtubeHero}
allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen={true}
/>
) : (
thumb &&
imageUrl && (
<div className="hidden md:block relative mb-8 w-full aspect-video overflow-auto rounded-lg border">
<Image
src={imageUrl}
alt={title}
fill
quality={100}
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
className="object-cover m-0"
/>
</div>
)
)}
<BlogMarkdownProcessor content={content} mdxSource={mdxSource} toc_depth={toc_depth} />
</div>
</article>
<div className="block lg:hidden py-8">
<div className="text-foreground-lighter text-sm">Share this article</div>
<ShareArticleActions title={title} slug={title} />
</div>
<div className="grid gap-8 py-8 lg:grid-cols-1">
<div>{prevPost && <NextCard post={prevPost} label="Last post" />}</div>
<div>
{nextPost && <NextCard post={nextPost} label="Next post" className="text-right" />}
</div>
</div>
</div>
<div className="relative col-span-12 space-y-8 lg:col-span-5 xl:col-span-3 xl:col-start-9">
<div className="space-y-6">
<div className="hidden lg:block">
<div>
<div className="flex flex-wrap gap-2">
{(tags as Tag[])?.map((tag) => {
const tagName = typeof tag === 'string' ? tag : tag.name
const tagId = typeof tag === 'string' ? tag : tag.id.toString()
return (
<Link href={`/blog/tags/${tagName}`} key={`category-badge-${tagId}`}>
<Badge>{tagName}</Badge>
</Link>
)
})}
</div>
</div>
</div>
<div className="hidden lg:block">
<div className="text-foreground text-sm">Share this article</div>
<ShareArticleActions title={title} slug={title} />
</div>
</div>
</div>
</div>
)
}
@@ -0,0 +1,78 @@
'use client'
import { MDXRemote } from 'next-mdx-remote'
import mdxComponents from '~/lib/mdx/mdxComponents'
import { ReactMarkdown } from 'react-markdown/lib/react-markdown'
import { useEffect, useState } from 'react'
interface BlogMarkdownProcessorProps {
content: string
mdxSource: any
toc_depth?: number
className?: string
}
interface TocEntry {
depth: number
text: string
id: string
}
function generateToc(content: string, maxDepth: number = 2): string {
const headingRegex = /^(#{1,6})\s+(.+)$/gm
const toc: TocEntry[] = []
let match
while ((match = headingRegex.exec(content)) !== null) {
const depth = match[1].length
if (depth <= maxDepth) {
const text = match[2].trim()
// Generate GitHub-style ID (lowercase, replace spaces with hyphens)
const id = text
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
toc.push({ depth, text, id })
}
}
// Convert TOC entries to markdown
return toc
.map((entry) => {
const indent = ' '.repeat(entry.depth - 1)
return `${indent}- [${entry.text}](#${entry.id})`
})
.join('\n')
}
export function BlogMarkdownProcessor({
content,
mdxSource,
toc_depth,
className,
}: BlogMarkdownProcessorProps) {
const [tocContent, setTocContent] = useState<string>('')
useEffect(() => {
if (content) {
const tocMarkdown = generateToc(content, toc_depth || 2)
setTocContent(tocMarkdown)
}
}, [content, toc_depth])
return (
<div className={className}>
<MDXRemote {...mdxSource} components={mdxComponents('blog')} />
{tocContent && (
<div className="hidden lg:block py-8">
<div>
<p className="text-foreground mb-4">On this page</p>
<div className="prose-toc">
<ReactMarkdown>{tocContent}</ReactMarkdown>
</div>
</div>
</div>
)}
</div>
)
}
+33
View File
@@ -0,0 +1,33 @@
'use client'
import Link from 'next/link'
type NextCardProps = {
post: {
path: string
title: string
formattedDate: string
}
label: string
className?: string
}
export const NextCard = ({ post, label, className }: NextCardProps) => {
return (
<Link href={`${post.path}`} as={`${post.path}`}>
<div className={className}>
<div className="hover:bg-control cursor-pointer rounded border p-6 transition">
<div className="space-y-4">
<div>
<p className="text-foreground-lighter text-sm">{label}</p>
</div>
<div>
<h4 className="text-foreground text-lg">{post.title}</h4>
<p className="small">{post.formattedDate}</p>
</div>
</div>
</div>
</div>
</Link>
)
}
+11
View File
@@ -0,0 +1,11 @@
import { ReactNode } from 'react'
import { RefreshRouteOnSave } from './RefreshRouteOnSave'
export default function BlogLayout({ children }: { children: ReactNode }) {
return (
<>
<RefreshRouteOnSave />
{children}
</>
)
}
+52
View File
@@ -0,0 +1,52 @@
export type Tag =
| string
| {
name: string
id: number
documentId: string
createdAt: string
updatedAt: string
publishedAt: string
}
export type CMSAuthor = {
author: string
author_image_url: {
url: string
}
author_url: string
position: string
}
export type StaticAuthor = {
author: string
author_image_url: string | null
author_url: string
position: string
}
export type BlogData = {
slug: string
title: string
description?: string
content: any
toc: any
author?: string
authors?: (CMSAuthor | StaticAuthor)[]
date: string
categories?: string[]
tags?: Tag[]
toc_depth?: number
video?: string
docs_url?: string
blog_url?: string
url?: string
source: string
image?: string
thumb?: string
youtubeHero?: string
launchweek?: number | string
meta_title?: string
meta_description?: string
isCMS?: boolean
}
+4 -2
View File
@@ -1,7 +1,9 @@
'use client'
import { CheckIcon } from '@heroicons/react/outline'
import Image from 'next/image'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { usePathname } from 'next/navigation'
import { Badge, IconDiscord, IconGitHubSolid, IconTwitterX, IconYoutubeSolid, cn } from 'ui'
import SectionContainer from '../Layouts/SectionContainer'
@@ -17,7 +19,7 @@ interface Props {
}
const Footer = (props: Props) => {
const { pathname } = useRouter()
const pathname = usePathname()
const isDarkLaunchWeek = useDarkLaunchWeeks()
const isGAWeek = pathname.includes('/ga-week')
+8 -6
View File
@@ -1,6 +1,8 @@
'use client'
import { useTheme } from 'next-themes'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { usePathname } from 'next/navigation'
import React, { useState } from 'react'
import { useWindowSize } from 'react-use'
@@ -33,7 +35,7 @@ interface Props {
const Nav = ({ hideNavbar, stickyNavbar = true }: Props) => {
const { resolvedTheme } = useTheme()
const router = useRouter()
const pathname = usePathname()
const { width } = useWindowSize()
const [open, setOpen] = useState(false)
const isLoggedIn = useIsLoggedIn()
@@ -42,10 +44,10 @@ const Nav = ({ hideNavbar, stickyNavbar = true }: Props) => {
const user = useUser()
const userMenu = useDropdownMenu(user)
const isHomePage = router.pathname === '/'
const isLaunchWeekPage = router.pathname.includes('/launch-week')
const isLaunchWeekXPage = router.pathname === '/launch-week/x'
const isGAWeekSection = router.pathname.startsWith('/ga-week')
const isHomePage = pathname === '/'
const isLaunchWeekPage = pathname?.includes('/launch-week')
const isLaunchWeekXPage = pathname === '/launch-week/x'
const isGAWeekSection = pathname?.startsWith('/ga-week')
const disableStickyNav = isLaunchWeekXPage || isGAWeekSection || isLaunchWeekPage || !stickyNavbar
const showLaunchWeekNavMode = (isLaunchWeekPage || isGAWeekSection) && !open
-15
View File
@@ -1,5 +1,4 @@
import { generateReadingTime } from './helpers'
const toc = require('markdown-toc')
// Payload API configuration
const PAYLOAD_URL = process.env.NEXT_PUBLIC_CMS_URL || 'http://localhost:3030'
@@ -273,11 +272,6 @@ function processPostData(post: any) {
const thumbUrl = post.thumb?.url ? `${PAYLOAD_URL}${post.thumb.url}` : null
const imageUrl = post.image?.url ? `${PAYLOAD_URL}${post.image.url}` : null
// Generate TOC from content for CMS posts
const tocResult = toc(markdownContent, {
maxdepth: post.toc_depth ? post.toc_depth : 2,
})
return {
slug: post.slug,
source: markdownContent,
@@ -307,10 +301,6 @@ function processPostData(post: any) {
isCMS: true,
content: markdownContent,
tags: post.tags || [],
toc: {
content: tocResult.content,
json: tocResult.json,
},
}
}
@@ -372,11 +362,6 @@ export async function getAllCMSPosts({
author_id: author.author_id || '',
position: author.position || '',
author_url: author.author_url || '#',
// author_image_url: author.author_image_url?.url
// ? author.author_image_url.url.includes('http')
// ? author.author_image_url.url
// : `${PAYLOAD_URL}${author.author_image_url.url}`
// : null,
author_image_url: author.author_image_url?.url
? `${PAYLOAD_URL}${author.author_image_url.url}`
: null,
+12 -9
View File
@@ -1,16 +1,19 @@
'use client'
import { sendTelemetryEvent } from 'common'
import { TelemetryEvent } from 'common/telemetry-constants'
import { API_URL } from 'lib/constants'
import { useRouter } from 'next/router'
import { useCallback } from 'react'
import { usePathname, useSearchParams } from 'next/navigation'
export function useSendTelemetryEvent() {
const router = useRouter()
const pathname = usePathname()
const searchParams = useSearchParams()
return useCallback(
(event: TelemetryEvent) => {
return sendTelemetryEvent(API_URL, event, router.pathname)
},
[router.pathname]
)
return (event: TelemetryEvent) => {
const url = new URL(API_URL ?? 'http://localhost:3000')
url.pathname = pathname ?? ''
url.search = searchParams?.toString() ?? ''
return sendTelemetryEvent(event, url.toString())
}
}
+2
View File
@@ -1,3 +1,5 @@
'use client'
import { useEffect } from 'react'
import { useTheme } from 'next-themes'
import { useRouter } from 'next/router'
+43
View File
@@ -0,0 +1,43 @@
interface TocEntry {
depth: number
text: string
id: string
}
export function generateToc(
content: string,
maxDepth: number = 2
): {
content: string
json: TocEntry[]
} {
const headingRegex = /^(#{1,6})\s+(.+)$/gm
const toc: TocEntry[] = []
let match
while ((match = headingRegex.exec(content)) !== null) {
const depth = match[1].length
if (depth <= maxDepth) {
const text = match[2].trim()
// Generate GitHub-style ID (lowercase, replace spaces with hyphens)
const id = text
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
toc.push({ depth, text, id })
}
}
// Convert TOC entries to markdown
const tocMarkdown = toc
.map((entry) => {
const indent = ' '.repeat(entry.depth - 1)
return `${indent}- [${entry.text}](#${entry.id})`
})
.join('\n')
return {
content: tocMarkdown,
json: toc,
}
}
+2 -1
View File
@@ -52,7 +52,8 @@
"icons": "workspace:*",
"lodash": "^4.0.0",
"lucide-react": "*",
"markdown-toc": "^1.2.0",
"marked": "^4.0.12",
"mdx-mermaid": "^1.3.2",
"next": "catalog:",
"next-contentlayer2": "0.5.3",
"next-mdx-remote": "^4.4.1",
-9
View File
@@ -1,9 +0,0 @@
import { NextApiRequest, NextApiResponse } from 'next'
export default function handler(req: NextApiRequest, res: NextApiResponse) {
// Disable Draft Mode by clearing the cookie
res.setDraftMode({ enable: false })
// Redirect to the homepage or wherever you want
res.redirect('/')
}
-40
View File
@@ -1,40 +0,0 @@
import { NextApiRequest, NextApiResponse } from 'next'
import { getCMSPostBySlug } from '../../lib/cms-posts'
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const { slug, secret } = req.query
// Check the secret and slug parameters
// You should set PREVIEW_SECRET in your environment variables for security
if (secret && secret !== process.env.PREVIEW_SECRET) {
return res.status(401).json({ message: 'Invalid token' })
}
// Check if the slug exists
if (!slug) {
return res.status(400).json({ message: 'Missing slug parameter' })
}
try {
// Fetch the post with preview flag set to true to get draft content
const post = await getCMSPostBySlug(String(slug), true)
if (!post) {
return res.status(404).json({ message: 'Post not found' })
}
// Enable Draft Mode by setting the cookie
res.setDraftMode({ enable: true })
// Set headers to prevent caching in draft mode
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate')
res.setHeader('Pragma', 'no-cache')
res.setHeader('Expires', '0')
// Redirect to the blog post page with draft mode enabled
res.redirect(`/blog/${post.slug}`)
} catch (error) {
console.error('[Preview API] Error enabling preview:', error)
return res.status(500).json({ message: 'Error enabling preview mode' })
}
}
-746
View File
@@ -1,746 +0,0 @@
import matter from 'gray-matter'
import { useState, useMemo, useEffect } from 'react'
import type { GetStaticProps, InferGetStaticPropsType } from 'next'
import { MDXRemote } from 'next-mdx-remote'
import { NextSeo } from 'next-seo'
import Image from 'next/image'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { ReactMarkdown } from 'react-markdown/lib/react-markdown'
import dayjs from 'dayjs'
import { ChevronLeft } from 'lucide-react'
import { useLivePreview } from '@payloadcms/live-preview-react'
import { Badge } from 'ui'
import authors from 'lib/authors.json'
import { isNotNullOrUndefined } from '~/lib/helpers'
import mdxComponents from '~/lib/mdx/mdxComponents'
import { mdxSerialize } from '~/lib/mdx/mdxSerialize'
import { getAllPostSlugs, getPostdata, getSortedPosts } from '~/lib/posts'
import { getAllCMSPostSlugs, getCMSPostBySlug, getAllCMSPosts } from '~/lib/cms-posts'
import ShareArticleActions from '~/components/Blog/ShareArticleActions'
import CTABanner from '~/components/CTABanner'
import LW11Summary from '~/components/LaunchWeek/11/LW11Summary'
import LW12Summary from '~/components/LaunchWeek/12/LWSummary'
import LW13Summary from '~/components/LaunchWeek/13/Releases/LWSummary'
import LW14Summary from '~/components/LaunchWeek/14/Releases/LWSummary'
import BlogLinks from '~/components/LaunchWeek/7/BlogLinks'
import LWXSummary from '~/components/LaunchWeek/X/LWXSummary'
import DefaultLayout from '~/components/Layouts/Default'
import { DraftModeBanner } from '~/components/Blog/DraftModeBanner'
type Post = ReturnType<typeof getSortedPosts>[number]
type CMSAuthor = {
author: string
author_image_url: {
url: string
}
author_url: string
position: string
}
type StaticAuthor = {
author: string
author_image_url: string | null
author_url: string
position: string
}
type BlogData = {
slug: string
title: string
description?: string
content: any
toc: any
author?: string
authors?: (CMSAuthor | StaticAuthor)[]
date: string
categories?: string[]
tags?:
| string[]
| Array<{
id: number
documentId: string
name: string
createdAt: string
updatedAt: string
publishedAt: string
}>
toc_depth?: number
video?: string
docs_url?: string
blog_url?: string
url?: string
source: string
image?: string
thumb?: string
youtubeHero?: string
launchweek?: number | string
meta_title?: string
meta_description?: string
isCMS?: boolean
}
type MatterReturn = {
data: BlogData
content: string
}
type Blog = {
slug: string
title: string
description?: string
content: any
toc: any
author?: string
authors?: (CMSAuthor | StaticAuthor)[]
date: string
categories?: string[]
tags?:
| string[]
| Array<{
id: number
documentId: string
name: string
createdAt: string
updatedAt: string
publishedAt: string
}>
toc_depth?: number
video?: string
docs_url?: string
blog_url?: string
url?: string
source: string
image?: string
thumb?: string
youtubeHero?: string
launchweek?: number | string
meta_title?: string
meta_description?: string
isCMS?: boolean
}
type BlogPostPageProps = {
prevPost: Post | null
nextPost: Post | null
relatedPosts: (Post & BlogData)[]
blog: Blog & BlogData
isDraftMode: boolean
}
type Params = {
slug: string
}
// table of contents extractor
const toc = require('markdown-toc')
type Tag =
| string
| {
name: string
id: number
documentId: string
createdAt: string
updatedAt: string
publishedAt: string
}
type Category = string | { name: string }
// Add a new type for processed blog data
type ProcessedBlogData = BlogData &
Blog & {
needsSerialization?: boolean
}
export async function getStaticPaths() {
// Get paths from static files
const staticPaths = getAllPostSlugs('_blog')
// Get paths from CMS
const cmsPaths = await getAllCMSPostSlugs()
// Combine both path sources
const paths = [...staticPaths, ...cmsPaths]
return {
paths,
fallback: 'blocking', // Set to 'blocking' to allow ISR for new CMS posts
}
}
export const getStaticProps: GetStaticProps<BlogPostPageProps, Params> = async ({
params,
draftMode = false,
}) => {
console.log('[getStaticProps] Called with params:', params, 'draftMode:', draftMode)
if (!params?.slug) {
console.error('[getStaticProps] Missing slug parameter:', params)
throw new Error('Missing slug for pages/blog/[slug].tsx')
}
const slug = `${params.slug}`
console.log(
`[getStaticProps] generating for slug: '${slug}', draft mode: ${draftMode ? 'true' : 'false'}`
)
// Try static post first
try {
const postContent = await getPostdata(slug, '_blog')
const parsedContent = matter(postContent) as unknown as MatterReturn
const content = parsedContent.content
const mdxSource = await mdxSerialize(content)
const blogPost = { ...parsedContent.data }
// Get all posts for navigation and related posts
const allStaticPosts = getSortedPosts({ directory: '_blog' })
const allCmsPosts = await getAllCMSPosts()
const allPosts = [...allStaticPosts, ...allCmsPosts].sort(
(a: any, b: any) => new Date(b.date).getTime() - new Date(a.date).getTime()
)
const currentIndex = allPosts.findIndex((post) => post.slug === slug)
const nextPost = currentIndex === allPosts.length - 1 ? null : allPosts[currentIndex + 1]
const prevPost = currentIndex === 0 ? null : allPosts[currentIndex - 1]
const tocResult = toc(content, { maxdepth: blogPost.toc_depth ? blogPost.toc_depth : 2 })
const processedContent = tocResult.content.replace(/%23/g, '')
const relatedPosts = getSortedPosts({
directory: '_blog',
limit: 3,
tags: mdxSource.scope.tags,
currentPostSlug: slug,
}) as unknown as (BlogData & Post)[]
return {
props: {
prevPost,
nextPost,
relatedPosts,
blog: {
...blogPost,
content: mdxSource,
toc: {
...tocResult,
content: processedContent,
},
},
isDraftMode: draftMode,
},
// Don't use revalidate in draft mode
...(draftMode ? {} : { revalidate: 60 * 10 }),
}
} catch (error) {
console.log('[getStaticProps] Static post not found, trying CMS post...')
// Not a static post, try CMS
}
// Try CMS post (handle draft mode logic)
const cmsPost = await getCMSPostBySlug(slug, draftMode)
if (!cmsPost) {
console.log(
'[getStaticProps] No CMS post found, checking published version (if in draft mode)...'
)
// Try to fetch published version if draft mode failed
if (draftMode) {
console.log('[getStaticProps] In draft mode but no draft found, trying published version...')
const publishedPost = await getCMSPostBySlug(slug, false)
console.log('[getStaticProps] Published post:', publishedPost)
if (!publishedPost) {
console.log('[getStaticProps] No published version found either, returning 404')
return { notFound: true }
}
console.log('[getStaticProps] Found published version, using that for draft mode')
const mdxSource = await mdxSerialize(publishedPost.content || '')
console.log('[getStaticProps] MDX source:', mdxSource)
return {
props: {
prevPost: null,
nextPost: null,
relatedPosts: [],
blog: {
...publishedPost,
tags: publishedPost.tags || [],
authors: publishedPost.authors || [],
isCMS: true,
content: mdxSource,
toc: publishedPost.toc,
image: publishedPost.image ?? undefined,
thumb: publishedPost.thumb ?? undefined,
},
isDraftMode: draftMode,
},
// Don't use revalidate in draft mode
...(draftMode ? {} : { revalidate: 60 * 10 }),
}
}
console.log('[getStaticProps] Not in draft mode and no CMS post found, returning 404')
return { notFound: true }
}
// For CMS posts, process content
console.log('[getStaticProps] Processing CMS post data for render')
const mdxSource = await mdxSerialize(cmsPost.content || '')
return {
props: {
prevPost: null,
nextPost: null,
relatedPosts: [],
blog: {
...cmsPost,
tags: cmsPost.tags || [],
authors: cmsPost.authors || [],
isCMS: true,
content: mdxSource,
toc: cmsPost.toc,
image: cmsPost.image ?? undefined,
thumb: cmsPost.thumb ?? undefined,
},
isDraftMode: draftMode,
},
// Don't use revalidate in draft mode
...(draftMode ? {} : { revalidate: 60 * 10 }),
}
}
function BlogPostPage(props: InferGetStaticPropsType<typeof getStaticProps>) {
const router = useRouter()
// Use the draft mode state passed from getStaticProps
const isDraftMode = props.isDraftMode
console.log('isDraftMode', isDraftMode)
const [previewData, setPreviewData] = useState<ProcessedBlogData>(props.blog)
// Only use live preview hook for CMS posts in draft mode
const shouldUseLivePreview = isDraftMode && props.blog.isCMS
const { data: livePreviewData, isLoading: isLivePreviewLoading } = useLivePreview({
initialData: props.blog,
serverURL: process.env.NEXT_PUBLIC_CMS_URL || 'http://localhost:3030',
depth: 2,
})
useEffect(() => {
console.log('livePreviewData', livePreviewData)
}, [livePreviewData])
console.log('[BlogPostPage] LivePreview data from hook:', livePreviewData)
console.log('[BlogPostPage] Initial data:', props.blog)
console.log(
'[BlogPostPage] Server URL for live preview:',
process.env.NEXT_PUBLIC_CMS_URL || 'http://localhost:3030'
)
// For LivePreview, we'll use the raw content directly with ReactMarkdown
// instead of trying to use MDXRemote which requires specific serialization
const isLivePreview = isDraftMode && (livePreviewData !== undefined || previewData !== props.blog)
// Extract raw content from data if available
const livePreviewContent = useMemo(() => {
// Priority 1: Use data from LivePreview hook (only in draft mode)
if (
isDraftMode &&
shouldUseLivePreview &&
livePreviewData &&
typeof livePreviewData === 'object'
) {
console.log('[BlogPostPage] Using livePreviewData for content')
// If content is a string, use it directly
if (typeof (livePreviewData as any).content === 'string') {
return (livePreviewData as any).content
}
// If content is from source property
if ((livePreviewData as any).source && typeof (livePreviewData as any).source === 'string') {
return (livePreviewData as any).source
}
}
// Priority 2: Use data from postMessage updates
if (isDraftMode && previewData !== props.blog) {
console.log('[BlogPostPage] Using previewData from postMessage for content')
// If content is a string, use it directly
if (typeof previewData.content === 'string') {
return previewData.content
}
// If content is from source property
if (previewData.source && typeof previewData.source === 'string') {
return previewData.source
}
}
// Fallback to props.blog.source
return props.blog.source || ''
}, [isDraftMode, shouldUseLivePreview, livePreviewData, previewData, props.blog])
// Only use the live preview data for metadata
const blogMetaData = useMemo(() => {
if (isDraftMode && shouldUseLivePreview) {
// Priority 1: Use data from LivePreview hook
if (livePreviewData && typeof livePreviewData === 'object') {
return { ...props.blog, ...livePreviewData }
}
// Priority 2: Use data from postMessage updates
if (previewData !== props.blog) {
return previewData
}
}
// Fallback to props.blog
return props.blog
}, [isDraftMode, shouldUseLivePreview, livePreviewData, previewData, props.blog])
const handlePreviewUpdate = (data: any) => {
console.log('[BlogPostPage] Received preview update:', data)
setPreviewData((prev) => {
const updatedData = {
...prev,
...data,
}
console.log('[BlogPostPage] Updated previewData:', updatedData)
return updatedData
})
// Force a router refresh to get the latest data
if (isDraftMode) {
console.log('[BlogPostPage] Refreshing router to get latest draft data')
router.replace(router.asPath, undefined, {
shallow: false,
scroll: false,
})
}
}
const content = blogMetaData.content
const isCMS = blogMetaData.isCMS
const isLaunchWeek7 = blogMetaData.launchweek === '7'
const isLaunchWeekX = blogMetaData.launchweek?.toString().toLocaleLowerCase() === 'x'
const isGAWeek = blogMetaData.launchweek?.toString().toLocaleLowerCase() === '11'
const isLaunchWeek12 = blogMetaData.launchweek?.toString().toLocaleLowerCase() === '12'
const isLaunchWeek13 = blogMetaData.launchweek?.toString().toLocaleLowerCase() === '13'
const isLaunchWeek14 = blogMetaData.launchweek?.toString().toLocaleLowerCase() === '14'
console.log('livePreviewData', livePreviewData)
// For CMS posts, the author info is already included
// For static posts, we need to look up the author in authors.json
const author = isCMS
? (blogMetaData.authors as CMSAuthor[]) || []
: (blogMetaData.author as string)
?.split(',')
.map((authorId: string) => {
const foundAuthor = authors.find((author) => author.author_id === authorId)
return foundAuthor
? {
author: foundAuthor.author || 'Author',
author_image_url: foundAuthor.author_image_url || null,
author_url: foundAuthor.author_url || '#',
position: foundAuthor.position || '',
}
: null
})
.filter(isNotNullOrUndefined) || []
const authorUrls = author.map((author) => author?.author_url).filter(isNotNullOrUndefined)
const { basePath } = useRouter()
const NextCard = (props: any) => {
const { post, label, className } = props
return (
<Link href={`${post.path}`} as={`${post.path}`}>
<div className={className}>
<div className="hover:bg-control cursor-pointer rounded border p-6 transition">
<div className="space-y-4">
<div>
<p className="text-foreground-lighter text-sm">{label}</p>
</div>
<div>
<h4 className="text-foreground text-lg">{post.title}</h4>
<p className="small">{post.formattedDate}</p>
</div>
</div>
</div>
</div>
</Link>
)
}
const toc = blogMetaData.toc && (
<div className="space-y-8 py-8 lg:py-0">
<div>
<div className="flex flex-wrap gap-2">
{(blogMetaData.tags as Tag[])?.map((tag) => {
const tagName = typeof tag === 'string' ? tag : tag.name
const tagId = typeof tag === 'string' ? tag : tag.id.toString()
return (
<Link href={`/blog/tags/${tagName}`} key={`category-badge-${tagId}`}>
<Badge>{tagName}</Badge>
</Link>
)
})}
</div>
</div>
<div>
<div>
<p className="text-foreground mb-4">On this page</p>
<div className="prose-toc">
{blogMetaData.toc && (
<ReactMarkdown>
{typeof blogMetaData.toc === 'string' ? blogMetaData.toc : blogMetaData.toc.content}
</ReactMarkdown>
)}
</div>
</div>
</div>
</div>
)
const imageUrl = isCMS ? blogMetaData.thumb! : `/images/blog/${blogMetaData.thumb}`
const meta = {
title: blogMetaData.meta_title ?? blogMetaData.title,
description: blogMetaData.meta_description ?? blogMetaData.description,
url: `https://supabase.com/blog/${blogMetaData.slug}`,
}
const processTag = (tag: Tag): string => {
return typeof tag === 'string' ? tag : tag.name
}
const processCategory = (category: Category): string => {
return typeof category === 'string' ? category : category.name
}
const tags = blogMetaData.tags
? Array.isArray(blogMetaData.tags)
? (blogMetaData.tags as Tag[]).map(processTag)
: []
: []
const categories = blogMetaData.categories
? Array.isArray(blogMetaData.categories)
? (blogMetaData.categories as Category[]).map(processCategory)
: []
: []
const generateReadingTime = (text: string | undefined): string => {
if (!text) return '0 min read'
const wordsPerMinute = 200
const numberOfWords = text.split(/\s/g).length
const minutes = Math.ceil(numberOfWords / wordsPerMinute)
return `${minutes} min read`
}
return (
<>
<NextSeo
title={meta.title}
description={meta.description}
openGraph={{
title: meta.title,
description: meta.description,
url: meta.url,
type: 'article',
videos: blogMetaData.video
? [
{
// youtube based video meta
url: blogMetaData.video,
type: 'application/x-shockwave-flash',
width: 640,
height: 385,
},
]
: undefined,
article: {
//
// to do: add expiration and modified dates
// https://github.com/garmeeh/next-seo#article
publishedTime: blogMetaData.date,
//
// to do: author urls should be internal in future
// currently we have external links to github profiles
authors: authorUrls,
tags: tags,
},
images: [
{
url: imageUrl,
alt: `${blogMetaData.title} thumbnail`,
},
],
}}
/>
{isLivePreviewLoading && (
<div className="fixed top-10 right-10 border rounded-full rounded-tr-none animate-spin transform w-10 h-10 bg-transparent" />
)}
{isDraftMode && <DraftModeBanner />}
<DefaultLayout className="overflow-x-hidden">
<div
className="
container mx-auto px-4 py-4 md:py-8 xl:py-10 sm:px-16
xl:px-20
"
>
<div className="grid grid-cols-12 gap-4">
<div className="hidden col-span-12 xl:block lg:col-span-2">
{/* Back button */}
<Link
href="/blog"
className="text-foreground-lighter hover:text-foreground flex cursor-pointer items-center text-sm transition"
>
<ChevronLeft style={{ padding: 0 }} />
Back
</Link>
</div>
<div className="col-span-12 lg:col-span-12 xl:col-span-10">
{/* Title and description */}
<div className="mb-6 lg:mb-10 max-w-5xl space-y-8">
<div className="space-y-4">
<Link href="/blog" className="text-brand hidden lg:inline-flex items-center">
Blog{' '}
{isLivePreviewLoading && (
<div className="text-xs text-foreground-lighter ml-4">Draft loading...</div>
)}
</Link>
<h1 className="text-2xl sm:text-4xl">{blogMetaData.title}</h1>
<div className="text-light flex space-x-3 text-sm">
<p>{dayjs(blogMetaData.date).format('DD MMM YYYY')}</p>
<p>•</p>
<p>{generateReadingTime(blogMetaData.source)}</p>
</div>
{author.length > 0 && (
<div className="hidden lg:flex justify-between">
<div className="flex-1 flex flex-col gap-3 pt-2 md:flex-row md:gap-0 lg:gap-3">
{author.map((author, i: number) => {
// Handle both static and CMS author image formats
const imageUrl =
typeof author.author_image_url === 'string'
? author.author_image_url
: (author.author_image_url as { url: string })?.url || ''
return (
<div className="mr-4 w-max" key={i}>
<Link
href={author.author_url}
target="_blank"
className="cursor-pointer"
>
<div className="flex items-center gap-3">
{imageUrl && (
<div className="w-10">
<Image
src={imageUrl}
className="border-default rounded-full border w-full aspect-square object-cover"
alt={`${author.author} avatar`}
width={40}
height={40}
/>
</div>
)}
<div className="flex flex-col">
<span className="text-foreground mb-0 text-sm">
{author.author}
</span>
<span className="text-foreground-lighter mb-0 text-xs">
{author.position}
</span>
</div>
</div>
</Link>
</div>
)
})}
</div>
</div>
)}
</div>
</div>
<div className="grid grid-cols-12 lg:gap-16 xl:gap-8">
{/* Content */}
<div className="col-span-12 lg:col-span-7 xl:col-span-7">
<article>
<div className={['prose prose-docs'].join(' ')}>
{blogMetaData.youtubeHero ? (
<iframe
className="w-full"
width="700"
height="350"
src={blogMetaData.youtubeHero}
allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen={true}
/>
) : (
blogMetaData.thumb && (
<div className="hidden md:block relative mb-8 w-full aspect-video overflow-auto rounded-lg border">
<Image
src={imageUrl}
alt={blogMetaData.title}
fill
quality={100}
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
className="object-cover m-0"
/>
</div>
)
)}
{/* Use ReactMarkdown for LivePreview mode, MDXRemote for normal mode */}
{isLivePreview ? (
<ReactMarkdown>{livePreviewContent}</ReactMarkdown>
) : (
<MDXRemote {...props.blog.content} components={mdxComponents('blog')} />
)}
</div>
</article>
{isLaunchWeek7 && <BlogLinks />}
{isLaunchWeekX && <LWXSummary />}
{isGAWeek && <LW11Summary />}
{isLaunchWeek12 && <LW12Summary />}
{isLaunchWeek13 && <LW13Summary />}
{isLaunchWeek14 && <LW14Summary />}
<div className="block lg:hidden py-8">
<div className="text-foreground-lighter text-sm">Share this article</div>
<ShareArticleActions title={blogMetaData.title} slug={blogMetaData.slug} />
</div>
<div className="grid gap-8 py-8 lg:grid-cols-1">
<div>
{props.prevPost && <NextCard post={props.prevPost} label="Last post" />}
</div>
<div>
{props.nextPost && (
<NextCard post={props.nextPost} label="Next post" className="text-right" />
)}
</div>
</div>
</div>
{/* Sidebar */}
<div className="relative col-span-12 space-y-8 lg:col-span-5 xl:col-span-3 xl:col-start-9">
<div className="space-y-6">
<div className="hidden lg:block">{toc}</div>
<div className="hidden lg:block">
<div className="text-foreground text-sm">Share this article</div>
<ShareArticleActions title={blogMetaData.title} slug={blogMetaData.slug} />
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<CTABanner />
</DefaultLayout>
</>
)
}
export default BlogPostPage
+1 -8
View File
@@ -5,16 +5,9 @@
<link>https://supabase.com</link>
<description>Latest news from Supabase</description>
<language>en</language>
<lastBuildDate>Tue, 27 May 2025 00:00:00 -0700</lastBuildDate>
<lastBuildDate>Wed, 21 May 2025 00:00:00 -0700</lastBuildDate>
<atom:link href="https://supabase.com/rss.xml" rel="self" type="application/rss+xml"/>
<item>
<guid>https://supabase.com/customers/acme</guid>
<title>This is a new customer from the CMS</title>
<link>https://supabase.com/customers/acme</link>
<description></description>
<pubDate>Tue, 27 May 2025 00:00:00 -0700</pubDate>
</item>
<item>
<guid>https://supabase.com/customers/kayhanspace</guid>
<title>Kayhan Space saw 8x improvement in developer speed when moving to Supabase</title>
<link>https://supabase.com/customers/kayhanspace</link>
+520 -282
View File
File diff suppressed because it is too large. Load diff