mirror of
https://github.com/supabase/supabase.git
synced 2026-10-15 14:19:11 +03:00
test app router blog page
This commit is contained in:
1 parent
f48308c496
commit
36a7321140
25 files changed
+1289
-1133
No files matched your search
@@ -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,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.
|
||||
|
||||
@@ -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
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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}`)
|
||||
}
|
||||
@@ -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'}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { ReactNode } from 'react'
|
||||
import { RefreshRouteOnSave } from './RefreshRouteOnSave'
|
||||
|
||||
export default function BlogLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<RefreshRouteOnSave />
|
||||
{children}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { useTheme } from 'next-themes'
|
||||
import { useRouter } from 'next/router'
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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('/')
|
||||
}
|
||||
@@ -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' })
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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>
|
||||
|
||||
Generated
+520
-282
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user