From 2ac8645e1cb258524dd76b0770275dfa9e35429d Mon Sep 17 00:00:00 2001 From: denkristoffer Date: Wed, 4 Sep 2024 11:00:05 +0200 Subject: [PATCH 01/10] feat: app router --- .npmrc | 1 + codegen.ts | 5 +- config/plugins.js | 13 - next-i18next.config.js | 10 - next.config.js | 16 +- package.json | 33 +- src/app/[locale]/[slug]/page.tsx | 71 + src/app/[locale]/layout.tsx | 70 + src/app/[locale]/not-found.tsx | 20 + src/app/[locale]/page.tsx | 108 + src/app/api/enable-draft/route.ts | 184 + .../app/apple-icon.png | Bin {public/favicons => src/app}/favicon.ico | Bin src/{pages/utils => app}/globals.css | 0 .../favicon-16x16.png => src/app/icon.png | Bin .../favicon-32x32.png => src/app/icon2.png | Bin src/app/layout.tsx | 10 + .../app/manifest.webmanifest | 0 src/app/sitemap.ts | 38 + .../features/article/ArticleAuthor.tsx | 12 +- .../features/article/ArticleContent.tsx | 9 +- .../features/article/ArticleHero.tsx | 26 +- .../features/article/ArticleImage.tsx | 2 + .../features/article/ArticleTile.tsx | 21 +- .../contentful/CtfPreviewProvider.tsx | 12 + src/components/features/contentful/index.ts | 1 + .../language-selector/LanguageSelector.tsx | 9 +- .../LanguageSelectorDesktop.tsx | 53 +- .../LanguageSelectorMobile.tsx | 25 +- src/components/features/seo/SeoFields.tsx | 57 - src/components/features/seo/index.ts | 1 - .../shared/format-date/FormatDate.tsx | 12 +- .../shared/i18n/TranslationProvider.tsx | 27 + src/components/templates/footer/footer.tsx | 4 +- src/components/templates/footer/index.ts | 2 +- src/components/templates/header/header.tsx | 4 +- src/components/templates/header/index.ts | 2 +- src/components/templates/layout/index.ts | 1 - src/components/templates/layout/layout.tsx | 18 - src/i18n/config.ts | 7 + src/i18n/index.ts | 48 + src/lib/__generated/graphql.schema.graphql | 187 +- src/lib/__generated/graphql.schema.json | 1418 +++++- src/lib/__generated/sdk.ts | 279 +- src/middleware.ts | 12 + src/pages/404.page.tsx | 31 - src/pages/[slug].page.tsx | 113 - src/pages/_app.page.tsx | 32 - src/pages/_document.page.tsx | 23 - src/pages/api/disable-draft.page.tsx | 26 - src/pages/api/draft.page.tsx | 77 - src/pages/index.page.tsx | 88 - src/pages/sitemap.xml/index.page.tsx | 50 - src/pages/utils/constants.ts | 1 - .../utils/get-serverside-translations.ts | 9 - tsconfig.json | 85 +- types/lib.es5.d.ts | 25 - yarn.lock | 4464 +++++++++-------- 58 files changed, 4932 insertions(+), 2920 deletions(-) create mode 100644 .npmrc delete mode 100644 next-i18next.config.js create mode 100644 src/app/[locale]/[slug]/page.tsx create mode 100644 src/app/[locale]/layout.tsx create mode 100644 src/app/[locale]/not-found.tsx create mode 100644 src/app/[locale]/page.tsx create mode 100644 src/app/api/enable-draft/route.ts rename public/favicons/apple-touch-icon.png => src/app/apple-icon.png (100%) rename {public/favicons => src/app}/favicon.ico (100%) rename src/{pages/utils => app}/globals.css (100%) rename public/favicons/favicon-16x16.png => src/app/icon.png (100%) rename public/favicons/favicon-32x32.png => src/app/icon2.png (100%) create mode 100644 src/app/layout.tsx rename public/favicons/site.webmanifest => src/app/manifest.webmanifest (100%) create mode 100644 src/app/sitemap.ts create mode 100644 src/components/features/contentful/CtfPreviewProvider.tsx delete mode 100644 src/components/features/seo/SeoFields.tsx delete mode 100644 src/components/features/seo/index.ts create mode 100644 src/components/shared/i18n/TranslationProvider.tsx delete mode 100644 src/components/templates/layout/index.ts delete mode 100644 src/components/templates/layout/layout.tsx create mode 100644 src/i18n/config.ts create mode 100644 src/i18n/index.ts create mode 100644 src/middleware.ts delete mode 100644 src/pages/404.page.tsx delete mode 100644 src/pages/[slug].page.tsx delete mode 100644 src/pages/_app.page.tsx delete mode 100644 src/pages/_document.page.tsx delete mode 100644 src/pages/api/disable-draft.page.tsx delete mode 100644 src/pages/api/draft.page.tsx delete mode 100644 src/pages/index.page.tsx delete mode 100644 src/pages/sitemap.xml/index.page.tsx delete mode 100644 src/pages/utils/constants.ts delete mode 100644 src/pages/utils/get-serverside-translations.ts delete mode 100644 types/lib.es5.d.ts diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..a597049 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +@contentful:registry=https://registry.yarnpkg.com diff --git a/codegen.ts b/codegen.ts index 8bb1365..ac9329f 100644 --- a/codegen.ts +++ b/codegen.ts @@ -2,7 +2,10 @@ import { CodegenConfig } from '@graphql-codegen/cli'; const endpointOverride = process.env.CONTENTFUL_GRAPHQL_ENDPOINT; const productionEndpoint = 'https://graphql.contentful.com/content/v1/spaces'; -export const endpoint = `${endpointOverride || productionEndpoint}/${process.env.CONTENTFUL_SPACE_ID}`; +export const endpoint = `${endpointOverride || productionEndpoint}/${ + process.env.CONTENTFUL_SPACE_ID +}/environments/${process.env.CONTENTFUL_SPACE_ENVIRONMENT || 'master'}`; + export const config: CodegenConfig = { overwrite: true, ignoreNoDocuments: true, diff --git a/config/plugins.js b/config/plugins.js index 882aa81..f5f62d1 100644 --- a/config/plugins.js +++ b/config/plugins.js @@ -1,19 +1,6 @@ const withBundleAnalyzer = require('@next/bundle-analyzer'); -const withPWA = require('next-pwa'); module.exports = [ - [ - withPWA, - { - pwa: { - disable: process.env.NODE_ENV !== 'production', - dest: `public`, - register: false, - swSrc: './service-worker.js', - publicExcludes: ['!favicon/**/*'], - }, - }, - ], [ withBundleAnalyzer, { diff --git a/next-i18next.config.js b/next-i18next.config.js deleted file mode 100644 index a5174f8..0000000 --- a/next-i18next.config.js +++ /dev/null @@ -1,10 +0,0 @@ -const path = require('path'); - -module.exports = { - i18n: { - defaultLocale: 'en-US', - locales: ['en-US', 'de-DE'], - localeDetection: false, - localePath: path.resolve('./public/locales'), - }, -}; diff --git a/next.config.js b/next.config.js index c0e44f7..6939ea3 100644 --- a/next.config.js +++ b/next.config.js @@ -3,7 +3,6 @@ const nextComposePlugins = require('next-compose-plugins'); const headers = require('./config/headers'); const plugins = require('./config/plugins'); -const { i18n } = require('./next-i18next.config.js'); /** * https://github.com/cyrilwanner/next-compose-plugins/issues/59 @@ -15,7 +14,6 @@ const { withPlugins } = nextComposePlugins.extend(() => ({})); * documentation: https://nextjs.org/docs/api-reference/next.config.js/introduction */ module.exports = withPlugins(plugins, { - i18n, /** * add the environment variables you would like exposed to the client here * documentation: https://nextjs.org/docs/api-reference/next.config.js/environment-variables @@ -42,7 +40,6 @@ module.exports = withPlugins(plugins, { // swcMinify: true, poweredByHeader: false, - reactStrictMode: false, compress: true, /** @@ -57,11 +54,18 @@ module.exports = withPlugins(plugins, { * Settings are the defaults */ images: { - domains: ['images.ctfassets.net','images.eu.ctfassets.net'], + remotePatterns: [ + { + protocol: 'https', + hostname: 'images.ctfassets.net', + }, + { + protocol: 'https', + hostname: 'images.eu.ctfassets.net', + }, + ], }, - pageExtensions: ['page.tsx', 'page.ts', 'page.jsx', 'page.js'], - webpack(config) { config.module.rules.push({ test: /\.svg$/, diff --git a/package.json b/package.json index c1078c1..25f52fa 100644 --- a/package.json +++ b/package.json @@ -27,32 +27,34 @@ }, "license": "MIT", "dependencies": { - "@contentful/f36-icons": "^4.23.2", - "@contentful/f36-tokens": "^4.0.1", + "@contentful/f36-icons": "^4.29.0", + "@contentful/f36-tokens": "^4.0.5", "@contentful/live-preview": "^4.5.6", "@contentful/rich-text-react-renderer": "^15.16.2", - "@next/bundle-analyzer": "^13.0.4", + "@next/bundle-analyzer": "^14.2.6", "dotenv": "^16.0.3", "graphql": "^16.6.0", - "next": "^13.4.1", + "i18next-browser-languagedetector": "^8.0.0", + "i18next-resources-to-backend": "^1.2.1", + "next": "^14.2.6", "next-compose-plugins": "^2.2.1", - "next-i18next": "^12.1.0", - "next-pwa": "^5.6.0", - "next-seo": "^5.15.0", - "next-sitemap": "^3.1.32", - "react": "18.2.0", - "react-dom": "18.2.0", + "next-i18n-router": "^5.5.1", + "react": "18.3.1", + "react-dom": "18.3.1", "react-focus-lock": "^2.9.2", - "sharp": "^0.32.6" + "react-i18next": "^15.0.1", + "sharp": "^0.33.5" }, "devDependencies": { "@babel/eslint-parser": "^7.19.1", "@contentful/rich-text-types": "^16.0.2", - "@graphql-codegen/cli": "2.13.12", + "@graphql-codegen/cli": "5.0.2", "@graphql-codegen/client-preset": "1.1.4", "@graphql-codegen/introspection": "2.2.1", + "@graphql-codegen/typescript-graphql-request": "^6.2.0", "@svgr/webpack": "^6.5.1", "@tailwindcss/typography": "^0.5.8", + "@types/negotiator": "^0.6.3", "@types/node": "18.11.9", "@types/react": "18.0.25", "@types/react-dom": "18.0.9", @@ -60,7 +62,7 @@ "@typescript-eslint/parser": "^5.32.0", "autoprefixer": "^10.4.13", "eslint": "8.26.0", - "eslint-config-next": "13.0.1", + "eslint-config-next": "14.2.7", "eslint-config-prettier": "^8.3.0", "eslint-import-resolver-typescript": "^2.4.0", "eslint-plugin-import": "^2.23.4", @@ -68,7 +70,7 @@ "eslint-plugin-react": "^7.22.0", "eslint-plugin-react-hooks": "^4.2.0", "husky": "^8.0.2", - "i18next": "^21.9.2", + "i18next": "^23.14.0", "i18next-http-backend": "^1.4.4", "lint-staged": "^13.0.3", "postcss": "^8.4.19", @@ -76,7 +78,6 @@ "prettier-plugin-tailwindcss": "^0.2.1", "tailwind-merge": "^1.8.0", "tailwindcss": "^3.2.4", - "typescript": "4.9.3", - "typescript-graphql-request": "^4.4.6" + "typescript": "5.5.4" } } diff --git a/src/app/[locale]/[slug]/page.tsx b/src/app/[locale]/[slug]/page.tsx new file mode 100644 index 0000000..cb114ce --- /dev/null +++ b/src/app/[locale]/[slug]/page.tsx @@ -0,0 +1,71 @@ +import { draftMode } from 'next/headers'; +import { notFound } from 'next/navigation'; + +import { ArticleContent, ArticleHero, ArticleTileGrid } from '@src/components/features/article'; +import { Container } from '@src/components/shared/container'; +import initTranslations from '@src/i18n'; +import { client, previewClient } from '@src/lib/client'; + +export async function generateStaticParams({ + params: { locale }, +}: { + params: { locale: string }; +}): Promise { + const gqlClient = client; + const { pageBlogPostCollection } = await gqlClient.pageBlogPostCollection({ locale, limit: 100 }); + + if (!pageBlogPostCollection?.items) { + throw new Error('No blog posts found'); + } + + return pageBlogPostCollection.items + .filter((blogPost): blogPost is NonNullable => Boolean(blogPost?.slug)) + .map(blogPost => { + return { + locale, + slug: blogPost.slug!, + }; + }); +} + +interface BlogPageProps { + params: { + locale: string; + slug: string; + }; +} + +export default async function Page({ params: { locale, slug } }: BlogPageProps) { + const { isEnabled: preview } = draftMode(); + const gqlClient = preview ? previewClient : client; + const { t } = await initTranslations({ locale }); + const { pageBlogPostCollection } = await gqlClient.pageBlogPost({ locale, slug, preview }); + const { pageLandingCollection } = await gqlClient.pageLanding({ locale, preview }); + const landingPage = pageLandingCollection?.items[0]; + const blogPost = pageBlogPostCollection?.items[0]; + const relatedPosts = blogPost?.relatedBlogPostsCollection?.items; + const isFeatured = Boolean( + blogPost?.slug && landingPage?.featuredBlogPost?.slug === blogPost.slug, + ); + + if (!blogPost) { + notFound(); + } + + return ( + <> + + + + + + + {relatedPosts && ( + +

