diff --git a/examples/cms-sanity/app/(blog)/AuthorAvatar.tsx b/examples/cms-sanity/app/(blog)/AuthorAvatar.tsx new file mode 100644 index 0000000000000..0d6865aed5db8 --- /dev/null +++ b/examples/cms-sanity/app/(blog)/AuthorAvatar.tsx @@ -0,0 +1,46 @@ +import { sanityFetch } from '@/lib/fetch' +import { urlForImage } from '@/lib/image' +import Image from 'next/image' +import Balancer from 'react-wrap-balancer' + +const query = /* groq */ `*[_type == "author" && _id == $id][0]` + +export async function AuthorAvatar(params: { id: string }) { + const data = await sanityFetch({ + query, + params, + tags: [`author:${params.id}`], + }) + const { name = 'Anonymous', image } = data + return ( +
+
+ {image?.alt +
+
+ {name} +
+
+ ) +} + +export function AuthorAvatarFallback() { + return ( +
+
+
+ Fetching author… +
+
+ ) +} diff --git a/examples/cms-sanity/app/(blog)/BlogHeader.tsx b/examples/cms-sanity/app/(blog)/BlogHeader.tsx new file mode 100644 index 0000000000000..3c1a37c814d52 --- /dev/null +++ b/examples/cms-sanity/app/(blog)/BlogHeader.tsx @@ -0,0 +1,27 @@ +'use client' + +import Link from 'next/link' +import { usePathname } from 'next/navigation' +import Balancer from 'react-wrap-balancer' + +export default function BlogHeader({ title }: { title: string }) { + const pathname = usePathname() + if (pathname === '/') { + return ( +
+

+ {title} +

+
+ ) + } + return ( +
+

+ + {title} + +

+
+ ) +} diff --git a/examples/cms-sanity/components/cover-image.js b/examples/cms-sanity/app/(blog)/CoverImage.tsx similarity index 56% rename from examples/cms-sanity/components/cover-image.js rename to examples/cms-sanity/app/(blog)/CoverImage.tsx index fa004df1bf8d2..13f98b6b6fc96 100644 --- a/examples/cms-sanity/components/cover-image.js +++ b/examples/cms-sanity/app/(blog)/CoverImage.tsx @@ -1,20 +1,28 @@ +import { urlForImage } from '@/lib/image' import cn from 'classnames' import Image from 'next/image' import Link from 'next/link' -import { urlForImage } from '../lib/sanity' -export default function CoverImage({ title, slug, image: source, priority }) { +interface CoverImageProps { + title: string + slug?: string + image?: { asset?: any; alt?: string } + priority?: boolean +} + +export default function CoverImage(props: CoverImageProps) { + const { title, slug, image: source, priority } = props const image = source?.asset?._ref ? (
{`Cover {slug ? ( - + {image} ) : ( diff --git a/examples/cms-sanity/app/(blog)/MoreStories.tsx b/examples/cms-sanity/app/(blog)/MoreStories.tsx new file mode 100644 index 0000000000000..e78a9ad70034f --- /dev/null +++ b/examples/cms-sanity/app/(blog)/MoreStories.tsx @@ -0,0 +1,68 @@ +import Link from 'next/link' +import { Suspense } from 'react' + +import { sanityFetch } from '@/lib/fetch' +import { postFields } from '@/lib/queries' +import { AuthorAvatar, AuthorAvatarFallback } from './AuthorAvatar' +import CoverImage from './CoverImage' +import Date from './PostDate' + +const query = /* groq */ `*[_type == "post" && _id != $skip] | order(date desc, _updatedAt desc) [0...$limit] { + ${postFields} +}` + +export default async function MoreStories(params: { + skip: string + limit: number +}) { + const data = await sanityFetch({ query, params, tags: ['post'] }) + + return ( + <> +
+ {data.map((post: any) => { + const { + _id, + title = 'Untitled', + slug, + mainImage, + excerpt, + author, + } = post + return ( +
+
+ +
+

+ {slug?.current ? ( + + {title} + + ) : ( + title + )} +

+
+ +
+ {excerpt && ( +

{excerpt}

+ )} + {author?._ref && ( + }> + + + )} +
+ ) + })} +
+ + ) +} diff --git a/examples/cms-sanity/app/(blog)/PostBody.module.css b/examples/cms-sanity/app/(blog)/PostBody.module.css new file mode 100644 index 0000000000000..87f49dffcea01 --- /dev/null +++ b/examples/cms-sanity/app/(blog)/PostBody.module.css @@ -0,0 +1,25 @@ +.portableText { + @apply text-lg leading-relaxed; +} + +.portableText p, +.portableText ul, +.portableText ol, +.portableText blockquote { + @apply my-6; +} + +.portableText h2 { + @apply mb-4 mt-12 text-3xl leading-snug; +} + +.portableText h3 { + @apply mb-4 mt-8 text-2xl leading-snug; +} + +.portableText a { + @apply text-blue-500 underline; +} +.portableText a:hover { + @apply text-blue-800; +} diff --git a/examples/cms-sanity/app/(blog)/PostBody.tsx b/examples/cms-sanity/app/(blog)/PostBody.tsx new file mode 100644 index 0000000000000..d918ce9673ba1 --- /dev/null +++ b/examples/cms-sanity/app/(blog)/PostBody.tsx @@ -0,0 +1,24 @@ +/** + * This component uses Portable Text to render a post body. + * + * You can learn more about Portable Text on: + * https://www.sanity.io/docs/block-content + * https://github.com/portabletext/react-portabletext + * https://portabletext.org/ + * + */ +import { PortableText } from '@portabletext/react' + +import styles from './PostBody.module.css' + +export default function PostBody({ + body, +}: { + body: React.ComponentProps['value'] +}) { + return ( +
+ +
+ ) +} diff --git a/examples/cms-sanity/app/(blog)/PostDate.tsx b/examples/cms-sanity/app/(blog)/PostDate.tsx new file mode 100644 index 0000000000000..8e5265fd49fde --- /dev/null +++ b/examples/cms-sanity/app/(blog)/PostDate.tsx @@ -0,0 +1,11 @@ +import { format, parseISO } from 'date-fns' +import Balancer from 'react-wrap-balancer' + +export default function PostDate({ dateString }: { dateString: string }) { + const date = parseISO(dateString) + return ( + + ) +} diff --git a/examples/cms-sanity/app/(blog)/PreviewBanner.tsx b/examples/cms-sanity/app/(blog)/PreviewBanner.tsx new file mode 100644 index 0000000000000..173967d7e1c8d --- /dev/null +++ b/examples/cms-sanity/app/(blog)/PreviewBanner.tsx @@ -0,0 +1,57 @@ +'use client' + +import { useEffect, useState, useTransition } from 'react' +import { useRouter } from 'next/navigation' + +function useRefresh() { + const router = useRouter() + const [loading, startTransition] = useTransition() + const [again, setAgain] = useState(false) + useEffect(() => { + function handleMessage(event: MessageEvent) { + if (event.data?.sanity && event.data.type === 'reload') { + startTransition(() => { + router.refresh() + setAgain(true) + }) + } + } + window.addEventListener('message', handleMessage) + return () => window.removeEventListener('message', handleMessage) + }, [router]) + + useEffect(() => { + if (!again) return + const timeout = setTimeout( + () => + startTransition(() => { + setAgain(false) + router.refresh() + }), + 1000, + ) + return () => clearTimeout(timeout) + }, [again]) + + return loading || again +} + +export default function PreviewBanner() { + const loading = useRefresh() + + return ( +
+ {'Previewing drafts. '} + + Back to published + +
+ ) +} diff --git a/examples/cms-sanity/app/(blog)/[slug]/page.tsx b/examples/cms-sanity/app/(blog)/[slug]/page.tsx new file mode 100644 index 0000000000000..0ec4fb5819a94 --- /dev/null +++ b/examples/cms-sanity/app/(blog)/[slug]/page.tsx @@ -0,0 +1,110 @@ +import type { Metadata, ResolvingMetadata } from 'next' +import { notFound } from 'next/navigation' +import { Suspense } from 'react' +import Balancer from 'react-wrap-balancer' + +import { AuthorAvatar, AuthorAvatarFallback } from '../AuthorAvatar' +import CoverImage from '../CoverImage' +import MoreStories from '../MoreStories' +import PostBody from '../PostBody' +import PostDate from '../PostDate' +import { client } from '@/lib/client' +import { urlForImage } from '@/lib/image' +import { sanityFetch } from '@/lib/fetch' +import { postFields } from '@/lib/queries' + +type Props = { + params: { slug: string } +} + +const query = /* groq */ `*[_type == "post" && slug.current == $slug][0] { + ${postFields} +}` + +export async function generateMetadata( + { params }: Props, + parent: ResolvingMetadata, +): Promise { + const [post, authorName] = await Promise.all([ + sanityFetch({ query, params, tags: [`post:${params.slug}`] }), + // @TODO necessary as there's problems with type inference when `author-{name,image}` is used + sanityFetch({ + query: /* groq */ `*[_type == "post" && slug.current == $slug][0].author->name`, + params, + tags: [`post:${params.slug}`, 'author'], + }), + ]) + // optionally access and extend (rather than replace) parent metadata + const parentTitle = (await parent).title?.absolute + const previousImages = (await parent).openGraph?.images || [] + + return { + authors: authorName ? [{ name: authorName }] : [], + metadataBase: new URL('http://groqsolid-nextjs-blog.sanity.build'), + title: `${parentTitle} | ${post.title}`, + openGraph: { + images: post.mainImage + ? [ + urlForImage(post.mainImage).height(1000).width(2000).url(), + ...previousImages, + ] + : previousImages, + }, + } satisfies Metadata +} + +export default async function BlogPostPage({ params }: Props) { + const { slug } = params + const data = await sanityFetch({ + query, + params, + tags: [`post:${params.slug}`], + }) + + if (!data) { + return notFound() + } + + const { _id, title = 'Untitled', author, mainImage, body } = data ?? {} + return ( + <> +
+

+ {title} +

+
+ {author?._ref && ( + }> + + + )} +
+
+ +
+
+
+ {author?._ref && ( + }> + + + )} +
+
+ +
+
+ +
+ + + ) +} diff --git a/examples/cms-sanity/app/(blog)/page.tsx b/examples/cms-sanity/app/(blog)/page.tsx new file mode 100644 index 0000000000000..759ef0da6fba5 --- /dev/null +++ b/examples/cms-sanity/app/(blog)/page.tsx @@ -0,0 +1,77 @@ +import Link from 'next/link' +import { Suspense } from 'react' +import Balancer from 'react-wrap-balancer' + +import { AuthorAvatar, AuthorAvatarFallback } from './AuthorAvatar' +import CoverImage from './CoverImage' +import MoreStories from './MoreStories' +import Date from './PostDate' +import { postFields } from '@/lib/queries' +import { sanityFetch } from '@/lib/fetch' + +export default async function BlogIndexPage() { + const data = await sanityFetch({ + query: /* groq */ ` +*[_type == "post"] | order(publishedAt desc, _updatedAt desc) [0] { + ${postFields} +}`, + tags: ['post'], + }) + const { _id, author, excerpt, mainImage, slug, title, publishedAt } = + data ?? {} + + return ( + <> + {data && ( +
+
+ +
+
+
+

+ {slug?.current ? ( + + {title} + + ) : ( + {title} + )} +

+
+ +
+
+
+ {excerpt && ( +

+ {excerpt} +

+ )} + {author?._ref && ( + }> + + + )} +
+
+
+ )} + {_id && ( + + )} + + ) +} diff --git a/examples/cms-sanity/app/(blog)/template.tsx b/examples/cms-sanity/app/(blog)/template.tsx new file mode 100644 index 0000000000000..e7165a37b524d --- /dev/null +++ b/examples/cms-sanity/app/(blog)/template.tsx @@ -0,0 +1,24 @@ +import Link from 'next/link' + +import BlogHeader from './BlogHeader' +import { EXAMPLE_NAME } from '@/lib/constants' +import PreviewBanner from './PreviewBanner' +import { draftMode } from 'next/headers' + +export default function Template({ children }: { children: React.ReactNode }) { + return ( + <> + {draftMode().isEnabled && } +
+ +
{children}
+
+
+ + Studio + +
+
+ + ) +} diff --git a/examples/cms-sanity/app/api/disable-draft/route.ts b/examples/cms-sanity/app/api/disable-draft/route.ts new file mode 100644 index 0000000000000..ec7d9b9c9c7f8 --- /dev/null +++ b/examples/cms-sanity/app/api/disable-draft/route.ts @@ -0,0 +1,7 @@ +import {draftMode} from 'next/headers' +import {redirect} from 'next/navigation' + +export function GET(): void { + draftMode().disable() + redirect('/') +} diff --git a/examples/cms-sanity/app/api/draft/route.ts b/examples/cms-sanity/app/api/draft/route.ts new file mode 100644 index 0000000000000..d30cdaae1ef3f --- /dev/null +++ b/examples/cms-sanity/app/api/draft/route.ts @@ -0,0 +1,45 @@ +import { urlSecretId, token } from '@/lib/env' +import { client } from '@/lib/client' + +import { draftMode } from 'next/headers' +import { redirect } from 'next/navigation' +import { isValidSecret } from 'sanity-plugin-iframe-pane/is-valid-secret' +import { resolveHref } from '@/lib/links' + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url) + const secret = searchParams.get('secret') + const slug = searchParams.get('slug') + const documentType = searchParams.get('type') + + if (!token) { + throw new Error( + 'The `SANITY_API_READ_TOKEN` environment variable is required.', + ) + } + if (!secret) { + return new Response('Invalid secret', { status: 401 }) + } + + const authenticatedClient = client.withConfig({ token }) + const validSecret = await isValidSecret( + authenticatedClient, + urlSecretId, + secret, + ) + if (!validSecret) { + return new Response('Invalid secret', { status: 401 }) + } + + const href = resolveHref(documentType!, slug!) + if (!href) { + return new Response( + 'Unable to resolve preview URL based on the current document type and slug', + { status: 400 }, + ) + } + + draftMode().enable() + + redirect(href) +} diff --git a/examples/cms-sanity/app/api/revalidate-tag/route.ts b/examples/cms-sanity/app/api/revalidate-tag/route.ts new file mode 100644 index 0000000000000..d640d71bff133 --- /dev/null +++ b/examples/cms-sanity/app/api/revalidate-tag/route.ts @@ -0,0 +1,32 @@ +/* eslint-disable no-process-env */ +import { revalidateTag } from 'next/cache' +import { type NextRequest, NextResponse } from 'next/server' +import { parseBody } from 'next-sanity/webhook' + +// Triggers a revalidation of the static data in the example above +export async function POST(req: NextRequest): Promise { + try { + const { body, isValidSignature } = await parseBody<{ + _type: string + _id: string + slug?: string | undefined + }>(req, process.env.SANITY_REVALIDATE_SECRET) + if (!isValidSignature) { + const message = 'Invalid signature' + return new Response(message, { status: 401 }) + } + + if (!body?._type) { + return new Response('Bad Request', { status: 400 }) + } + + revalidateTag(body._type) + if (body.slug && typeof body.slug === 'string') { + revalidateTag(`${body._type}:${body.slug}`) + } + return NextResponse.json({ revalidated: true, now: Date.now(), body }) + } catch (err: any) { + console.error(err) + return new Response(err.message, { status: 500 }) + } +} diff --git a/examples/cms-sanity/public/favicon/favicon.ico b/examples/cms-sanity/app/favicon.ico similarity index 100% rename from examples/cms-sanity/public/favicon/favicon.ico rename to examples/cms-sanity/app/favicon.ico diff --git a/examples/cms-sanity/app/layout.tsx b/examples/cms-sanity/app/layout.tsx new file mode 100644 index 0000000000000..7046e32847c9d --- /dev/null +++ b/examples/cms-sanity/app/layout.tsx @@ -0,0 +1,29 @@ +import 'tailwindcss/tailwind.css' + +import type { Metadata } from 'next' + +import { Inter } from 'next/font/google' +import { CMS_NAME } from '@/lib/constants' + +const inter = Inter({ + variable: '--font-inter', + subsets: ['latin'], + display: 'swap', +}) + +export const metadata = { + title: `Next.js and ${CMS_NAME} Example`, + description: `This is a blog built with Next.js and ${CMS_NAME}.`, +} satisfies Metadata + +export default function RootLayout({ + children, +}: { + children: React.ReactNode +}) { + return ( + + {children} + + ) +} diff --git a/examples/cms-sanity/app/studio/[[...index]]/Studio.tsx b/examples/cms-sanity/app/studio/[[...index]]/Studio.tsx new file mode 100644 index 0000000000000..a154501fa4d7f --- /dev/null +++ b/examples/cms-sanity/app/studio/[[...index]]/Studio.tsx @@ -0,0 +1,9 @@ +'use client' + +import { NextStudio } from 'next-sanity/studio' + +import config from '@/sanity.config' + +export default function Studio() { + return +} diff --git a/examples/cms-sanity/app/studio/[[...index]]/loading.tsx b/examples/cms-sanity/app/studio/[[...index]]/loading.tsx new file mode 100644 index 0000000000000..47e9fb09d8e3b --- /dev/null +++ b/examples/cms-sanity/app/studio/[[...index]]/loading.tsx @@ -0,0 +1 @@ +export { NextStudioLoading as default } from 'next-sanity/studio/loading' diff --git a/examples/cms-sanity/app/studio/[[...index]]/page.tsx b/examples/cms-sanity/app/studio/[[...index]]/page.tsx new file mode 100644 index 0000000000000..1d2038d26e650 --- /dev/null +++ b/examples/cms-sanity/app/studio/[[...index]]/page.tsx @@ -0,0 +1,18 @@ +/** + * This route is responsible for the built-in authoring environment using Sanity Studio. + * All routes under your studio path is handled by this file using Next.js' catch-all routes: + * https://nextjs.org/docs/routing/dynamic-routes#catch-all-routes + * + * You can learn more about the next-sanity package here: + * https://github.com/sanity-io/next-sanity + */ + +import Studio from './Studio' + +export const dynamic = 'force-static' + +export { metadata } from 'next-sanity/studio/metadata' + +export default function StudioPage() { + return +} diff --git a/examples/cms-sanity/components/alert.js b/examples/cms-sanity/components/alert.js deleted file mode 100644 index b924cb097f169..0000000000000 --- a/examples/cms-sanity/components/alert.js +++ /dev/null @@ -1,42 +0,0 @@ -import Container from './container' -import cn from 'classnames' -import { EXAMPLE_PATH } from '../lib/constants' - -export default function Alert({ preview }) { - return ( -
- -
- {preview ? ( - <> - This page is a preview.{' '} - - Click here - {' '} - to exit preview mode. - - ) : ( - <> - The source code for this blog is{' '} - - available on GitHub - - . - - )} -
-
-
- ) -} diff --git a/examples/cms-sanity/components/avatar.js b/examples/cms-sanity/components/avatar.js deleted file mode 100644 index a7be69edf9ab8..0000000000000 --- a/examples/cms-sanity/components/avatar.js +++ /dev/null @@ -1,23 +0,0 @@ -import Image from 'next/image' -import { urlForImage } from '../lib/sanity' - -export default function Avatar({ name, picture }) { - return ( -
-
- {name} -
-
{name}
-
- ) -} diff --git a/examples/cms-sanity/components/container.js b/examples/cms-sanity/components/container.js deleted file mode 100644 index fc1c29dfb0747..0000000000000 --- a/examples/cms-sanity/components/container.js +++ /dev/null @@ -1,3 +0,0 @@ -export default function Container({ children }) { - return
{children}
-} diff --git a/examples/cms-sanity/components/date.js b/examples/cms-sanity/components/date.js deleted file mode 100644 index 882b66e59eb61..0000000000000 --- a/examples/cms-sanity/components/date.js +++ /dev/null @@ -1,8 +0,0 @@ -import { parseISO, format } from 'date-fns' - -export default function Date({ dateString }) { - if (!dateString) return null - - const date = parseISO(dateString) - return -} diff --git a/examples/cms-sanity/components/footer.js b/examples/cms-sanity/components/footer.js deleted file mode 100644 index da9eed88ec263..0000000000000 --- a/examples/cms-sanity/components/footer.js +++ /dev/null @@ -1,30 +0,0 @@ -import Container from './container' -import { EXAMPLE_PATH } from '../lib/constants' - -export default function Footer() { - return ( - - ) -} diff --git a/examples/cms-sanity/components/header.js b/examples/cms-sanity/components/header.js deleted file mode 100644 index 05cb9af247f7f..0000000000000 --- a/examples/cms-sanity/components/header.js +++ /dev/null @@ -1,12 +0,0 @@ -import Link from 'next/link' - -export default function Header() { - return ( -

- - Blog - - . -

- ) -} diff --git a/examples/cms-sanity/components/hero-post.js b/examples/cms-sanity/components/hero-post.js deleted file mode 100644 index 68a3ec945e633..0000000000000 --- a/examples/cms-sanity/components/hero-post.js +++ /dev/null @@ -1,37 +0,0 @@ -import Avatar from '../components/avatar' -import Date from '../components/date' -import CoverImage from '../components/cover-image' -import Link from 'next/link' - -export default function HeroPost({ - title, - coverImage, - date, - excerpt, - author, - slug, -}) { - return ( -
-
- -
-
-
-

- - {title} - -

-
- -
-
-
-

{excerpt}

- {author && } -
-
-
- ) -} diff --git a/examples/cms-sanity/components/intro.js b/examples/cms-sanity/components/intro.js deleted file mode 100644 index 5931b3c5961bd..0000000000000 --- a/examples/cms-sanity/components/intro.js +++ /dev/null @@ -1,28 +0,0 @@ -import { CMS_NAME, CMS_URL } from '../lib/constants' - -export default function Intro() { - return ( -
-

- Blog. -

-

- A statically generated blog example using{' '} - - Next.js - {' '} - and{' '} - - {CMS_NAME} - - . -

-
- ) -} diff --git a/examples/cms-sanity/components/landing-preview.js b/examples/cms-sanity/components/landing-preview.js deleted file mode 100644 index fcb6c70126b36..0000000000000 --- a/examples/cms-sanity/components/landing-preview.js +++ /dev/null @@ -1,8 +0,0 @@ -import { usePreview } from '../lib/sanity' -import { indexQuery } from '../lib/queries' -import Landing from './landing' - -export default function LandingPreview({ allPosts }) { - const previewAllPosts = usePreview(null, indexQuery) - return -} diff --git a/examples/cms-sanity/components/landing.js b/examples/cms-sanity/components/landing.js deleted file mode 100644 index f22b5a846c1ed..0000000000000 --- a/examples/cms-sanity/components/landing.js +++ /dev/null @@ -1,34 +0,0 @@ -import Layout from './layout' -import Head from 'next/head' -import { CMS_NAME } from '../lib/constants' -import Container from './container' -import Intro from './intro' -import HeroPost from './hero-post' -import MoreStories from './more-stories' - -export default function Landing({ allPosts, preview }) { - const [heroPost, ...morePosts] = allPosts || [] - return ( - <> - - - {`Next.js Blog Example with ${CMS_NAME}`} - - - - {heroPost && ( - - )} - {morePosts.length > 0 && } - - - - ) -} diff --git a/examples/cms-sanity/components/layout.js b/examples/cms-sanity/components/layout.js deleted file mode 100644 index 99d95353131e0..0000000000000 --- a/examples/cms-sanity/components/layout.js +++ /dev/null @@ -1,16 +0,0 @@ -import Alert from '../components/alert' -import Footer from '../components/footer' -import Meta from '../components/meta' - -export default function Layout({ preview, children }) { - return ( - <> - -
- -
{children}
-
-