diff --git a/templates/developer-portfolio/.env.example b/templates/developer-portfolio/.env.example index 1bc9117a31..8cafc26bfa 100644 --- a/templates/developer-portfolio/.env.example +++ b/templates/developer-portfolio/.env.example @@ -2,6 +2,11 @@ MONGODB_URI=mongodb://127.0.0.1/payload-example-custom-server PAYLOAD_SECRET=PAYLOAD_CUSTOM_SERVER_EXAMPLE_SECRET_KEY PAYLOAD_PUBLIC_SERVER_URL=http://localhost:3000 PAYLOAD_PUBLIC_DRAFT_SECRET=EXAMPLE_DRAFT_SECRET -COOKIE_DOMAIN=localhost REVALIDATION_KEY=EXAMPLE_REVALIDATION_KEY -ENABLE_PAYLOAD_CLOUD=false +COOKIE_DOMAIN=localhost + +# Next.js vars +NEXT_PUBLIC_SERVER_URL=http://localhost:3000 +NEXT_PUBLIC_IS_LIVE= +NEXT_PRIVATE_DRAFT_SECRET=EXAMPLE_DRAFT_SECRET +NEXT_PRIVATE_REVALIDATION_KEY=EXAMPLE_REVALIDATION_KEY diff --git a/templates/developer-portfolio/next.config.js b/templates/developer-portfolio/next.config.js index ff5bd3af77..c75e8f400f 100644 --- a/templates/developer-portfolio/next.config.js +++ b/templates/developer-portfolio/next.config.js @@ -1,18 +1,11 @@ require('dotenv').config() -const imageDomains = ['localhost'] -if (process.env.PAYLOAD_PUBLIC_SERVER_URL) { - const { hostname } = new URL(process.env.PAYLOAD_PUBLIC_SERVER_URL) - imageDomains.push(hostname) -} - -// eslint-disable-next-line no-console -console.log('allowed image domains:', imageDomains.join(', ')) - module.exports = { reactStrictMode: true, swcMinify: true, images: { - domains: imageDomains, + domains: ['localhost', process.env.NEXT_PUBLIC_SERVER_URL] + .filter(Boolean) + .map(url => url.replace(/https?:\/\//, '')), }, } diff --git a/templates/developer-portfolio/src/app/[slug]/page.tsx b/templates/developer-portfolio/src/app/[slug]/page.tsx index f8748c390b..7fd308430a 100644 --- a/templates/developer-portfolio/src/app/[slug]/page.tsx +++ b/templates/developer-portfolio/src/app/[slug]/page.tsx @@ -1,11 +1,20 @@ import { Metadata, ResolvingMetadata } from 'next' import { notFound } from 'next/navigation' -import { Media } from '../../payload/payload-types' +import { Media, Page } from '../../payload/payload-types' +import { staticHome } from '../../payload/seed/home-static' import { ContentLayout } from '../_components/content/contentLayout' -import { fetchPage } from '../_utils/api' +import { fetchPage, fetchPages } from '../_utils/api' import { parsePreviewOptions } from '../_utils/preview' +// Payload Cloud caches all files through Cloudflare, so we don't need Next.js to cache them as well +// This means that we can turn off Next.js data caching and instead rely solely on the Cloudflare CDN +// To do this, we include the `no-cache` header on the fetch requests used to get the data for this page +// But we also need to force Next.js to dynamically render this page on each request for preview mode to work +// See https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config#dynamic +// If you are not using Payload Cloud then this line can be removed, see `../../../README.md#cache` +export const dynamic = 'force-dynamic' + interface LandingPageProps { params: { slug: string @@ -13,21 +22,75 @@ interface LandingPageProps { searchParams: Record } +export default async function LandingPage({ params, searchParams }: LandingPageProps) { + const { slug } = params + const options = parsePreviewOptions(searchParams) + + let page: Page | null = null + + try { + page = await fetchPage(slug, options) + } catch (error) { + // when deploying this template on Payload Cloud, this page needs to build before the APIs are live + // so swallow the error here and simply render the page with fallback data where necessary + // in production you may want to redirect to a 404 page or at least log the error somewhere + // console.error(error) + } + + // if no `home` page exists, render a static one using dummy content + // you should delete this code once you have a home page in the CMS + // this is really only useful for those who are demoing this template + if (!page) { + page = staticHome + } + + if (!page) { + return notFound() + } + + return +} + +export async function generateStaticParams() { + try { + const pages = await fetchPages() + return pages.map(({ slug }) => ({ params: { slug } })) + } catch (error) { + return [] + } +} + export async function generateMetadata( - { searchParams, params }: LandingPageProps, + { params, searchParams }: LandingPageProps, parent?: ResolvingMetadata, ): Promise { - const defaultTitle = (await parent)?.title?.absolute + const { slug } = params const options = parsePreviewOptions(searchParams) - const page = await fetchPage(params.slug, options) + let page: Page | null = null + + try { + page = await fetchPage(slug, options) + } catch (error) { + // don't throw an error if the fetch fails + // this is so that we can render a static home page for the demo + // when deploying this template on Payload Cloud, this page needs to build before the APIs are live + // in production you may want to redirect to a 404 page or at least log the error somewhere + } + + const defaultTitle = (await parent)?.title?.absolute const title = page?.meta?.title || defaultTitle const description = page?.meta?.description || 'A portfolio of work by a digital professional.' const images = [] + if (page?.meta?.image) { images.push((page.meta.image as Media).url) } + if (!page) { + page = staticHome + } + return { title, description, @@ -38,17 +101,3 @@ export async function generateMetadata( }, } } - -const LandingPage = async ({ params, searchParams }: LandingPageProps) => { - const { slug } = params - const options = parsePreviewOptions(searchParams) - const page = await fetchPage(slug, options) - - if (!page?.layout) { - notFound() - } - - return -} - -export default LandingPage diff --git a/templates/developer-portfolio/src/app/_components/content/formBlock.tsx b/templates/developer-portfolio/src/app/_components/content/formBlock.tsx index 3e708cca5f..08aec2f0e0 100644 --- a/templates/developer-portfolio/src/app/_components/content/formBlock.tsx +++ b/templates/developer-portfolio/src/app/_components/content/formBlock.tsx @@ -4,7 +4,6 @@ import { useForm } from 'react-hook-form' import { Data } from 'payload/dist/admin/components/forms/Form/types' import { Form as FormTypes } from '../../../payload/payload-types' -import { serverUrl } from '../../_utils/api' import { Block } from '../ui/block' import { Button } from '../ui/button' import { Dialog, DialogContent } from '../ui/dialog' @@ -92,7 +91,7 @@ export const FormBlock: FC = props => { })) try { - const req = await fetch(`${serverUrl}/api/form-submissions`, { + const req = await fetch(`${process.env.PAYLOAD_PUBLIC_SERVER_URL}/api/form-submissions`, { method: 'POST', headers: { 'Content-Type': 'application/json', // Correct typo: "Content-Types" to "Content-Type" diff --git a/templates/developer-portfolio/src/app/_utils/api.ts b/templates/developer-portfolio/src/app/_utils/api.ts index 27c683802f..d2945921fa 100644 --- a/templates/developer-portfolio/src/app/_utils/api.ts +++ b/templates/developer-portfolio/src/app/_utils/api.ts @@ -1,8 +1,6 @@ import type { Header, Page, Profile, Project } from '../../payload/payload-types' import type { DraftOptions } from './preview' -export const serverUrl = process.env.PAYLOAD_PUBLIC_SERVER_URL ?? 'http://localhost:3000' - const initPreviewRequest = (init: RequestInit, qs: URLSearchParams, token: string): void => { if (!token) { throw new Error('No token provided when attempting to preview content') @@ -16,12 +14,15 @@ const initPreviewRequest = (init: RequestInit, qs: URLSearchParams, token: strin } export const fetchProfile = async (): Promise => { - const url = `${serverUrl}/api/globals/profile?locale=en` + if (!process.env.PAYLOAD_PUBLIC_SERVER_URL) throw new Error('PAYLOAD_PUBLIC_SERVER_URL not found') + + const url = `${process.env.PAYLOAD_PUBLIC_SERVER_URL}/api/globals/profile?locale=en` const profile: Profile = await fetch(url, { cache: 'force-cache', next: { tags: ['global.profile'] }, }).then(res => { + if (!res.ok) throw new Error('Error fetching profile doc') return res.json() }) @@ -29,11 +30,16 @@ export const fetchProfile = async (): Promise => { } export const fetchHeader = async (): Promise
=> { - const url = `${serverUrl}/api/globals/header?locale=en` + if (!process.env.PAYLOAD_PUBLIC_SERVER_URL) throw new Error('PAYLOAD_PUBLIC_SERVER_URL not found') + + const url = `${process.env.PAYLOAD_PUBLIC_SERVER_URL}/api/globals/header?locale=en` const header: Header = await fetch(url, { cache: 'force-cache', next: { tags: ['global.header'] }, - }).then(res => res.json()) + }).then(res => { + if (!res.ok) throw new Error('Error fetching header doc') + return res.json() + }) return header } @@ -44,15 +50,20 @@ export const fetchPage = async ( slug: string, options: FetchPageOptions, ): Promise => { + if (!process.env.PAYLOAD_PUBLIC_SERVER_URL) throw new Error('PAYLOAD_PUBLIC_SERVER_URL not found') + const qs = new URLSearchParams({ 'where[slug][equals]': slug }) const init: RequestInit = { next: { tags: [`pages/${slug}`] } } if (options.draft) { initPreviewRequest(init, qs, options.payloadToken) } - const url = `${serverUrl}/api/pages?${qs.toString()}` + const url = `${process.env.PAYLOAD_PUBLIC_SERVER_URL}/api/pages?${qs.toString()}` const page: Page = await fetch(url, init) - .then(res => res.json()) + .then(res => { + if (!res.ok) throw new Error('Error fetching page doc') + return res.json() + }) .then(res => res?.docs?.[0]) return page @@ -64,6 +75,8 @@ export const fetchProject = async ( slug: string, options: FetchProjectOptions, ): Promise => { + if (!process.env.PAYLOAD_PUBLIC_SERVER_URL) throw new Error('PAYLOAD_PUBLIC_SERVER_URL not found') + const qs = new URLSearchParams(`where[slug][equals]=${slug}`) const init: RequestInit = {} @@ -73,10 +86,56 @@ export const fetchProject = async ( init.next = { tags: [`projects/${slug}`] } } - const url = `${serverUrl}/api/projects?${qs.toString()}` + const url = `${process.env.PAYLOAD_PUBLIC_SERVER_URL}/api/projects?${qs.toString()}` const project: Project = await fetch(url, init) - .then(res => res.json()) + .then(res => { + if (!res.ok) throw new Error('Error fetching project doc') + return res.json() + }) .then(res => res?.docs?.[0]) return project } + +export const fetchPages = async (options?: FetchPageOptions): Promise => { + if (!process.env.PAYLOAD_PUBLIC_SERVER_URL) throw new Error('PAYLOAD_PUBLIC_SERVER_URL not found') + + const qs = new URLSearchParams() + + const init: RequestInit = { next: { tags: ['pages'] } } + if (options?.draft) { + initPreviewRequest(init, qs, options.payloadToken) + } + + const url = `${process.env.PAYLOAD_PUBLIC_SERVER_URL}/api/pages?${qs.toString()}` + const pages: Page[] = await fetch(url, init) + .then(res => { + if (!res.ok) throw new Error('Error fetching pages doc') + return res.json() + }) + .then(res => res?.docs || []) + + return pages +} + +export const fetchProjects = async (options?: FetchProjectOptions): Promise => { + if (!process.env.PAYLOAD_PUBLIC_SERVER_URL) throw new Error('PAYLOAD_PUBLIC_SERVER_URL not found') + + const init: RequestInit = {} + + if (options.draft) { + initPreviewRequest(init, undefined, options.payloadToken) + } else { + init.next = { tags: ['projects'] } + } + + const url = `${process.env.PAYLOAD_PUBLIC_SERVER_URL}/api/projects` + const projects: Project[] = await fetch(url, init) + .then(res => { + if (!res.ok) throw new Error('Error fetching projects doc') + return res.json() + }) + .then(res => res?.docs || []) + + return projects +} diff --git a/templates/developer-portfolio/src/app/layout.tsx b/templates/developer-portfolio/src/app/layout.tsx index 6e395a18d3..09b144ac4f 100644 --- a/templates/developer-portfolio/src/app/layout.tsx +++ b/templates/developer-portfolio/src/app/layout.tsx @@ -3,26 +3,36 @@ import { Inter } from 'next/font/google' const inter = Inter({ subsets: ['latin'] }) +import { Header, Profile } from '../payload/payload-types' import { Footer } from './_components/siteLayout/footer' import { NavBar } from './_components/siteLayout/navBar' import { Backdrop } from './_components/ui/backdrop/backdrop' import { ThemeProvider } from './_provider/themeProvider' -import { fetchHeader, fetchProfile, serverUrl } from './_utils/api' +import { fetchHeader, fetchProfile } from './_utils/api' import './globals.css' export async function generateMetadata() { - const profile = await fetchProfile() + let profile: Profile | null = null + + try { + profile = await fetchProfile() + } catch (error) {} return { - metadataBase: new URL(serverUrl), + metadataBase: new URL(process.env.NEXT_PUBLIC_SERVER_URL || 'https://payloadcms.com'), title: `Portfolio | ${profile.name}`, description: 'My professional portfolio featuring past projects and contact info.', } } export default async function RootLayout({ children }: { children: React.ReactNode }) { - const [profile, header] = await Promise.all([fetchProfile(), fetchHeader()]) + let profile: Profile | null = null + let header: Header | null = null + + try { + ;[profile, header] = await Promise.all([fetchProfile(), fetchHeader()]) + } catch (error) {} return ( diff --git a/templates/developer-portfolio/src/app/page.tsx b/templates/developer-portfolio/src/app/page.tsx index 51d456aab8..30adeacea3 100644 --- a/templates/developer-portfolio/src/app/page.tsx +++ b/templates/developer-portfolio/src/app/page.tsx @@ -1,30 +1,98 @@ import React from 'react' import { Metadata, ResolvingMetadata } from 'next' +import { notFound } from 'next/navigation' -import { Media } from '../payload/payload-types' +import { Media, Page, Profile } from '../payload/payload-types' +import { staticHome } from '../payload/seed/home-static' import { ContentLayout } from './_components/content/contentLayout' -import { fetchPage, fetchProfile } from './_utils/api' +import { fetchPage, fetchPages, fetchProfile } from './_utils/api' import { parsePreviewOptions } from './_utils/preview' +// Payload Cloud caches all files through Cloudflare, so we don't need Next.js to cache them as well +// This means that we can turn off Next.js data caching and instead rely solely on the Cloudflare CDN +// To do this, we include the `no-cache` header on the fetch requests used to get the data for this page +// But we also need to force Next.js to dynamically render this page on each request for preview mode to work +// See https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config#dynamic +// If you are not using Payload Cloud then this line can be removed, see `../../../README.md#cache` +export const dynamic = 'force-dynamic' + interface LandingPageProps { + params: { + slug: string + } searchParams: Record } +export default async function LandingPage({ params, searchParams }: LandingPageProps) { + const { slug } = params + const options = parsePreviewOptions(searchParams) + + let page: Page | null = null + let profile: Profile | null = null + + try { + ;[page, profile] = await Promise.all([fetchPage('home', options), fetchProfile()]) + } catch (error) { + // when deploying this template on Payload Cloud, this page needs to build before the APIs are live + // so swallow the error here and simply render the page with fallback data where necessary + // in production you may want to redirect to a 404 page or at least log the error somewhere + // console.error(error) + } + + // if no `home` page exists, render a static one using dummy content + // you should delete this code once you have a home page in the CMS + // this is really only useful for those who are demoing this template + if (!page && !profile && slug === 'home') { + page = staticHome + } + + if (!page) { + return notFound() + } + + return +} + +export async function generateStaticParams() { + try { + const pages = await fetchPages() + return pages.map(({ slug }) => ({ params: { slug } })) + } catch (error) { + return [] + } +} + export async function generateMetadata( - { searchParams }: LandingPageProps, + { params, searchParams }: LandingPageProps, parent?: ResolvingMetadata, ): Promise { - const defaultTitle = (await parent)?.title?.absolute + const { slug } = params const options = parsePreviewOptions(searchParams) - const page = await fetchPage('home', options) + let page: Page | null = null + + try { + page = await fetchPage('home', options) + } catch (error) { + // don't throw an error if the fetch fails + // this is so that we can render a static home page for the demo + // when deploying this template on Payload Cloud, this page needs to build before the APIs are live + // in production you may want to redirect to a 404 page or at least log the error somewhere + } + + const defaultTitle = (await parent)?.title?.absolute const title = page?.meta?.title || defaultTitle const description = page?.meta?.description || 'A portfolio of work by a digital professional.' const images = [] + if (page?.meta?.image) { images.push((page.meta.image as Media).url) } + if (!page && slug === 'home') { + page = staticHome + } + return { title, description, @@ -36,10 +104,3 @@ export async function generateMetadata( }, } } - -export default async function LandingPage({ searchParams }: LandingPageProps) { - const options = parsePreviewOptions(searchParams) - const [page, profile] = await Promise.all([fetchPage('home', options), fetchProfile()]) - - return -} diff --git a/templates/developer-portfolio/src/app/projects/[slug]/page.tsx b/templates/developer-portfolio/src/app/projects/[slug]/page.tsx index b0c8b836e4..a6d6eaa02b 100644 --- a/templates/developer-portfolio/src/app/projects/[slug]/page.tsx +++ b/templates/developer-portfolio/src/app/projects/[slug]/page.tsx @@ -1,11 +1,15 @@ import { Metadata, ResolvingMetadata } from 'next' import { notFound } from 'next/navigation' -import { Media } from '../../../payload/payload-types' +import { Media, Profile, Project } from '../../../payload/payload-types' import { ProjectDetails } from '../../_components/content/projectDetails/projectDetails' -import { fetchProfile, fetchProject } from '../../_utils/api' +import { fetchProfile, fetchProject, fetchProjects } from '../../_utils/api' import { parsePreviewOptions } from '../../_utils/preview' +// Force this page to be dynamic so that Next.js does not cache it +// See the note in '../../[slug]/page.tsx' about this +export const dynamic = 'force-dynamic' + interface ProjectPageProps { params: { slug: string @@ -13,14 +17,48 @@ interface ProjectPageProps { searchParams: Record } +export default async function ProjectPage({ params, searchParams }: ProjectPageProps) { + let project: Project | null = null + let profile: Profile | null = null + + try { + ;[project, profile] = await Promise.all([ + fetchProject(params.slug, parsePreviewOptions(searchParams)), + fetchProfile(), + ]) + } catch (error) { + console.error(error) // eslint-disable-line no-console + } + + if (!project) { + notFound() + } + + return +} + +export async function generateStaticParams() { + try { + const pages = await fetchProjects() + return pages.map(({ slug }) => ({ params: { slug } })) + } catch (error) { + return [] + } +} + export async function generateMetadata( { params, searchParams }: ProjectPageProps, parent?: ResolvingMetadata, ): Promise { - const [project, previousTitle] = await Promise.all([ - fetchProject(params.slug, parsePreviewOptions(searchParams)), - (await parent)?.title.absolute, - ]) + let project: Project | null = null + let previousTitle: string | null = null + + try { + ;[project, previousTitle] = await Promise.all([ + fetchProject(params.slug, parsePreviewOptions(searchParams)), + (await parent)?.title.absolute, + ]) + } catch (error) {} const images: string[] = [] if (project?.meta?.image) { @@ -44,16 +82,3 @@ export async function generateMetadata( }, } } - -export default async function ProjectPage({ params, searchParams }: ProjectPageProps) { - const [project, profile] = await Promise.all([ - fetchProject(params.slug, parsePreviewOptions(searchParams)), - fetchProfile(), - ]) - - if (!project) { - notFound() - } - - return -} diff --git a/templates/developer-portfolio/src/payload/seed/home-static.ts b/templates/developer-portfolio/src/payload/seed/home-static.ts new file mode 100644 index 0000000000..3840ed5773 --- /dev/null +++ b/templates/developer-portfolio/src/payload/seed/home-static.ts @@ -0,0 +1,14 @@ +import type { Page } from '../payload-types' + +export const staticHome: Page = { + id: '', + title: 'Profile Landing Page', + slug: 'home', + createdAt: '', + updatedAt: '', + meta: { + title: 'Payload Developer Portfolio Template', + description: 'An open-source developer portfolio built with Payload and Next.js.', + }, + layout: [], +}