From 092786b15c5c990cf58a27d948ea2116c9d12ce5 Mon Sep 17 00:00:00 2001 From: Aman Varshney Date: Sun, 22 Feb 2026 03:18:08 +0530 Subject: [PATCH 1/3] Update blog and docs styles --- apps/blog/public/img/logo-dark.svg | 22 ++ apps/blog/public/img/logo-white.svg | 3 + apps/blog/public/img/logo.svg | 3 + apps/blog/source.config.ts | 12 + apps/blog/src/app/(blog)/[slug]/page.tsx | 125 ++----- apps/blog/src/app/(blog)/layout.tsx | 24 +- apps/blog/src/app/(blog)/page.tsx | 100 +----- apps/blog/src/app/global.css | 11 +- apps/blog/src/app/layout.tsx | 4 +- apps/blog/src/components/BlogGrid.tsx | 319 ++++++++++++------ .../src/components/CompaniesUsingPrisma.tsx | 0 .../src/components/blog/blog-compact-item.tsx | 38 +++ .../components/blog/blog-featured-card.tsx | 64 ++++ .../blog/src/components/blog/blog-filters.tsx | 43 +++ apps/blog/src/components/blog/blog-footer.tsx | 87 +++++ apps/blog/src/components/blog/blog-hero.tsx | 9 + .../src/components/blog/blog-list-item.tsx | 61 ++++ apps/blog/src/components/blog/blog-nav.tsx | 97 ++++++ .../src/components/blog/blog-post-header.tsx | 65 ++++ .../src/components/blog/blog-post-toc.tsx | 115 +++++++ apps/blog/src/components/blog/blog-search.tsx | 27 ++ apps/blog/src/components/blog/blog-shell.tsx | 18 + apps/blog/src/lib/blog-data.ts | 250 ++++++++++++++ apps/docs/src/app/(docs)/v6/layout.tsx | 3 +- apps/docs/src/components/ai-chat-sidebar.tsx | 34 +- apps/docs/src/lib/layout.shared.tsx | 2 +- .../ui/src/components}/discord.tsx | 10 +- pnpm-lock.yaml | 73 ++++ 28 files changed, 1287 insertions(+), 332 deletions(-) create mode 100644 apps/blog/public/img/logo-dark.svg create mode 100644 apps/blog/public/img/logo-white.svg create mode 100644 apps/blog/public/img/logo.svg delete mode 100644 apps/blog/src/components/CompaniesUsingPrisma.tsx create mode 100644 apps/blog/src/components/blog/blog-compact-item.tsx create mode 100644 apps/blog/src/components/blog/blog-featured-card.tsx create mode 100644 apps/blog/src/components/blog/blog-filters.tsx create mode 100644 apps/blog/src/components/blog/blog-footer.tsx create mode 100644 apps/blog/src/components/blog/blog-hero.tsx create mode 100644 apps/blog/src/components/blog/blog-list-item.tsx create mode 100644 apps/blog/src/components/blog/blog-nav.tsx create mode 100644 apps/blog/src/components/blog/blog-post-header.tsx create mode 100644 apps/blog/src/components/blog/blog-post-toc.tsx create mode 100644 apps/blog/src/components/blog/blog-search.tsx create mode 100644 apps/blog/src/components/blog/blog-shell.tsx create mode 100644 apps/blog/src/lib/blog-data.ts rename {apps/docs/src/components/icons => packages/ui/src/components}/discord.tsx (91%) diff --git a/apps/blog/public/img/logo-dark.svg b/apps/blog/public/img/logo-dark.svg new file mode 100644 index 0000000000..8db99f452c --- /dev/null +++ b/apps/blog/public/img/logo-dark.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/blog/public/img/logo-white.svg b/apps/blog/public/img/logo-white.svg new file mode 100644 index 0000000000..5ce03bcc02 --- /dev/null +++ b/apps/blog/public/img/logo-white.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/apps/blog/public/img/logo.svg b/apps/blog/public/img/logo.svg new file mode 100644 index 0000000000..9f3600bdff --- /dev/null +++ b/apps/blog/public/img/logo.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/blog/source.config.ts b/apps/blog/source.config.ts index 40e49f29eb..d36921e97e 100644 --- a/apps/blog/source.config.ts +++ b/apps/blog/source.config.ts @@ -13,6 +13,15 @@ import lastModified from 'fumadocs-mdx/plugins/last-modified'; import { z } from 'zod'; import convert from 'npm-to-yarn'; +const blogPostTypes = [ + 'release', + 'user-story', + 'tutorial', + 'community', + 'postgres', + 'changelog', +] as const; + export const blogPosts = defineCollections({ type: 'doc', dir: 'content/blog', @@ -21,6 +30,9 @@ export const blogPosts = defineCollections({ date: z.coerce.date(), heroImagePath: z.string().optional(), metaImagePath: z.string().optional(), + type: z.enum(blogPostTypes).optional(), + tags: z.array(z.string()).optional(), + featured: z.boolean().optional(), }), postprocess: { includeProcessedMarkdown: true, diff --git a/apps/blog/src/app/(blog)/[slug]/page.tsx b/apps/blog/src/app/(blog)/[slug]/page.tsx index 66c459ae90..6cb051ab01 100644 --- a/apps/blog/src/app/(blog)/[slug]/page.tsx +++ b/apps/blog/src/app/(blog)/[slug]/page.tsx @@ -1,10 +1,10 @@ import { notFound } from 'next/navigation'; -import Link from 'next/link'; -import { InlineTOC } from 'fumadocs-ui/components/inline-toc'; -import { getMDXComponents } from '@/mdx-components'; import { createRelativeLink } from 'fumadocs-ui/mdx'; +import { BlogPostHeader } from '@/components/blog/blog-post-header'; +import { BlogPostTOC } from '@/components/blog/blog-post-toc'; +import { normalizePostMeta } from '@/lib/blog-data'; import { blog } from '@/lib/source'; -import Image from 'next/image'; +import { getMDXComponents } from '@/mdx-components'; export default async function Page(props: { params: Promise<{ slug: string }>; @@ -13,107 +13,40 @@ export default async function Page(props: { const page = blog.getPage([params.slug]); if (!page) notFound(); + + const post = normalizePostMeta(page); const MDX = page.data.body; - const formatDate = (value: unknown) => { - const date = - value instanceof Date ? value : new Date((value as string) ?? ''); - if (Number.isNaN(date.getTime())) return ''; - return date.toLocaleDateString('en-US', { - year: 'numeric', - month: 'long', - day: 'numeric', - }); - }; - const getHeroImageSrc = () => { - const data = page.data as any; - const rel = - (data.heroImagePath as string | undefined) ?? - (data.metaImagePath as string | undefined); - if (rel) { - if (rel.startsWith('/')) return rel; - const base = page.url.startsWith('/') ? page.url : `/${page.url}`; - const baseClean = base.endsWith('/') ? base.slice(0, -1) : base; - const relClean = rel.replace(/^\.\//, '').replace(/^\/+/, ''); - return `${baseClean}/${relClean}`; - } - const absolute = - (data.heroImageUrl as string | undefined) ?? - (data.metaImageUrl as string | undefined); - return absolute ?? null; - }; - const heroSrc = getHeroImageSrc(); return ( - <> - {/* Hero image */} - {heroSrc ? ( -
-
- {(page.data +
+
+ + +
+
-
- ) : null} - - {/* Title + meta */} -
- - ← Back to Blog - -

- {page.data.title} -

- {page.data.description ? ( -

{page.data.description}

- ) : null} -

- {page.data.authors?.length ? page.data.authors.join(', ') : null} - {page.data.date ? ( - <> - {' • '} - {formatDate(page.data.date)} - - ) : null} -

-
+ - {/* Body */} -
-
- - +
-
- - {/* Newsletter CTA */} -
-
-

- Don’t miss the next post! -

-

- Sign up for the Prisma Newsletter to stay up to date with the latest - releases and posts. -

- - Sign up - -
- + ); } diff --git a/apps/blog/src/app/(blog)/layout.tsx b/apps/blog/src/app/(blog)/layout.tsx index ec90473df7..3e3e8dc93b 100644 --- a/apps/blog/src/app/(blog)/layout.tsx +++ b/apps/blog/src/app/(blog)/layout.tsx @@ -1,23 +1,5 @@ -import { HomeLayout } from 'fumadocs-ui/layouts/home'; -import { BaseLayoutProps } from 'fumadocs-ui/layouts/shared'; -export function baseOptions(): BaseLayoutProps { - return { - nav: { - title: 'My App', - }, - links: [ - { - url: '/docs', - text: 'Docs', - }, - { - url: '/blog', - text: 'Blog', - }, - ], - }; -} +import { BlogShell } from '@/components/blog/blog-shell'; -export default function Layout({ children, }: { children: React.ReactNode; }) { - return {children}; +export default function Layout({ children }: { children: React.ReactNode }) { + return {children}; } diff --git a/apps/blog/src/app/(blog)/page.tsx b/apps/blog/src/app/(blog)/page.tsx index 6c24bbf845..2950f80273 100644 --- a/apps/blog/src/app/(blog)/page.tsx +++ b/apps/blog/src/app/(blog)/page.tsx @@ -1,97 +1,27 @@ -import { Suspense } from 'react'; -import { blog } from '@/lib/source'; -import { BlogGrid } from '@/components/BlogGrid'; +import { Suspense } from "react"; +import { BlogGrid } from "@/components/BlogGrid"; +import { getBlogPosts } from "@/lib/blog-data"; export default function BlogHome() { - const posts = blog.getPages().sort((a, b) => { - const aTime = - a.data.date instanceof Date - ? a.data.date.getTime() - : new Date((a.data.date as unknown as string) ?? '').getTime(); - const bTime = - b.data.date instanceof Date - ? b.data.date.getTime() - : new Date((b.data.date as unknown as string) ?? '').getTime(); - return bTime - aTime; - }); + const posts = getBlogPosts(); - const formatDate = (value: unknown) => { - const date = - value instanceof Date ? value : new Date((value as string) ?? ''); - if (Number.isNaN(date.getTime())) return ''; - return date.toLocaleDateString('en-US', { - year: 'numeric', - month: 'long', - day: 'numeric', - }); - }; - const getPrimaryAuthor = (post: (typeof posts)[number]) => { - const data = post.data as any; - const authors = Array.isArray(data.authors) ? data.authors : []; - return authors.length > 0 ? authors[0] : null; - }; - const getCardImageSrc = (post: (typeof posts)[number]) => { - const data = post.data as any; - const rel = - (data.heroImagePath as string | undefined) ?? - (data.metaImagePath as string | undefined); - if (rel) { - // If frontmatter already provides an absolute path, use it directly - if (rel.startsWith('/')) { - return rel; - } - const base = post.url.startsWith('/') ? post.url : `/${post.url}`; - const baseClean = base.endsWith('/') ? base.slice(0, -1) : base; - const relClean = rel.replace(/^\.\//, '').replace(/^\/+/, ''); - return `${baseClean}/${relClean}`; - } - const absolute = - (data.heroImageUrl as string | undefined) ?? - (data.metaImageUrl as string | undefined); - return absolute ?? null; - }; - const items = posts.map((post) => { - const data = post.data as any; - return { - url: post.url, - title: data.title as string, - date: data.date ? new Date(data.date).toISOString() : '', - description: (data.description as string) ?? '', - author: getPrimaryAuthor(post), - imageSrc: getCardImageSrc(post), - imageAlt: (data.heroImageAlt as string) ?? (data.title as string), - seriesTitle: data.series?.title ?? null, - }; - }); return ( -
-

Prisma Blog

-

- Guides, announcements and articles about Prisma, databases and the data - access layer. -

- - {/* Category pills (static "Show all" to match layout) */} -
- - Show all - -
- - {/* Grid with pagination */} +
- {items.slice(0, 12).map((post) => ( -
- ))} +
+
+

+ Blog +

+

+ Loading posts... +

+
} > - +
); diff --git a/apps/blog/src/app/global.css b/apps/blog/src/app/global.css index b9f9e62842..bf06c9862c 100644 --- a/apps/blog/src/app/global.css +++ b/apps/blog/src/app/global.css @@ -1,7 +1,16 @@ -@import "tailwindcss"; +@import 'tailwindcss'; @import '@prisma-docs/eclipse/styles/globals.css'; @import 'fumadocs-ui/css/shadcn.css'; @import 'fumadocs-ui/css/preset.css'; @import 'fumadocs-openapi/css/preset.css'; @import '@prisma-docs/ui/styles'; +@layer base { + html { + @apply scroll-smooth; + } + + body { + @apply min-h-screen bg-background text-foreground antialiased; + } +} diff --git a/apps/blog/src/app/layout.tsx b/apps/blog/src/app/layout.tsx index 3f0c7dbe77..d68b632d6a 100644 --- a/apps/blog/src/app/layout.tsx +++ b/apps/blog/src/app/layout.tsx @@ -17,11 +17,11 @@ export default function Layout({ children }: LayoutProps<'/'>) { return ( - + {children} diff --git a/apps/blog/src/components/BlogGrid.tsx b/apps/blog/src/components/BlogGrid.tsx index 5c74969b9a..9a8e7c5783 100644 --- a/apps/blog/src/components/BlogGrid.tsx +++ b/apps/blog/src/components/BlogGrid.tsx @@ -1,150 +1,253 @@ 'use client'; -import { useMemo } from 'react'; -import Image from 'next/image'; -import Link from 'next/link'; +import { useEffect, useMemo } from 'react'; import { usePathname, useRouter, useSearchParams } from 'next/navigation'; -import { Button } from '@prisma-docs/eclipse'; - -type BlogCardItem = { - url: string; - title: string; - date: string; // ISO string - description?: string | null; - author?: string | null; - imageSrc?: string | null; - imageAlt?: string | null; - seriesTitle?: string | null; -}; +import { Grid2x2, List } from 'lucide-react'; +import { BlogCompactItem } from '@/components/blog/blog-compact-item'; +import { BlogFeaturedCard } from '@/components/blog/blog-featured-card'; +import { BlogFilters } from '@/components/blog/blog-filters'; +import { BlogHero } from '@/components/blog/blog-hero'; +import { BlogListItem } from '@/components/blog/blog-list-item'; +import { BlogSearch } from '@/components/blog/blog-search'; +import { + type BlogPostFilterType, + type BlogPostItem, + filterPosts, + getFeaturedPost, + isBlogPostType, + paginatePosts, +} from '@/lib/blog-data'; function parsePage(value: string | null): number { - const n = parseInt(value ?? '1', 10); - return Number.isNaN(n) || n < 1 ? 1 : n; + const parsed = parseInt(value ?? '1', 10); + return Number.isNaN(parsed) || parsed < 1 ? 1 : parsed; +} + +function parseType(value: string | null): BlogPostFilterType { + if (!value || value === 'all') return 'all'; + return isBlogPostType(value) ? value : 'all'; +} + +function parseView(value: string | null): 'cards' | 'compact' { + return value === 'compact' ? 'compact' : 'cards'; } export function BlogGrid({ items, pageSize = 12, }: { - items: BlogCardItem[]; + items: BlogPostItem[]; pageSize?: number; }) { const router = useRouter(); const pathname = usePathname(); const searchParams = useSearchParams(); - const rawPage = useMemo( - () => parsePage(searchParams.get('page')), - [searchParams], + + const query = searchParams.get('q') ?? ''; + const selectedType = parseType(searchParams.get('type')); + const view = parseView(searchParams.get('view')); + const rawPage = parsePage(searchParams.get('page')); + const nextView = view === 'cards' ? 'compact' : 'cards'; + const ToggleIcon = nextView === 'compact' ? List : Grid2x2; + const toggleLabel = nextView === 'compact' ? 'List view' : 'Cards view'; + + const filteredByType = useMemo( + () => filterPosts(items, { query, type: selectedType }), + [items, query, selectedType], + ); + const filteredByQuery = useMemo( + () => filterPosts(items, { query, type: 'all' }), + [items, query], ); - const totalPages = Math.max(1, Math.ceil(items.length / pageSize)); - const currentPage = Math.min(rawPage, totalPages); - - const visibleItems = useMemo( - () => - items.slice( - (currentPage - 1) * pageSize, - currentPage * pageSize, - ), - [items, currentPage, pageSize], + + const featured = useMemo(() => { + if (rawPage !== 1) return null; + return getFeaturedPost(filteredByType); + }, [filteredByType, rawPage]); + + const feedSource = useMemo(() => { + if (!featured) return filteredByType; + return filteredByType.filter((post) => post.id !== featured.id); + }, [featured, filteredByType]); + + const pagination = useMemo( + () => paginatePosts(feedSource, rawPage, pageSize), + [feedSource, rawPage, pageSize], ); - const setPage = (newPage: number) => { + useEffect(() => { + if (pagination.currentPage === rawPage) return; + const params = new URLSearchParams(searchParams.toString()); - if (newPage <= 1) { - params.delete('page'); - } else { - params.set('page', String(newPage)); - } - const query = params.toString(); - router.replace(query ? `${pathname}?${query}` : pathname); - window.scrollTo({ top: 0, behavior: 'smooth' }); - }; + if (pagination.currentPage <= 1) params.delete('page'); + else params.set('page', String(pagination.currentPage)); + + const queryString = params.toString(); + router.replace(queryString ? `${pathname}?${queryString}` : pathname); + }, [pagination.currentPage, pathname, rawPage, router, searchParams]); + + const counts = useMemo(() => { + const nextCounts: Record = { + all: filteredByQuery.length, + release: 0, + 'user-story': 0, + community: 0, + tutorial: 0, + postgres: 0, + changelog: 0, + }; - const formatDate = (iso: string) => { - if (!iso) return ''; - const date = new Date(iso); - if (Number.isNaN(date.getTime())) return ''; - return date.toLocaleDateString('en-US', { - year: 'numeric', - month: 'long', - day: 'numeric', + filteredByQuery.forEach((post) => { + nextCounts[post.type] += 1; }); + + return nextCounts; + }, [filteredByQuery]); + + const replaceParams = ( + updates: Partial<{ + q: string; + type: BlogPostFilterType; + view: 'cards' | 'compact'; + page: number; + }>, + options?: { resetPage?: boolean; scrollToTop?: boolean }, + ) => { + const params = new URLSearchParams( + typeof window !== 'undefined' ? window.location.search : searchParams.toString(), + ); + const resetPage = options?.resetPage ?? false; + const scrollToTop = options?.scrollToTop ?? false; + + if (updates.q !== undefined) { + const next = updates.q.trim(); + if (next) params.set('q', next); + else params.delete('q'); + } + + if (updates.type !== undefined) { + if (updates.type === 'all') params.delete('type'); + else params.set('type', updates.type); + } + + if (updates.view !== undefined) { + if (updates.view === 'cards') params.delete('view'); + else params.set('view', updates.view); + } + + if (updates.page !== undefined) { + if (updates.page <= 1) params.delete('page'); + else params.set('page', String(updates.page)); + } + + if (resetPage) params.delete('page'); + + const queryString = params.toString(); + router.replace(queryString ? `${pathname}?${queryString}` : pathname); + + if (scrollToTop) { + window.scrollTo({ top: 0, behavior: 'smooth' }); + } }; + const hasResults = filteredByType.length > 0; + return ( - <> -
- {visibleItems.map((post) => ( - + + +
+ replaceParams({ type }, { resetPage: true })} + selectedType={selectedType} + /> +
+ + replaceParams( + { + q: nextQuery, + }, + { resetPage: true }, + ) + } + query={query} + /> + +
- {totalPages > 1 ? ( + {featured ? : null} + + {hasResults ? ( +
+ {view === 'cards' + ? pagination.items.map((post) => ) + : pagination.items.map((post) => )} +
+ ) : ( +
+

No posts matched this filter.

+ +
+ )} + + {pagination.totalPages > 1 ? ( ) : null} - +
); } - diff --git a/apps/blog/src/components/CompaniesUsingPrisma.tsx b/apps/blog/src/components/CompaniesUsingPrisma.tsx deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/apps/blog/src/components/blog/blog-compact-item.tsx b/apps/blog/src/components/blog/blog-compact-item.tsx new file mode 100644 index 0000000000..4fdcaa3c81 --- /dev/null +++ b/apps/blog/src/components/blog/blog-compact-item.tsx @@ -0,0 +1,38 @@ +import Link from 'next/link'; +import type { BlogPostItem } from '@/lib/blog-data'; +import { BLOG_TYPE_LABELS } from '@/lib/blog-data'; + +export function BlogCompactItem({ post }: { post: BlogPostItem }) { + return ( +
+
+ + {BLOG_TYPE_LABELS[post.type]} + + {post.dateLabel} +
+ + {post.title} + +
+ + {getInitials(post.author)} + + {post.author} +
+
+ ); +} + +function getInitials(author: string): string { + const values = author + .split(/\s+/) + .filter(Boolean) + .slice(0, 2) + .map((part) => part[0]?.toUpperCase() ?? ''); + + return values.join('') || 'PT'; +} diff --git a/apps/blog/src/components/blog/blog-featured-card.tsx b/apps/blog/src/components/blog/blog-featured-card.tsx new file mode 100644 index 0000000000..fddd06f044 --- /dev/null +++ b/apps/blog/src/components/blog/blog-featured-card.tsx @@ -0,0 +1,64 @@ +import Image from 'next/image'; +import Link from 'next/link'; +import type { BlogPostItem } from '@/lib/blog-data'; +import { BLOG_TYPE_LABELS } from '@/lib/blog-data'; + +export function BlogFeaturedCard({ post }: { post: BlogPostItem }) { + return ( +
+ + {post.imageSrc ? ( + {post.imageAlt} + ) : ( +
+ )} + + +
+
+ + {BLOG_TYPE_LABELS[post.type]} + + {post.dateLabel} +
+ + + {post.title} + + + {post.summary ?

{post.summary}

: null} + +
+ + {getInitials(post.author)} + + {post.author} +
+
+
+ ); +} + +function getInitials(author: string): string { + const values = author + .split(/\s+/) + .filter(Boolean) + .slice(0, 2) + .map((part) => part[0]?.toUpperCase() ?? ''); + + return values.join('') || 'PT'; +} diff --git a/apps/blog/src/components/blog/blog-filters.tsx b/apps/blog/src/components/blog/blog-filters.tsx new file mode 100644 index 0000000000..84f8b393d1 --- /dev/null +++ b/apps/blog/src/components/blog/blog-filters.tsx @@ -0,0 +1,43 @@ +import { + BLOG_TYPE_LABELS, + BLOG_TYPE_ORDER, + type BlogPostFilterType, +} from '@/lib/blog-data'; + +type Counts = Record; + +export function BlogFilters({ + selectedType, + onTypeChange, + counts, +}: { + selectedType: BlogPostFilterType; + onTypeChange: (type: BlogPostFilterType) => void; + counts: Counts; +}) { + const options: BlogPostFilterType[] = ['all', ...BLOG_TYPE_ORDER]; + + return ( +
+
+ {options.map((type) => ( + + ))} +
+
+ ); +} diff --git a/apps/blog/src/components/blog/blog-footer.tsx b/apps/blog/src/components/blog/blog-footer.tsx new file mode 100644 index 0000000000..40353b660c --- /dev/null +++ b/apps/blog/src/components/blog/blog-footer.tsx @@ -0,0 +1,87 @@ +import Image from 'next/image'; +import Link from 'next/link'; + +const footerColumns = [ + { + title: 'Products', + links: [ + { label: 'ORM', href: 'https://www.prisma.io/orm' }, + { label: 'Studio', href: 'https://www.prisma.io/studio' }, + { label: 'Optimize', href: 'https://www.prisma.io/optimize' }, + { label: 'Accelerate', href: 'https://www.prisma.io/accelerate' }, + { label: 'Pricing', href: 'https://www.prisma.io/pricing' }, + { label: 'Changelog', href: 'https://www.prisma.io/changelog' }, + ], + }, + { + title: 'Resources', + links: [ + { label: 'Docs', href: 'https://www.prisma.io/docs' }, + { label: 'Ecosystem', href: 'https://www.prisma.io/ecosystem' }, + { label: 'Playground', href: 'https://playground.prisma.io' }, + { label: 'Customer Stories', href: 'https://www.prisma.io/blog' }, + { label: 'Data Guide', href: 'https://www.prisma.io/dataguide' }, + { label: 'Benchmarks', href: 'https://benchmarks.prisma.io' }, + ], + }, + { + title: 'Contact', + links: [ + { label: 'Community', href: 'https://www.prisma.io/community' }, + { label: 'Support', href: 'https://www.prisma.io/support' }, + { label: 'Partners', href: 'https://www.prisma.io/partners' }, + { label: 'Enterprise', href: 'https://www.prisma.io/enterprise' }, + { label: 'OSS Friends', href: 'https://www.prisma.io/oss-friends' }, + ], + }, + { + title: 'Company', + links: [ + { label: 'About', href: 'https://www.prisma.io/about' }, + { label: 'Blog', href: '/' }, + { label: 'Data DX', href: 'https://www.prisma.io/datadx' }, + { label: 'Careers', href: 'https://www.prisma.io/careers' }, + { label: 'Legal', href: 'https://www.prisma.io/legal' }, + ], + }, +]; + +export function BlogFooter() { + return ( +
+
+ + Prisma + + +
+ {footerColumns.map((column) => ( +
+

+ {column.title} +

+
    + {column.links.map((link) => ( +
  • + + {link.label} + +
  • + ))} +
+
+ ))} +
+
+
+ ); +} diff --git a/apps/blog/src/components/blog/blog-hero.tsx b/apps/blog/src/components/blog/blog-hero.tsx new file mode 100644 index 0000000000..ce48a0dea5 --- /dev/null +++ b/apps/blog/src/components/blog/blog-hero.tsx @@ -0,0 +1,9 @@ +export function BlogHero() { + return ( +
+

+ Blog +

+
+ ); +} diff --git a/apps/blog/src/components/blog/blog-list-item.tsx b/apps/blog/src/components/blog/blog-list-item.tsx new file mode 100644 index 0000000000..29ccd3a26b --- /dev/null +++ b/apps/blog/src/components/blog/blog-list-item.tsx @@ -0,0 +1,61 @@ +import Image from 'next/image'; +import Link from 'next/link'; +import type { BlogPostItem } from '@/lib/blog-data'; +import { BLOG_TYPE_LABELS } from '@/lib/blog-data'; + +export function BlogListItem({ post }: { post: BlogPostItem }) { + return ( +
+
+
+ + {BLOG_TYPE_LABELS[post.type]} + + {post.dateLabel} +
+ + + {post.title} + + +
+ + {getInitials(post.author)} + + {post.author} +
+
+ + + {post.imageSrc ? ( + {post.imageAlt} + ) : ( +
+ )} + +
+ ); +} + +function getInitials(author: string): string { + const values = author + .split(/\s+/) + .filter(Boolean) + .slice(0, 2) + .map((part) => part[0]?.toUpperCase() ?? ''); + + return values.join('') || 'PT'; +} diff --git a/apps/blog/src/components/blog/blog-nav.tsx b/apps/blog/src/components/blog/blog-nav.tsx new file mode 100644 index 0000000000..c926aa5879 --- /dev/null +++ b/apps/blog/src/components/blog/blog-nav.tsx @@ -0,0 +1,97 @@ +import Image from "next/image"; +import Link from "next/link"; +import { ChevronDown, Github } from "lucide-react"; +import { DiscordIcon } from "@prisma-docs/ui/components/discord"; + +const navLinks = [ + { href: "https://www.prisma.io/products", label: "Products", hasMenu: true }, + { href: "https://www.prisma.io/pricing", label: "Pricing", hasMenu: false }, + { + href: "https://www.prisma.io/resources", + label: "Resources", + hasMenu: true, + }, + { href: "https://www.prisma.io/partners", label: "Partners", hasMenu: false }, + { href: "/", label: "Blog", hasMenu: false }, +]; + +export function BlogNav() { + return ( +
+
+
+ + Prisma + + + +
+ + +
+
+ ); +} diff --git a/apps/blog/src/components/blog/blog-post-header.tsx b/apps/blog/src/components/blog/blog-post-header.tsx new file mode 100644 index 0000000000..45bdec3d43 --- /dev/null +++ b/apps/blog/src/components/blog/blog-post-header.tsx @@ -0,0 +1,65 @@ +import { BLOG_TYPE_LABELS, type BlogPostType } from '@/lib/blog-data'; + +export function BlogPostHeader({ + title, + description, + author, + dateLabel, + type, + tags, +}: { + title: string; + description?: string; + author: string; + dateLabel: string; + type: BlogPostType; + tags: string[]; +}) { + const displayTags = [BLOG_TYPE_LABELS[type], ...tags.filter((tag) => tag !== type)].slice(0, 6); + + return ( +
+

+ {title} +

+ +
+ + {getInitials(author)} + + {author} + {dateLabel ? : null} + {dateLabel ? {dateLabel} : null} +
+ + {description ?

{description}

: null} + +
+ {displayTags.map((tag) => ( + + {formatTag(tag)} + + ))} +
+
+ ); +} + +function getInitials(author: string): string { + const values = author + .split(/\s+/) + .filter(Boolean) + .slice(0, 2) + .map((part) => part[0]?.toUpperCase() ?? ''); + + return values.join('') || 'PT'; +} + +function formatTag(tag: string): string { + return tag + .replace(/-/g, ' ') + .replace(/\b\w/g, (letter) => letter.toUpperCase()); +} diff --git a/apps/blog/src/components/blog/blog-post-toc.tsx b/apps/blog/src/components/blog/blog-post-toc.tsx new file mode 100644 index 0000000000..74a80c11ca --- /dev/null +++ b/apps/blog/src/components/blog/blog-post-toc.tsx @@ -0,0 +1,115 @@ +'use client'; + +import { useEffect, useMemo, useState } from 'react'; + +type TocItem = { + title?: string; + url?: string; + depth?: number; + items?: TocItem[]; +}; + +export function BlogPostTOC({ + items, + className, +}: { + items: TocItem[]; + className?: string; +}) { + const flatItems = useMemo(() => flattenItems(items), [items]); + const [active, setActive] = useState(''); + + useEffect(() => { + if (flatItems.length === 0) return; + + const applyHash = () => { + const hash = window.location.hash || ''; + if (hash) setActive(hash); + }; + + applyHash(); + + const targets = flatItems + .map((item) => document.getElementById(item.url.replace(/^#/, ''))) + .filter((node): node is HTMLElement => node instanceof HTMLElement); + + if (targets.length === 0) return; + + const observer = new IntersectionObserver( + (entries) => { + const visible = entries + .filter((entry) => entry.isIntersecting) + .sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top); + + if (visible.length === 0) return; + + const first = visible[0]; + if (!first.target.id) return; + setActive(`#${first.target.id}`); + }, + { + rootMargin: '-22% 0px -62% 0px', + threshold: [0, 1], + }, + ); + + targets.forEach((target) => observer.observe(target)); + window.addEventListener('hashchange', applyHash); + + return () => { + observer.disconnect(); + window.removeEventListener('hashchange', applyHash); + }; + }, [flatItems]); + + if (flatItems.length === 0) return null; + + return ( + + ); +} + +function flattenItems(items: TocItem[]): Array<{ title: string; url: string; depth: number }> { + const result: Array<{ title: string; url: string; depth: number }> = []; + + const walk = (entry: TocItem[]) => { + entry.forEach((item) => { + if (item.title && item.url) { + result.push({ + title: item.title, + url: item.url, + depth: item.depth ?? 2, + }); + } + + if (Array.isArray(item.items) && item.items.length > 0) { + walk(item.items); + } + }); + }; + + walk(items); + return result; +} diff --git a/apps/blog/src/components/blog/blog-search.tsx b/apps/blog/src/components/blog/blog-search.tsx new file mode 100644 index 0000000000..80f8dc7000 --- /dev/null +++ b/apps/blog/src/components/blog/blog-search.tsx @@ -0,0 +1,27 @@ +import { Search } from 'lucide-react'; + +export function BlogSearch({ + query, + onQueryChange, +}: { + query: string; + onQueryChange: (value: string) => void; +}) { + return ( + + ); +} diff --git a/apps/blog/src/components/blog/blog-shell.tsx b/apps/blog/src/components/blog/blog-shell.tsx new file mode 100644 index 0000000000..7862b164ac --- /dev/null +++ b/apps/blog/src/components/blog/blog-shell.tsx @@ -0,0 +1,18 @@ +import { BlogFooter } from '@/components/blog/blog-footer'; +import { BlogNav } from '@/components/blog/blog-nav'; + +export function BlogShell({ children }: { children: React.ReactNode }) { + return ( +
+
+
+ +
{children}
+ +
+
+ ); +} diff --git a/apps/blog/src/lib/blog-data.ts b/apps/blog/src/lib/blog-data.ts new file mode 100644 index 0000000000..d19681a52f --- /dev/null +++ b/apps/blog/src/lib/blog-data.ts @@ -0,0 +1,250 @@ +import { blog as blogSource } from '@/lib/source'; + +export const BLOG_TYPE_ORDER = [ + 'release', + 'user-story', + 'community', + 'tutorial', + 'postgres', + 'changelog', +] as const; + +export type BlogPostType = (typeof BLOG_TYPE_ORDER)[number]; + +export type BlogPostFilterType = BlogPostType | 'all'; + +export const BLOG_TYPE_LABELS: Record = { + all: 'All', + 'user-story': 'User Story', + release: 'Release', + community: 'Community', + tutorial: 'Tutorial', + postgres: 'Postgres', + changelog: 'Changelog', +}; + +type SourcePage = ReturnType[number]; + +export type BlogPostItem = { + id: string; + slug: string; + url: string; + title: string; + dateISO: string; + dateLabel: string; + dateValue: number; + description: string; + summary: string; + author: string; + authors: string[]; + imageSrc: string | null; + imageAlt: string; + type: BlogPostType; + tags: string[]; + featured: boolean; +}; + +export function isBlogPostType(value: unknown): value is BlogPostType { + return typeof value === 'string' && BLOG_TYPE_ORDER.includes(value as BlogPostType); +} + +export function inferPostType(slug: string, title: string): BlogPostType { + const text = `${slug} ${title}`.toLowerCase(); + + if ( + text.includes('changelog') || + text.includes("what's new") || + text.includes('whats-new') || + text.includes('wnip') + ) { + return 'changelog'; + } + + if ( + text.includes('customer') || + text.includes('customer-story') || + text.includes('success-story') || + text.includes('case-study') || + text.includes('case study') + ) { + return 'user-story'; + } + + if (text.includes('postgres')) { + return 'postgres'; + } + + if ( + text.includes('tutorial') || + text.includes('guide') || + text.includes('how to') || + text.includes('fullstack') || + text.includes('build ') + ) { + return 'tutorial'; + } + + if ( + text.includes('release') || + text.includes('announcing') || + /orm-\d/.test(text) || + /prisma-\d/.test(text) + ) { + return 'release'; + } + + return 'community'; +} + +function toDate(value: unknown): Date | null { + const parsed = value instanceof Date ? value : new Date((value as string) ?? ''); + return Number.isNaN(parsed.getTime()) ? null : parsed; +} + +function formatDate(date: Date | null): string { + if (!date) return ''; + + return date.toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + }); +} + +function getImageSrc(page: SourcePage): string | null { + const data = page.data as unknown as Record; + const relRaw = + (data.heroImagePath as string | undefined) ?? (data.metaImagePath as string | undefined); + + if (relRaw) { + if (relRaw.startsWith('/')) return relRaw; + + const base = page.url.startsWith('/') ? page.url : `/${page.url}`; + const baseClean = base.endsWith('/') ? base.slice(0, -1) : base; + const relClean = relRaw.replace(/^\.\//, '').replace(/^\/+/, ''); + return `${baseClean}/${relClean}`; + } + + const absoluteRaw = + (data.heroImageUrl as string | undefined) ?? (data.metaImageUrl as string | undefined); + return absoluteRaw ?? null; +} + +function sanitizeText(value: unknown): string { + return String(value ?? '').replace(/\s+/g, ' ').trim(); +} + +function getSummary(data: Record): string { + const description = sanitizeText(data.description); + if (description) return description; + + const metaDescription = sanitizeText(data.metaDescription); + if (metaDescription) return metaDescription; + + const excerpt = sanitizeText(data.excerpt); + if (excerpt) return excerpt; + + return ''; +} + +function unique(values: string[]): string[] { + return [...new Set(values.map((value) => value.trim()).filter(Boolean))]; +} + +export function normalizePostMeta(page: SourcePage): BlogPostItem { + const data = page.data as unknown as Record; + const authors = Array.isArray(data.authors) ? data.authors.map((value) => String(value)) : []; + const date = toDate(data.date); + const title = sanitizeText(data.title); + const slug = page.slugs.join('/'); + const rawType = data.type; + const type = isBlogPostType(rawType) ? rawType : inferPostType(slug, title); + const explicitTags = Array.isArray(data.tags) + ? data.tags.map((value) => String(value).trim().toLowerCase()) + : []; + + return { + id: page.url, + slug, + url: page.url, + title, + dateISO: date ? date.toISOString() : '', + dateLabel: formatDate(date), + dateValue: date ? date.getTime() : 0, + description: getSummary(data), + summary: getSummary(data), + author: authors[0] ?? 'Prisma Team', + authors, + imageSrc: getImageSrc(page), + imageAlt: sanitizeText(data.heroImageAlt) || title, + type, + tags: unique([type, ...explicitTags]), + featured: Boolean(data.featured), + }; +} + +export function getBlogPosts(): BlogPostItem[] { + const pages = blogSource.getPages(); + + return pages.map(normalizePostMeta).sort((a, b) => b.dateValue - a.dateValue); +} + +export function filterPosts( + posts: BlogPostItem[], + options: { + query?: string; + type?: BlogPostFilterType; + }, +): BlogPostItem[] { + const query = options.query?.trim().toLowerCase() ?? ''; + const filterType = options.type ?? 'all'; + + return posts.filter((post) => { + const matchesType = filterType === 'all' ? true : post.type === filterType; + if (!matchesType) return false; + + if (!query) return true; + + const haystack = [ + post.title, + post.description, + post.summary, + post.author, + ...post.tags, + BLOG_TYPE_LABELS[post.type], + ] + .join(' ') + .toLowerCase(); + + return haystack.includes(query); + }); +} + +export function paginatePosts( + posts: BlogPostItem[], + page: number, + pageSize: number, +): { + items: BlogPostItem[]; + currentPage: number; + totalPages: number; + totalItems: number; +} { + const totalItems = posts.length; + const totalPages = Math.max(1, Math.ceil(totalItems / pageSize)); + const currentPage = Math.min(Math.max(1, page), totalPages); + const start = (currentPage - 1) * pageSize; + const items = posts.slice(start, start + pageSize); + + return { + items, + currentPage, + totalPages, + totalItems, + }; +} + +export function getFeaturedPost(posts: BlogPostItem[]): BlogPostItem | null { + if (posts.length === 0) return null; + return posts.find((post) => post.featured) ?? posts[0]; +} diff --git a/apps/docs/src/app/(docs)/v6/layout.tsx b/apps/docs/src/app/(docs)/v6/layout.tsx index fea68729f1..372264de5c 100644 --- a/apps/docs/src/app/(docs)/v6/layout.tsx +++ b/apps/docs/src/app/(docs)/v6/layout.tsx @@ -3,7 +3,7 @@ import { VersionSwitcher } from "@/components/version-switcher"; import type { LinkItemType } from "fumadocs-ui/layouts/shared"; import { DocsLayout } from "@/components/layout/notebook"; import { sourceV6 } from "@/lib/source"; -import { DiscordIcon } from "@/components/icons/discord"; +import { DiscordIcon } from "@prisma-docs/ui/components/discord"; export default async function Layout({ children, @@ -82,4 +82,3 @@ export default async function Layout({ ); } - diff --git a/apps/docs/src/components/ai-chat-sidebar.tsx b/apps/docs/src/components/ai-chat-sidebar.tsx index 737c3cb1fc..2b67d735e8 100644 --- a/apps/docs/src/components/ai-chat-sidebar.tsx +++ b/apps/docs/src/components/ai-chat-sidebar.tsx @@ -86,7 +86,7 @@ const SourcesDisplay = ({ href={source.source_url} target="_blank" rel="noopener noreferrer" - className="flex items-center gap-1.5 px-2 py-1 text-xs rounded-md bg-fd-muted hover:bg-fd-accent transition-colors max-w-[200px] group" + className="flex items-center gap-1.5 px-2 py-1 text-xs rounded-md bg-fd-muted hover:bg-fd-accent transition-colors max-w-50 group" > {source.title || source.subtitle} @@ -132,13 +132,13 @@ const FeedbackButtons = ({ "h-7 w-7", currentReaction === "upvote" && "text-green-600 dark:text-green-400 bg-green-50 dark:bg-green-950", - disabled && "opacity-50 cursor-not-allowed" + disabled && "opacity-50 cursor-not-allowed", )} > @@ -159,13 +159,13 @@ const FeedbackButtons = ({ "h-7 w-7", currentReaction === "downvote" && "text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950", - disabled && "opacity-50 cursor-not-allowed" + disabled && "opacity-50 cursor-not-allowed", )} > @@ -296,7 +296,7 @@ const ChatInner = ({ } className={cn( buttonVariants({ variant: "ghost", size: "icon-sm" }), - "disabled:opacity-50" + "disabled:opacity-50", )} > @@ -312,7 +312,7 @@ const ChatInner = ({ {...props} onClick={onClose} className={cn( - buttonVariants({ variant: "ghost", size: "icon-sm" }) + buttonVariants({ variant: "ghost", size: "icon-sm" }), )} > @@ -429,7 +429,9 @@ const ChatInner = ({
- Something went wrong + + Something went wrong + {error}
@@ -523,14 +525,18 @@ export const AIChatSidebar = ({ onClick={() => setIsOpen(!isOpen)} className={cn( buttonVariants({ variant: "outline" }), - "hidden shrink-0 shadow-none md:inline-flex items-center gap-2 h-8 group cursor-pointer" + "hidden shrink-0 shadow-none md:inline-flex items-center gap-2 h-8 group cursor-pointer", )} > Ask AI
- {isMac ? "⌘" : "Ctrl"} - I + + {isMac ? "⌘" : "Ctrl"} + + + I +
@@ -542,7 +548,7 @@ export const AIChatSidebar = ({ "inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-[500px]", "translate-x-full data-[state=open]:translate-x-0", "pointer-events-none data-[state=open]:pointer-events-auto", - "hidden md:flex" + "hidden md:flex", )} data-state={isOpen ? "open" : "closed"} > @@ -551,7 +557,7 @@ export const AIChatSidebar = ({ onClose={() => setIsOpen(false)} />
, - document.body + document.body, )}
@@ -563,7 +569,7 @@ export const AIChatSidebar = ({