Update Contentful example for App Router. (#54205)

This PR updates the `cms-contentful` example to use:

- App Router
- TypeScript
- Draft Mode (previously Preview Mode)
- ISR / Data Cache (revalidations through `revalidateTag`)

Further, it combines many separate files into more manageable single files, and tries to better bucket provider-specific logic into the `lib/` folder. I'm hoping this can be the foundation for re-writing the rest of the `cms-*` examples to use App Router.

Overall, the code is much easier to reason about IMO. Pretty happy with the change. I sprinkled some `any`'s throughout here, but if someone wants to make it better, go for it! 

https://app-router-contentful.vercel.app/
This commit is contained in:
Lee Robinson 2023-08-21 08:21:37 -05:00 committed by GitHub
parent 71d424e804
commit 0718aec93b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
71 changed files with 539 additions and 825 deletions

View file

@ -4,13 +4,13 @@ This example showcases Next.js's [Static Generation](https://nextjs.org/docs/bas
## Demo
### [https://next-blog-contentful.vercel.app/](https://next-blog-contentful.vercel.app/)
### [https://app-router-contentful.vercel.app/](https://app-router-contentful.vercel.app/)
## Deploy your own
Using the Deploy Button below, you'll deploy the Next.js project as well as connect it to your Contentful space using the Vercel Contentful Integration.
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fvercel%2Fnext.js%2Ftree%2Fcanary%2Fexamples%2Fcms-contentful&project-name=nextjs-contentful-blog&repository-name=nextjs-contentful-blog&demo-title=Next.js+Blog&demo-description=Static+blog+with+multiple+authors+using+Preview+Mode&demo-url=https%3A%2F%2Fnext-blog-contentful.vercel.app%2F&demo-image=https%3A%2F%2Fassets.vercel.com%2Fimage%2Fupload%2Fv1625705016%2Ffront%2Fexamples%2FCleanShot_2021-07-07_at_19.43.15_2x.png&integration-ids=oac_aZtAZpDfT1lX3zrnWy7KT9VA&env=CONTENTFUL_PREVIEW_SECRET&envDescription=Any%20URL%20friendly%20value%20to%20secure%20Preview%20Mode)
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fvercel%2Fnext.js%2Ftree%2Fcanary%2Fexamples%2Fcms-contentful&project-name=nextjs-contentful-blog&repository-name=nextjs-contentful-blog&demo-title=Next.js+Blog&demo-description=Static+blog+with+multiple+authors+using+Draft+Mode&demo-url=https%3A%2F%2Fnext-blog-contentful.vercel.app%2F&demo-image=https%3A%2F%2Fassets.vercel.com%2Fimage%2Fupload%2Fv1625705016%2Ffront%2Fexamples%2FCleanShot_2021-07-07_at_19.43.15_2x.png&integration-ids=oac_aZtAZpDfT1lX3zrnWy7KT9VA&env=CONTENTFUL_PREVIEW_SECRET&envDescription=Any%20URL%20friendly%20value%20to%20secure%20Draft%20Mode)
### Related examples
@ -152,7 +152,7 @@ After setting up the content model (either manually or by running `npm run setup
**Content model overview**
![Content model overview](./docs/content-model-overview.png)
![Content model overview](https://github.com/vercel/next.js/assets/9113740/d3f76907-7046-4d94-b285-eb89b87aa223)
### Step 4. Populate Content
@ -171,7 +171,7 @@ Next, create another entry with the content type **Post**:
**Important:** For each entry and asset, you need to click on **Publish**. If not, the entry will be in draft state.
![Published content entry](./docs/content-entry-publish.png)
![Published content entry](https://github.com/vercel/next.js/assets/9113740/e1b4a3fe-45f4-4851-91db-8908d3ca18e9)
### Step 5. Set up environment variables
@ -188,7 +188,7 @@ Then set each variable on `.env.local`:
- `CONTENTFUL_SPACE_ID` should be the **Space ID** field of your API Key
- `CONTENTFUL_ACCESS_TOKEN` should be the **[Content Delivery API](https://www.contentful.com/developers/docs/references/content-delivery-api/) - access token** field of your API key
- `CONTENTFUL_PREVIEW_ACCESS_TOKEN` should be the **[Content Preview API](https://www.contentful.com/developers/docs/references/content-preview-api/) - access token** field of your API key
- `CONTENTFUL_PREVIEW_SECRET` should be any value you want. It must be URL friendly as the dashboard will send it as a query parameter to enable preview mode
- `CONTENTFUL_PREVIEW_SECRET` should be any value you want. It must be URL friendly as the dashboard will send it as a query parameter to enable Next.js Draft Mode
- - `CONTENTFUL_REVALIDATE_SECRET` should be any value you want. This will be the value you pass in as a secret header from the Contentful Webhook settings to use **[On-Demand Revalidation](https://vercel.com/docs/concepts/next.js/incremental-static-regeneration#on-demand-revalidation)**
Your `.env.local` file should look like this:
@ -215,19 +215,19 @@ yarn dev
Your blog should be up and running on [http://localhost:3000](http://localhost:3000)! If it doesn't work, post on [GitHub discussions](https://github.com/vercel/next.js/discussions).
### Step 7. Try preview mode
### Step 7. Try Draft Mode
In your Contentful space, go to **Settings > Content preview** and add a new content preview for development.
The **Name** field may be anything, like `Development`. Then, under **Content preview URLs**, check **Post** and set its value to:
```
http://localhost:3000/api/preview?secret=<CONTENTFUL_PREVIEW_SECRET>&slug={entry.fields.slug}
http://localhost:3000/api/draft?secret=<CONTENTFUL_PREVIEW_SECRET>&slug={entry.fields.slug}
```
Replace `<CONTENTFUL_PREVIEW_SECRET>` with its respective value in `.env.local`.
![Content preview setup](./docs/content-preview-setup.png)
![Content preview setup](https://github.com/vercel/next.js/assets/9113740/f1383d68-ea2b-4adf-974f-235b8c098745)
Once saved, go to one of the posts you've created and:
@ -235,9 +235,9 @@ Once saved, go to one of the posts you've created and:
- The state of the post will switch to **CHANGED** automatically. **Do not** publish it. By doing this, the post will be in draft state.
- In the sidebar, you will see the **Open preview** button. Click on it!
![Content entry overview](./docs/content-entry-preview.png)
![Content entry overview](https://github.com/vercel/next.js/assets/9113740/cc0dff9a-c57e-4ec4-85f1-22ab74af2b6b)
You will now be able to see the updated title. To exit preview mode, you can click on **Click here to exit preview mode** at the top of the page.
You will now be able to see the updated title. To manually exit Draft Mode, you can navigate to `/api/disable-draft` in the browser.
### Step 8. Deploy on Vercel
@ -253,9 +253,9 @@ To deploy your local project to Vercel, push it to GitHub/GitLab/Bitbucket and [
Alternatively, you can deploy using our template by clicking on the Deploy button below.
This will deploy the Next.js project as well as connect it to your Contentful space using the Vercel Contentful Integration. If you are using Preview Mode, make sure to add `CONTENTFUL_PREVIEW_SECRET` as an [Environment Variable](https://vercel.com/docs/environment-variables) as well.
This will deploy the Next.js project as well as connect it to your Contentful space using the Vercel Contentful Integration. If you are using Draft Mode, make sure to add `CONTENTFUL_PREVIEW_SECRET` as an [Environment Variable](https://vercel.com/docs/concepts/projects/environment-variables) as well.
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fvercel%2Fnext.js%2Ftree%2Fcanary%2Fexamples%2Fcms-contentful&project-name=nextjs-contentful-blog&repository-name=nextjs-contentful-blog&demo-title=Next.js+Blog&demo-description=Static+blog+with+multiple+authors+using+Preview+Mode&demo-url=https%3A%2F%2Fnext-blog-contentful.vercel.app%2F&demo-image=https%3A%2F%2Fassets.vercel.com%2Fimage%2Fupload%2Fv1625705016%2Ffront%2Fexamples%2FCleanShot_2021-07-07_at_19.43.15_2x.png&integration-ids=oac_aZtAZpDfT1lX3zrnWy7KT9VA&env=CONTENTFUL_PREVIEW_SECRET,CONTENTFUL_REVALIDATE_SECRET&envDescription=Any%20URL%20friendly%20value%20to%20secure%20Your%20App)
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fvercel%2Fnext.js%2Ftree%2Fcanary%2Fexamples%2Fcms-contentful&project-name=nextjs-contentful-blog&repository-name=nextjs-contentful-blog&demo-title=Next.js+Blog&demo-description=Static+blog+with+multiple+authors+using+Draft+Mode&demo-url=https%3A%2F%2Fnext-blog-contentful.vercel.app%2F&demo-image=https%3A%2F%2Fassets.vercel.com%2Fimage%2Fupload%2Fv1625705016%2Ffront%2Fexamples%2FCleanShot_2021-07-07_at_19.43.15_2x.png&integration-ids=oac_aZtAZpDfT1lX3zrnWy7KT9VA&env=CONTENTFUL_PREVIEW_SECRET,CONTENTFUL_REVALIDATE_SECRET&envDescription=Any%20URL%20friendly%20value%20to%20secure%20Your%20App)
### Step 9. Try using On-Demand Revalidation
@ -273,21 +273,21 @@ In your Contentful space, go to **Settings > Webhooks** and add a new webhook:
- **Specify Triggers:** You can choose to trigger for all events or specific events only, such as the Publishing and Unpublishing of Entries and Assets, as shown below.
![Content webhook url](./docs/content-webhook-url.png)
![Content webhook url](https://github.com/vercel/next.js/assets/9113740/c8df492a-57d6-42a1-8a3c-b0de3d6ad42f)
- **Specify Secret Header:** Add a secret header named `x-vercel-reval-key` and enter the value of the
`CONTENTFUL_REVALIDATE_SECRET` from before.
![Content secret header](./docs/content-secret-header.png)
![Content secret header](https://github.com/vercel/next.js/assets/9113740/574935e6-0d31-4e4f-b914-8b01bdf03d5e)
- **Set Content type:** Set content type to `application/json` in the dropdown.
![Content publish changes](./docs/content-content-type.png)
![Content publish changes](https://github.com/vercel/next.js/assets/9113740/78bd856c-ece1-4bf3-a330-1d544abd858d)
- **Edit post:** Now, try editing the title of one of your blog posts in Contentful and click Publish. You should see the changed reflected in the website you just deployed, all without triggering a build! Behind the scenes a call was made to the revalidate api that triggers a revalidation of both the landing page and the specific post that was changed.
![Content publish changes](./docs/content-publish-changes.png)
![Content publish changes](https://github.com/vercel/next.js/assets/9113740/ad96bfa7-89c1-4e46-9d9c-9067176c9769)
- **Verify:** You can verify if your request was made successfully by checking the webhook request log on Contentful and checking for a successful 200 status code, or by having your functions tab open on Vercel when committing the change (log drains may also be used). If you are experiencing issues with the api call, ensure you have correctly entered in the value for environment variable `CONTENTFUL_REVALIDATE_SECRET` within your Vercel deployment.
![Content successful request](./docs/content-successful-request.png)
![Content successful request](https://github.com/vercel/next.js/assets/9113740/ed1ffbe9-4dbf-4ec6-9c1f-39c8949c4d38)

View file

@ -0,0 +1,6 @@
import { draftMode } from 'next/headers'
export async function GET(request: Request) {
draftMode().disable()
return new Response('Draft mode is disabled')
}

View file

@ -0,0 +1,22 @@
import { draftMode } from 'next/headers'
import { redirect } from 'next/navigation'
import { getPreviewPostBySlug } from '../../../lib/api'
export async function GET(request: Request) {
const { searchParams } = new URL(request.url)
const secret = searchParams.get('secret')
const slug = searchParams.get('slug')
if (secret !== process.env.CONTENTFUL_PREVIEW_SECRET) {
return new Response('Invalid token', { status: 401 })
}
const post = await getPreviewPostBySlug(slug)
if (!post) {
return new Response('Invalid slug', { status: 401 })
}
draftMode().enable()
redirect(`/posts/${post.slug}`)
}

View file

@ -0,0 +1,15 @@
import { NextRequest, NextResponse } from 'next/server'
import { revalidateTag } from 'next/cache'
export async function POST(request: NextRequest) {
const requestHeaders = new Headers(request.headers)
const secret = requestHeaders.get('x-vercel-reval-key')
if (secret !== process.env.CONTENTFUL_REVALIDATE_SECRET) {
return NextResponse.json({ message: 'Invalid secret' }, { status: 401 })
}
revalidateTag('posts')
return NextResponse.json({ revalidated: true, now: Date.now() })
}

View file

@ -0,0 +1,24 @@
import ContentfulImage from '@/lib/contentful-image'
export default function Avatar({
name,
picture,
}: {
name: string
picture: any
}) {
return (
<div className="flex items-center">
<div className="mr-4 w-12 h-12">
<ContentfulImage
alt={name}
className="object-cover h-full rounded-full"
height={48}
width={48}
src={picture.url}
/>
</div>
<div className="text-xl font-bold">{name}</div>
</div>
)
}

View file

@ -1,13 +1,25 @@
import ContentfulImage from './contentful-image'
import ContentfulImage from '../lib/contentful-image'
import Link from 'next/link'
import cn from 'classnames'
export default function CoverImage({ title, url, slug }) {
function cn(...classes: any[]) {
return classes.filter(Boolean).join(' ')
}
export default function CoverImage({
title,
url,
slug,
}: {
title: string
url: string
slug?: string
}) {
const image = (
<ContentfulImage
alt={`Cover Image for ${title}`}
priority
width={2000}
height={1000}
alt={`Cover Image for ${title}`}
className={cn('shadow-small', {
'hover:shadow-medium transition-shadow duration-200': slug,
})}

View file

@ -1,6 +1,6 @@
import { format } from 'date-fns'
export default function DateComponent({ dateString }) {
export default function DateComponent({ dateString }: { dateString: string }) {
return (
<time dateTime={dateString}>
{format(new Date(dateString), 'LLLL d, yyyy')}

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View file

@ -1,5 +1,3 @@
/* purgecss start ignore */
@tailwind base;
@tailwind components;
/* purgecss end ignore */
@tailwind utilities;

View file

@ -1,17 +1,29 @@
import Container from './container'
import { EXAMPLE_PATH } from '../lib/constants'
import './globals.css'
import { Inter } from 'next/font/google'
import { EXAMPLE_PATH, CMS_NAME } from '@/lib/constants'
export default function Footer() {
export const metadata = {
title: `Next.js and ${CMS_NAME} Example`,
description: `This is a blog built with Next.js and ${CMS_NAME}.`,
}
const inter = Inter({
variable: '--font-inter',
subsets: ['latin'],
display: 'swap',
})
function Footer() {
return (
<footer className="bg-accent-1 border-t border-accent-2">
<Container>
<div className="container mx-auto px-5">
<div className="py-28 flex flex-col lg:flex-row items-center">
<h3 className="text-4xl lg:text-5xl font-bold tracking-tighter leading-tight text-center lg:text-left mb-10 lg:mb-0 lg:pr-4 lg:w-1/2">
Statically Generated with Next.js.
Built with Next.js.
</h3>
<div className="flex flex-col lg:flex-row justify-center items-center lg:pl-4 lg:w-1/2">
<a
href="https://nextjs.org/docs/basic-features/pages"
href="https://nextjs.org/docs"
className="mx-3 bg-black hover:bg-white hover:text-black border border-black text-white font-bold py-3 px-12 lg:px-8 duration-200 transition-colors mb-6 lg:mb-0"
>
Read Documentation
@ -24,7 +36,24 @@ export default function Footer() {
</a>
</div>
</div>
</Container>
</div>
</footer>
)
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en" className={inter.variable}>
<body>
<section className="min-h-screen">
<main>{children}</main>
<Footer />
</section>
</body>
</html>
)
}

View file

@ -0,0 +1,61 @@
import Link from 'next/link'
import Avatar from './avatar'
import DateComponent from './date'
import CoverImage from './cover-image'
function PostPreview({
title,
coverImage,
date,
excerpt,
author,
slug,
}: {
title: string
coverImage: any
date: string
excerpt: string
author: any
slug: string
}) {
return (
<div>
<div className="mb-5">
<CoverImage title={title} slug={slug} url={coverImage.url} />
</div>
<h3 className="text-3xl mb-3 leading-snug">
<Link href={`/posts/${slug}`} className="hover:underline">
{title}
</Link>
</h3>
<div className="text-lg mb-4">
<DateComponent dateString={date} />
</div>
<p className="text-lg leading-relaxed mb-4">{excerpt}</p>
{author && <Avatar name={author.name} picture={author.picture} />}
</div>
)
}
export default function MoreStories({ morePosts }: { morePosts: any[] }) {
return (
<section>
<h2 className="mb-8 text-6xl md:text-7xl font-bold tracking-tighter leading-tight">
More Stories
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 md:gap-x-16 lg:gap-x-32 gap-y-20 md:gap-y-32 mb-32">
{morePosts.map((post) => (
<PostPreview
key={post.slug}
title={post.title}
coverImage={post.coverImage}
date={post.date}
author={post.author}
slug={post.slug}
excerpt={post.excerpt}
/>
))}
</div>
</section>
)
}

View file

@ -0,0 +1,101 @@
import Link from 'next/link'
import { draftMode } from 'next/headers'
import Date from './date'
import CoverImage from './cover-image'
import Avatar from './avatar'
import MoreStories from './more-stories'
import { getAllPosts } from '@/lib/api'
import { CMS_NAME, CMS_URL } from '@/lib/constants'
function Intro() {
return (
<section className="flex-col md:flex-row flex items-center md:justify-between mt-16 mb-16 md:mb-12">
<h1 className="text-6xl md:text-8xl font-bold tracking-tighter leading-tight md:pr-8">
Blog.
</h1>
<h2 className="text-center md:text-left text-lg mt-5 md:pl-8">
A statically generated blog example using{' '}
<a
href="https://nextjs.org/"
className="underline hover:text-success duration-200 transition-colors"
>
Next.js
</a>{' '}
and{' '}
<a
href={CMS_URL}
className="underline hover:text-success duration-200 transition-colors"
>
{CMS_NAME}
</a>
.
</h2>
</section>
)
}
function HeroPost({
title,
coverImage,
date,
excerpt,
author,
slug,
}: {
title: string
coverImage: any
date: string
excerpt: string
author: any
slug: string
}) {
return (
<section>
<div className="mb-8 md:mb-16">
<CoverImage title={title} slug={slug} url={coverImage.url} />
</div>
<div className="md:grid md:grid-cols-2 md:gap-x-16 lg:gap-x-8 mb-20 md:mb-28">
<div>
<h3 className="mb-4 text-4xl lg:text-6xl leading-tight">
<Link href={`/posts/${slug}`} className="hover:underline">
{title}
</Link>
</h3>
<div className="mb-4 md:mb-0 text-lg">
<Date dateString={date} />
</div>
</div>
<div>
<p className="text-lg leading-relaxed mb-4">{excerpt}</p>
{author && <Avatar name={author.name} picture={author.picture} />}
</div>
</div>
</section>
)
}
export default async function Page() {
const { isEnabled } = draftMode()
const allPosts = await getAllPosts(isEnabled)
const heroPost = allPosts[0]
const morePosts = allPosts.slice(1)
return (
<div className="container mx-auto px-5">
<Intro />
{heroPost && (
<HeroPost
title={heroPost.title}
coverImage={heroPost.coverImage}
date={heroPost.date}
author={heroPost.author}
slug={heroPost.slug}
excerpt={heroPost.excerpt}
/>
)}
<MoreStories morePosts={morePosts} />
</div>
)
}

View file

@ -0,0 +1,69 @@
import Link from 'next/link'
import { draftMode } from 'next/headers'
import MoreStories from '../../more-stories'
import Avatar from '../../avatar'
import Date from '../../date'
import CoverImage from '../../cover-image'
import { Markdown } from '@/lib/markdown'
import { getAllPosts, getPostAndMorePosts } from '@/lib/api'
export async function generateStaticParams() {
const allPosts = await getAllPosts(false)
return allPosts.map((post) => ({
slug: post.slug,
}))
}
export default async function PostPage({
params,
}: {
params: { slug: string }
}) {
const { isEnabled } = draftMode()
const { post, morePosts } = await getPostAndMorePosts(params.slug, isEnabled)
return (
<div className="container mx-auto px-5">
<h2 className="text-2xl md:text-4xl font-bold tracking-tight md:tracking-tighter leading-tight mb-20 mt-8">
<Link href="/" className="hover:underline">
Blog
</Link>
.
</h2>
<article>
<h1 className="text-6xl md:text-7xl lg:text-8xl font-bold tracking-tighter leading-tight md:leading-none mb-12 text-center md:text-left">
{post.title}
</h1>
<div className="hidden md:block md:mb-12">
{post.author && (
<Avatar name={post.author.name} picture={post.author.picture} />
)}
</div>
<div className="mb-8 md:mb-16 sm:mx-0">
<CoverImage title={post.title} url={post.coverImage.url} />
</div>
<div className="max-w-2xl mx-auto">
<div className="block md:hidden mb-6">
{post.author && (
<Avatar name={post.author.name} picture={post.author.picture} />
)}
</div>
<div className="mb-6 text-lg">
<Date dateString={post.date} />
</div>
</div>
<div className="max-w-2xl mx-auto">
<div className="prose">
<Markdown content={post.content} />
</div>
</div>
</article>
<hr className="border-accent-2 mt-28 mb-24" />
<MoreStories morePosts={morePosts} />
</div>
)
}

View file

@ -1,42 +0,0 @@
import Container from './container'
import cn from 'classnames'
import { EXAMPLE_PATH } from '../lib/constants'
export default function Alert({ preview }) {
return (
<div
className={cn('border-b', {
'bg-accent-7 border-accent-7 text-white': preview,
'bg-accent-1 border-accent-2': !preview,
})}
>
<Container>
<div className="py-2 text-center text-sm">
{preview ? (
<>
This is page is a preview.{' '}
<a
href="/api/exit-preview"
className="underline hover:text-cyan duration-200 transition-colors"
>
Click here
</a>{' '}
to exit preview mode.
</>
) : (
<>
The source code for this blog is{' '}
<a
href={`https://github.com/vercel/next.js/tree/canary/examples/${EXAMPLE_PATH}`}
className="underline hover:text-success duration-200 transition-colors"
>
available on GitHub
</a>
.
</>
)}
</div>
</Container>
</div>
)
}

