Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .eslintrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"extends": ["next/core-web-vitals", "next/typescript", "prettier"]
}
47 changes: 45 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -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
Empty file added .gitkeep
Empty file.
2 changes: 2 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
graphql/generated/*
components/ui/**/*.mdx
29 changes: 29 additions & 0 deletions .storybook/main.ts
Original file line number Diff line number Diff line change
@@ -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
28 changes: 28 additions & 0 deletions .storybook/preview.ts
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"typescript.tsdk": "node_modules/typescript/lib"
}
21 changes: 3 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down
171 changes: 171 additions & 0 deletions app/[[...slug]]/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<Header
logo={header.logo}
navItems={header.navItems}
sticky={header.sticky}
actions={header.actions}
/>
{type === 'NodePage' && (
<NodePageComponent
node={entity as FragmentOf<typeof NodePageFragment>}
environment={environment}
/>
)}
{type === 'NodeArticle' && (
<NodeArticleComponent
node={entity as FragmentOf<typeof NodeArticleFragment>}
environment={environment}
/>
)}
{type === 'TermTags' && (
<TermTagsComponent
term={entity as FragmentOf<typeof TermTagsFragment>}
/>
)}
<Footer
logo={footer.logo}
copyrightText={footer.copyrightText}
columns={[]}
/>
</>
)
}
Binary file added app/favicon.ico
Binary file not shown.
Loading