From 36a73211401d3d393e622b481e073cdcf75efc13 Mon Sep 17 00:00:00 2001 From: Francesco Sansalvadore Date: Tue, 27 May 2025 19:27:02 +0200 Subject: [PATCH] test app router blog page --- apps/cms/src/collections/Posts/index.ts | 2 +- apps/design-system/public/llms.txt | 2 +- apps/ui-library/public/llms.txt | 44 +- apps/www/app/api/disable-draft/route.ts | 13 + apps/www/app/api/preview/route.ts | 22 + apps/www/app/blog/RefreshRouteOnSave.tsx | 16 + apps/www/app/blog/[slug]/page.tsx | 277 ++++++ apps/www/app/blog/components/AuthorList.tsx | 57 ++ apps/www/app/blog/components/BlogContent.tsx | 105 +++ .../blog/components/BlogMarkdownProcessor.tsx | 78 ++ apps/www/app/blog/components/NextCard.tsx | 33 + apps/www/app/blog/layout.tsx | 11 + apps/www/app/blog/types.ts | 52 ++ apps/www/components/Footer/index.tsx | 6 +- apps/www/components/Nav/index.tsx | 14 +- apps/www/lib/cms-posts.tsx | 15 - apps/www/lib/telemetry.ts | 21 +- apps/www/lib/theme.utils.ts | 2 + apps/www/lib/toc.ts | 43 + apps/www/package.json | 3 +- apps/www/pages/api/disable-draft.ts | 9 - apps/www/pages/api/preview.ts | 40 - apps/www/pages/blog/[slug].tsx | 746 ---------------- apps/www/public/customers-rss.xml | 9 +- pnpm-lock.yaml | 802 ++++++++++++------ 25 files changed, 1289 insertions(+), 1133 deletions(-) create mode 100644 apps/www/app/api/disable-draft/route.ts create mode 100644 apps/www/app/api/preview/route.ts create mode 100644 apps/www/app/blog/RefreshRouteOnSave.tsx create mode 100644 apps/www/app/blog/[slug]/page.tsx create mode 100644 apps/www/app/blog/components/AuthorList.tsx create mode 100644 apps/www/app/blog/components/BlogContent.tsx create mode 100644 apps/www/app/blog/components/BlogMarkdownProcessor.tsx create mode 100644 apps/www/app/blog/components/NextCard.tsx create mode 100644 apps/www/app/blog/layout.tsx create mode 100644 apps/www/app/blog/types.ts create mode 100644 apps/www/lib/toc.ts delete mode 100644 apps/www/pages/api/disable-draft.ts delete mode 100644 apps/www/pages/api/preview.ts delete mode 100644 apps/www/pages/blog/[slug].tsx diff --git a/apps/cms/src/collections/Posts/index.ts b/apps/cms/src/collections/Posts/index.ts index 8f2b02fbe77..6b1cd79a926 100644 --- a/apps/cms/src/collections/Posts/index.ts +++ b/apps/cms/src/collections/Posts/index.ts @@ -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, }, diff --git a/apps/design-system/public/llms.txt b/apps/design-system/public/llms.txt index 34725799b8c..51d4ce98c00 100644 --- a/apps/design-system/public/llms.txt +++ b/apps/design-system/public/llms.txt @@ -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. diff --git a/apps/ui-library/public/llms.txt b/apps/ui-library/public/llms.txt index a88fe2f8eed..9cf6e428a09 100644 --- a/apps/ui-library/public/llms.txt +++ b/apps/ui-library/public/llms.txt @@ -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 diff --git a/apps/www/app/api/disable-draft/route.ts b/apps/www/app/api/disable-draft/route.ts new file mode 100644 index 00000000000..8cb14a79bf6 --- /dev/null +++ b/apps/www/app/api/disable-draft/route.ts @@ -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') +} diff --git a/apps/www/app/api/preview/route.ts b/apps/www/app/api/preview/route.ts new file mode 100644 index 00000000000..61e1ddc4fcd --- /dev/null +++ b/apps/www/app/api/preview/route.ts @@ -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}`) +} diff --git a/apps/www/app/blog/RefreshRouteOnSave.tsx b/apps/www/app/blog/RefreshRouteOnSave.tsx new file mode 100644 index 00000000000..1108f486c33 --- /dev/null +++ b/apps/www/app/blog/RefreshRouteOnSave.tsx @@ -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 ( + router.refresh()} + serverURL={process.env.NEXT_PUBLIC_CMS_URL || 'http://localhost:3030'} + /> + ) +} diff --git a/apps/www/app/blog/[slug]/page.tsx b/apps/www/app/blog/[slug]/page.tsx new file mode 100644 index 00000000000..661694da1bc --- /dev/null +++ b/apps/www/app/blog/[slug]/page.tsx @@ -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[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 { + 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 && } + +
+
+
+ + + Back + +
+
+
+
+ + Blog + +

{blogMetaData.title}

+
+

{dayjs(blogMetaData.date).format('DD MMM YYYY')}

+

•

+

{generateReadingTime(blogMetaData.source)}

+
+ +
+
+ +
+
+
+ + {blogMetaData.launchweek === '7' && } + {blogMetaData.launchweek?.toString().toLowerCase() === 'x' && } + {blogMetaData.launchweek?.toString().toLowerCase() === '11' && } + {blogMetaData.launchweek?.toString().toLowerCase() === '12' && } + {blogMetaData.launchweek?.toString().toLowerCase() === '13' && } + {blogMetaData.launchweek?.toString().toLowerCase() === '14' && } +
+ + ) +} diff --git a/apps/www/app/blog/components/AuthorList.tsx b/apps/www/app/blog/components/AuthorList.tsx new file mode 100644 index 00000000000..96bd23cdfc6 --- /dev/null +++ b/apps/www/app/blog/components/AuthorList.tsx @@ -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 ( +
+
+ {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 ( +
+ +
+ {imageUrl && ( +
+ {`${author.author} +
+ )} +
+ {author.author} + {author.position} +
+
+ +
+ ) + })} +
+
+ ) +} diff --git a/apps/www/app/blog/components/BlogContent.tsx b/apps/www/app/blog/components/BlogContent.tsx new file mode 100644 index 00000000000..3af1a2d7d00 --- /dev/null +++ b/apps/www/app/blog/components/BlogContent.tsx @@ -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 ( +
+
+
+
+ {youtubeHero ? ( +