View file

@ -1,17 +0,0 @@
import ContentfulImage from './contentful-image'
export default function Avatar({ name, picture }) {
return (
<div className="flex items-center">
<div className="relative w-12 h-12 mr-4">
<ContentfulImage
src={picture.url}
layout="fill"
className="rounded-full"
alt={name}
/>
</div>
<div className="text-xl font-bold">{name}</div>
</div>
)
}

View file

@ -1,3 +0,0 @@
export default function Container({ children }) {
return <div className="container mx-auto px-5">{children}</div>
}

View file

@ -1,11 +0,0 @@
import Image from 'next/image'
const contentfulLoader = ({ src, width, quality }) => {
return `${src}?w=${width}&q=${quality || 75}`
}
const ContentfulImage = (props) => {
return <Image loader={contentfulLoader} {...props} />
}
export default ContentfulImage

View file

@ -1,12 +0,0 @@
import Link from 'next/link'
export default function Header() {
return (
<h2 className="text-2xl md:text-4xl font-bold tracking-tight md:tracking-tighter leading-tight mb-20 mt-8">
<Link href="/" className="hover:underline">
Blog
</Link>
.
</h2>
)
}

View file

@ -1,37 +0,0 @@
import Link from 'next/link'
import Avatar from '../components/avatar'
import DateComponent from '../components/date'
import CoverImage from '../components/cover-image'
export default function HeroPost({
title,
coverImage,
date,
excerpt,
author,
slug,
}) {
return (
<section>
<div className="mb-8 md:mb-16">
<CoverImage title={title} slug={slug} url={coverImage.url} />
</div>
<div className="md:grid md:grid-cols-2 md:gap-x-16 lg:gap-x-8 mb-20 md:mb-28">
<div>
<h3 className="mb-4 text-4xl lg:text-6xl leading-tight">
<Link href={`/posts/${slug}`} className="hover:underline">
{title}
</Link>
</h3>
<div className="mb-4 md:mb-0 text-lg">
<DateComponent dateString={date} />
</div>
</div>
<div>
<p className="text-lg leading-relaxed mb-4">{excerpt}</p>
{author && <Avatar name={author.name} picture={author.picture} />}
</div>
</div>
</section>
)
}