{t('article.relatedArticles')}

+ +
+ )} + + ); +} diff --git a/src/app/[locale]/layout.tsx b/src/app/[locale]/layout.tsx new file mode 100644 index 0000000..9396887 --- /dev/null +++ b/src/app/[locale]/layout.tsx @@ -0,0 +1,70 @@ +import { dir } from 'i18next'; +import type { Metadata, Viewport } from 'next'; +import { Urbanist } from 'next/font/google'; +import { draftMode } from 'next/headers'; + +import { ContentfulPreviewProvider } from '@src/components/features/contentful'; +import TranslationsProvider from '@src/components/shared/i18n/TranslationProvider'; +import { Footer } from '@src/components/templates/footer'; +import { Header } from '@src/components/templates/header'; +import initTranslations from '@src/i18n'; +import { locales } from '@src/i18n/config'; + +export async function generateMetadata() { + const metatadata: Metadata = { + metadataBase: new URL(process.env.NEXT_PUBLIC_BASE_URL!), + other: { + 'msapplication-TileColor': '#ffffff', + 'msapplication-config': '/favicons/browserconfig.xml', + }, + } as Metadata; + + return metatadata; +} + +export const viewport: Viewport = { + themeColor: '#ffffff', +}; + +export async function generateStaticParams(): Promise { + return locales.map(locale => ({ locale })); +} + +const urbanist = Urbanist({ subsets: ['latin'], variable: '--font-urbanist' }); + +interface LayoutProps { + children: React.ReactNode; + params: { locale: string }; +} + +export default async function PageLayout({ children, params }: LayoutProps) { + const { isEnabled: preview } = draftMode(); + const { locale } = params; + const { resources } = await initTranslations({ locale }); + + return ( + + + + + + + + +
+
+ {children} +
+
+
+ + + + + ); +} diff --git a/src/app/[locale]/not-found.tsx b/src/app/[locale]/not-found.tsx new file mode 100644 index 0000000..a1b86b3 --- /dev/null +++ b/src/app/[locale]/not-found.tsx @@ -0,0 +1,20 @@ +import Link from 'next/link'; + +import { Container } from '@src/components/shared/container'; +import initTranslations from '@src/i18n'; +import { defaultLocale } from '@src/i18n/config'; + +export default async function NotFound() { + const { t } = await initTranslations({ locale: defaultLocale }); + + return ( + +

{t('notFound.title')}

+

+ {t('notFound.description')} + + +

+
+ ); +} diff --git a/src/app/[locale]/page.tsx b/src/app/[locale]/page.tsx new file mode 100644 index 0000000..023c6b1 --- /dev/null +++ b/src/app/[locale]/page.tsx @@ -0,0 +1,108 @@ +import type { Metadata } from 'next'; +import { draftMode } from 'next/headers'; +import Link from 'next/link'; +import { notFound } from 'next/navigation'; + +import { ArticleHero, ArticleTileGrid } from '@src/components/features/article'; +import { Container } from '@src/components/shared/container'; +import TranslationsProvider from '@src/components/shared/i18n/TranslationProvider'; +import initTranslations from '@src/i18n'; +import { locales } from '@src/i18n/config'; +import { PageBlogPostOrder } from '@src/lib/__generated/sdk'; +import { client, previewClient } from '@src/lib/client'; + +interface LandingPageProps { + params: { + locale: string; + }; +} + +export async function generateMetadata({ params }: LandingPageProps): Promise { + const { isEnabled: preview } = draftMode(); + const gqlClient = preview ? previewClient : client; + const landingPageData = await gqlClient.pageLanding({ locale: params.locale, preview }); + const page = landingPageData.pageLandingCollection?.items[0]; + + // optionally access and extend (rather than replace) parent metadata + // const previousImages = (await parent).openGraph?.images || []; + + const languages = locales.length > 1 ? {} : undefined; + + if (languages) { + for (const locale of locales) { + languages[locale] = `/${locale}`; + } + } + + let metadata: Metadata = { + alternates: { + canonical: '/', + languages, + }, + twitter: { + card: 'summary_large_image', + }, + }; + + if (page?.seoFields) { + metadata = { + title: page.seoFields.pageTitle, + description: page.seoFields.pageDescription, + robots: { + follow: !page.seoFields.nofollow, + index: !page.seoFields.noindex, + }, + }; + } + + return metadata; +} + +export default async function Page({ params: { locale } }: LandingPageProps) { + const { isEnabled: preview } = draftMode(); + const { t, resources } = await initTranslations({ locale }); + const gqlClient = preview ? previewClient : client; + + const landingPageData = await gqlClient.pageLanding({ locale, preview }); + const page = landingPageData.pageLandingCollection?.items[0]; + + if (!page) { + notFound(); + } + + const blogPostsData = await gqlClient.pageBlogPostCollection({ + limit: 6, + locale, + order: PageBlogPostOrder.PublishedDateDesc, + where: { + slug_not: page?.featuredBlogPost?.slug, + }, + preview, + }); + const posts = blogPostsData.pageBlogPostCollection?.items; + + if (!page?.featuredBlogPost || !posts) { + return; + } + + return ( + + + + + + + + {/* Tutorial: contentful-and-the-starter-template.md */} + {/* Uncomment the line below to make the Greeting field available to render */} + {/**/} + {/*
{page.greeting}
*/} + {/*
*/} + + +

