diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 00000000..4360d9b1 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": ["next/core-web-vitals", "next/typescript", "prettier"] +} diff --git a/.gitignore b/.gitignore index 58f51bc2..e9a812fb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,47 @@ -node_modules/ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. -# system +# dependencies +/node_modules +/.pnp +.pnp.js +.yarn/install-state.gz + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc .DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env*.local + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +.env + +# yalc +.yalc + +# gql:fragments (generated) - do not commit +graphql/drupal/fragments/generated + +*storybook.log +storybook-static diff --git a/.gitkeep b/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 00000000..46d711ee --- /dev/null +++ b/.prettierignore @@ -0,0 +1,2 @@ +graphql/generated/* +components/ui/**/*.mdx diff --git a/.storybook/main.ts b/.storybook/main.ts new file mode 100644 index 00000000..5d24f2d8 --- /dev/null +++ b/.storybook/main.ts @@ -0,0 +1,29 @@ +import type { StorybookConfig } from '@storybook/nextjs' + +import { join, dirname } from 'path' + +/** + * This function is used to resolve the absolute path of a package. + * It is needed in projects that use Yarn PnP or are set up within a monorepo. + */ +function getAbsolutePath(value: string): any { + return dirname(require.resolve(join(value, 'package.json'))) +} +const config: StorybookConfig = { + stories: [ + '../components/**/*.mdx', + '../components/**/*.stories.@(js|jsx|mjs|ts|tsx)', + ], + addons: [ + getAbsolutePath('@storybook/addon-onboarding'), + getAbsolutePath('@storybook/addon-links'), + getAbsolutePath('@storybook/addon-essentials'), + getAbsolutePath('@storybook/addon-interactions'), + ], + framework: { + name: getAbsolutePath('@storybook/nextjs'), + options: {}, + }, + staticDirs: ['../static'], +} +export default config diff --git a/.storybook/preview.ts b/.storybook/preview.ts new file mode 100644 index 00000000..e23228e8 --- /dev/null +++ b/.storybook/preview.ts @@ -0,0 +1,28 @@ +import type { Preview } from '@storybook/react' + +import '../app/globals.css' + +const preview: Preview = { + parameters: { + options: { + storySort: { + order: [ + 'Tokens', + 'Primitives', + 'Blocks', + 'Pages', + 'Form', + ['*', 'Form - Demo'], + ], + }, + }, + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/i, + }, + }, + }, +} + +export default preview diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..25fa6215 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "typescript.tsdk": "node_modules/typescript/lib" +} diff --git a/README.md b/README.md index ddcc7a33..3287ce90 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,8 @@ -# Drupal Decoupled +# Drupal Decoupled: Next.js -Using Drupal as a headless CMS with a Decoupled front-end implementation is a great way to get an enterprise-quality CMS, paired with a great modern development experience using Remix, Next.js, Astro and/or others. +## Getting Started -Unlock the full potential of Drupal as an API-first CMS. Our quick-start guides and ready-to-use examples help you get started in no time! - -## Explore Drupal Decoupled - -Visit the [docs](https://drupal-decoupled.octahedroid.com/) to see how to use this project. - - -### Quickstart -- [Drupal](https://drupal-decoupled.octahedroid.com/docs/getting-started/quick-start/drupal/) -- [Remix](https://drupal-decoupled.octahedroid.com/docs/getting-started/quick-start/remix) -- [Next.js](https://drupal-decoupled.octahedroid.com/docs/getting-started/quick-start/next) - -### Step by step -- [Drupal](https://drupal-decoupled.octahedroid.com/docs/getting-started/step-by-step/drupal/install/) -- [Remix](https://drupal-decoupled.octahedroid.com/docs/01-getting-started/02-step-by-step/02-starters/01-remix/) -- [Next.js](https://drupal-decoupled.octahedroid.com/docs/01-getting-started/02-step-by-step/02-starters/02-next/) +Visit the docs to see how to use this [Next.js](https://drupal-decoupled.octahedroid.com/docs/getting-started/quickstart/next) starter. ## Supporting organizations diff --git a/app/[[...slug]]/page.tsx b/app/[[...slug]]/page.tsx new file mode 100644 index 00000000..a5139089 --- /dev/null +++ b/app/[[...slug]]/page.tsx @@ -0,0 +1,171 @@ +import { FragmentOf, readFragment } from 'gql.tada' +import { headers } from 'next/headers' +import { redirect } from 'next/navigation' + +import { NodeArticleFragment, NodePageFragment } from '@/graphql/fragments/node' +import { TermTagsFragment } from '@/graphql/fragments/terms' +import { graphql } from '@/graphql/gql.tada' +import { EntityFragmentType } from '@/graphql/types' +import NodeArticleComponent from '@/integration/node/NodeArticle' +import NodePageComponent from '@/integration/node/NodePage' +import TermTagsComponent from '@/integration/taxonomy/TermTags' +import { getClient } from '@/utils/client' +import { calculatePath } from '@/utils/routes' + +import { PageProps } from '@/.next/types/app/layout' +import { Footer, Header } from '@/components/blocks' +import { MenuFragment, MenuItemFragment } from '@/graphql/fragments/menu' + +async function getDrupalData({ params }: { params: { slug: string[] } }) { + const pathFromParams = params.slug?.join('/') || '/home' + const requestUrl = (await headers()).get('x-url') + const path = calculatePath({ + path: pathFromParams, + url: requestUrl!, + }) + + const client = await getClient({ + url: process.env.DRUPAL_GRAPHQL_URI!, + auth: { + uri: process.env.DRUPAL_AUTH_URI!, + clientId: process.env.DRUPAL_CLIENT_ID!, + clientSecret: process.env.DRUPAL_CLIENT_SECRET!, + }, + }) + const nodeRouteQuery = graphql( + ` + query route($path: String!) { + route(path: $path) { + __typename + ... on RouteInternal { + entity { + __typename + ... on NodePage { + id + title + } + ...NodePageFragment + ...NodeArticleFragment + ...TermTagsFragment + } + } + } + + menuMain: menu(name: MAIN) { + ...MenuFragment + } + + menuFooter: menu(name: FOOTER) { + ...MenuFragment + } + } + `, + [NodePageFragment, NodeArticleFragment, TermTagsFragment, MenuFragment] + ) + + const { data, error } = await client.query(nodeRouteQuery, { + path, + }) + + if (error) { + throw error + } + + if ( + !data || + !data?.route || + data?.route.__typename !== 'RouteInternal' || + !data.route.entity + ) { + return redirect('/404') + } + + const menuMain = readFragment(MenuFragment, data.menuMain) + const navItems = menuMain + ? menuMain.items.map((item) => { + const menuItem = readFragment(MenuItemFragment, item) + + return { + label: menuItem.label, + href: menuItem.href || undefined, + expanded: menuItem.expanded, + } + }) + : [] + + return { + type: data.route.entity.__typename, + header: { + logo: { + // add DRUPAL URI as env variable + src: `${process.env.DRUPAL_AUTH_URI}/sites/default/files/2024-09/drupal-decoupled.png`, + alt: 'Company Logo', + }, + navItems, + sticky: true, + actions: [ + { + text: 'Docs', + href: 'https://drupal-decoupled.octahedroid.com/docs', + }, + { + text: 'Quickstart', + href: 'https://drupal-decoupled.octahedroid.com/docs/getting-started/quick-start/drupal', + }, + ], + }, + footer: { + logo: { + // add DRUPAL URI as env variable + src: `${process.env.DRUPAL_AUTH_URI}/sites/default/files/2024-09/drupal-decoupled.png`, + alt: 'Company Logo', + }, + copyrightText: `© ${new Date().getFullYear()} Drupal Decoupled`, + navItems: [], + }, + entity: data.route.entity as EntityFragmentType, + environment: process.env.ENVIRONMENT!, + } +} + +export default async function Page({ params }: PageProps) { + const { type, entity, environment, header, footer } = await getDrupalData({ + params: await params, + }) + if (!type || !entity) { + return null + } + + return ( + <> +
+ {type === 'NodePage' && ( + } + environment={environment} + /> + )} + {type === 'NodeArticle' && ( + } + environment={environment} + /> + )} + {type === 'TermTags' && ( + } + /> + )} +