View file

@ -1,28 +0,0 @@
import { CMS_NAME, CMS_URL } from '../lib/constants'
export default function Intro() {
return (
<section className="flex-col md:flex-row flex items-center md:justify-between mt-16 mb-16 md:mb-12">
<h1 className="text-6xl md:text-8xl font-bold tracking-tighter leading-tight md:pr-8">
Blog.
</h1>
<h4 className="text-center md:text-left text-lg mt-5 md:pl-8">
A statically generated blog example using{' '}
<a
href="https://nextjs.org/"
className="underline hover:text-success duration-200 transition-colors"
>
Next.js
</a>{' '}
and{' '}
<a
href={CMS_URL}
className="underline hover:text-success duration-200 transition-colors"
>
{CMS_NAME}
</a>
.
</h4>
</section>
)
}

View file

@ -1,16 +0,0 @@
import Alert from '../components/alert'
import Footer from '../components/footer'
import Meta from '../components/meta'
export default function Layout({ preview, children }) {
return (
<>
<Meta />
<div className="min-h-screen">
<Alert preview={preview} />
<main>{children}</main>
</div>
<Footer />
</>
)
}

View file

@ -1,18 +0,0 @@
.markdown {
@apply text-lg leading-relaxed;
}
.markdown p,
.markdown ul,
.markdown ol,
.markdown blockquote {
@apply my-6;
}
.markdown h2 {
@apply text-3xl mt-12 mb-4 leading-snug;
}
.markdown h3 {
@apply text-2xl mt-8 mb-4 leading-snug;
}

