From b1742dc6b8f3ddc0ef835f1e2e0d77ddc753e53e Mon Sep 17 00:00:00 2001 From: JohnAlbin Date: Tue, 12 Mar 2024 01:02:21 +0800 Subject: [PATCH] feat(basic-starter): upgrade starters to App Router Fixes #601 --- starters/basic-starter/README.md | 2 +- starters/basic-starter/app/[...slug]/page.tsx | 129 ++++++++++++++ .../app/api/disable-draft/route.ts | 6 + starters/basic-starter/app/api/draft/route.ts | 7 + .../basic-starter/app/api/revalidate/route.ts | 28 +++ starters/basic-starter/app/layout.tsx | 37 ++++ .../{pages/index.tsx => app/page.tsx} | 37 +--- starters/basic-starter/components/Layout.tsx | 15 -- .../components/misc/DraftAlert/Client.tsx | 38 +++++ .../components/misc/DraftAlert/index.tsx | 13 ++ .../components/misc/PreviewAlert.tsx | 30 ---- starters/basic-starter/lib/drupal.ts | 4 +- starters/basic-starter/pages/[...slug].tsx | 92 ---------- starters/basic-starter/pages/_app.tsx | 6 - starters/basic-starter/pages/_document.tsx | 13 -- .../basic-starter/pages/api/exit-preview.ts | 9 - starters/basic-starter/pages/api/preview.ts | 10 -- .../basic-starter/pages/api/revalidate.ts | 29 ---- starters/graphql-starter/README.md | 4 + .../[...slug].tsx => app/[...slug]/page.tsx} | 160 ++++++++++-------- .../app/api/disable-draft/route.ts | 6 + .../graphql-starter/app/api/draft/route.ts | 7 + .../app/api/revalidate/route.ts | 28 +++ starters/graphql-starter/app/layout.tsx | 37 ++++ .../{pages/index.tsx => app/page.tsx} | 35 +--- .../graphql-starter/components/Layout.tsx | 15 -- .../components/misc/DraftAlert/Client.tsx | 38 +++++ .../components/misc/DraftAlert/index.tsx | 13 ++ .../components/misc/PreviewAlert.tsx | 30 ---- .../lib/next-drupal-graphql.ts | 13 +- starters/graphql-starter/pages/_app.tsx | 6 - starters/graphql-starter/pages/_document.tsx | 13 -- .../graphql-starter/pages/api/exit-preview.ts | 9 - starters/graphql-starter/pages/api/preview.ts | 10 -- .../graphql-starter/pages/api/revalidate.ts | 29 ---- 35 files changed, 501 insertions(+), 457 deletions(-) create mode 100644 starters/basic-starter/app/[...slug]/page.tsx create mode 100644 starters/basic-starter/app/api/disable-draft/route.ts create mode 100644 starters/basic-starter/app/api/draft/route.ts create mode 100644 starters/basic-starter/app/api/revalidate/route.ts create mode 100644 starters/basic-starter/app/layout.tsx rename starters/basic-starter/{pages/index.tsx => app/page.tsx} (50%) delete mode 100644 starters/basic-starter/components/Layout.tsx create mode 100644 starters/basic-starter/components/misc/DraftAlert/Client.tsx create mode 100644 starters/basic-starter/components/misc/DraftAlert/index.tsx delete mode 100644 starters/basic-starter/components/misc/PreviewAlert.tsx delete mode 100644 starters/basic-starter/pages/[...slug].tsx delete mode 100644 starters/basic-starter/pages/_app.tsx delete mode 100644 starters/basic-starter/pages/_document.tsx delete mode 100644 starters/basic-starter/pages/api/exit-preview.ts delete mode 100644 starters/basic-starter/pages/api/preview.ts delete mode 100644 starters/basic-starter/pages/api/revalidate.ts rename starters/graphql-starter/{pages/[...slug].tsx => app/[...slug]/page.tsx} (51%) create mode 100644 starters/graphql-starter/app/api/disable-draft/route.ts create mode 100644 starters/graphql-starter/app/api/draft/route.ts create mode 100644 starters/graphql-starter/app/api/revalidate/route.ts create mode 100644 starters/graphql-starter/app/layout.tsx rename starters/graphql-starter/{pages/index.tsx => app/page.tsx} (61%) delete mode 100644 starters/graphql-starter/components/Layout.tsx create mode 100644 starters/graphql-starter/components/misc/DraftAlert/Client.tsx create mode 100644 starters/graphql-starter/components/misc/DraftAlert/index.tsx delete mode 100644 starters/graphql-starter/components/misc/PreviewAlert.tsx delete mode 100644 starters/graphql-starter/pages/_app.tsx delete mode 100644 starters/graphql-starter/pages/_document.tsx delete mode 100644 starters/graphql-starter/pages/api/exit-preview.ts delete mode 100644 starters/graphql-starter/pages/api/preview.ts delete mode 100644 starters/graphql-starter/pages/api/revalidate.ts diff --git a/starters/basic-starter/README.md b/starters/basic-starter/README.md index 0075ec73..03dfcfb4 100644 --- a/starters/basic-starter/README.md +++ b/starters/basic-starter/README.md @@ -1,6 +1,6 @@ # Basic Starter -A simple starter for building your site with Next.js' Pages Router and Drupal. +A simple starter for building your site with Next.js and Drupal. ## How to use diff --git a/starters/basic-starter/app/[...slug]/page.tsx b/starters/basic-starter/app/[...slug]/page.tsx new file mode 100644 index 00000000..4e61889c --- /dev/null +++ b/starters/basic-starter/app/[...slug]/page.tsx @@ -0,0 +1,129 @@ +import { draftMode } from "next/headers" +import { notFound } from "next/navigation" +import { getDraftData } from "next-drupal/draft" +import { Article } from "@/components/drupal/Article" +import { BasicPage } from "@/components/drupal/BasicPage" +import { drupal } from "@/lib/drupal" +import type { Metadata, ResolvingMetadata } from "next" +import type { DrupalNode, JsonApiParams } from "next-drupal" + +async function getNode(slug: string[]) { + const path = `/${slug.join("/")}` + + const params: JsonApiParams = {} + + const draftData = getDraftData() + + if (draftData.path === path) { + params.resourceVersion = draftData.resourceVersion + } + + // Translating the path also allows us to discover the entity type. + const translatedPath = await drupal.translatePath(path) + + if (!translatedPath) { + throw new Error("Resource not found", { cause: "NotFound" }) + } + + const type = translatedPath.jsonapi?.resourceName! + const uuid = translatedPath.entity.uuid + + if (type === "node--article") { + params.include = "field_image,uid" + } + + const resource = await drupal.getResource(type, uuid, { + params, + }) + + if (!resource) { + throw new Error( + `Failed to fetch resource: ${translatedPath?.jsonapi?.individual}`, + { + cause: "DrupalError", + } + ) + } + + return resource +} + +type NodePageParams = { + slug: string[] +} +type NodePageProps = { + params: NodePageParams + searchParams: { [key: string]: string | string[] | undefined } +} + +export async function generateMetadata( + { params: { slug } }: NodePageProps, + parent: ResolvingMetadata +): Promise { + let node + try { + node = await getNode(slug) + } catch (e) { + // If we fail to fetch the node, don't return any metadata. + return {} + } + + return { + title: node.title, + } +} + +const RESOURCE_TYPES = ["node--page", "node--article"] + +export async function generateStaticParams(): Promise { + const resources = await drupal.getResourceCollectionPathSegments( + RESOURCE_TYPES, + { + // The pathPrefix will be removed from the returned path segments array. + // pathPrefix: "/blog", + // The list of locales to return. + // locales: ["en", "es"], + // The default locale. + // defaultLocale: "en", + } + ) + + return resources.map((resource) => { + // resources is an array containing objects like: { + // path: "/blog/some-category/a-blog-post", + // type: "node--article", + // locale: "en", // or `undefined` if no `locales` requested. + // segments: ["blog", "some-category", "a-blog-post"], + // } + return { + slug: resource.segments, + } + }) +} + +export default async function NodePage({ + params: { slug }, + searchParams, +}: NodePageProps) { + const isDraftMode = draftMode().isEnabled + + let node + try { + node = await getNode(slug) + } catch (error) { + // If getNode throws an error, tell Next.js the path is 404. + notFound() + } + + // If we're not in draft mode and the resource is not published, return a 404. + if (!isDraftMode && node?.status === false) { + notFound() + } + + return ( + <> + {node.type === "node--page" && } + {node.type === "node--article" &&
} + + ) +} diff --git a/starters/basic-starter/app/api/disable-draft/route.ts b/starters/basic-starter/app/api/disable-draft/route.ts new file mode 100644 index 00000000..81900948 --- /dev/null +++ b/starters/basic-starter/app/api/disable-draft/route.ts @@ -0,0 +1,6 @@ +import { disableDraftMode } from "next-drupal/draft" +import type { NextRequest } from "next/server" + +export async function GET(request: NextRequest) { + return disableDraftMode() +} diff --git a/starters/basic-starter/app/api/draft/route.ts b/starters/basic-starter/app/api/draft/route.ts new file mode 100644 index 00000000..b8757e2a --- /dev/null +++ b/starters/basic-starter/app/api/draft/route.ts @@ -0,0 +1,7 @@ +import { drupal } from "@/lib/drupal" +import { enableDraftMode } from "next-drupal/draft" +import type { NextRequest } from "next/server" + +export async function GET(request: NextRequest): Promise { + return enableDraftMode(request, drupal) +} diff --git a/starters/basic-starter/app/api/revalidate/route.ts b/starters/basic-starter/app/api/revalidate/route.ts new file mode 100644 index 00000000..9882273a --- /dev/null +++ b/starters/basic-starter/app/api/revalidate/route.ts @@ -0,0 +1,28 @@ +import { revalidatePath } from "next/cache" +import type { NextRequest } from "next/server" + +async function handler(request: NextRequest) { + const searchParams = request.nextUrl.searchParams + const path = searchParams.get("path") + const secret = searchParams.get("secret") + + // Validate secret. + if (secret !== process.env.DRUPAL_REVALIDATE_SECRET) { + return new Response("Invalid secret.", { status: 401 }) + } + + // Validate path. + if (!path) { + return new Response("Invalid path.", { status: 400 }) + } + + try { + revalidatePath(path) + + return new Response("Revalidated.") + } catch (error) { + return new Response((error as Error).message, { status: 500 }) + } +} + +export { handler as GET, handler as POST } diff --git a/starters/basic-starter/app/layout.tsx b/starters/basic-starter/app/layout.tsx new file mode 100644 index 00000000..3b3c9652 --- /dev/null +++ b/starters/basic-starter/app/layout.tsx @@ -0,0 +1,37 @@ +import { DraftAlert } from "@/components/misc/DraftAlert" +import { HeaderNav } from "@/components/navigation/HeaderNav" +import type { Metadata } from "next" +import type { ReactNode } from "react" + +import "@/styles/globals.css" + +export const metadata: Metadata = { + title: { + default: "Next.js for Drupal", + template: "%s | Next.js for Drupal", + }, + description: "A Next.js site powered by a Drupal backend.", + icons: { + icon: "/favicon.ico", + }, +} + +export default function RootLayout({ + // Layouts must accept a children prop. + // This will be populated with nested layouts or pages + children, +}: { + children: ReactNode +}) { + return ( + + + +
+ +
{children}
+
+ + + ) +} diff --git a/starters/basic-starter/pages/index.tsx b/starters/basic-starter/app/page.tsx similarity index 50% rename from starters/basic-starter/pages/index.tsx rename to starters/basic-starter/app/page.tsx index e5963b98..01aaac5a 100644 --- a/starters/basic-starter/pages/index.tsx +++ b/starters/basic-starter/app/page.tsx @@ -1,14 +1,15 @@ -import Head from "next/head" import { ArticleTeaser } from "@/components/drupal/ArticleTeaser" -import { Layout } from "@/components/Layout" import { drupal } from "@/lib/drupal" -import type { InferGetStaticPropsType, GetStaticProps } from "next" +import type { Metadata } from "next" import type { DrupalNode } from "next-drupal" -export const getStaticProps = (async (context) => { - const nodes = await drupal.getResourceCollectionFromContext( +export const metadata: Metadata = { + description: "A Next.js site powered by a Drupal backend.", +} + +export default async function Home() { + const nodes = await drupal.getResourceCollection( "node--article", - context, { params: { "filter[status]": 1, @@ -19,28 +20,8 @@ export const getStaticProps = (async (context) => { } ) - return { - props: { - nodes, - }, - } -}) satisfies GetStaticProps<{ - nodes: DrupalNode[] -}> - -export default function Home({ - nodes, -}: InferGetStaticPropsType) { return ( - - - Next.js for Drupal - - + <>

Latest Articles.

{nodes?.length ? ( nodes.map((node) => ( @@ -52,6 +33,6 @@ export default function Home({ ) : (

No nodes found

)} -
+ ) } diff --git a/starters/basic-starter/components/Layout.tsx b/starters/basic-starter/components/Layout.tsx deleted file mode 100644 index cb1f4012..00000000 --- a/starters/basic-starter/components/Layout.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { HeaderNav } from "@/components/navigation/HeaderNav" -import { PreviewAlert } from "@/components/misc/PreviewAlert" -import type { ReactNode } from "react" - -export function Layout({ children }: { children: ReactNode }) { - return ( - <> - -
- -
{children}
-
- - ) -} diff --git a/starters/basic-starter/components/misc/DraftAlert/Client.tsx b/starters/basic-starter/components/misc/DraftAlert/Client.tsx new file mode 100644 index 00000000..00932ae4 --- /dev/null +++ b/starters/basic-starter/components/misc/DraftAlert/Client.tsx @@ -0,0 +1,38 @@ +"use client" + +import { useEffect, useState } from "react" + +export function DraftAlertClient({ + isDraftEnabled, +}: { + isDraftEnabled: boolean +}) { + const [showDraftAlert, setShowDraftAlert] = useState(false) + + useEffect(() => { + setShowDraftAlert(isDraftEnabled && window.top === window.self) + }, [isDraftEnabled]) + + if (!showDraftAlert) { + return null + } + + function buttonHandler() { + void fetch("/api/disable-draft") + setShowDraftAlert(false) + } + + return ( +
+

+ This page is a draft. + +

+
+ ) +} diff --git a/starters/basic-starter/components/misc/DraftAlert/index.tsx b/starters/basic-starter/components/misc/DraftAlert/index.tsx new file mode 100644 index 00000000..a07f0d67 --- /dev/null +++ b/starters/basic-starter/components/misc/DraftAlert/index.tsx @@ -0,0 +1,13 @@ +import { Suspense } from "react" +import { draftMode } from "next/headers" +import { DraftAlertClient } from "./Client" + +export function DraftAlert() { + const isDraftEnabled = draftMode().isEnabled + + return ( + + + + ) +} diff --git a/starters/basic-starter/components/misc/PreviewAlert.tsx b/starters/basic-starter/components/misc/PreviewAlert.tsx deleted file mode 100644 index abca6a68..00000000 --- a/starters/basic-starter/components/misc/PreviewAlert.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { useEffect, useState } from "react" -import { useRouter } from "next/router" - -export function PreviewAlert() { - const router = useRouter() - const isPreview = router.isPreview - const [showPreviewAlert, setShowPreviewAlert] = useState(false) - - useEffect(() => { - setShowPreviewAlert(isPreview && window.top === window.self) - }, [isPreview]) - - if (!showPreviewAlert) { - return null - } - - return ( -
-

- This page is a preview.{" "} - -

-
- ) -} diff --git a/starters/basic-starter/lib/drupal.ts b/starters/basic-starter/lib/drupal.ts index 3c23e79e..cda70b32 100644 --- a/starters/basic-starter/lib/drupal.ts +++ b/starters/basic-starter/lib/drupal.ts @@ -1,10 +1,10 @@ -import { DrupalClient } from "next-drupal" +import { NextDrupal } from "next-drupal" const baseUrl = process.env.NEXT_PUBLIC_DRUPAL_BASE_URL as string const clientId = process.env.DRUPAL_CLIENT_ID as string const clientSecret = process.env.DRUPAL_CLIENT_SECRET as string -export const drupal = new DrupalClient(baseUrl, { +export const drupal = new NextDrupal(baseUrl, { auth: { clientId, clientSecret, diff --git a/starters/basic-starter/pages/[...slug].tsx b/starters/basic-starter/pages/[...slug].tsx deleted file mode 100644 index 9894d5f0..00000000 --- a/starters/basic-starter/pages/[...slug].tsx +++ /dev/null @@ -1,92 +0,0 @@ -import Head from "next/head" -import { Article } from "@/components/drupal/Article" -import { BasicPage } from "@/components/drupal/BasicPage" -import { Layout } from "@/components/Layout" -import { drupal } from "@/lib/drupal" -import type { - GetStaticPaths, - GetStaticProps, - InferGetStaticPropsType, -} from "next" -import type { DrupalNode } from "next-drupal" - -const RESOURCE_TYPES = ["node--page", "node--article"] - -export const getStaticPaths = (async (context) => { - return { - paths: await drupal.getStaticPathsFromContext(RESOURCE_TYPES, context), - fallback: "blocking", - } -}) satisfies GetStaticPaths - -export const getStaticProps = (async (context) => { - const path = await drupal.translatePathFromContext(context) - - if (!path) { - return { - notFound: true, - } - } - - const type = path?.jsonapi?.resourceName - - let params = {} - if (type === "node--article") { - params = { - include: "field_image,uid", - } - } - - const resource = await drupal.getResourceFromContext( - path, - context, - { - params, - } - ) - - // At this point, we know the path exists and it points to a resource. - // If we receive an error, it means something went wrong on Drupal. - // We throw an error to tell revalidation to skip this for now. - // Revalidation can try again on next request. - if (!resource) { - throw new Error(`Failed to fetch resource: ${path?.jsonapi?.individual}`) - } - - // If we're not in preview mode and the resource is not published, - // Return page not found. - if (!context.preview && resource?.status === false) { - return { - notFound: true, - } - } - - return { - props: { - resource, - }, - } -}) satisfies GetStaticProps<{ - resource: DrupalNode -}> - -export default function NodePage({ - resource, -}: InferGetStaticPropsType) { - if (!resource) return null - - return ( - - - {resource.title} - - - {resource.type === "node--page" && } - {resource.type === "node--article" &&
} - - ) -} diff --git a/starters/basic-starter/pages/_app.tsx b/starters/basic-starter/pages/_app.tsx deleted file mode 100644 index 70739e9b..00000000 --- a/starters/basic-starter/pages/_app.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import "@/styles/globals.css" -import type { AppProps } from "next/app" - -export default function App({ Component, pageProps }: AppProps) { - return -} diff --git a/starters/basic-starter/pages/_document.tsx b/starters/basic-starter/pages/_document.tsx deleted file mode 100644 index 097cb7ff..00000000 --- a/starters/basic-starter/pages/_document.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { Html, Head, Main, NextScript } from "next/document" - -export default function Document() { - return ( - - - -
- - - - ) -} diff --git a/starters/basic-starter/pages/api/exit-preview.ts b/starters/basic-starter/pages/api/exit-preview.ts deleted file mode 100644 index f8847b39..00000000 --- a/starters/basic-starter/pages/api/exit-preview.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { drupal } from "@/lib/drupal" -import type { NextApiRequest, NextApiResponse } from "next" - -export default async function exit( - request: NextApiRequest, - response: NextApiResponse -) { - await drupal.previewDisable(request, response) -} diff --git a/starters/basic-starter/pages/api/preview.ts b/starters/basic-starter/pages/api/preview.ts deleted file mode 100644 index a0733440..00000000 --- a/starters/basic-starter/pages/api/preview.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { drupal } from "@/lib/drupal" -import type { NextApiRequest, NextApiResponse } from "next" - -export default async function draft( - request: NextApiRequest, - response: NextApiResponse -) { - // Enables Preview mode and Draft mode. - await drupal.preview(request, response, { enable: true }) -} diff --git a/starters/basic-starter/pages/api/revalidate.ts b/starters/basic-starter/pages/api/revalidate.ts deleted file mode 100644 index 368a16c8..00000000 --- a/starters/basic-starter/pages/api/revalidate.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { NextApiRequest, NextApiResponse } from "next" - -export default async function handler( - request: NextApiRequest, - response: NextApiResponse -) { - let path = request.query.path as string - const secret = request.query.secret as string - - // Validate secret. - if (secret !== process.env.DRUPAL_REVALIDATE_SECRET) { - return response.status(401).json({ message: "Invalid secret." }) - } - - // Validate path. - if (!path) { - return response.status(400).json({ message: "Invalid path." }) - } - - try { - await response.revalidate(path) - - return response.json({}) - } catch (error) { - return response.status(404).json({ - message: (error as Error).message, - }) - } -} diff --git a/starters/graphql-starter/README.md b/starters/graphql-starter/README.md index 37621e7a..1e754465 100644 --- a/starters/graphql-starter/README.md +++ b/starters/graphql-starter/README.md @@ -6,6 +6,10 @@ A next-drupal starter for building your site with Next.js and GraphQL. `npx create-next-app -e https://github.com/chapter-three/next-drupal-graphql-starter` +## Deploy to Vercel + +[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fchapter-three%2Fnext-drupal-graphql-starter&env=NEXT_PUBLIC_DRUPAL_BASE_URL,NEXT_IMAGE_DOMAIN,DRUPAL_CLIENT_ID,DRUPAL_CLIENT_SECRET&envDescription=Learn%20more%20about%20environment%20variables&envLink=https%3A%2F%2Fnext-drupal.org%2Fdocs%2Fenvironment-variables&project-name=next-drupal&demo-title=Next.js%20for%20Drupal&demo-description=A%20next-generation%20front-end%20for%20your%20Drupal%20site.&demo-url=https%3A%2F%2Fdemo.next-drupal.org&demo-image=https%3A%2F%2Fnext-drupal.org%2Fimages%2Fdemo-screenshot.jpg) + ## Documentation See https://next-drupal.org diff --git a/starters/graphql-starter/pages/[...slug].tsx b/starters/graphql-starter/app/[...slug]/page.tsx similarity index 51% rename from starters/graphql-starter/pages/[...slug].tsx rename to starters/graphql-starter/app/[...slug]/page.tsx index 4ca04106..9fb1921b 100644 --- a/starters/graphql-starter/pages/[...slug].tsx +++ b/starters/graphql-starter/app/[...slug]/page.tsx @@ -1,54 +1,14 @@ -import Head from "next/head" +import { draftMode } from "next/headers" +import { notFound } from "next/navigation" +import { getDraftData } from "next-drupal/draft" import { Article } from "@/components/drupal/Article" import { BasicPage } from "@/components/drupal/BasicPage" -import { Layout } from "@/components/Layout" import { drupal } from "@/lib/drupal" -import type { - GetStaticPaths, - GetStaticProps, - InferGetStaticPropsType, -} from "next" +import type { Metadata, ResolvingMetadata } from "next" import type { DrupalArticle, DrupalPage, NodesPath } from "@/types" -export const getStaticPaths = (async (context) => { - // Fetch the paths for the first 50 articles and pages. - // We'll fall back to on-demand generation for the rest. - const data = await drupal.query<{ - nodeArticles: NodesPath - nodePages: NodesPath - }>({ - query: `query { - nodeArticles(first: 50) { - nodes { - path, - } - } - nodePages(first: 50) { - nodes { - path, - } - } - }`, - }) - - // Build static paths. - const paths = [ - ...(data?.nodeArticles?.nodes as { path: string }[]), - ...(data?.nodePages?.nodes as { path: string }[]), - ].map(({ path }) => ({ params: { slug: path.split("/").filter(Boolean) } })) - - return { - paths, - fallback: "blocking", - } -}) satisfies GetStaticPaths - -export const getStaticProps = (async (context) => { - if (!context?.params?.slug) { - return { - notFound: true, - } - } +async function getNode(slug: string[]) { + const path = `/${slug.join("/")}` const data = await drupal.query<{ route: { entity: DrupalArticle | DrupalPage } @@ -92,46 +52,96 @@ export const getStaticProps = (async (context) => { } }`, variables: { - path: `/${(context.params.slug as []).join("/")}`, + path, }, }) const resource = data?.route?.entity - // If we're not in preview mode and the resource is not published, - // Return page not found. - if (!resource || (!context.preview && resource?.status === false)) { - return { - notFound: true, - } + if (!resource) { + throw new Error(`Failed to fetch resource: ${path}`, { + cause: "DrupalError", + }) + } + + return resource +} + +type NodePageParams = { + slug: string[] +} +type NodePageProps = { + params: NodePageParams + searchParams: { [key: string]: string | string[] | undefined } +} + +export async function generateMetadata( + { params: { slug } }: NodePageProps, + parent: ResolvingMetadata +): Promise { + let node + try { + node = await getNode(slug) + } catch (e) { + // If we fail to fetch the node, don't return any metadata. + return {} } return { - props: { - resource, - }, + title: node.title, + } +} + +export async function generateStaticParams(): Promise { + // Fetch the paths for the first 50 articles and pages. + // We'll fall back to on-demand generation for the rest. + const data = await drupal.query<{ + nodeArticles: NodesPath + nodePages: NodesPath + }>({ + query: `query { + nodeArticles(first: 50) { + nodes { + path, + } + } + nodePages(first: 50) { + nodes { + path, + } + } + }`, + }) + + return [ + ...(data?.nodeArticles?.nodes as { path: string }[]), + ...(data?.nodePages?.nodes as { path: string }[]), + ].map(({ path }) => ({ slug: path.split("/").filter(Boolean) })) +} + +export default async function Page({ + params: { slug }, + searchParams, +}: NodePageProps) { + const isDraftMode = draftMode().isEnabled + + let node + try { + node = await getNode(slug) + } catch (error) { + // If getNode throws an error, tell Next.js the path is 404. + notFound() } -}) satisfies GetStaticProps<{ - resource: DrupalArticle | DrupalPage -}> -export default function Page({ - resource, -}: InferGetStaticPropsType) { - if (!resource) return null + // If we're not in draft mode and the resource is not published, return a 404. + if (!isDraftMode && node?.status === false) { + notFound() + } return ( - - - {resource.title} - - - {resource.__typename === "NodePage" && } - {resource.__typename === "NodeArticle" &&
} - + <> + {node.__typename === "NodePage" && } + {node.__typename === "NodeArticle" &&
} + ) } diff --git a/starters/graphql-starter/app/api/disable-draft/route.ts b/starters/graphql-starter/app/api/disable-draft/route.ts new file mode 100644 index 00000000..81900948 --- /dev/null +++ b/starters/graphql-starter/app/api/disable-draft/route.ts @@ -0,0 +1,6 @@ +import { disableDraftMode } from "next-drupal/draft" +import type { NextRequest } from "next/server" + +export async function GET(request: NextRequest) { + return disableDraftMode() +} diff --git a/starters/graphql-starter/app/api/draft/route.ts b/starters/graphql-starter/app/api/draft/route.ts new file mode 100644 index 00000000..b8757e2a --- /dev/null +++ b/starters/graphql-starter/app/api/draft/route.ts @@ -0,0 +1,7 @@ +import { drupal } from "@/lib/drupal" +import { enableDraftMode } from "next-drupal/draft" +import type { NextRequest } from "next/server" + +export async function GET(request: NextRequest): Promise { + return enableDraftMode(request, drupal) +} diff --git a/starters/graphql-starter/app/api/revalidate/route.ts b/starters/graphql-starter/app/api/revalidate/route.ts new file mode 100644 index 00000000..9882273a --- /dev/null +++ b/starters/graphql-starter/app/api/revalidate/route.ts @@ -0,0 +1,28 @@ +import { revalidatePath } from "next/cache" +import type { NextRequest } from "next/server" + +async function handler(request: NextRequest) { + const searchParams = request.nextUrl.searchParams + const path = searchParams.get("path") + const secret = searchParams.get("secret") + + // Validate secret. + if (secret !== process.env.DRUPAL_REVALIDATE_SECRET) { + return new Response("Invalid secret.", { status: 401 }) + } + + // Validate path. + if (!path) { + return new Response("Invalid path.", { status: 400 }) + } + + try { + revalidatePath(path) + + return new Response("Revalidated.") + } catch (error) { + return new Response((error as Error).message, { status: 500 }) + } +} + +export { handler as GET, handler as POST } diff --git a/starters/graphql-starter/app/layout.tsx b/starters/graphql-starter/app/layout.tsx new file mode 100644 index 00000000..3b3c9652 --- /dev/null +++ b/starters/graphql-starter/app/layout.tsx @@ -0,0 +1,37 @@ +import { DraftAlert } from "@/components/misc/DraftAlert" +import { HeaderNav } from "@/components/navigation/HeaderNav" +import type { Metadata } from "next" +import type { ReactNode } from "react" + +import "@/styles/globals.css" + +export const metadata: Metadata = { + title: { + default: "Next.js for Drupal", + template: "%s | Next.js for Drupal", + }, + description: "A Next.js site powered by a Drupal backend.", + icons: { + icon: "/favicon.ico", + }, +} + +export default function RootLayout({ + // Layouts must accept a children prop. + // This will be populated with nested layouts or pages + children, +}: { + children: ReactNode +}) { + return ( + + + +
+ +
{children}
+
+ + + ) +} diff --git a/starters/graphql-starter/pages/index.tsx b/starters/graphql-starter/app/page.tsx similarity index 61% rename from starters/graphql-starter/pages/index.tsx rename to starters/graphql-starter/app/page.tsx index 14544955..1ea902b9 100644 --- a/starters/graphql-starter/pages/index.tsx +++ b/starters/graphql-starter/app/page.tsx @@ -1,11 +1,13 @@ -import Head from "next/head" import { ArticleTeaser } from "@/components/drupal/ArticleTeaser" -import { Layout } from "@/components/Layout" import { drupal } from "@/lib/drupal" -import type { InferGetStaticPropsType, GetStaticProps } from "next" +import type { Metadata } from "next" import type { DrupalArticle } from "@/types" -export const getStaticProps = (async (context) => { +export const metadata: Metadata = { + description: "A Next.js site powered by a Drupal backend.", +} + +export default async function Home() { // Fetch the first 10 articles. const data = await drupal.query<{ nodeArticles: { @@ -38,29 +40,10 @@ export const getStaticProps = (async (context) => { } `, }) + const nodes = data?.nodeArticles?.nodes ?? [] - return { - props: { - nodes: data?.nodeArticles?.nodes ?? [], - }, - } -}) satisfies GetStaticProps<{ - nodes: DrupalArticle[] -}> - -export default function Home({ - nodes, -}: InferGetStaticPropsType) { return ( - - - Next.js for Drupal - - + <>

Latest Articles.

{nodes?.length ? ( nodes.map((node) => ( @@ -72,6 +55,6 @@ export default function Home({ ) : (

No nodes found

)} -
+ ) } diff --git a/starters/graphql-starter/components/Layout.tsx b/starters/graphql-starter/components/Layout.tsx deleted file mode 100644 index cb1f4012..00000000 --- a/starters/graphql-starter/components/Layout.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { HeaderNav } from "@/components/navigation/HeaderNav" -import { PreviewAlert } from "@/components/misc/PreviewAlert" -import type { ReactNode } from "react" - -export function Layout({ children }: { children: ReactNode }) { - return ( - <> - -
- -
{children}
-
- - ) -} diff --git a/starters/graphql-starter/components/misc/DraftAlert/Client.tsx b/starters/graphql-starter/components/misc/DraftAlert/Client.tsx new file mode 100644 index 00000000..00932ae4 --- /dev/null +++ b/starters/graphql-starter/components/misc/DraftAlert/Client.tsx @@ -0,0 +1,38 @@ +"use client" + +import { useEffect, useState } from "react" + +export function DraftAlertClient({ + isDraftEnabled, +}: { + isDraftEnabled: boolean +}) { + const [showDraftAlert, setShowDraftAlert] = useState(false) + + useEffect(() => { + setShowDraftAlert(isDraftEnabled && window.top === window.self) + }, [isDraftEnabled]) + + if (!showDraftAlert) { + return null + } + + function buttonHandler() { + void fetch("/api/disable-draft") + setShowDraftAlert(false) + } + + return ( +
+

+ This page is a draft. + +

+
+ ) +} diff --git a/starters/graphql-starter/components/misc/DraftAlert/index.tsx b/starters/graphql-starter/components/misc/DraftAlert/index.tsx new file mode 100644 index 00000000..a07f0d67 --- /dev/null +++ b/starters/graphql-starter/components/misc/DraftAlert/index.tsx @@ -0,0 +1,13 @@ +import { Suspense } from "react" +import { draftMode } from "next/headers" +import { DraftAlertClient } from "./Client" + +export function DraftAlert() { + const isDraftEnabled = draftMode().isEnabled + + return ( + + + + ) +} diff --git a/starters/graphql-starter/components/misc/PreviewAlert.tsx b/starters/graphql-starter/components/misc/PreviewAlert.tsx deleted file mode 100644 index abca6a68..00000000 --- a/starters/graphql-starter/components/misc/PreviewAlert.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { useEffect, useState } from "react" -import { useRouter } from "next/router" - -export function PreviewAlert() { - const router = useRouter() - const isPreview = router.isPreview - const [showPreviewAlert, setShowPreviewAlert] = useState(false) - - useEffect(() => { - setShowPreviewAlert(isPreview && window.top === window.self) - }, [isPreview]) - - if (!showPreviewAlert) { - return null - } - - return ( -
-

- This page is a preview.{" "} - -

-
- ) -} diff --git a/starters/graphql-starter/lib/next-drupal-graphql.ts b/starters/graphql-starter/lib/next-drupal-graphql.ts index 2e06c914..358ef889 100644 --- a/starters/graphql-starter/lib/next-drupal-graphql.ts +++ b/starters/graphql-starter/lib/next-drupal-graphql.ts @@ -1,11 +1,12 @@ -// This is an example GraphQL implementation using DrupalClient. +// This is an example GraphQL implementation using NextDrupalFetch, a +// lower-level class that contains helper methods and no JSON:API methods. -import { DrupalClient } from "next-drupal" +import { NextDrupalFetch } from "next-drupal" import type { BaseUrl, NextDrupalFetchOptions } from "next-drupal" const DEFAULT_API_PREFIX = "/graphql" -export class NextDrupalGraphQL extends DrupalClient { +export class NextDrupalGraphQL extends NextDrupalFetch { endpoint: string constructor(baseUrl: BaseUrl, options: NextDrupalFetchOptions = {}) { @@ -23,12 +24,6 @@ export class NextDrupalGraphQL extends DrupalClient { method: "POST", body: JSON.stringify(payload), withAuth: true, // Make authenticated requests using OAuth. - // TODO: Remove headers when switching from extending DrupalClient to - // NextDrupalFetch, since they will be redundant. - headers: { - "Content-Type": "application/json", - Accept: "application/json", - }, }) if (!response?.ok) { diff --git a/starters/graphql-starter/pages/_app.tsx b/starters/graphql-starter/pages/_app.tsx deleted file mode 100644 index 70739e9b..00000000 --- a/starters/graphql-starter/pages/_app.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import "@/styles/globals.css" -import type { AppProps } from "next/app" - -export default function App({ Component, pageProps }: AppProps) { - return -} diff --git a/starters/graphql-starter/pages/_document.tsx b/starters/graphql-starter/pages/_document.tsx deleted file mode 100644 index 097cb7ff..00000000 --- a/starters/graphql-starter/pages/_document.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { Html, Head, Main, NextScript } from "next/document" - -export default function Document() { - return ( - - - -
- - - - ) -} diff --git a/starters/graphql-starter/pages/api/exit-preview.ts b/starters/graphql-starter/pages/api/exit-preview.ts deleted file mode 100644 index f8847b39..00000000 --- a/starters/graphql-starter/pages/api/exit-preview.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { drupal } from "@/lib/drupal" -import type { NextApiRequest, NextApiResponse } from "next" - -export default async function exit( - request: NextApiRequest, - response: NextApiResponse -) { - await drupal.previewDisable(request, response) -} diff --git a/starters/graphql-starter/pages/api/preview.ts b/starters/graphql-starter/pages/api/preview.ts deleted file mode 100644 index a0733440..00000000 --- a/starters/graphql-starter/pages/api/preview.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { drupal } from "@/lib/drupal" -import type { NextApiRequest, NextApiResponse } from "next" - -export default async function draft( - request: NextApiRequest, - response: NextApiResponse -) { - // Enables Preview mode and Draft mode. - await drupal.preview(request, response, { enable: true }) -} diff --git a/starters/graphql-starter/pages/api/revalidate.ts b/starters/graphql-starter/pages/api/revalidate.ts deleted file mode 100644 index 368a16c8..00000000 --- a/starters/graphql-starter/pages/api/revalidate.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { NextApiRequest, NextApiResponse } from "next" - -export default async function handler( - request: NextApiRequest, - response: NextApiResponse -) { - let path = request.query.path as string - const secret = request.query.secret as string - - // Validate secret. - if (secret !== process.env.DRUPAL_REVALIDATE_SECRET) { - return response.status(401).json({ message: "Invalid secret." }) - } - - // Validate path. - if (!path) { - return response.status(400).json({ message: "Invalid path." }) - } - - try { - await response.revalidate(path) - - return response.json({}) - } catch (error) { - return response.status(404).json({ - message: (error as Error).message, - }) - } -}