{t('landingPage.latestArticles')}

+ +
+
+ ); +} diff --git a/src/app/api/enable-draft/route.ts b/src/app/api/enable-draft/route.ts new file mode 100644 index 0000000..df6c10e --- /dev/null +++ b/src/app/api/enable-draft/route.ts @@ -0,0 +1,184 @@ +import { cookies, draftMode } from 'next/headers'; +import { redirect } from 'next/navigation'; +import { NextRequest } from 'next/server'; + +interface VercelJwt { + bypass: string; + aud: string; + iat: number; + sub: string; +} + +const getVercelJwtCookie = (request: NextRequest): string | undefined => { + const vercelJwtCookie = request.cookies.get('_vercel_jwt'); + if (!vercelJwtCookie) return; + return vercelJwtCookie.value; +}; + +const parseVercelJwtCookie = (vercelJwtCookie: string): VercelJwt => { + const base64Payload = vercelJwtCookie.split('.')[1]; + if (!base64Payload) throw new Error('Malformed `_vercel_jwt` cookie value'); + + const base64 = base64Payload.replace('-', '+').replace('_', '/'); + const payload = atob(base64); + const vercelJwt = JSON.parse(payload); + + assertVercelJwt(vercelJwt); + + return vercelJwt; +}; + +function assertVercelJwt(value: object): asserts value is VercelJwt { + const vercelJwt = value as VercelJwt; + if (typeof vercelJwt.bypass !== 'string') + throw new TypeError("'bypass' property in VercelJwt is not a string"); + if (typeof vercelJwt.aud !== 'string') + throw new TypeError("'aud' property in VercelJwt is not a string"); + if (typeof vercelJwt.sub !== 'string') + throw new TypeError("'sub' property in VercelJwt is not a string"); + if (typeof vercelJwt.iat !== 'number') + throw new TypeError("'iat' property in VercelJwt is not a number"); +} + +interface ParsedRequestUrl { + origin: string; + host: string; + path: string; + bypassToken: string; + contentfulPreviewSecret: string; +} + +const parseRequestUrl = (requestUrl: string | undefined): ParsedRequestUrl => { + if (!requestUrl) throw new Error('missing `url` value in request'); + const { searchParams, origin, host } = new URL(requestUrl); + + const rawPath = searchParams.get('path') || ''; + const bypassToken = searchParams.get('x-vercel-protection-bypass') || ''; + const contentfulPreviewSecret = searchParams.get('x-contentful-preview-secret') || ''; + + // to allow query parameters to be passed through to the redirected URL, the original `path` should already be + // URI encoded, and thus must be decoded here + const path = decodeURIComponent(rawPath); + + return { origin, path, host, bypassToken, contentfulPreviewSecret }; +}; + +const buildRedirectUrl = ({ + path, + base, + bypassTokenFromQuery, +}: { + path: string; + base: string; + bypassTokenFromQuery?: string; +}): string => { + const redirectUrl = new URL(path, base); + + // if the bypass token is provided in the query, we assume Vercel has _not_ already set the actual + // token that bypasses authentication. thus we provided it here, on the redirect + if (bypassTokenFromQuery) { + redirectUrl.searchParams.set('x-vercel-protection-bypass', bypassTokenFromQuery); + redirectUrl.searchParams.set('x-vercel-set-bypass-cookie', 'samesitenone'); + } + + return redirectUrl.toString(); +}; + +function enableDraftMode() { + draftMode().enable(); + const cookieStore = cookies(); + const cookie = cookieStore.get('__prerender_bypass')!; + cookies().set({ + name: '__prerender_bypass', + value: cookie?.value, + httpOnly: true, + path: '/', + secure: true, + sameSite: 'none', + }); +} + +export async function GET(request: NextRequest): Promise { + const { + origin: base, + path, + host, + bypassToken: bypassTokenFromQuery, + contentfulPreviewSecret: contentfulPreviewSecretFromQuery, + } = parseRequestUrl(request.url); + // if we're in development, we don't need to check, we can just enable draft mode + if (process.env.NODE_ENV === 'development') { + enableDraftMode(); + const redirectUrl = buildRedirectUrl({ path, base, bypassTokenFromQuery }); + return redirect(redirectUrl); + } + + const vercelJwtCookie = getVercelJwtCookie(request); + let bypassToken: string; + let aud: string; + let vercelJwt: VercelJwt | null = null; + + if (bypassTokenFromQuery) { + bypassToken = bypassTokenFromQuery; + aud = host; + } else if (contentfulPreviewSecretFromQuery) { + bypassToken = contentfulPreviewSecretFromQuery; + aud = host; + } else { + // if we don't have a bypass token from the query we fall back to the _vercel_jwt cookie to find + // the correct authorization bypass elements + if (!vercelJwtCookie) { + return new Response('Missing _vercel_jwt cookie required for authorization bypass', { + status: 401, + }); + } + try { + vercelJwt = parseVercelJwtCookie(vercelJwtCookie); + } catch (e) { + if (!(e instanceof Error)) throw e; + return new Response('Malformed bypass authorization token in _vercel_jwt cookie', { + status: 401, + }); + } + bypassToken = vercelJwt.bypass; + aud = vercelJwt.aud; + } + + // certain Vercel account tiers may not have a VERCEL_AUTOMATION_BYPASS_SECRET, so we fallback to checking the value against the CONTENTFUL_PREVIEW_SECRET + // env var, which is supported as a workaround for these accounts + if ( + bypassToken !== process.env.VERCEL_AUTOMATION_BYPASS_SECRET && + contentfulPreviewSecretFromQuery !== process.env.CONTENTFUL_PREVIEW_SECRET + ) { + return new Response( + 'The bypass token you are authorized with does not match the bypass secret for this deployment. You might need to redeploy or go back and try the link again.', + { status: 403 }, + ); + } + + if (aud !== host) { + return new Response( + `The bypass token you are authorized with is not valid for this host (${host}). You might need to redeploy or go back and try the link again.`, + { status: 403 }, + ); + } + + if (!path) { + return new Response('Missing required value for query parameter `path`', { + status: 400, + }); + } + + enableDraftMode(); + + // if a _vercel_jwt cookie was found, we do _not_ want to pass through the bypassToken to the redirect query. this + // is because Vercel will not "process" (and remove) the query parameter when a _vercel_jwt cookie is present. + const bypassTokenForRedirect = vercelJwtCookie ? undefined : bypassTokenFromQuery; + + const redirectUrl = buildRedirectUrl({ + path, + base, + bypassTokenFromQuery: bypassTokenForRedirect, + }); + redirect(redirectUrl); +} diff --git a/public/favicons/apple-touch-icon.png b/src/app/apple-icon.png similarity index 100% rename from public/favicons/apple-touch-icon.png rename to src/app/apple-icon.png diff --git a/public/favicons/favicon.ico b/src/app/favicon.ico similarity index 100% rename from public/favicons/favicon.ico rename to src/app/favicon.ico diff --git a/src/pages/utils/globals.css b/src/app/globals.css similarity index 100% rename from src/pages/utils/globals.css rename to src/app/globals.css diff --git a/public/favicons/favicon-16x16.png b/src/app/icon.png similarity index 100% rename from public/favicons/favicon-16x16.png rename to src/app/icon.png diff --git a/public/favicons/favicon-32x32.png b/src/app/icon2.png similarity index 100% rename from public/favicons/favicon-32x32.png rename to src/app/icon2.png diff --git a/src/app/layout.tsx b/src/app/layout.tsx new file mode 100644 index 0000000..4662a75 --- /dev/null +++ b/src/app/layout.tsx @@ -0,0 +1,10 @@ +import '@src/app/globals.css'; + +interface LayoutProps { + children: React.ReactNode; + params: { locale: string }; +} + +export default async function RootLayout({ children }: LayoutProps) { + return children; +} diff --git a/public/favicons/site.webmanifest b/src/app/manifest.webmanifest similarity index 100% rename from public/favicons/site.webmanifest rename to src/app/manifest.webmanifest diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts new file mode 100644 index 0000000..b6acebf --- /dev/null +++ b/src/app/sitemap.ts @@ -0,0 +1,38 @@ +import path from 'node:path'; + +import type { MetadataRoute } from 'next'; + +import { defaultLocale, locales } from '@src/i18n/config'; +import type { SitemapPagesFieldsFragment } from '@src/lib/__generated/sdk'; +import { client } from '@src/lib/client'; + +type SitemapFieldsWithoutTypename = Omit; +type SitemapPageCollection = SitemapFieldsWithoutTypename[keyof SitemapFieldsWithoutTypename]; + +export default async function sitemap(): Promise { + const promises = + locales?.map(locale => client.sitemapPages({ locale })).filter(page => Boolean(page)) || []; + const dataPerLocale: SitemapFieldsWithoutTypename[] = await Promise.all(promises); + const fields = dataPerLocale + .flatMap((localeData, index) => + Object.values(localeData).flatMap((pageCollection: SitemapPageCollection) => + pageCollection?.items.map(item => { + const localeForUrl = locales?.[index] === defaultLocale ? undefined : locales?.[index]; + const url = new URL( + path.join(localeForUrl || '', item?.slug || ''), + process.env.NEXT_PUBLIC_BASE_URL!, + ).toString(); + + return item && !item.seoFields?.excludeFromSitemap + ? { + lastModified: item.sys.publishedAt, + url, + } + : undefined; + }), + ), + ) + .filter(field => field !== undefined); + + return fields; +} diff --git a/src/components/features/article/ArticleAuthor.tsx b/src/components/features/article/ArticleAuthor.tsx index 71adda5..d959934 100644 --- a/src/components/features/article/ArticleAuthor.tsx +++ b/src/components/features/article/ArticleAuthor.tsx @@ -1,4 +1,9 @@ -import { useContentfulInspectorMode } from '@contentful/live-preview/react'; +'use client'; + +import { + useContentfulInspectorMode, + useContentfulLiveUpdates, +} from '@contentful/live-preview/react'; import { CtfImage } from '@src/components/features/contentful'; import { PageBlogPostFieldsFragment } from '@src/lib/__generated/sdk'; @@ -8,14 +13,15 @@ interface ArticleAuthorProps { } export const ArticleAuthor = ({ article }: ArticleAuthorProps) => { - const { author } = article; + const { author } = useContentfulLiveUpdates(article); const inspectorProps = useContentfulInspectorMode({ entryId: author?.sys.id }); return (
+ {...inspectorProps({ fieldId: 'avatar' })} + > {author?.avatar && ( { - const { content } = article; + const { content } = useContentfulLiveUpdates(article); const inspectorProps = useContentfulInspectorMode({ entryId: article.sys.id }); return ( diff --git a/src/components/features/article/ArticleHero.tsx b/src/components/features/article/ArticleHero.tsx index e48003e..efa2621 100644 --- a/src/components/features/article/ArticleHero.tsx +++ b/src/components/features/article/ArticleHero.tsx @@ -1,5 +1,10 @@ -import { useContentfulInspectorMode } from '@contentful/live-preview/react'; -import { useTranslation } from 'next-i18next'; +'use client'; + +import { + useContentfulInspectorMode, + useContentfulLiveUpdates, +} from '@contentful/live-preview/react'; +import { useTranslation } from 'react-i18next'; import { twMerge } from 'tailwind-merge'; import { ArticleAuthor } from '@src/components/features/article/ArticleAuthor'; @@ -12,7 +17,9 @@ interface ArticleHeroProps { article: PageBlogPostFieldsFragment; isFeatured?: boolean; isReversedLayout?: boolean; + locale?: string; } + export const ArticleHero = ({ article, isFeatured, @@ -20,15 +27,15 @@ export const ArticleHero = ({ }: ArticleHeroProps) => { const { t } = useTranslation(); const inspectorProps = useContentfulInspectorMode({ entryId: article.sys.id }); - - const { title, shortDescription, publishedDate } = article; + const { title, shortDescription, publishedDate } = useContentfulLiveUpdates(article); return (
+ )} + >
{article.featuredImage && ( + )} + > {t('article.featured')} )} @@ -55,7 +63,8 @@ export const ArticleHero = ({ 'ml-auto hidden pl-2 text-xs text-gray600', isReversedLayout ? 'lg:block' : '', )} - {...inspectorProps({ fieldId: 'publishedDate' })}> + {...inspectorProps({ fieldId: 'publishedDate' })} + >
@@ -67,7 +76,8 @@ export const ArticleHero = ({ )}
+ {...inspectorProps({ fieldId: 'publishedDate' })} + >
diff --git a/src/components/features/article/ArticleImage.tsx b/src/components/features/article/ArticleImage.tsx index 2c2f33f..05b946c 100644 --- a/src/components/features/article/ArticleImage.tsx +++ b/src/components/features/article/ArticleImage.tsx @@ -1,3 +1,5 @@ +'use client'; + import { useContentfulInspectorMode } from '@contentful/live-preview/react'; import { twMerge } from 'tailwind-merge'; diff --git a/src/components/features/article/ArticleTile.tsx b/src/components/features/article/ArticleTile.tsx index a66f22e..543b498 100644 --- a/src/components/features/article/ArticleTile.tsx +++ b/src/components/features/article/ArticleTile.tsx @@ -1,4 +1,9 @@ -import { useContentfulInspectorMode } from '@contentful/live-preview/react'; +'use client'; + +import { + useContentfulInspectorMode, + useContentfulLiveUpdates, +} from '@contentful/live-preview/react'; import Link from 'next/link'; import { HTMLProps } from 'react'; import { twMerge } from 'tailwind-merge'; @@ -13,21 +18,22 @@ interface ArticleTileProps extends HTMLProps { } export const ArticleTile = ({ article, className }: ArticleTileProps) => { - const { title, publishedDate } = article; + const { featuredImage, publishedDate, slug, title } = useContentfulLiveUpdates(article); const inspectorProps = useContentfulInspectorMode({ entryId: article.sys.id }); return ( - +
- {article.featuredImage && ( + )} + > + {featuredImage && (
)} @@ -42,7 +48,8 @@ export const ArticleTile = ({ article, className }: ArticleTileProps) => {
+ {...inspectorProps({ fieldId: 'publishedDate' })} + >
diff --git a/src/components/features/contentful/CtfPreviewProvider.tsx b/src/components/features/contentful/CtfPreviewProvider.tsx new file mode 100644 index 0000000..f502be7 --- /dev/null +++ b/src/components/features/contentful/CtfPreviewProvider.tsx @@ -0,0 +1,12 @@ +'use client'; + +import { ContentfulLivePreviewInitConfig } from '@contentful/live-preview'; +import { ContentfulLivePreviewProvider } from '@contentful/live-preview/react'; +import { PropsWithChildren } from 'react'; + +export const ContentfulPreviewProvider = ({ + children, + ...props +}: PropsWithChildren) => { + return {children}; +}; diff --git a/src/components/features/contentful/index.ts b/src/components/features/contentful/index.ts index c7ce9e1..9978c0e 100644 --- a/src/components/features/contentful/index.ts +++ b/src/components/features/contentful/index.ts @@ -1,2 +1,3 @@ export * from './CtfRichText'; export * from './CtfImage'; +export * from './CtfPreviewProvider'; diff --git a/src/components/features/language-selector/LanguageSelector.tsx b/src/components/features/language-selector/LanguageSelector.tsx index 78934e5..1743f06 100644 --- a/src/components/features/language-selector/LanguageSelector.tsx +++ b/src/components/features/language-selector/LanguageSelector.tsx @@ -1,7 +1,8 @@ -import { useRouter } from 'next/router'; +'use client'; -import { LanguageSelectorDesktop } from './LanguageSelectorDesktop'; -import { LanguageSelectorMobile } from './LanguageSelectorMobile'; +import { LanguageSelectorDesktop } from '@src/components/features/language-selector/LanguageSelectorDesktop'; +import { LanguageSelectorMobile } from '@src/components/features/language-selector/LanguageSelectorMobile'; +import { locales } from '@src/i18n/config'; const localeName = locale => locale.split('-')[0]; @@ -11,8 +12,6 @@ const displayName = locale => }); export const LanguageSelector = () => { - const { locales } = useRouter(); - return locales && locales.length > 1 ? ( <>
diff --git a/src/components/features/language-selector/LanguageSelectorDesktop.tsx b/src/components/features/language-selector/LanguageSelectorDesktop.tsx index 000a4c7..ddbf91a 100644 --- a/src/components/features/language-selector/LanguageSelectorDesktop.tsx +++ b/src/components/features/language-selector/LanguageSelectorDesktop.tsx @@ -1,10 +1,13 @@ import { LanguageIcon, ChevronDownTrimmedIcon, ChevronUpTrimmedIcon } from '@contentful/f36-icons'; +import { useCurrentLocale } from 'next-i18n-router/client'; import Link from 'next/link'; -import { useRouter } from 'next/router'; +import { usePathname } from 'next/navigation'; import { KeyboardEvent, useEffect, useRef, useState } from 'react'; import FocusLock from 'react-focus-lock'; import { twMerge } from 'tailwind-merge'; +import i18nConfig, { locales } from '@src/i18n/config'; + const useClickOutside = (ref, setIsOpen) => { useEffect(() => { const handleClickOutside = event => { @@ -21,12 +24,14 @@ const useClickOutside = (ref, setIsOpen) => { }; export const LanguageSelectorDesktop = ({ localeName, displayName }) => { - const router = useRouter(); + const currentLocale = useCurrentLocale(i18nConfig); const menuRef = useRef(null); const containerRef = useRef(null); - const localesToShow = router.locales?.filter(locale => locale !== router.locale); - + const localesToShow = locales.filter(locale => locale !== currentLocale); const [isOpen, setIsOpen] = useState(false); + const pathname = usePathname(); + const pathnameHasLocale = locales.includes(pathname.slice(1, 6)); + const pathnameWithoutLocale = pathname.slice(6); useClickOutside(containerRef, setIsOpen); @@ -91,7 +96,7 @@ export const LanguageSelectorDesktop = ({ localeName, displayName }) => { onClick={() => setIsOpen(currentState => !currentState)} > - {localeName(router.locale)} + {localeName(currentLocale)} {isOpen ? ( ) : ( @@ -109,24 +114,26 @@ export const LanguageSelectorDesktop = ({ localeName, displayName }) => { role="menu" onKeyDown={handleMenuKeyDown} > - {localesToShow?.map((availableLocale, index) => ( -
  • - handleMenuItemKeydown(e, index)} - role="menuitem" - className="block py-2" - href={{ - pathname: router.pathname, - query: router.query, - }} - as={router.asPath} - locale={availableLocale} - onClick={() => setIsOpen(false)} - > - {displayName(availableLocale).of(localeName(availableLocale))} - -
  • - ))} + {localesToShow?.map((availableLocale, index) => { + return ( +
  • + handleMenuItemKeydown(e, index)} + role="menuitem" + className="block py-2" + href={ + pathnameHasLocale + ? `/${availableLocale}${pathnameWithoutLocale}` + : `/${availableLocale}${pathname}` + } + locale={availableLocale} + onClick={() => setIsOpen(false)} + > + {displayName(availableLocale).of(localeName(availableLocale))} + +
  • + ); + })}
    diff --git a/src/components/features/language-selector/LanguageSelectorMobile.tsx b/src/components/features/language-selector/LanguageSelectorMobile.tsx index 854f7b6..b872f0b 100644 --- a/src/components/features/language-selector/LanguageSelectorMobile.tsx +++ b/src/components/features/language-selector/LanguageSelectorMobile.tsx @@ -1,17 +1,24 @@ +'use client'; + import { LanguageIcon, CloseIcon } from '@contentful/f36-icons'; -import { useTranslation } from 'next-i18next'; -import { useRouter } from 'next/router'; +import { useCurrentLocale } from 'next-i18n-router/client'; +import { usePathname, useRouter } from 'next/navigation'; import { useEffect, useState } from 'react'; import FocusLock from 'react-focus-lock'; +import { useTranslation } from 'react-i18next'; import { twMerge } from 'tailwind-merge'; import { Portal } from '@src/components/shared/portal'; +import i18nConfig, { locales } from '@src/i18n/config'; export const LanguageSelectorMobile = ({ localeName, displayName }) => { - const { locale, locales } = useRouter(); + const currentLocale = useCurrentLocale(i18nConfig); + const pathname = usePathname(); const router = useRouter(); const { t } = useTranslation(); const [showDrawer, setShowDrawer] = useState(false); + const pathnameHasLocale = locales.includes(pathname.slice(1, 6)); + const pathnameWithoutLocale = pathname.slice(6); useEffect(() => { const close = e => { @@ -66,11 +73,15 @@ export const LanguageSelectorMobile = ({ localeName, displayName }) => {

    {t('common.language')}

    { - const locale = String(event.target.value); - router.push( - pathnameHasLocale - ? `/${locale}${pathnameWithoutLocale}` - : `/${locale}${pathname}`, - {}, - ); - setShowDrawer(!showDrawer); - }} + onChange={onChange} > {locales?.map(availableLocale => (