View file

@ -1,42 +0,0 @@
import Head from 'next/head'
import { CMS_NAME, HOME_OG_IMAGE_URL } from '../lib/constants'
export default function Meta() {
return (
<Head>
<link
rel="apple-touch-icon"
sizes="180x180"
href="/favicon/apple-touch-icon.png"
/>
<link
rel="icon"
type="image/png"
sizes="32x32"
href="/favicon/favicon-32x32.png"
/>
<link
rel="icon"
type="image/png"
sizes="16x16"
href="/favicon/favicon-16x16.png"
/>
<link rel="manifest" href="/favicon/site.webmanifest" />
<link
rel="mask-icon"
href="/favicon/safari-pinned-tab.svg"
color="#000000"
/>
<link rel="shortcut icon" href="/favicon/favicon.ico" />
<meta name="msapplication-TileColor" content="#000000" />
<meta name="msapplication-config" content="/favicon/browserconfig.xml" />
<meta name="theme-color" content="#000" />
<link rel="alternate" type="application/rss+xml" href="/feed.xml" />
<meta
name="description"
content={`A statically generated blog example using Next.js and ${CMS_NAME}.`}
/>
<meta property="og:image" content={HOME_OG_IMAGE_URL} />
</Head>
)
}

View file

@ -1,24 +0,0 @@
import PostPreview from '../components/post-preview'
export default function MoreStories({ posts }) {
return (
<section>
<h2 className="mb-8 text-6xl md:text-7xl font-bold tracking-tighter leading-tight">
More Stories
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 md:gap-x-16 lg:gap-x-32 gap-y-20 md:gap-y-32 mb-32">
{posts.map((post) => (
<PostPreview
key={post.slug}
title={post.title}
coverImage={post.coverImage}
date={post.date}
author={post.author}
slug={post.slug}
excerpt={post.excerpt}
/>
))}
</div>
</section>
)
}

