Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 57 additions & 9 deletions apps/docs/src/app/(docs)/(default)/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,27 +1,75 @@
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: "Prisma ORM v7.4",
description: "Query caching, partial indexes, and major performance improvements.",
href: "https://www.prisma.io/blog/prisma-orm-v7-4-query-caching-partial-indexes-and-major-performance-improvements",
gradient: "orm" as const,
badge: "New",
},
{
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,
},
{
title: "Announcing Prisma ORM 7",
description:
"Major performance gains, a Rust-free client, and a streamlined developer experience.",
href: "https://www.prisma.io/blog/announcing-prisma-orm-7-0-0",
gradient: "orm" as const,
},
];

export default async function Layout({ children }: { children: React.ReactNode }) {
const { nav, ...base } = baseOptions();

const navbarLinks: LinkItemType[] = [
...links,
{
type: 'custom',
type: "custom",
children: <VersionSwitcher currentVersion={LATEST_VERSION} />,
},
];

// 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 (
<DocsLayout
{...base}
links={navbarLinks}
nav={{ ...nav }}
sidebar={{ collapsible: false }}
sidebar={{
collapsible: false,
footer: ({ className, ...props }: ComponentProps<"div">) => (
<div className={cn("flex flex-col p-4 pt-2 gap-3", className)} {...props}>
<SidebarBannerCarousel slides={slides} />
{props.children}
</div>
),
}}
tree={source.pageTree}
>
{children}
Expand Down
193 changes: 193 additions & 0 deletions apps/docs/src/components/sidebar-banner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
"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<Set<string>>(new Set());
const [mounted, setMounted] = useState(false);
const [dismissingHref, setDismissingHref] = useState<string | null>(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(() => {
const next = new Set(dismissedIds);
next.add(href);
setDismissedIds(next);
localStorage.setItem(DISMISSED_KEY, JSON.stringify([...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 (
<div
key={`peek-${i}`}
className="border border-stroke-neutral bg-background-default shadow-drop-low transition-all duration-300 ease-out"
aria-hidden
style={{
height: hovered ? 10 : 7,
marginLeft: inset,
marginRight: inset,
borderRadius: "12px 12px 0 0",
borderBottom: "none",
opacity: hovered ? 0.4 + (arr.length - i) * 0.15 : 0.25 + (arr.length - i) * 0.1,
}}
/>
);
});

return (
<div
className="flex flex-col"
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
{/* Peek cards above — each one narrower, creating depth perspective */}
{peekCount > 0 && (
<div className="flex flex-col -mb-px">{peekCards}</div>
)}

{/* Front card */}
<div
className={cn(
"relative rounded-high border border-stroke-neutral overflow-hidden shadow-drop-low",
"bg-background-default transition-shadow hover:shadow-drop",
)}
>
{/* Title + description */}
<div className="p-3 pb-0">
<div className="flex items-center gap-1.5 mb-1">
<span className="text-sm font-semibold text-foreground-neutral leading-tight">
{front.title}
</span>
{front.badge && (
<span
className={cn(
"text-2xs font-medium px-1.5 py-0.5 rounded-circle shrink-0",
front.gradient === "ppg"
? "bg-background-ppg text-foreground-ppg"
: "bg-background-orm text-foreground-orm",
)}
>
{front.badge}
</span>
)}
</div>
<p className="text-xs text-foreground-neutral-weak truncate">{front.description}</p>
</div>

{/* Image preview */}
<div
className={cn(
"relative mx-3 mt-2 rounded-square overflow-hidden aspect-video",
!front.image && (front.gradient === "ppg" ? "bg-gradient-ppg" : "bg-gradient-orm"),
)}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
{front.image ? (
<img
src={front.image.startsWith("http") ? front.image : `/docs${front.image}`}
alt=""
className="absolute inset-0 size-full object-cover"
/>
) : (
<div className="flex items-center justify-center size-full">
<svg
viewBox="0 0 28 37"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="h-8 w-auto opacity-40"
>
<path
d="M27.4 8.42L15.52.32a3.2 3.2 0 00-3.36 0L.32 8.42A3.22 3.22 0 000 11.1v16.2a3.22 3.22 0 001.6 2.78l11.88 7.6a3.2 3.2 0 003.36 0l11.56-7.6a3.2 3.2 0 001.6-2.78V11.1a3.22 3.22 0 00-1.6-2.68zM12.16 33.48L2.24 27.18a1.6 1.6 0 01-.8-1.38v-7.4l10.72 6.5v8.58zm1.28-10.6L2.28 16.22l5.08-3.16 11.16 6.76-5.08 3.06zm13.12-4.56v7.38a1.6 1.6 0 01-.8 1.38l-9.92 6.3v-8.56l10.72-6.5z"
fill="currentColor"
className={cn(
front.gradient === "ppg"
? "text-foreground-ppg-strong"
: "text-foreground-orm-strong",
)}
/>
</svg>
</div>
)}
</div>

{/* Action bar — appears on hover */}
<div
className={cn(
"flex items-center justify-between px-3 overflow-hidden transition-all duration-300 ease-out",
hovered ? "max-h-12 opacity-100 py-2.5" : "max-h-0 opacity-0 py-0",
)}
>
<Link
href={front.href}
className={cn(
"text-xs font-medium transition-colors",
front.gradient === "ppg"
? "text-foreground-ppg hover:text-foreground-ppg-strong"
: "text-foreground-orm hover:text-foreground-orm-strong",
)}
>
Read more
</Link>
<button
type="button"
onClick={(e) => handleDismiss(e, front.href)}
className="text-xs text-foreground-neutral-weaker hover:text-foreground-neutral-weak transition-colors"
>
Dismiss
</button>
</div>

{/* Bottom padding when action bar is hidden */}
<div className={cn("transition-all duration-300 ease-out", hovered ? "h-0" : "h-3")} />
</div>
</div>
);
}
20 changes: 20 additions & 0 deletions apps/docs/src/lib/og-image.ts
Original file line number Diff line number Diff line change
@@ -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<string | null> {
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(/<meta[^>]+property=["']og:image["'][^>]+content=["']([^"']+)["']/i) ??
html.match(/<meta[^>]+content=["']([^"']+)["'][^>]+property=["']og:image["']/i);
return match?.[1] ?? null;
} catch {
return null;
}
}
Loading