diff --git a/apps/docs/src/app/(docs)/(default)/layout.tsx b/apps/docs/src/app/(docs)/(default)/layout.tsx index 834ff4f2c4..c035e368f4 100644 --- a/apps/docs/src/app/(docs)/(default)/layout.tsx +++ b/apps/docs/src/app/(docs)/(default)/layout.tsx @@ -1,27 +1,61 @@ -import { source } from '@/lib/source'; -import { baseOptions, links } from '@/lib/layout.shared'; -import { VersionSwitcher } from '@/components/version-switcher'; -import type { LinkItemType } from 'fumadocs-ui/layouts/shared'; -import { DocsLayout } from '@/components/layout/notebook'; -import { LATEST_VERSION } from '@/lib/version'; +import type { ComponentProps } from "react"; +import { source } from "@/lib/source"; +import { baseOptions, links } from "@/lib/layout.shared"; +import { VersionSwitcher } from "@/components/version-switcher"; +import type { LinkItemType } from "fumadocs-ui/layouts/shared"; +import { DocsLayout } from "@/components/layout/notebook"; +import { LATEST_VERSION } from "@/lib/version"; +import { SidebarBannerCarousel } from "@/components/sidebar-banner"; +import { fetchOgImage } from "@/lib/og-image"; +import { cn } from "@prisma-docs/ui/lib/cn"; -export default async function Layout({ children, }: { children: React.ReactNode; }) { +// Sidebar announcement slides — set to [] to hide the banner +const SIDEBAR_SLIDES = [ + { + title: "The Next Evolution of Prisma ORM", + description: "Prisma Next: a full TypeScript rewrite with a new query API, SQL builder, and extensible architecture.", + href: "https://pris.ly/pn-anouncement", + gradient: "orm" as const, + badge: "New", + }, +]; + +export default async function Layout({ children }: { children: React.ReactNode }) { const { nav, ...base } = baseOptions(); const navbarLinks: LinkItemType[] = [ ...links, { - type: 'custom', + type: "custom", children: , }, ]; + // Resolve OG images server-side for slides that don't have a hardcoded image + const slides = await Promise.all( + SIDEBAR_SLIDES.map(async (slide) => { + if (slide.href.startsWith("http")) { + const ogImage = await fetchOgImage(slide.href); + if (ogImage) return { ...slide, image: ogImage }; + } + return slide; + }), + ); + return ( ) => ( +
+ + {props.children} +
+ ), + }} tree={source.pageTree} > {children} diff --git a/apps/docs/src/components/sidebar-banner.tsx b/apps/docs/src/components/sidebar-banner.tsx new file mode 100644 index 0000000000..dc82b33e69 --- /dev/null +++ b/apps/docs/src/components/sidebar-banner.tsx @@ -0,0 +1,197 @@ +"use client"; + +import { useEffect, useState } from "react"; +import Link from "next/link"; +import { cn } from "@prisma-docs/ui/lib/cn"; + +interface BannerSlide { + title: string; + description: string; + href: string; + gradient?: "orm" | "ppg"; + badge?: string; + image?: string; +} + +interface SidebarBannerCarouselProps { + slides: BannerSlide[]; +} + +const DISMISSED_KEY = "sidebar-banner-dismissed-ids"; + +export function SidebarBannerCarousel({ slides }: SidebarBannerCarouselProps) { + const [dismissedIds, setDismissedIds] = useState>(new Set()); + const [mounted, setMounted] = useState(false); + const [dismissingHref, setDismissingHref] = useState(null); + const [hovered, setHovered] = useState(false); + + useEffect(() => { + try { + const stored = JSON.parse(localStorage.getItem(DISMISSED_KEY) || "[]"); + setDismissedIds(new Set(stored)); + } catch { + /* empty */ + } + setMounted(true); + }, []); + + if (!mounted) return null; + + const visibleSlides = slides.filter( + (s) => !dismissedIds.has(s.href) && s.href !== dismissingHref, + ); + + if (visibleSlides.length === 0) return null; + + const peekCount = Math.min(visibleSlides.length - 1, 3); + + function handleDismiss(e: React.MouseEvent, href: string) { + e.preventDefault(); + e.stopPropagation(); + setDismissingHref(href); + setTimeout(() => { + setDismissedIds((prev) => { + const next = new Set(prev); + next.add(href); + localStorage.setItem(DISMISSED_KEY, JSON.stringify([...next])); + return next; + }); + setDismissingHref(null); + }, 300); + } + + const front = visibleSlides[0]; + + // Peek cards rendered furthest-back first so DOM order = visual stacking + const peekCards = visibleSlides.slice(1, 4).map((_, idx, arr) => { + // i=1 is closest to front, i=peekCount is furthest back + const i = arr.length - idx; + const inset = i * 4; + return ( +
+ ); + }); + + return ( +
setHovered(true)} + onMouseLeave={() => setHovered(false)} + > + {/* Peek cards above — each one narrower, creating depth perspective */} + {peekCount > 0 && ( +
{peekCards}
+ )} + + {/* Front card */} +
+ {/* Title + description */} +
+
+ + {front.title} + + {front.badge && ( + + {front.badge} + + )} +
+

{front.description}

+
+ + {/* Image preview */} +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {front.image ? ( + + ) : ( +
+ + + +
+ )} +
+ + {/* Action bar — appears on hover */} +
+ + Read more + + +
+ + {/* Bottom padding when action bar is hidden */} +
+
+
+ ); +} diff --git a/apps/docs/src/lib/og-image.ts b/apps/docs/src/lib/og-image.ts new file mode 100644 index 0000000000..1e875e8914 --- /dev/null +++ b/apps/docs/src/lib/og-image.ts @@ -0,0 +1,20 @@ +/** + * Fetches the og:image URL from a given page URL. + * Returns null if the fetch fails or no og:image is found. + */ +export async function fetchOgImage(url: string): Promise { + try { + const res = await fetch(url, { + next: { revalidate: 86400 }, + signal: AbortSignal.timeout(5000), + }); + if (!res.ok) return null; + const html = await res.text(); + const match = + html.match(/]+property=["']og:image["'][^>]+content=["']([^"']+)["']/i) ?? + html.match(/]+content=["']([^"']+)["'][^>]+property=["']og:image["']/i); + return match?.[1] ?? null; + } catch { + return null; + } +}