View file

@ -1,28 +0,0 @@
import { documentToReactComponents } from '@contentful/rich-text-react-renderer'
import { BLOCKS } from '@contentful/rich-text-types'
import markdownStyles from './markdown-styles.module.css'
import RichTextAsset from './rich-text-asset'
const customMarkdownOptions = (content) => ({
renderNode: {
[BLOCKS.EMBEDDED_ASSET]: (node) => (
<RichTextAsset
id={node.data.target.sys.id}
assets={content.links.assets.block}
/>
),
},
})
export default function PostBody({ content }) {
return (
<div className="max-w-2xl mx-auto">
<div className={markdownStyles['markdown']}>
{documentToReactComponents(
content.json,
customMarkdownOptions(content)
)}
</div>
</div>
)
}

View file

@ -1,26 +0,0 @@
import Avatar from '../components/avatar'
import DateComponent from '../components/date'
import CoverImage from '../components/cover-image'
import PostTitle from '../components/post-title'
export default function PostHeader({ title, coverImage, date, author }) {
return (
<>
<PostTitle>{title}</PostTitle>
<div className="hidden md:block md:mb-12">
{author && <Avatar name={author.name} picture={author.picture} />}
</div>
<div className="mb-8 md:mb-16 sm:mx-0">
<CoverImage title={title} url={coverImage.url} />
</div>
<div className="max-w-2xl mx-auto">
<div className="block md:hidden mb-6">
{author && <Avatar name={author.name} picture={author.picture} />}
</div>
<div className="mb-6 text-lg">
<DateComponent dateString={date} />
</div>
</div>
</>
)
}

View file

@ -1,31 +0,0 @@
import Link from 'next/link'
import Avatar from '../components/avatar'
import DateComponent from '../components/date'
import CoverImage from './cover-image'
export default function PostPreview({
title,
coverImage,
date,
excerpt,
author,
slug,
}) {
return (
<div>
<div className="mb-5">
<CoverImage title={title} slug={slug} url={coverImage.url} />
</div>
<h3 className="text-3xl mb-3 leading-snug">
<Link href={`/posts/${slug}`} className="hover:underline">
{title}
</Link>
</h3>
<div className="text-lg mb-4">
<DateComponent dateString={date} />
</div>
<p className="text-lg leading-relaxed mb-4">{excerpt}</p>
{author && <Avatar name={author.name} picture={author.picture} />}
</div>
)
}

View file

@ -1,7 +0,0 @@
export default function PostTitle({ children }) {
return (
<h1 className="text-6xl md:text-7xl lg:text-8xl font-bold tracking-tighter leading-tight md:leading-none mb-12 text-center md:text-left">
{children}
</h1>
)
}

View file

@ -1,11 +0,0 @@
import Image from 'next/image'
export default function RichTextAsset({ id, assets }) {
const asset = assets?.find((asset) => asset.sys.id === id)
if (asset?.url) {
return <Image src={asset.url} layout="fill" alt={asset.description} />
}
return null
}

View file

@ -1,3 +0,0 @@
export default function SectionSeparator() {
return <hr className="border-accent-2 mt-28 mb-24" />
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 700 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

View file

@ -1,34 +1,34 @@
const POST_GRAPHQL_FIELDS = `
slug
title
coverImage {
url
}
date
author {
name
picture {
slug
title
coverImage {
url
}
}
excerpt
content {
json
links {
assets {
block {
sys {
id
date
author {
name
picture {
url
}
}
excerpt
content {
json
links {
assets {
block {
sys {
id
}
url
description
}
url
description
}
}
}
}
`
async function fetchGraphQL(query, preview = false) {
async function fetchGraphQL(query: string, preview = false): Promise<any> {
return fetch(
`https://graphql.contentful.com/content/v1/spaces/${process.env.CONTENTFUL_SPACE_ID}`,
{
@ -42,19 +42,20 @@ async function fetchGraphQL(query, preview = false) {
}`,
},
body: JSON.stringify({ query }),
next: { tags: ['posts'] },
}
).then((response) => response.json())
}
function extractPost(fetchResponse) {
function extractPost(fetchResponse: any): any {
return fetchResponse?.data?.postCollection?.items?.[0]
}
function extractPostEntries(fetchResponse) {
function extractPostEntries(fetchResponse: any): any[] {
return fetchResponse?.data?.postCollection?.items
}
export async function getPreviewPostBySlug(slug) {
export async function getPreviewPostBySlug(slug: string): Promise<any> {
const entry = await fetchGraphQL(
`query {
postCollection(where: { slug: "${slug}" }, preview: true, limit: 1) {
@ -68,34 +69,26 @@ export async function getPreviewPostBySlug(slug) {
return extractPost(entry)
}
export async function getAllPostsWithSlug() {
export async function getAllPosts(isDraftMode: boolean): Promise<any[]> {
const entries = await fetchGraphQL(
`query {
postCollection(where: { slug_exists: true }, order: date_DESC) {
items {
${POST_GRAPHQL_FIELDS}
}
}
}`
)
return extractPostEntries(entries)
}
export async function getAllPostsForHome(preview) {
const entries = await fetchGraphQL(
`query {
postCollection(order: date_DESC, preview: ${preview ? 'true' : 'false'}) {
postCollection(where: { slug_exists: true }, order: date_DESC, preview: ${
isDraftMode ? 'true' : 'false'
}) {
items {
${POST_GRAPHQL_FIELDS}
}
}
}`,
preview
isDraftMode
)
return extractPostEntries(entries)
}
export async function getPostAndMorePosts(slug, preview) {
export async function getPostAndMorePosts(
slug: string,
preview: boolean
): Promise<any> {
const entry = await fetchGraphQL(
`query {
postCollection(where: { slug: "${slug}" }, preview: ${

View file

@ -1,5 +0,0 @@
export const EXAMPLE_PATH = 'cms-contentful'
export const CMS_NAME = 'Contentful'
export const CMS_URL = 'https://www.contentful.com'
export const HOME_OG_IMAGE_URL =
'https://og-image.vercel.app/Next.js%20Blog%20Example%20with%20**Contentful**.png?theme=light&md=1&fontSize=100px&images=https%3A%2F%2Fassets.vercel.com%2Fimage%2Fupload%2Ffront%2Fassets%2Fdesign%2Fnextjs-black-logo.svg&images=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyOSIgaGVpZ2h0PSIzMiI%2BCiAgPHBhdGggZmlsbD0iI0ZGRDg1RiIgZD0iTTkuNyAyMi4zQzggMjAuNyA3IDE4LjUgNyAxNnMxLTQuNyAyLjYtNi4zYzEuNC0xLjQgMS40LTMuNiAwLTVzLTMuNi0xLjQtNSAwQzEuOCA3LjYgMCAxMS42IDAgMTZzMS44IDguNCA0LjcgMTEuM2MxLjQgMS40IDMuNiAxLjQgNSAwIDEuMy0xLjQgMS4zLTMuNiAwLTV6Ij48L3BhdGg%2BCiAgPHBhdGggZmlsbD0iIzNCQjRFNyIgZD0iTTkuNyA5LjdDMTEuMyA4IDEzLjUgNyAxNiA3czQuNyAxIDYuMyAyLjZjMS40IDEuNCAzLjYgMS40IDUgMHMxLjQtMy42IDAtNUMyNC40IDEuOCAyMC40IDAgMTYgMFM3LjYgMS44IDQuNyA0LjdjLTEuNCAxLjQtMS40IDMuNiAwIDUgMS40IDEuMyAzLjYgMS4zIDUgMHoiPjwvcGF0aD4KICA8cGF0aCBmaWxsPSIjRUQ1QzY4IiBkPSJNMjIuMyAyMi4zQzIwLjcgMjQgMTguNSAyNSAxNiAyNXMtNC43LTEtNi4zLTIuNmMtMS40LTEuNC0zLjYtMS40LTUgMHMtMS40IDMuNiAwIDVDNy42IDMwLjIgMTEuNiAzMiAxNiAzMnM4LjQtMS44IDExLjMtNC43YzEuNC0xLjQgMS40LTMuNiAwLTUtMS40LTEuMy0zLjYtMS4zLTUgMHoiPjwvcGF0aD4KICA8Y2lyY2xlIGN4PSI3LjIiIGN5PSI3LjIiIHI9IjMuNSIgZmlsbD0iIzMwOEJDNSI%2BPC9jaXJjbGU%2BCiAgPGNpcmNsZSBjeD0iNy4yIiBjeT0iMjQuOCIgcj0iMy41IiBmaWxsPSIjRDU0NjVGIj48L2NpcmNsZT4KPC9zdmc%2B'

View file

@ -0,0 +1,3 @@
export const EXAMPLE_PATH = 'cms-contentful'
export const CMS_NAME = 'Contentful'
export const CMS_URL = 'https://www.contentful.com'

View file

@ -0,0 +1,18 @@
'use client'
import Image from 'next/image'
interface ContentfulImageProps {
src: string
width?: number
quality?: number
[key: string]: any // For other props that might be passed
}
const contentfulLoader = ({ src, width, quality }: ContentfulImageProps) => {
return `${src}?w=${width}&q=${quality || 75}`
}
export default function ContentfulImage(props: ContentfulImageProps) {
return <Image alt={props.alt} loader={contentfulLoader} {...props} />
}

View file

@ -0,0 +1,51 @@
import Image from 'next/image'
import { documentToReactComponents } from '@contentful/rich-text-react-renderer'
import { BLOCKS } from '@contentful/rich-text-types'
interface Asset {
sys: {
id: string
}
url: string
description: string
}
interface AssetLink {
block: Asset[]
}
interface Content {
json: any
links: {
assets: AssetLink
}
}
function RichTextAsset({
id,
assets,
}: {
id: string
assets: Asset[] | undefined
}) {
const asset = assets?.find((asset) => asset.sys.id === id)
if (asset?.url) {
return <Image src={asset.url} layout="fill" alt={asset.description} />
}
return null
}
export function Markdown({ content }: { content: Content }) {
return documentToReactComponents(content.json, {
renderNode: {
[BLOCKS.EMBEDDED_ASSET]: (node: any) => (
<RichTextAsset
id={node.data.target.sys.id}
assets={content.links.assets.block}
/>
),
},
})
}

View file

@ -2,5 +2,6 @@
module.exports = {
images: {
loader: 'custom',
formats: ['image/avif', 'image/webp'],
},
}

View file

@ -4,20 +4,23 @@
"dev": "next",
"build": "next build",
"start": "next start",
"setup": "node ./contentful/setup.js"
"setup": "node ./lib/setup.js"
},
"dependencies": {
"@contentful/rich-text-react-renderer": "^15.4.0",
"classnames": "2.3.1",
"date-fns": "2.28.0",
"@contentful/rich-text-react-renderer": "^15.17.1",
"@contentful/rich-text-types": "^16.2.1",
"@tailwindcss/typography": "0.5.9",
"@types/node": "^20.5.0",
"@types/react": "^18.2.20",
"@types/react-dom": "^18.2.7",
"autoprefixer": "10.4.15",
"contentful-import": "^9.0.4",
"date-fns": "2.30.0",
"next": "latest",
"postcss": "8.4.28",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"autoprefixer": "10.4.2",
"contentful-import": "^7.8.7",
"postcss": "8.4.5",
"tailwindcss": "^3.0.15"
"react-dom": "^18.2.0",
"tailwindcss": "^3.3.3",
"typescript": "^5.1.6"
}
}

View file

@ -1,7 +0,0 @@
import '../styles/index.css'
function MyApp({ Component, pageProps }) {
return <Component {...pageProps} />
}
export default MyApp

View file

@ -1,13 +0,0 @@
import { Html, Head, Main, NextScript } from 'next/document'
export default function Document() {
return (
<Html lang="en">
<Head />
<body>
<Main />
<NextScript />
</body>
</Html>
)
}

View file

@ -1,8 +0,0 @@
export default async function exit(_, res) {
// Exit Draft Mode by removing the cookie
res.setDraftMode({ enable: false })
// Redirect the user back to the index page.
res.writeHead(307, { Location: '/' })
res.end()
}

View file

@ -1,33 +0,0 @@
import { getPreviewPostBySlug } from '../../lib/api'
export default async function preview(req, res) {
const { secret, slug } = req.query
if (secret !== process.env.CONTENTFUL_PREVIEW_SECRET || !slug) {
return res.status(401).json({ message: 'Invalid token' })
}
// Fetch the headless CMS to check if the provided `slug` exists
const post = await getPreviewPostBySlug(slug)
// If the slug doesn't exist prevent preview mode from being enabled
if (!post) {
return res.status(401).json({ message: 'Invalid slug' })
}
// Enable Draft Mode by setting the cookie
res.setDraftMode({ enable: true })
// Redirect to the path from the fetched post
// We don't redirect to req.query.slug as that might lead to open redirect vulnerabilities
// res.writeHead(307, { Location: `/posts/${post.slug}` })
const url = `/posts/${post.slug}`
res.setHeader('Content-Type', 'text/html')
res.write(
`<!DOCTYPE html><html><head><meta http-equiv="Refresh" content="0; url=${url}" />
<script>window.location.href = '${url}'</script>
</head>
</html>`
)
res.end()
}

View file

@ -1,31 +0,0 @@
// pages/api/revalidate.js
export default async function handler(req, res) {
// should be secret, custom header coming in from Contentful
let inboundRevalToken = req.headers['x-vercel-reval-key']
// Check for secret to confirm this is a valid request
if (!inboundRevalToken) {
return res
.status(401)
.json({ message: 'x-vercel-reval-key header not defined' })
} else if (inboundRevalToken !== process.env.CONTENTFUL_REVALIDATE_SECRET) {
return res.status(401).json({ message: 'Invalid token' })
}
try {
// Note: if this fails to parse you may have forget to set the
// "content-type" header correctly as mentioned here https://github.com/vercel/next.js/blob/canary/examples/cms-contentful/README.md#step-9-try-using-on-demand-revalidation
let postSlug = req.body.fields.slug['en-US']
// revalidate the individual post and the home page
await res.revalidate(`/posts/${postSlug}`)
await res.revalidate('/')
return res.json({ revalidated: true })
} catch (err) {
// If there was an error, Next.js will continue
// to show the last successfully generated page
return res.status(500).send('Error revalidating')
}
}

View file

@ -1,43 +0,0 @@
import Container from '../components/container'
import MoreStories from '../components/more-stories'
import HeroPost from '../components/hero-post'
import Intro from '../components/intro'
import Layout from '../components/layout'
import { getAllPostsForHome } from '../lib/api'
import Head from 'next/head'
import { CMS_NAME } from '../lib/constants'
export default function Index({ preview, allPosts }) {
const heroPost = allPosts[0]
const morePosts = allPosts.slice(1)
return (
<>
<Layout preview={preview}>
<Head>
<title>{`Next.js Blog Example with ${CMS_NAME}`}</title>
</Head>
<Container>
<Intro />
{heroPost && (
<HeroPost
title={heroPost.title}
coverImage={heroPost.coverImage}
date={heroPost.date}
author={heroPost.author}
slug={heroPost.slug}
excerpt={heroPost.excerpt}
/>
)}
{morePosts.length > 0 && <MoreStories posts={morePosts} />}
</Container>
</Layout>
</>
)
}
export async function getStaticProps({ preview = false }) {
const allPosts = (await getAllPostsForHome(preview)) ?? []
return {
props: { preview, allPosts },
}
}

View file

@ -1,74 +0,0 @@
import { useRouter } from 'next/router'
import Head from 'next/head'
import ErrorPage from 'next/error'
import Container from '../../components/container'
import PostBody from '../../components/post-body'
import MoreStories from '../../components/more-stories'
import Header from '../../components/header'
import PostHeader from '../../components/post-header'
import SectionSeparator from '../../components/section-separator'
import Layout from '../../components/layout'
import { getAllPostsWithSlug, getPostAndMorePosts } from '../../lib/api'
import PostTitle from '../../components/post-title'
import { CMS_NAME } from '../../lib/constants'
export default function Post({ post, morePosts, preview }) {
const router = useRouter()
if (!router.isFallback && !post) {
return <ErrorPage statusCode={404} />
}
return (
<Layout preview={preview}>
<Container>
<Header />
{router.isFallback ? (
<PostTitle>Loading</PostTitle>
) : (
<>
<article>
<Head>
<title>
{`${post.title} | Next.js Blog Example with ${CMS_NAME}`}
</title>
<meta property="og:image" content={post.coverImage.url} />
</Head>
<PostHeader
title={post.title}
coverImage={post.coverImage}
date={post.date}
author={post.author}
/>
<PostBody content={post.content} />
</article>
<SectionSeparator />
{morePosts && morePosts.length > 0 && (
<MoreStories posts={morePosts} />
)}
</>
)}
</Container>
</Layout>
)
}
export async function getStaticProps({ params, preview = false }) {
const data = await getPostAndMorePosts(params.slug, preview)
return {
props: {
preview,
post: data?.post ?? null,
morePosts: data?.morePosts ?? null,
},
}
}
export async function getStaticPaths() {
const allPosts = await getAllPostsWithSlug()
return {
paths: allPosts?.map(({ slug }) => `/posts/${slug}`) ?? [],
fallback: true,
}
}

View file

@ -1,5 +1,3 @@
// If you want to use other PostCSS plugins, see the following:
// https://tailwindcss.com/docs/using-with-preprocessors
module.exports = {
plugins: {
tailwindcss: {},

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

View file

@ -1,9 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<browserconfig>
<msapplication>
<tile>
<square150x150logo src="/favicons/mstile-150x150.png"/>
<TileColor>#000000</TileColor>
</tile>
</msapplication>
</browserconfig>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 595 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 880 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

View file

@ -1,33 +0,0 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="1024.000000pt" height="1024.000000pt" viewBox="0 0 1024.000000 1024.000000"
preserveAspectRatio="xMidYMid meet">
<metadata>
Created by potrace 1.11, written by Peter Selinger 2001-2013
</metadata>
<g transform="translate(0.000000,1024.000000) scale(0.100000,-0.100000)"
fill="#000000" stroke="none">
<path d="M4785 10234 c-22 -2 -92 -9 -155 -14 -1453 -131 -2814 -915 -3676
-2120 -480 -670 -787 -1430 -903 -2235 -41 -281 -46 -364 -46 -745 0 -381 5
-464 46 -745 278 -1921 1645 -3535 3499 -4133 332 -107 682 -180 1080 -224
155 -17 825 -17 980 0 687 76 1269 246 1843 539 88 45 105 57 93 67 -8 6 -383
509 -833 1117 l-818 1105 -1025 1517 c-564 834 -1028 1516 -1032 1516 -4 1 -8
-673 -10 -1496 -3 -1441 -4 -1499 -22 -1533 -26 -49 -46 -69 -88 -91 -32 -16
-60 -19 -211 -19 l-173 0 -46 29 c-30 19 -52 44 -67 73 l-21 45 2 2005 3 2006
31 39 c16 21 50 48 74 61 41 20 57 22 230 22 204 0 238 -8 291 -66 15 -16 570
-852 1234 -1859 664 -1007 1572 -2382 2018 -3057 l810 -1227 41 27 c363 236
747 572 1051 922 647 743 1064 1649 1204 2615 41 281 46 364 46 745 0 381 -5
464 -46 745 -278 1921 -1645 3535 -3499 4133 -327 106 -675 179 -1065 223 -96
10 -757 21 -840 13z m2094 -3094 c48 -24 87 -70 101 -118 8 -26 10 -582 8
-1835 l-3 -1798 -317 486 -318 486 0 1307 c0 845 4 1320 10 1343 16 56 51 100
99 126 41 21 56 23 213 23 148 0 174 -2 207 -20z"/>
<path d="M7843 789 c-35 -22 -46 -37 -15 -20 22 13 58 40 52 41 -3 0 -20 -10
-37 -21z"/>
<path d="M7774 744 c-18 -14 -18 -15 4 -4 12 6 22 13 22 15 0 8 -5 6 -26 -11z"/>
<path d="M7724 714 c-18 -14 -18 -15 4 -4 12 6 22 13 22 15 0 8 -5 6 -26 -11z"/>
<path d="M7674 684 c-18 -14 -18 -15 4 -4 12 6 22 13 22 15 0 8 -5 6 -26 -11z"/>
<path d="M7598 644 c-38 -20 -36 -28 2 -9 17 9 30 18 30 20 0 7 -1 6 -32 -11z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

View file

@ -1,19 +0,0 @@
{
"name": "Next.js",
"short_name": "Next.js",
"icons": [
{
"src": "/favicons/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/favicons/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"theme_color": "#000000",
"background_color": "#000000",
"display": "standalone"
}

View file

@ -1,38 +0,0 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
'./pages/**/*.{js,ts,jsx,tsx}',
'./components/**/*.{js,ts,jsx,tsx}',
],
theme: {
extend: {
colors: {
'accent-1': '#FAFAFA',
'accent-2': '#EAEAEA',
'accent-7': '#333',
success: '#0070f3',
cyan: '#79FFE1',
},
spacing: {
28: '7rem',
},
letterSpacing: {
tighter: '-.04em',
},
lineHeight: {
tight: 1.2,
},
fontSize: {
'5xl': '2.5rem',
'6xl': '2.75rem',
'7xl': '4.5rem',
'8xl': '6.25rem',
},
boxShadow: {
small: '0 5px 10px rgba(0, 0, 0, 0.12)',
medium: '0 8px 30px rgba(0, 0, 0, 0.12)',
},
},
},
plugins: [],
}

View file

@ -0,0 +1,21 @@
import type { Config } from 'tailwindcss'
import typography from '@tailwindcss/typography'
export default {
content: [
'./app/**/*.{ts,tsx}',
'./components/**/*.{ts,tsx}',
'./lib/**/*.{ts,tsx}',
],
theme: {
extend: {
fontFamily: {
sans: ['var(--font-inter)'],
},
},
},
future: {
hoverOnlyWhenSupported: true,
},
plugins: [typography],
} satisfies Config

View file

@ -0,0 +1,28 @@
{
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

View file

@ -1,18 +0,0 @@
{
"Main": {
"name": {
"type": "Text",
"config": {
"label": "name"
}
},
"picture": {
"type": "Image",
"config": {
"constraint": {},
"thumbnails": [],
"label": "picture"
}
}
}
}

View file

@ -1,52 +0,0 @@
{
"Main": {
"title": {
"type": "StructuredText",
"config": {
"single": "heading1, heading2, heading3, heading4, heading5, heading6",
"label": "title"
}
},
"content": {
"type": "StructuredText",
"config": {
"multi": "paragraph, preformatted, heading1, heading2, heading3, heading4, heading5, heading6, strong, em, hyperlink, image, embed, list-item, o-list-item, o-list-item",
"label": "content"
}
},
"excerpt": {
"type": "Text",
"config": {
"label": "excerpt"
}
},
"coverimage": {
"type": "Image",
"config": {
"constraint": {},
"thumbnails": [],
"label": "coverimage"
}
},
"date": {
"type": "Date",
"config": {
"label": "date"
}
},
"author": {
"type": "Link",
"config": {
"select": "document",
"customtypes": ["author"],
"label": "author"
}
},
"uid": {
"type": "UID",
"config": {
"label": "slug"
}
}
}
}