Update Sanity example for App Router (#63045)

This PR updates the `cms-sanity` example to use:
- App Router
- TypeScript
- Sanity Studio v3 instead of v2
- Embeds the Studio inside the next app on the `/studio` route.
- ISR / Data Cache (revalidations through `revalidatePath` while in Live
Visual Editing, time-based to match the Sanity API CDN in production).
- Support Vercel Visual Editing out of the box.
- The new `next-sanity/image` component.
- Vercel Speed Insights.
- The Sanity Presentation Tool for live content previews.
- Sanity Portable Text setup to fully support `@tailwindcss/typography`.
- [AI Assist](https://www.sanity.io/docs/ai-assist)
  - Auto fill in `alt` text on images.
  - Preset prompts for content creation
This commit is contained in:
Cody Olsen 2024-03-11 03:49:18 +01:00 committed by GitHub
parent e148eac4a8
commit 41019c2314
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
102 changed files with 2096 additions and 1502 deletions

View file

@ -27,26 +27,26 @@ export default async function PostPage({
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">
<h2 className="mb-20 mt-8 text-2xl font-bold leading-tight tracking-tight md:text-4xl md:tracking-tighter">
<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">
<h1 className="mb-12 text-center text-6xl font-bold leading-tight tracking-tighter md:text-left md:text-7xl md:leading-none lg:text-8xl">
{post.title}
</h1>
<div className="hidden md:block md:mb-12">
<div className="hidden md:mb-12 md:block">
{post.author && (
<Avatar name={post.author.name} picture={post.author.picture} />
)}
</div>
<div className="mb-8 md:mb-16 sm:mx-0">
<div className="mb-8 sm:mx-0 md:mb-16">
<CoverImage title={post.title} url={post.coverImage.url} />
</div>
<div className="max-w-2xl mx-auto">
<div className="block md:hidden mb-6">
<div className="mx-auto max-w-2xl">
<div className="mb-6 block md:hidden">
{post.author && (
<Avatar name={post.author.name} picture={post.author.picture} />
)}
@ -56,7 +56,7 @@ export default async function PostPage({
</div>
</div>
<div className="max-w-2xl mx-auto">
<div className="mx-auto max-w-2xl">
<div className="prose">
<Markdown content={post.content} />
</div>

View file

@ -4,8 +4,8 @@ import Avatar from "./avatar";
export default function PostBody({ content }) {
return (
<div className="prose lg:prose-xl max-w-2xl mx-auto">
<div className="block md:hidden mb-6">
<div className="prose lg:prose-xl mx-auto max-w-2xl">
<div className="mb-6 block md:hidden">
{content.author.length ? (
<Avatar
name={`${content.author[0].firstName} ${content.author[0].lastName}`}

View file

@ -1,4 +1,4 @@
# https://github.com/vercel/next.js/tree/canary/examples/cms-sanity#using-the-sanity-cli
NEXT_PUBLIC_SANITY_PROJECT_ID=
NEXT_PUBLIC_SANITY_DATASET=
SANITY_API_READ_TOKEN=
SANITY_REVALIDATE_SECRET=

View file

@ -0,0 +1,4 @@
{
"extends": "next/core-web-vitals",
"root": true
}

View file

@ -39,4 +39,4 @@ next-env.d.ts
# Env files created by scripts for working locally
.env
studio/.env.development
.env.local

View file

@ -1,17 +1,267 @@
# A statically generated blog example using Next.js and Sanity
This example showcases Next.js's [Static Generation](https://nextjs.org/docs/basic-features/pages) feature using [Sanity](https://www.sanity.io/) as the data source.
![Screenshot of Sanity Studio using Presentation Tool to do Visual Editing](https://github.com/sanity-io/next.js/assets/81981/59ecd9d6-7a78-41c6-95f7-275f66fe3c9d)
You'll get:
This starter is a statically generated blog that uses Next.js App Router for the frontend and [Sanity][sanity-homepage] to handle its content. It comes with a native Sanity Studio that offers features like real-time collaboration and visual editing with live updates using [Presentation][presentation].
- Next.js deployed with the [Sanity Vercel Integration][integration].
- Sanity Studio running on localhost and deployed in the [cloud](https://www.sanity.io/docs/deployment).
- Sub-second as-you-type previews in Next.js
- [On-demand revalidation of pages](https://nextjs.org/blog/next-12-1#on-demand-incremental-static-regeneration-beta) with [GROQ powered webhooks](https://www.sanity.io/docs/webhooks)
The Studio connects to Sanity Content Lake, which gives you hosted content APIs with a flexible query language, on-demand image transformations, powerful patching, and more. You can use this starter to kick-start a blog or learn these technologies.
## Features
- A performant, static blog with editable posts, authors, and site settings
- A native and customizable authoring environment, accessible on `yourblog.com/studio`
- Real-time and collaborative content editing with fine-grained revision history
- Side-by-side instant content preview that works across your whole site
- Support for block content and the most advanced custom fields capability in the industry
- Incremental Static Revalidation; no need to wait for a rebuild to publish new content
- Unsplash integration setup for easy media management
- [Sanity AI Assist preconfigured for image alt text generation](https://www.sanity.io/docs/ai-assist?utm_source=github.com&utm_medium=organic_social&utm_campaign=ai-assist&utm_content=)
- Out of the box support for [Vercel Visual Editing](https://www.sanity.io/blog/visual-editing-sanity-vercel?utm_source=github.com&utm_medium=referral&utm_campaign=may-vercel-launch).
## Demo
### [https://next-blog-sanity.vercel.app](https://next-blog-sanity.vercel.app)
### [https://next-blog.sanity.build](https://next-blog.sanity.build)
## Deploy your own
Use the Deploy Button below, you'll deploy the example using [Vercel](https://vercel.com?utm_source=github&utm_medium=readme&utm_campaign=next-example) as well as connect it to your Sanity dataset using [the Sanity Vercel Integration][integration].
[![Deploy with Vercel](https://vercel.com/button)][vercel-deploy]
## How to use
Execute [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app) with [npm](https://docs.npmjs.com/cli/init), [Yarn](https://yarnpkg.com/lang/en/docs/cli/create/), or [pnpm](https://pnpm.io) to bootstrap the example:
```bash
npx create-next-app --example cms-sanity next-sanity-blog
```
```bash
yarn create next-app --example cms-sanity next-sanity-blog
```
```bash
pnpm create next-app --example cms-sanity next-sanity-blog
```
# Configuration
- [Step 1. Set up the environment](#step-1-set-up-the-environment)
- [Reuse remote envionment variables](#reuse-remote-envionment-variables)
- [Using the Sanity CLI](#using-the-sanity-cli)
- [Creating a read token](#creating-a-read-token)
- [Step 2. Run Next.js locally in development mode](#step-2-run-nextjs-locally-in-development-mode)
- [Step 3. Populate content](#step-3-populate-content)
- [Step 4. Deploy to production](#step-4-deploy-to-production)
- [Next steps](#next-steps)
## Step 1. Set up the environment
### Reuse remote envionment variables
If you started with [deploying your own](#deploy-your-own) then you can run this to reuse the environment variables from the Vercel project and skip to the next step:
```bash
npx vercel link
npx vercel pull
```
### Using the Sanity CLI
Copy the `.env.local.example` file to `.env.local` to get started:
```bash
cp -i .env.local.example .env.local
```
Run the setup command to get setup with a Sanity project, dataset and their relevant environment variables:
```bash
npm run setup
```
```bash
yarn setup
```
```bash
pnpm run setup
```
You'll be asked multiple questions, here's a sample output of what you can expect:
```bash
Need to install the following packages:
sanity@3.30.1
Ok to proceed? (y) y
You're setting up a new project!
We'll make sure you have an account with Sanity.io.
Press ctrl + C at any time to quit.
Prefer web interfaces to terminals?
You can also set up best practice Sanity projects with
your favorite frontends on https://www.sanity.io/templates
Looks like you already have a Sanity-account. Sweet!
✔ Fetching existing projects
? Select project to use Templates [r0z1eifg]
? Select dataset to use blog-vercel
? Would you like to add configuration files for a Sanity project in this Next.js folder? No
Detected framework Next.js, using prefix 'NEXT_PUBLIC_'
Found existing NEXT_PUBLIC_SANITY_PROJECT_ID, replacing value.
Found existing NEXT_PUBLIC_SANITY_DATASET, replacing value.
```
It's important that when you're asked `Would you like to add configuration files for a Sanity project in this Next.js folder?` that you answer `No` as this example is alredy setup with the required configuration files.
#### Creating a read token
This far your `.env.local` file should have values for `NEXT_PUBLIC_SANITY_PROJECT_ID` and `NEXT_PUBLIC_SANITY_DATASET`.
Before you can run the project you need to setup a read token (`SANITY_API_READ_TOKEN`), it's used for authentication when Sanity Studio is live previewing your application.
1. Go to [manage.sanity.io](https://manage.sanity.io/) and select your project.
2. Click on the `🔌 API` tab.
3. Click on `+ Add API token`.
4. Name it "next blog live preview read token" and set `Permissions` to `Viewer` and hit `Save`.
5. Copy the token and add it to your `.env.local` file.
```bash
SANITY_API_READ_TOKEN="<paste your token here>"
```
Your `.env.local` file should look something like this:
```bash
NEXT_PUBLIC_SANITY_PROJECT_ID="r0z1eifg"
NEXT_PUBLIC_SANITY_DATASET="blog-vercel"
SANITY_API_READ_TOKEN="sk..."
```
> [!CAUTION]
> Make sure to add `.env.local` to your `.gitignore` file so you don't accidentally commit it to your repository.
## Step 2. Run Next.js locally in development mode
```bash
npm install && npm run dev
```
```bash
yarn install && yarn dev
```
```bash
pnpm install && pnpm 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 3. Populate content
Open your Sanity Studio that should be running on [http://localhost:3000/studio](http://localhost:3000/studio).
By default you're taken to the [Presentation tool][presentation], which has a preview of the blog on the left hand side, and a list of documents on the right hand side.
<details>
<summary>View screenshot ✨</summary>
![screenshot](https://github.com/vercel/next.js/assets/81981/07cbc580-4a03-4837-9aa4-90b632c95630)
</details>
We're all set to do some content creation!
- Click on the **"+ Create"** button top left and select **Post**
- Type some dummy data for the **Title**
- **Generate** a **Slug**
<details>
<summary>Now that you have a slug you should see the post show up in the preview on the left hand side ✨</summary>
![screenshot](https://github.com/vercel/next.js/assets/81981/05b74848-6ae4-442b-8995-0b7e2180aa74)
</details>
- Fill in **Content** with some dummy text
<details>
<summary>Or generate it with AI Assist ✨</summary>
If you've enabled [AI Assist][enable-ai-assist] you click on the sparkles ✨ button and generate a draft based on your title and then on **Generate sample content**.
![screenshot](https://github.com/vercel/next.js/assets/81981/2276d8ad-5b55-447c-befe-d53249f091e1)
</details>
- Summarize the **Content** in the **Excerpt** field
<details>
<summary>Or have AI Assist summarize it for you ✨</summary>
If you've enabled [AI Assist][enable-ai-assist] you click on the sparkles ✨ button and then on **Generate sample content**.
![screenshot](https://github.com/vercel/next.js/assets/81981/d24b9b37-cd88-4519-8094-f4c956102450)
</details>
- Select a **Cover Image** from [Unsplash].
<details>
<summary>Unsplash is available in the **Select** dropdown ✨</summary>
![screenshot](https://github.com/vercel/next.js/assets/81981/204d004d-9396-434e-8795-a8b68a2ed89b)
</details>
<details>
<summary>Click the "Crop image" button to adjust hotspots and cropping ✨</summary>
![screenshot](https://github.com/vercel/next.js/assets/81981/e905fc6e-5bab-46a7-baec-7cb08747772c)
</details>
<details>
<summary>You can preview the results live on the left side, and additional formats on the right side ✨</summary>
![screenshot](https://github.com/vercel/next.js/assets/81981/6c59eef0-d2d9-4d77-928a-98e99df4b1df)
</details>
- Customize the blog name, description and more.
<details>
<summary>Click "Structure" at the top center, then on "Settings" on the left hand side ✨</summary>
![screenshot](https://github.com/vercel/next.js/assets/81981/14f48d83-af81-4589-900e-a7a598cc608a)
</details>
<details>
<summary>Once you have a "Settings" document, you can customize it inside "Presentation" ✨</summary>
![screenshot](https://github.com/vercel/next.js/assets/81981/e3473f7b-5e7e-46ab-8d43-cae54a4b929b)
</details>
> [!IMPORTANT]
> For each post record, you need to click **Publish** after saving for it to be visible outside Draft Mode. In production new content is using [Time-based Revalidation](https://nextjs.org/docs/app/building-your-application/data-fetching/fetching-caching-and-revalidating#time-based-revalidation), which means it may take up to 1 minute before changes show up. Since a stale-while-revalidate pattern is used you may need to refresh a couple of times to see the changes.
## Step 4. Deploy to production
> [!NOTE]
> If you already [deployed with Vercel earlier](#deploy-your-own) you can skip this step.
To deploy your local project to Vercel, push it to [GitHub](https://docs.github.com/en/get-started/importing-your-projects-to-github/importing-source-code-to-github/adding-locally-hosted-code-to-github)/GitLab/Bitbucket and [import to Vercel](https://vercel.com/new?utm_source=github&utm_medium=readme&utm_campaign=next-example).
> [!IMPORTANT]
> When you import your project on Vercel, make sure to click on **Environment Variables** and set them to match your `.env.local` file.
After it's deployed link your local code to the Vercel project:
```bash
npx vercel link
```
> [!TIP]
> In production you can exit Draft Mode by clickin on _"Back to published"_ at the top. On [Preview deployments](https://vercel.com/docs/deployments/preview-deployments) you can [toggle Draft Mode in the Vercel Toolbar](https://vercel.com/docs/workflow-collaboration/draft-mode#enabling-draft-mode-in-the-vercel-toolbar).
## Next steps
- [Join the Sanity community](https://slack.sanity.io/)
## Related examples
@ -38,298 +288,10 @@ You'll get:
- [Blog Starter](/examples/blog-starter)
- [WordPress](/examples/cms-wordpress)
# Configuration
- [Step 1. Set up the environment](#step-1-set-up-the-environment)
- [Step 2. Run Next.js locally in development mode](#step-2-run-nextjs-locally-in-development-mode)
- [Step 3. Populate content](#step-3-populate-content)
- [Step 4. Deploy to production & use Preview Mode from anywhere](#step-4-deploy-to-production--use-preview-mode-from-anywhere)
- [If you didn't Deploy with Vercel earlier do so now](#if-you-didnt-deploy-with-vercel-earlier-do-so-now)
- [Configure CORS for production](#configure-cors-for-production)
- [Add the preview secret environment variable](#add-the-preview-secret-environment-variable)
- [How to test locally that the secret is setup correctly](#how-to-test-locally-that-the-secret-is-setup-correctly)
- [How to start Preview Mode for Next.js in production from a local Studio](#how-to-start-preview-mode-for-nextjs-in-production-from-a-local-studio)
- [If you regret sending a preview link to someone](#if-you-regret-sending-a-preview-link-to-someone)
- [Step 5. Deploy your Studio and publish from anywhere](#step-5-deploy-your-studio-and-publish-from-anywhere)
- [Step 6. Setup Revalidation Webhook](#step-6-setup-revalidation-webhook)
- [Testing the Webhook](#testing-the-webhook)
- [Next steps](#next-steps)
## Step 1. Set up the environment
Use the Deploy Button below, you'll deploy the example using [Vercel](https://vercel.com?utm_source=github&utm_medium=readme&utm_campaign=next-example) as well as connect it to your Sanity dataset using [the Sanity Vercel Integration][integration].
[![Deploy with Vercel](https://vercel.com/button)][vercel-deploy]
[Clone the repository](https://docs.github.com/en/repositories/creating-and-managing-repositories/cloning-a-repository) that Vercel created for you and from the root directory of your local checkout.
Then link your clone to Vercel:
```bash
npx vercel link
```
Download the environment variables needed to connect Next.js and Studio to your Sanity project:
```bash
npx vercel env pull
```
## Step 2. Run Next.js locally in development mode
```bash
npm install && npm run dev
```
```bash
yarn install && 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).
Note: This also installs dependencies for Sanity Studio as a post-install step.
## Step 3. Populate content
In another terminal start up the studio:
```bash
npm run studio:dev
```
Your studio should be up and running on [http://localhost:3333](http://localhost:3333)!
### Create content
Create content in Sanity Studio and live preview it in Next.js, side-by-side, by opening these URLs:
- [`http://localhost:3333`](http://localhost:3333)
- [`http://localhost:3000/api/preview`](http://localhost:3000/api/preview)
<details>
<summary>View screenshot ✨</summary>
![screenshot](https://user-images.githubusercontent.com/81981/182991870-7a0f6e54-b35e-4728-922b-409fcf1d6cc3.png)
</details>
We're all set to do some content creation!
- Click on the **"Create new document"** button top left and select **Post**
- Type some dummy data for the **Title**
- **Generate** a **Slug**
<details>
<summary>View screenshot ✨</summary>
![screenshot](https://user-images.githubusercontent.com/81981/182993687-b6313086-f60a-4b36-b038-4c1c63b53c54.png)
</details>
- Set the **Date**
- Select a **Cover Image** from [Unsplash].
<details>
<summary>View screenshot ✨</summary>
![screenshot](https://user-images.githubusercontent.com/81981/182994571-f204c41c-e1e3-44f4-82b3-99fefbd25bec.png)
</details>
- Let's create an **Author** inline, click **Create new**.
- Give the **Author** a **Name**.
- After selecting a **Picture** of a **face** from [Unsplash], set a hotspot to ensure pixel-perfect cropping.
<details>
<summary>View screenshot ✨</summary>
![screenshot](https://user-images.githubusercontent.com/81981/182995772-33d63e45-4920-48c5-aa47-ccb7ce10170c.png)
</details>
- Create a couple more **Posts** and watch how the layout adapt to more content.
**Important:** For each post record, you need to click **Publish** after saving for it to be visible outside Preview Mode.
To exit Preview Mode, you can click on _"Click here to exit preview mode"_ at the top.
## Step 4. Deploy to production & use Preview Mode from anywhere
### If you didn't [Deploy with Vercel earlier](#step-1-set-up-the-environment) do so now
To deploy your local project to Vercel, push it to [GitHub](https://docs.github.com/en/get-started/importing-your-projects-to-github/importing-source-code-to-github/adding-locally-hosted-code-to-github)/GitLab/Bitbucket and [import to Vercel](https://vercel.com/new?utm_source=github&utm_medium=readme&utm_campaign=next-example).
**Important**: When you import your project on Vercel, make sure to click on **Environment Variables** and set them to match your `.env.local` file.
After it's deployed link your local code to the Vercel project:
```bash
npx vercel link
```
### Configure CORS for production
Add your `production url` to the list over CORS origins.
<details>
<summary>Don't remember the production url? 🤔</summary>
No worries, it's easy to find out. Go to your [Vercel Dashboard](https://vercel.com/) and click on your project:
![screenshot](https://user-images.githubusercontent.com/81981/183002637-6aa6b1d8-e0ee-4a9b-bcc0-d49799fcc984.png)
In the screenshot above the `production url` is `https://cms-sanity.vercel.app`.
</details>
```bash
npm --prefix studio run cors:add -- [your production url] --credentials
```
### Add the preview secret environment variable
It's required to set a secret that makes Preview Mode activation links unique. Otherwise anyone could see your unpublished content by just opening `[your production url]/api/preview`.
Run this and it'll prompt you for a value:
```bash
npx vercel env add SANITY_STUDIO_PREVIEW_SECRET
```
The secret can be any combination of random words and letters as long as it's URL safe.
You can generate one in your DevTools console using `copy(Math.random().toString(36).substr(2, 10))` if you don't feel like inventing one.
You should see something like this in your terminal afterwards:
```bash
$ npx vercel env add SANITY_STUDIO_PREVIEW_SECRET
Vercel CLI 27.3.7
? Whats the value of SANITY_STUDIO_PREVIEW_SECRET? 2whpu1jefs
? Add SANITY_STUDIO_PREVIEW_SECRET to which Environments (select multiple)? Production, Preview, Development
✅ Added Environment Variable SANITY_STUDIO_PREVIEW_SECRET to Project cms-sanity [1s]
```
Redeploy production to apply the secret to the preview api:
```bash
npx vercel --prod
```
After it deploys it should now start preview mode if you launch `[your production url]/api/preview?secret=[your preview secret]`. You can send that preview url to people you want to show the content you're working on before you publish it.
### How to test locally that the secret is setup correctly
In order to test that the secret will prevent unauthorized people from activating preview mode, start by updating the local `.env` with the secret you just made:
```bash
npx vercel env pull
```
Restart your Next.js and Studio processes so the secret is applied:
```bash
npm run dev
```
```bash
npm run studio:dev
```
And now you'll get an error if `[secret]` is incorrect when you try to open `https://localhost:3000/api/preview?secret=[secret]`.
### How to start Preview Mode for Next.js in production from a local Studio
Run this to make the Studio open previews at `[your production url]/api/preview` instead of `http://localhost:3000/api/preview`
```bash
SANITY_STUDIO_PREVIEW_URL=[your production url] npm run studio:dev
```
### If you regret sending a preview link to someone
Revoke their access by creating a new secret:
```bash
npx vercel env rm SANITY_STUDIO_PREVIEW_SECRET
npx vercel env add SANITY_STUDIO_PREVIEW_SECRET
npx vercel --prod
```
## Step 5. Deploy your Studio and publish from anywhere
Live previewing content is fun, but collaborating on content in real-time is next-level:
```bash
SANITY_STUDIO_PREVIEW_URL=[your production url] npm run studio:deploy
```
If it's successful you should see something like this in your terminal:
```bash
SANITY_STUDIO_PREVIEW_URL="https://cms-sanity.vercel.app" npm run studio:deploy
? Studio hostname (<value>.sanity.studio): cms-sanity
Including the following environment variables as part of the JavaScript bundle:
- SANITY_STUDIO_PREVIEW_URL
- SANITY_STUDIO_PREVIEW_SECRET
- SANITY_STUDIO_API_PROJECT_ID
- SANITY_STUDIO_API_DATASET
✔ Deploying to Sanity.Studio
Success! Studio deployed to https://cms-sanity.sanity.studio/
```
This snippet is stripped from verbose information, you'll see a lot of extra stuff in your terminal.
## Step 6. Setup Revalidation Webhook
Using GROQ Webhooks Next.js can rebuild pages that have changed content. It rebuilds so fast it can almost compete with Preview Mode.
Create a secret and give it a value the same way you did for `SANITY_STUDIO_PREVIEW_SECRET` in [Step 4](#add-the-preview-secret-environment-variable). It's used to verify that webhook payloads came from Sanity infra, and set it as the value for `SANITY_REVALIDATE_SECRET`:
```bash
npx vercel env add SANITY_REVALIDATE_SECRET
```
You should see something like this in your terminal afterwards:
```bash
$ npx vercel env add SANITY_REVALIDATE_SECRET
Vercel CLI 27.3.7
? Whats the value of SANITY_REVALIDATE_SECRET? jwh3nr85ft
? Add SANITY_REVALIDATE_SECRET to which Environments (select multiple)? Production, Preview, Development
✅ Added Environment Variable SANITY_REVALIDATE_SECRET to Project cms-sanity [1s]
```
Apply the secret to production:
```bash
npx vercel --prod
```
Wormhole into the [manager](https://manage.sanity.io/) by running:
```bash
(cd studio && npx sanity hook create)
```
- **Name** it "On-demand Revalidation".
- Set the **URL** to`[your production url]/api/revalidate`, for example: `https://cms-sanity.vercel.app/api/revalidate`
- Set the **Trigger on** field to <label><input type=checkbox checked> Create</label> <label><input type=checkbox checked> Update</label> <label><input type=checkbox checked> Delete</label>
- Set the **Filter** to `_type == "post" || _type == "author"`
- Set the **Secret** to the same value you gave `SANITY_REVALIDATE_SECRET` earlier.
- Hit **Save**!
### Testing the Webhook
- Open the Deployment function log. (**Vercel Dashboard > Deployment > Functions** and filter by `api/revalidate`)
- Edit a Post in your Sanity Studio and publish.
- The log should start showing calls.
- And the published changes show up on the site after you reload.
## Next steps
- Mount your preview inside the Sanity Studio for comfortable side-by-side editing
- [Join the Sanity community](https://slack.sanity.io/)
[vercel-deploy]: https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fvercel%2Fnext.js%2Ftree%2Fcanary%2Fexamples%2Fcms-sanity&repository-name=cms-sanity&project-name=cms-sanity&demo-title=Blog%20using%20Next.js%20%26%20Sanity&demo-description=On-demand%20ISR%2C%20sub-second%20as-you-type%20previews&demo-url=https%3A%2F%2Fnext-blog-sanity.vercel.app%2F&demo-image=https%3A%2F%2Fuser-images.githubusercontent.com%2F110497645%2F182727236-75c02b1b-faed-4ae2-99ce-baa089f7f363.png&integration-ids=oac_hb2LITYajhRQ0i4QznmKH7gx
[vercel-deploy]: https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fvercel%2Fnext.js%2Ftree%2Fcanary%2Fexamples%2Fcms-sanity&repository-name=cms-sanity&project-name=cms-sanity&demo-title=Blog%20using%20Next.js%20%26%20Sanity&demo-description=Real-time%20updates%2C%20seamless%20editing%2C%20no%20rebuild%20delays.&demo-url=https%3A%2F%2Fnext-blog.sanity.build%2F&demo-image=https%3A%2F%2Fgithub.com%2Fsanity-io%2Fnext-sanity%2Fassets%2F81981%2Fb81296a9-1f53-4eec-8948-3cb51aca1259&integration-ids=oac_hb2LITYajhRQ0i4QznmKH7gx
[integration]: https://www.sanity.io/docs/vercel-integration
[`sanity.json`]: studio/sanity.json
[`.env.local.example`]: .env.local.example
[unsplash]: https://unsplash.com
[sanity-homepage]: https://www.sanity.io?utm_source=github.com&utm_medium=referral&utm_campaign=nextjs-v3vercelstarter
[presentation]: https://www.sanity.io/docs/presentation
[enable-ai-assist]: https://www.sanity.io/plugins/ai-assist#enabling-the-ai-assist-api

View file

@ -0,0 +1,12 @@
"use server";
import { draftMode } from "next/headers";
export async function disableDraftMode() {
"use server";
await Promise.allSettled([
draftMode().disable(),
// Simulate a delay to show the loading state
new Promise((resolve) => setTimeout(resolve, 1000)),
]);
}

View file

@ -0,0 +1,52 @@
"use client";
import { useRouter } from "next/navigation";
import { useSyncExternalStore, useTransition } from "react";
import { disableDraftMode } from "./actions";
const emptySubscribe = () => () => {};
export default function AlertBanner() {
const router = useRouter();
const [pending, startTransition] = useTransition();
const shouldShow = useSyncExternalStore(
emptySubscribe,
() => window.top === window,
() => false,
);
if (!shouldShow) return null;
return (
<div
className={`${
pending ? "animate-pulse" : ""
} fixed top-0 left-0 z-50 w-full border-b bg-white/95 text-black backdrop-blur`}
>
<div className="py-2 text-center text-sm">
{pending ? (
"Disabling draft mode..."
) : (
<>
{"Previewing drafts. "}
<button
type="button"
onClick={() =>
startTransition(() =>
disableDraftMode().then(() => {
router.refresh();
}),
)
}
className="hover:text-cyan underline transition-colors duration-200"
>
Back to published
</button>
</>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,31 @@
import { Image } from "next-sanity/image";
import { Author } from "@/sanity/lib/queries";
import { urlForImage } from "@/sanity/lib/utils";
export default function Avatar({ name, picture }: Author) {
return (
<div className="flex items-center text-xl">
{picture?.asset?._ref ? (
<div className="mr-4 h-12 w-12">
<Image
alt={picture?.alt || ""}
className="h-full rounded-full object-cover"
height={48}
width={48}
src={
urlForImage(picture)
?.height(96)
.width(96)
.fit("crop")
.url() as string
}
/>
</div>
) : (
<div className="mr-1">By </div>
)}
<div className="text-pretty text-xl font-bold">{name}</div>
</div>
);
}

View file

@ -0,0 +1,31 @@
import { Image } from "next-sanity/image";
import { urlForImage } from "@/sanity/lib/utils";
interface CoverImageProps {
image: any;
priority?: boolean;
}
export default function CoverImage(props: CoverImageProps) {
const { image: source, priority } = props;
const image = source?.asset?._ref ? (
<Image
className="h-auto w-full"
width={2000}
height={1000}
alt={source?.alt || ""}
src={urlForImage(source)?.height(1000).width(2000).url() as string}
sizes="100vw"
priority={priority}
/>
) : (
<div className="bg-slate-50" style={{ paddingTop: "50%" }} />
);
return (
<div className="shadow-md transition-shadow duration-200 group-hover:shadow-lg sm:mx-0">
{image}
</div>
);
}

View file

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

View file

@ -0,0 +1,115 @@
import "../globals.css";
import { SpeedInsights } from "@vercel/speed-insights/next";
import { Metadata } from "next";
import { PortableTextBlock, VisualEditing, toPlainText } from "next-sanity";
import { Inter } from "next/font/google";
import { draftMode } from "next/headers";
import { Suspense } from "react";
import AlertBanner from "./alert-banner";
import PortableText from "./portable-text";
import * as demo from "@/sanity/lib/demo";
import { sanityFetch } from "@/sanity/lib/fetch";
import { SettingsQueryResponse, settingsQuery } from "@/sanity/lib/queries";
import { resolveOpenGraphImage } from "@/sanity/lib/utils";
export async function generateMetadata(): Promise<Metadata> {
const settings = await sanityFetch<SettingsQueryResponse>({
query: settingsQuery,
// Metadata should never contain stega
stega: false,
});
const title = settings?.title || demo.title;
const description = settings?.description || demo.description;
const ogImage = resolveOpenGraphImage(settings?.ogImage);
let metadataBase: URL | undefined = undefined;
try {
metadataBase = settings?.ogImage?.metadataBase
? new URL(settings.ogImage.metadataBase)
: undefined;
} catch {
// ignore
}
return {
metadataBase,
title: {
template: `%s | ${title}`,
default: title,
},
description: toPlainText(description),
openGraph: {
images: ogImage ? [ogImage] : [],
},
};
}
const inter = Inter({
variable: "--font-inter",
subsets: ["latin"],
display: "swap",
});
async function Footer() {
const data = await sanityFetch<SettingsQueryResponse>({
query: settingsQuery,
});
const footer = data?.footer || ([] as PortableTextBlock[]);
return (
<footer className="bg-accent-1 border-accent-2 border-t">
<div className="container mx-auto px-5">
{footer.length > 0 ? (
<PortableText
className="prose-sm text-pretty bottom-0 w-full max-w-none bg-white py-12 text-center md:py-20"
value={footer}
/>
) : (
<div className="flex flex-col items-center py-28 lg:flex-row">
<h3 className="mb-10 text-center text-4xl font-bold leading-tight tracking-tighter lg:mb-0 lg:w-1/2 lg:pr-4 lg:text-left lg:text-5xl">
Built with Next.js.
</h3>
<div className="flex flex-col items-center justify-center lg:w-1/2 lg:flex-row lg:pl-4">
<a
href="https://nextjs.org/docs"
className="mx-3 mb-6 border border-black bg-black py-3 px-12 font-bold text-white transition-colors duration-200 hover:bg-white hover:text-black lg:mb-0 lg:px-8"
>
Read Documentation
</a>
<a
href="https://github.com/vercel/next.js/tree/canary/examples/cms-sanity"
className="mx-3 font-bold hover:underline"
>
View on GitHub
</a>
</div>
</div>
)}
</div>
</footer>
);
}
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" className={`${inter.variable} bg-white text-black`}>
<body>
<section className="min-h-screen">
{draftMode().isEnabled && <AlertBanner />}
<main>{children}</main>
<Suspense>
<Footer />
</Suspense>
</section>
{draftMode().isEnabled && <VisualEditing />}
<SpeedInsights />
</body>
</html>
);
}

View file

@ -0,0 +1,52 @@
import Link from "next/link";
import Avatar from "./avatar";
import CoverImage from "./cover-image";
import DateComponent from "./date";
import { sanityFetch } from "@/sanity/lib/fetch";
import {
MoreStoriesQueryResponse,
moreStoriesQuery,
} from "@/sanity/lib/queries";
export default async function MoreStories(params: {
skip: string;
limit: number;
}) {
const data = await sanityFetch<MoreStoriesQueryResponse>({
query: moreStoriesQuery,
params,
});
return (
<>
<div className="mb-32 grid grid-cols-1 gap-y-20 md:grid-cols-2 md:gap-x-16 md:gap-y-32 lg:gap-x-32">
{data?.map((post) => {
const { _id, title, slug, coverImage, excerpt, author } = post;
return (
<article key={_id}>
<Link href={`/posts/${slug}`} className="group mb-5 block">
<CoverImage image={coverImage} priority={false} />
</Link>
<h3 className="text-balance mb-3 text-3xl leading-snug">
<Link href={`/posts/${slug}`} className="hover:underline">
{title}
</Link>
</h3>
<div className="mb-4 text-lg">
<DateComponent dateString={post.date} />
</div>
{excerpt && (
<p className="text-pretty mb-4 text-lg leading-relaxed">
{excerpt}
</p>
)}
{author && <Avatar name={author.name} picture={author.picture} />}
</article>
);
})}
</div>
</>
);
}

View file

@ -0,0 +1,73 @@
"use client";
/**
* This file is used for onboarding when you don't have any posts yet and are using the template for the first time.
* Once you have content, and know where to go to access the Sanity Studio and create content, you can delete this file.
*/
import Link from "next/link";
import { useSyncExternalStore } from "react";
const emptySubscribe = () => () => {};
export default function Onboarding() {
const target = useSyncExternalStore(
emptySubscribe,
() => (window.top === window ? undefined : "_blank"),
() => "_blank",
);
return (
<div className="grid grid-flow-row gap-6 py-60 text-center">
<svg
className="mx-auto h-10 w-10 text-gray-400"
aria-hidden="true"
width="512"
height="512"
viewBox="0 0 512 512"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect width="512" height="512" fill="#F03E2F" rx="30" />
<path
d="M161.527 136.723C161.527 179.76 187.738 205.443 240.388 219.095L296 232.283C345.687 243.852 376 272.775 376 319.514C376 341.727 369.162 360.931 357.538 375.971C357.538 329.232 333.607 303.78 276.171 288.74L221.47 276.246C177.709 266.065 143.977 242.464 143.977 191.56C143.977 170.505 150.359 151.994 161.527 136.723Z"
fill="white"
/>
<path
opacity="0.5"
d="M323.35 308.176C347.054 323.679 357.538 345.197 357.538 376.202C337.709 401.654 303.293 416 262.724 416C194.575 416 146.484 381.756 136 322.753H201.641C210.074 350.056 232.41 362.551 262.268 362.551C298.735 362.32 322.895 342.652 323.35 308.176Z"
fill="white"
/>
<path
opacity="0.5"
d="M195.715 200.816C172.923 186.007 161.527 165.183 161.527 136.954C180.672 111.503 213.493 96 253.835 96C323.35 96 363.692 133.252 373.721 185.776H310.359C303.293 165.183 285.971 148.986 254.291 148.986C220.33 148.986 197.311 169.116 195.715 200.816Z"
fill="white"
/>
</svg>
<div>
<h3 className="text-sm font-semibold text-gray-900">No posts</h3>
<p className="mt-1 text-sm text-gray-500">
Get started by creating a new post.
</p>
</div>
<div>
<Link
className="inline-flex items-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
href="/studio/intent/create/template=post;type=post/"
target={target}
>
<svg
className="-ml-0.5 mr-1.5 h-5 w-5"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path d="M10.75 4.75a.75.75 0 00-1.5 0v4.5h-4.5a.75.75 0 000 1.5h4.5v4.5a.75.75 0 001.5 0v-4.5h4.5a.75.75 0 000-1.5h-4.5v-4.5z" />
</svg>
Create Post
</Link>
</div>
</div>
);
}

View file

@ -0,0 +1,116 @@
import Link from "next/link";
import { Suspense } from "react";
import Avatar from "./avatar";
import CoverImage from "./cover-image";
import DateComponent from "./date";
import MoreStories from "./more-stories";
import Onboarding from "./onboarding";
import PortableText from "./portable-text";
import * as demo from "@/sanity/lib/demo";
import { sanityFetch } from "@/sanity/lib/fetch";
import {
HeroQueryResponse,
Post,
SettingsQueryResponse,
heroQuery,
settingsQuery,
} from "@/sanity/lib/queries";
function Intro(props: { title: string | null | undefined; description: any }) {
const title = props.title || demo.title;
const description = props.description?.length
? props.description
: demo.description;
return (
<section className="mt-16 mb-16 flex flex-col items-center lg:mb-12 lg:flex-row lg:justify-between">
<h1 className="text-balance text-6xl font-bold leading-tight tracking-tighter lg:pr-8 lg:text-8xl">
{title || demo.title}
</h1>
<h2 className="text-pretty mt-5 text-center text-lg lg:pl-8 lg:text-left">
<PortableText
className="prose-lg"
value={description?.length ? description : demo.description}
/>
</h2>
</section>
);
}
function HeroPost({
title,
slug,
excerpt,
coverImage,
date,
author,
}: Pick<
Post,
"title" | "coverImage" | "date" | "excerpt" | "author" | "slug"
>) {
return (
<article>
<Link className="group mb-8 block md:mb-16" href={`/posts/${slug}`}>
<CoverImage image={coverImage} priority />
</Link>
<div className="mb-20 md:mb-28 md:grid md:grid-cols-2 md:gap-x-16 lg:gap-x-8">
<div>
<h3 className="text-pretty mb-4 text-4xl leading-tight lg:text-6xl">
<Link href={`/posts/${slug}`} className="hover:underline">
{title}
</Link>
</h3>
<div className="mb-4 text-lg md:mb-0">
<DateComponent dateString={date} />
</div>
</div>
<div>
{excerpt && (
<p className="text-pretty mb-4 text-lg leading-relaxed">
{excerpt}
</p>
)}
{author && <Avatar name={author.name} picture={author.picture} />}
</div>
</div>
</article>
);
}
export default async function Page() {
const [settings, heroPost] = await Promise.all([
sanityFetch<SettingsQueryResponse>({
query: settingsQuery,
}),
sanityFetch<HeroQueryResponse>({ query: heroQuery }),
]);
return (
<div className="container mx-auto px-5">
<Intro title={settings?.title} description={settings?.description} />
{heroPost ? (
<HeroPost
title={heroPost.title}
slug={heroPost.slug}
coverImage={heroPost.coverImage}
excerpt={heroPost.excerpt}
date={heroPost.date}
author={heroPost.author}
/>
) : (
<Onboarding />
)}
{heroPost?._id && (
<aside>
<h2 className="mb-8 text-6xl font-bold leading-tight tracking-tighter md:text-7xl">
More Stories
</h2>
<Suspense>
<MoreStories skip={heroPost._id} limit={100} />
</Suspense>
</aside>
)}
</div>
);
}

View file

@ -0,0 +1,49 @@
/**
* This component uses Portable Text to render a post body.
*
* You can learn more about Portable Text on:
* https://www.sanity.io/docs/block-content
* https://github.com/portabletext/react-portabletext
* https://portabletext.org/
*
*/
import {
PortableText,
type PortableTextComponents,
type PortableTextBlock,
} from "next-sanity";
export default function CustomPortableText({
className,
value,
}: {
className?: string;
value: PortableTextBlock[];
}) {
const components: PortableTextComponents = {
block: {
h5: ({ children }) => (
<h5 className="mb-2 text-sm font-semibold">{children}</h5>
),
h6: ({ children }) => (
<h6 className="mb-1 text-xs font-semibold">{children}</h6>
),
},
marks: {
link: ({ children, value }) => {
return (
<a href={value?.href} rel="noreferrer noopener">
{children}
</a>
);
},
},
};
return (
<div className={["prose", className].filter(Boolean).join(" ")}>
<PortableText components={components} value={value} />
</div>
);
}

View file

@ -0,0 +1,118 @@
import type { Metadata, ResolvingMetadata } from "next";
import { groq } from "next-sanity";
import Link from "next/link";
import { notFound } from "next/navigation";
import { Suspense } from "react";
import Avatar from "../../avatar";
import CoverImage from "../../cover-image";
import DateComponent from "../../date";
import MoreStories from "../../more-stories";
import PortableText from "../../portable-text";
import { sanityFetch } from "@/sanity/lib/fetch";
import {
PostQueryResponse,
SettingsQueryResponse,
postQuery,
settingsQuery,
} from "@/sanity/lib/queries";
import { resolveOpenGraphImage } from "@/sanity/lib/utils";
import * as demo from "@/sanity/lib/demo";
type Props = {
params: { slug: string };
};
export async function generateStaticParams() {
return sanityFetch<{ slug: string }[]>({
query: groq`*[_type == "post" && defined(slug.current)]{"slug": slug.current}`,
perspective: "published",
stega: false,
});
}
export async function generateMetadata(
{ params }: Props,
parent: ResolvingMetadata,
): Promise<Metadata> {
const post = await sanityFetch<PostQueryResponse>({
query: postQuery,
params,
stega: false,
});
const previousImages = (await parent).openGraph?.images || [];
const ogImage = resolveOpenGraphImage(post?.coverImage);
return {
authors: post?.author?.name ? [{ name: post?.author?.name }] : [],
title: post?.title,
description: post?.excerpt,
openGraph: {
images: ogImage ? [ogImage, ...previousImages] : previousImages,
},
} satisfies Metadata;
}
export default async function PostPage({ params }: Props) {
const [post, settings] = await Promise.all([
sanityFetch<PostQueryResponse>({
query: postQuery,
params,
}),
sanityFetch<SettingsQueryResponse>({
query: settingsQuery,
}),
]);
if (!post?._id) {
return notFound();
}
return (
<div className="container mx-auto px-5">
<h2 className="mb-16 mt-10 text-2xl font-bold leading-tight tracking-tight md:text-4xl md:tracking-tighter">
<Link href="/" className="hover:underline">
{settings?.title || demo.title}
</Link>
</h2>
<article>
<h1 className="text-balance mb-12 text-6xl font-bold leading-tight tracking-tighter md:text-7xl md:leading-none lg:text-8xl">
{post.title}
</h1>
<div className="hidden md:mb-12 md:block">
{post.author && (
<Avatar name={post.author.name} picture={post.author.picture} />
)}
</div>
<div className="mb-8 sm:mx-0 md:mb-16">
<CoverImage image={post.coverImage} priority />
</div>
<div className="mx-auto max-w-2xl">
<div className="mb-6 block md:hidden">
{post.author && (
<Avatar name={post.author.name} picture={post.author.picture} />
)}
</div>
<div className="mb-6 text-lg">
<div className="mb-4 text-lg">
<DateComponent dateString={post.date} />
</div>
</div>
</div>
{post.content?.length && (
<PortableText className="mx-auto max-w-2xl" value={post.content} />
)}
</article>
<aside>
<hr className="border-accent-2 mb-24 mt-28" />
<h2 className="mb-8 text-6xl font-bold leading-tight tracking-tighter md:text-7xl">
Recent Stories
</h2>
<Suspense>
<MoreStories skip={post._id} limit={2} />
</Suspense>
</aside>
</div>
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="512" height="512" fill="#F03E2F" rx="30" />
<path d="M161.527 136.723C161.527 179.76 187.738 205.443 240.388 219.095L296 232.283C345.687 243.852 376 272.775 376 319.514C376 341.727 369.162 360.931 357.538 375.971C357.538 329.232 333.607 303.78 276.171 288.74L221.47 276.246C177.709 266.065 143.977 242.464 143.977 191.56C143.977 170.505 150.359 151.994 161.527 136.723Z" fill="white" />
<path opacity="0.5" d="M323.35 308.176C347.054 323.679 357.538 345.197 357.538 376.202C337.709 401.654 303.293 416 262.724 416C194.575 416 146.484 381.756 136 322.753H201.641C210.074 350.056 232.41 362.551 262.268 362.551C298.735 362.32 322.895 342.652 323.35 308.176Z" fill="white" />
<path opacity="0.5" d="M195.715 200.816C172.923 186.007 161.527 165.183 161.527 136.954C180.672 111.503 213.493 96 253.835 96C323.35 96 363.692 133.252 373.721 185.776H310.359C303.293 165.183 285.971 148.986 254.291 148.986C220.33 148.986 197.311 169.116 195.715 200.816Z" fill="white" />
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -0,0 +1,23 @@
import "../globals.css";
import { Inter } from "next/font/google";
const inter = Inter({
variable: "--font-inter",
subsets: ["latin"],
display: "swap",
});
export { metadata, viewport } from "next-sanity/studio";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" className={inter.variable}>
<body className="min-h-screen">{children}</body>
</html>
);
}

View file

@ -0,0 +1,9 @@
import { NextStudio } from "next-sanity/studio";
import config from "@/sanity.config";
export const dynamic = "force-static";
export default function StudioPage() {
return <NextStudio config={config} />;
}

View file

@ -0,0 +1,27 @@
/**
* This file is used to allow Presentation to set the app in Draft Mode, which will load Visual Editing
* and query draft content and preview the content as it will appear once everything is published
*/
import { validatePreviewUrl } from "@sanity/preview-url-secret";
import { draftMode } from "next/headers";
import { redirect } from "next/navigation";
import { client } from "@/sanity/lib/client";
import { token } from "@/sanity/lib/token";
const clientWithToken = client.withConfig({ token });
export async function GET(request: Request) {
const { isValid, redirectTo = "/" } = await validatePreviewUrl(
clientWithToken,
request.url,
);
if (!isValid) {
return new Response("Invalid secret", { status: 401 });
}
draftMode().enable();
redirect(redirectTo);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View file

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

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 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,23 +0,0 @@
import Image from "next/image";
import { urlForImage } from "../lib/sanity";
export default function Avatar({ name, picture }) {
return (
<div className="flex items-center">
<div className="relative w-12 h-12 mr-4">
<Image
src={
picture?.asset?._ref
? urlForImage(picture).height(96).width(96).fit("crop").url()
: "https://source.unsplash.com/96x96/?face"
}
className="rounded-full"
height={96}
width={96}
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,38 +0,0 @@
import cn from "classnames";
import Image from "next/image";
import Link from "next/link";
import { urlForImage } from "../lib/sanity";
export default function CoverImage({ title, slug, image: source, priority }) {
const image = source?.asset?._ref ? (
<div
className={cn("shadow-small", {
"hover:shadow-medium transition-shadow duration-200": slug,
})}
>
<Image
className="w-full h-auto"
width={2000}
height={1000}
alt={`Cover Image for ${title}`}
src={urlForImage(source).height(1000).width(2000).url()}
sizes="100vw"
priority={priority}
/>
</div>
) : (
<div style={{ paddingTop: "50%", backgroundColor: "#ddd" }} />
);
return (
<div className="sm:mx-0">
{slug ? (
<Link href={`/posts/${slug}`} aria-label={title}>
{image}
</Link>
) : (
image
)}
</div>
);
}

View file

@ -1,8 +0,0 @@
import { parseISO, format } from "date-fns";
export default function Date({ dateString }) {
if (!dateString) return null;
const date = parseISO(dateString);
return <time dateTime={dateString}>{format(date, "LLLL d, yyyy")}</time>;
}

View file

@ -1,30 +0,0 @@
import Container from "./container";
import { EXAMPLE_PATH } from "../lib/constants";
export default function Footer() {
return (
<footer className="bg-accent-1 border-t border-accent-2">
<Container>
<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.
</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"
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
</a>
<a
href={`https://github.com/vercel/next.js/tree/canary/examples/${EXAMPLE_PATH}`}
className="mx-3 font-bold hover:underline"
>
View on GitHub
</a>
</div>
</div>
</Container>
</footer>
);
}

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 Avatar from "../components/avatar";
import Date from "../components/date";
import CoverImage from "../components/cover-image";
import Link from "next/link";
export default function HeroPost({
title,
coverImage,
date,
excerpt,
author,
slug,
}) {
return (
<section>
<div className="mb-8 md:mb-16">
<CoverImage slug={slug} title={title} image={coverImage} priority />
</div>
<div className="mb-20 md:grid md:grid-cols-2 md:gap-x-16 lg:gap-x-8 md:mb-28">
<div>
<h3 className="mb-4 text-4xl leading-tight lg:text-6xl">
<Link href={`/posts/${slug}`} className="hover:underline">
{title}
</Link>
</h3>
<div className="mb-4 text-lg md:mb-0">
<Date dateString={date} />
</div>
</div>
<div>
<p className="mb-4 text-lg leading-relaxed">{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,8 +0,0 @@
import { usePreview } from "../lib/sanity";
import { indexQuery } from "../lib/queries";
import Landing from "./landing";
export default function LandingPreview({ allPosts }) {
const previewAllPosts = usePreview(null, indexQuery);
return <Landing data={previewAllPosts ?? allPosts} preview />;
}

View file

@ -1,34 +0,0 @@
import Layout from "./layout";
import Head from "next/head";
import { CMS_NAME } from "../lib/constants";
import Container from "./container";
import Intro from "./intro";
import HeroPost from "./hero-post";
import MoreStories from "./more-stories";
export default function Landing({ allPosts, preview }) {
const [heroPost, ...morePosts] = allPosts || [];
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>
</>
);
}

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} key="ogImage" />
</Head>
);
}

View file

@ -1,24 +0,0 @@
import PostPlug from "./post-plug";
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) => (
<PostPlug
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,10 +0,0 @@
import markdownStyles from "./markdown-styles.module.css";
import { PortableText } from "@portabletext/react";
export default function PostBody({ content }) {
return (
<div className={`max-w-2xl mx-auto ${markdownStyles.markdown}`}>
<PortableText value={content} />
</div>
);
}

View file

@ -1,26 +0,0 @@
import Avatar from "../components/avatar";
import Date 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} image={coverImage} priority />
</div>
<div className="max-w-2xl mx-auto">
<div className="block mb-6 md:hidden">
{author && <Avatar name={author.name} picture={author.picture} />}
</div>
<div className="mb-6 text-lg">
<Date dateString={date} />
</div>
</div>
</>
);
}

View file

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

View file

@ -1,9 +0,0 @@
import { usePreview } from "../lib/sanity";
import { postQuery } from "../lib/queries";
import Post from "./post";
export default function PostPreview({ data }) {
const slug = data?.post?.slug;
const previewData = usePreview(null, postQuery, { slug });
return <Post data={previewData ?? data} preview />;
}

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,65 +0,0 @@
import { useRouter } from "next/router";
import { urlForImage } from "../lib/sanity";
import ErrorPage from "next/error";
import Layout from "./layout";
import Container from "./container";
import Header from "./header";
import PostTitle from "./post-title";
import Head from "next/head";
import { CMS_NAME } from "../lib/constants";
import PostHeader from "./post-header";
import PostBody from "./post-body";
import SectionSeparator from "./section-separator";
import MoreStories from "./more-stories";
export default function Post({ data = {}, preview = false }) {
const router = useRouter();
const { post, morePosts } = data;
const slug = post?.slug;
if (!router.isFallback && !slug) {
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>
{post.coverImage?.asset?._ref && (
<meta
key="ogImage"
property="og:image"
content={urlForImage(post.coverImage)
.width(1200)
.height(627)
.fit("crop")
.url()}
/>
)}
</Head>
<PostHeader
title={post.title}
coverImage={post.coverImage}
date={post.date}
author={post.author}
/>
<PostBody content={post.content} />
</article>
<SectionSeparator />
{morePosts.length > 0 && <MoreStories posts={morePosts} />}
</>
)}
</Container>
</Layout>
);
}

View file

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

View file

@ -1,15 +0,0 @@
export const sanityConfig = {
// Find your project ID and dataset in `sanity.json` in your studio project
dataset: process.env.NEXT_PUBLIC_SANITY_DATASET || "production",
projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID,
useCdn:
typeof document !== "undefined" && process.env.NODE_ENV === "production",
// useCdn == true gives fast, cheap responses using a globally distributed cache.
// When in production the Sanity API is only queried on build-time, and on-demand when responding to webhooks.
// Thus the data need to be fresh and API response time is less important.
// When in development/working locally, it's more important to keep costs down as hot reloading can incurr a lot of API calls
// And every page load calls getStaticProps.
// To get the lowest latency, lowest cost, and latest data, use the Instant Preview mode
apiVersion: "2022-03-13",
// see https://www.sanity.io/docs/api-versioning for how versioning works
};

View file

@ -1,5 +0,0 @@
export const EXAMPLE_PATH = "cms-sanity";
export const CMS_NAME = "Sanity";
export const CMS_URL = "https://sanity.io/";
export const HOME_OG_IMAGE_URL =
"https://og-image.vercel.app/Next.js%20Blog%20Example%20with%20**Sanity**.png?theme=light&md=1&fontSize=75px&images=https%3A%2F%2Fassets.vercel.com%2Fimage%2Fupload%2Ffront%2Fassets%2Fdesign%2Fnextjs-black-logo.svg&images=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB2aWV3Qm94PSIwIDAgMTA1IDIyIiBmaWxsPSJub25lIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMWVtIj48dGl0bGU%2BU2FuaXR5PC90aXRsZT48cGF0aCBvcGFjaXR5PSIwLjciIGQ9Ik03OC4xNzkzIDcuOTkyNjFWMjEuMDAyOEg3My45MDMxVjEwLjIxMzhMNzguMTc5MyA3Ljk5MjYxWiIgZmlsbD0iY3VycmVudENvbG9yIj48L3BhdGg%2BPHBhdGggb3BhY2l0eT0iMC43IiBkPSJNMjAuOTUxMSAyMS4zM0wzMC45NDQgMTYuMTA1MUwyOS43MTIxIDEyLjkxNDFMMjMuMTMzMiAxNS45ODIxTDIwLjk1MTEgMjEuMzNaIiBmaWxsPSJjdXJyZW50Q29sb3IiPjwvcGF0aD48cGF0aCBvcGFjaXR5PSIwLjUiIGQ9Ik03My45MDMxIDEwLjIwMjdMODQuNzQ0MyA0LjY1NDc3TDgyLjkxMjYgMS41NTcxTDczLjkwMzEgNS45NTk5N1YxMC4yMDI3WiIgZmlsbD0iY3VycmVudENvbG9yIj48L3BhdGg%2BPHBhdGggb3BhY2l0eT0iMC43IiBkPSJNNDMuMzcwNSA2Ljk2MjMzVjIxLjAwMjhIMzkuMjkyN1YxLjAwNzE0TDQzLjM3MDUgNi45NjIzM1oiIGZpbGw9ImN1cnJlbnRDb2xvciI%2BPC9wYXRoPjxwYXRoIG9wYWNpdHk9IjAuNSIgZD0iTTI3LjEyOTkgNi4xODYxN0wyMC45NTExIDIxLjMzTDE3Ljc3MzEgMTguNTk0M0wyNS4xMzUzIDEuMDA3MTRMMjcuMTI5OSA2LjE4NjE3WiIgZmlsbD0iY3VycmVudENvbG9yIj48L3BhdGg%2BPHBhdGggZD0iTTI1LjEzNTMgMS4wMDcxNEgyOS4zNDc3TDM3LjEzODYgMjEuMDAyOEgzMi44MjY5TDI1LjEzNTMgMS4wMDcxNFoiIGZpbGw9ImN1cnJlbnRDb2xvciI%2BPC9wYXRoPjxwYXRoIGQ9Ik00NC4wMDEyIDEuMDA3MTRMNTIuOTgyNCAxNC42NjgyVjIxLjAwMjhMMzkuMjkyNyAxLjAwNzE0SDQ0LjAwMTJaIiBmaWxsPSJjdXJyZW50Q29sb3IiPjwvcGF0aD48cGF0aCBkPSJNNjQuOTE4MyAxLjAwNzE0SDYwLjY3MzlWMjEuMDA2M0g2NC45MTgzVjEuMDA3MTRaIiBmaWxsPSJjdXJyZW50Q29sb3IiPjwvcGF0aD48cGF0aCBkPSJNNzMuOTAzMSA0LjY1NDc0SDY3LjM3VjEuMDA3MTRIODIuNTg2N0w4NC43NDQzIDQuNjU0NzRINzguMTc5M0g3My45MDMxWiIgZmlsbD0iY3VycmVudENvbG9yIj48L3BhdGg%2BPHBhdGggb3BhY2l0eT0iMC41IiBkPSJNOTcuMjc1NCAxMy40MTUzVjIxLjAwMjhIOTMuMDYyOVYxMy40MTUzIiBmaWxsPSJjdXJyZW50Q29sb3IiPjwvcGF0aD48cGF0aCBkPSJNOTMuMDYyOSAxMy40MTUyTDEwMC4xOTEgMS4wMDcxNEgxMDQuNjY2TDk3LjI3NTQgMTMuNDE1Mkg5My4wNjI5WiIgZmlsbD0iY3VycmVudENvbG9yIj48L3BhdGg%2BPHBhdGggb3BhY2l0eT0iMC43IiBkPSJNOTMuMDYzIDEzLjQxNTJMODUuNzM2MyAxLjAwNzE0SDkwLjM0NTZMOTUuMzA5MiA5LjUxMDA4TDkzLjA2MyAxMy40MTUyWiIgZmlsbD0iY3VycmVudENvbG9yIj48L3BhdGg%2BPHBhdGggZD0iTTEuOTYxMjYgMy4zMTQ3OUMxLjk2MTI2IDYuMDk5MjEgMy43MTE0NSA3Ljc1NTk1IDcuMjE1MzYgOC42Mjk1NkwxMC45MjgzIDkuNDc1MzNDMTQuMjQ0NCAxMC4yMjM2IDE2LjI2MzkgMTIuMDgyMiAxNi4yNjM5IDE1LjExMDNDMTYuMjg5NyAxNi40Mjk1IDE1Ljg1MzEgMTcuNzE3MyAxNS4wMjc0IDE4Ljc1NzlDMTUuMDI3NCAxNS43MzY4IDEzLjQzNjcgMTQuMTA0NCA5LjU5OTcyIDEzLjEyMjlMNS45NTQwOSAxMi4zMDg1QzMuMDM0NzUgMTEuNjU0MSAwLjc4MTQ3OCAxMC4xMjYyIDAuNzgxNDc4IDYuODM3MDlDMC43NjYxMjMgNS41NjY5MyAxLjE4MTE2IDQuMzI3ODEgMS45NjEyNiAzLjMxNDc5IiBmaWxsPSJjdXJyZW50Q29sb3IiPjwvcGF0aD48cGF0aCBvcGFjaXR5PSIwLjciIGQ9Ik01Mi45ODI0IDEzLjY0MTVWMS4wMDcxNEg1Ny4wNjAyVjIxLjAwMjhINTIuOTgyNFYxMy42NDE1WiIgZmlsbD0iY3VycmVudENvbG9yIj48L3BhdGg%2BPHBhdGggb3BhY2l0eT0iMC43IiBkPSJNMTIuNzQ1OCAxNC4zNjg5QzE0LjMyOTQgMTUuMzY0MyAxNS4wMjM4IDE2Ljc1NjUgMTUuMDIzOCAxOC43NTQ0QzEzLjcxMyAyMC40MDQxIDExLjQxMDEgMjEuMzMgOC43MDMzMyAyMS4zM0M0LjE0NzE4IDIxLjMzIDAuOTU4NTc3IDE5LjEyNjggMC4yNSAxNS4yOTgySDQuNjI1NDdDNS4xODg3OCAxNy4wNTU5IDYuNjgwMzQgMTcuODcwMyA4LjY3MTQ0IDE3Ljg3MDNDMTEuMTAxOSAxNy44NzAzIDEyLjcxNzQgMTYuNTk2NCAxMi43NDkzIDE0LjM2MTkiIGZpbGw9ImN1cnJlbnRDb2xvciI%2BPC9wYXRoPjxwYXRoIG9wYWNpdHk9IjAuNyIgZD0iTTQuMjM1NjcgNy40NDI2N0MzLjUxMjUgNy4wMjA0NSAyLjkxOTIgNi40MTM3NSAyLjUxODczIDUuNjg2OTdDMi4xMTgyNyA0Ljk2MDE5IDEuOTI1NTggNC4xNDA0NSAxLjk2MTEzIDMuMzE0NzZDMy4yMjU5NCAxLjY3ODkxIDUuNDI2MDggMC42Nzk5OTMgOC4xMDgwNCAwLjY3OTk5M0MxMi43NDkyIDAuNjc5OTkzIDE1LjQzNDcgMy4wODg1MiAxNi4wOTcyIDYuNDc4NTZIMTEuODg4M0MxMS40MjQyIDUuMTQyMDMgMTAuMjYyMSA0LjEwMTM2IDguMTQzNDcgNC4xMDEzNkM1Ljg3OTU3IDQuMTAxMzYgNC4zMzQ4NyA1LjM5NjExIDQuMjQ2MjkgNy40NDI2NyIgZmlsbD0iY3VycmVudENvbG9yIj48L3BhdGg%2BPC9zdmc%2B&widths=undefined&widths=auto&heights=250&heights=150";

View file

@ -1,37 +0,0 @@
const postFields = `
_id,
name,
title,
date,
excerpt,
coverImage,
"slug": slug.current,
"author": author->{name, picture},
`;
export const indexQuery = `
*[_type == "post"] | order(date desc, _updatedAt desc) {
${postFields}
}`;
export const postQuery = `
{
"post": *[_type == "post" && slug.current == $slug] | order(_updatedAt desc) [0] {
content,
${postFields}
},
"morePosts": *[_type == "post" && slug.current != $slug] | order(date desc, _updatedAt desc) [0...2] {
content,
${postFields}
}
}`;
export const postSlugsQuery = `
*[_type == "post" && defined(slug.current)][].slug.current
`;
export const postBySlugQuery = `
*[_type == "post" && slug.current == $slug][0] {
${postFields}
}
`;

View file

@ -1,13 +0,0 @@
import createImageUrlBuilder from "@sanity/image-url";
import { definePreview } from "next-sanity/preview";
import { sanityConfig } from "./config";
export const imageBuilder = createImageUrlBuilder(sanityConfig);
export const urlForImage = (source) =>
imageBuilder.image(source).auto("format").fit("max");
export const usePreview = definePreview({
projectId: sanityConfig.projectId,
dataset: sanityConfig.dataset,
});

View file

@ -1,35 +0,0 @@
/**
* Server-side Sanity utilities. By having these in a separate file from the
* utilities we use on the client side, we are able to tree-shake (remove)
* code that is not used on the client side.
*/
import { createClient } from "next-sanity";
import { sanityConfig } from "./config";
export const sanityClient = createClient(sanityConfig);
export const previewClient = createClient({
...sanityConfig,
useCdn: false,
// Fallback to using the WRITE token until https://www.sanity.io/docs/vercel-integration starts shipping a READ token.
// As this client only exists on the server and the token is never shared with the browser, we don't risk escalating permissions to untrustworthy users
token:
process.env.SANITY_API_READ_TOKEN || process.env.SANITY_API_WRITE_TOKEN,
});
export const getClient = (preview) => (preview ? previewClient : sanityClient);
export function overlayDrafts(docs) {
const documents = docs || [];
const overlayed = documents.reduce((map, doc) => {
if (!doc._id) {
throw new Error("Ensure that `_id` is included in query projection");
}
const isDraft = doc._id.startsWith("drafts.");
const id = isDraft ? doc._id.slice(7) : doc._id;
return isDraft || !map.has(id) ? map.set(id, doc) : map;
}, new Map());
return Array.from(overlayed.values());
}

View file

@ -1,9 +1,10 @@
/** @type {import('next').NextConfig} */
module.exports = {
images: {
remotePatterns: [
{ hostname: "cdn.sanity.io" },
{ hostname: "source.unsplash.com" },
],
experimental: {
// Used to guard against accidentally leaking SANITY_API_READ_TOKEN to the browser
taint: true,
},
logging: {
fetches: { fullUrl: false },
},
};

View file

@ -4,23 +4,38 @@
"dev": "next",
"build": "next build",
"start": "next start",
"studio:dev": "npm --prefix studio run start",
"studio:deploy": "npx vercel env pull && npm --prefix studio run deploy"
"lint": "next lint",
"presetup": "echo 'about to setup env variables, follow the guide here: https://github.com/vercel/next.js/tree/canary/examples/cms-sanity#using-the-sanity-cli'",
"setup": "npx sanity@latest init --env .env.local",
"postsetup": "echo 'create the read token by following the rest of the guide: https://github.com/vercel/next.js/tree/canary/examples/cms-sanity#creating-a-read-token'"
},
"dependencies": {
"@portabletext/react": "^2.0.1",
"@sanity/assist": "^2.0.1",
"@sanity/icons": "^2.10.3",
"@sanity/image-url": "^1.0.2",
"@sanity/webhook": "^2.0.0",
"classnames": "^2.3.1",
"date-fns": "^2.29.3",
"@sanity/vision": "^3.32.0",
"@tailwindcss/typography": "^0.5.10",
"@types/node": "^20.11.25",
"@types/react": "^18.2.64",
"@types/react-dom": "^18.2.21",
"@vercel/speed-insights": "^1.0.10",
"autoprefixer": "^10.4.18",
"date-fns": "^3.3.1",
"next": "latest",
"next-sanity": "^4.1.2",
"react": "^18",
"react-dom": "^18"
"next-sanity": "8.3.0",
"postcss": "^8.4.35",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"rxjs": "^7.8.1",
"sanity": "^3.32.0",
"sanity-plugin-asset-source-unsplash": "^1.1.2",
"server-only": "^0.0.1",
"styled-components": "^6.1.8",
"tailwindcss": "^3.4.1",
"typescript": "5.3.3"
},
"devDependencies": {
"autoprefixer": "^10.4.13",
"postcss": "^8.4.21",
"tailwindcss": "^3.2.4"
"eslint": "^8.57.0",
"eslint-config-next": "latest"
}
}

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,40 +0,0 @@
import { postBySlugQuery } from "../../lib/queries";
import { previewClient } from "../../lib/sanity.server";
function redirectToPreview(res, Location) {
// Enable Draft Mode by setting the cookie
res.setDraftMode({ enable: true });
// Redirect to a preview capable route
res.writeHead(307, { Location });
res.end();
}
export default async function preview(req, res) {
const secret = process.env.SANITY_STUDIO_PREVIEW_SECRET;
// Only require a secret when in production
if (!secret && process.env.NODE_ENV === "production") {
throw new TypeError(`Missing SANITY_STUDIO_PREVIEW_SECRET`);
}
// Check the secret if it's provided, enables running preview mode locally before the env var is setup
if (secret && req.query.secret !== secret) {
return res.status(401).json({ message: "Invalid secret" });
}
// If no slug is provided open preview mode on the frontpage
if (!req.query.slug) {
return redirectToPreview(res, "/");
}
// Check if the post with the given `slug` exists
const post = await previewClient.fetch(postBySlugQuery, {
slug: req.query.slug,
});
// If the slug doesn't exist prevent preview mode from being enabled
if (!post) {
return res.status(401).json({ message: "Invalid slug" });
}
// Redirect to the path from the fetched post
// We don't redirect to req.query.slug as that might lead to open redirect vulnerabilities
redirectToPreview(res, `/posts/${post.slug}`);
}

View file

@ -1,79 +0,0 @@
import { isValidSignature, SIGNATURE_HEADER_NAME } from "@sanity/webhook";
import { sanityClient } from "../../lib/sanity.server";
// Next.js will by default parse the body, which can lead to invalid signatures
export const config = {
api: {
bodyParser: false,
},
};
const AUTHOR_UPDATED_QUERY = /* groq */ `
*[_type == "author" && _id == $id] {
"slug": *[_type == "post" && references(^._id)].slug.current
}["slug"][]`;
const POST_UPDATED_QUERY = /* groq */ `*[_type == "post" && _id == $id].slug.current`;
const getQueryForType = (type) => {
switch (type) {
case "author":
return AUTHOR_UPDATED_QUERY;
case "post":
return POST_UPDATED_QUERY;
default:
throw new TypeError(`Unknown type: ${type}`);
}
};
const log = (msg, error) =>
console[error ? "error" : "log"](`[revalidate] ${msg}`);
async function readBody(readable) {
const chunks = [];
for await (const chunk of readable) {
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
}
return Buffer.concat(chunks).toString("utf8");
}
export default async function revalidate(req, res) {
const signature = req.headers[SIGNATURE_HEADER_NAME];
const body = await readBody(req); // Read the body into a string
if (
!isValidSignature(
body,
signature,
process.env.SANITY_REVALIDATE_SECRET?.trim(),
)
) {
const invalidSignature = "Invalid signature";
log(invalidSignature, true);
res.status(401).json({ success: false, message: invalidSignature });
return;
}
const jsonBody = JSON.parse(body);
const { _id: id, _type } = jsonBody;
if (typeof id !== "string" || !id) {
const invalidId = "Invalid _id";
log(invalidId, true);
return res.status(400).json({ message: invalidId });
}
log(`Querying post slug for _id '${id}', type '${_type}' ..`);
const slug = await sanityClient.fetch(getQueryForType(_type), { id });
const slugs = (Array.isArray(slug) ? slug : [slug]).map(
(_slug) => `/posts/${_slug}`,
);
const staleRoutes = ["/", ...slugs];
try {
await Promise.all(staleRoutes.map((route) => res.revalidate(route)));
const updatedRoutes = `Updated routes: ${staleRoutes.join(", ")}`;
log(updatedRoutes);
return res.status(200).json({ message: updatedRoutes });
} catch (err) {
log(err.message, true);
return res.status(500).json({ message: err.message });
}
}

View file

@ -1,28 +0,0 @@
import { indexQuery } from "../lib/queries";
import { getClient, overlayDrafts } from "../lib/sanity.server";
import { PreviewSuspense } from "next-sanity/preview";
import { lazy } from "react";
import Landing from "../components/landing";
const LandingPreview = lazy(() => import("../components/landing-preview"));
export default function IndexPage({ allPosts, preview }) {
if (preview) {
return (
<PreviewSuspense fallback="Loading...">
<LandingPreview allPosts={allPosts} />
</PreviewSuspense>
);
}
return <Landing allPosts={allPosts} />;
}
export async function getStaticProps({ preview = false }) {
const allPosts = overlayDrafts(await getClient(preview).fetch(indexQuery));
return {
props: { allPosts, preview },
// If webhooks isn't setup then attempt to re-generate in 1 minute intervals
revalidate: process.env.SANITY_REVALIDATE_SECRET ? undefined : 60,
};
}

View file

@ -1,49 +0,0 @@
import { lazy } from "react";
import { PreviewSuspense } from "next-sanity/preview";
import { postQuery, postSlugsQuery } from "../../lib/queries";
import {
getClient,
overlayDrafts,
sanityClient,
} from "../../lib/sanity.server";
import Post from "../../components/post";
const PostPreview = lazy(() => import("../../components/post-preview"));
export default function PostPage({ preview, data }) {
if (preview) {
return (
<PreviewSuspense fallback="Loading...">
<PostPreview data={data} />
</PreviewSuspense>
);
}
return <Post data={data} />;
}
export async function getStaticProps({ params, preview = false }) {
const { post, morePosts } = await getClient(preview).fetch(postQuery, {
slug: params.slug,
});
return {
props: {
preview,
data: {
post,
morePosts: overlayDrafts(morePosts),
},
},
// If webhooks isn't setup then attempt to re-generate in 1 minute intervals
revalidate: process.env.SANITY_REVALIDATE_SECRET ? undefined : 60,
};
}
export async function getStaticPaths() {
const paths = await sanityClient.fetch(postSlugsQuery);
return {
paths: paths.map((slug) => ({ params: { 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

@ -7,12 +7,4 @@ loadEnvConfig(__dirname, dev, { info: () => null, error: console.error });
const projectId = process.env.NEXT_PUBLIC_SANITY_PROJECT_ID;
const dataset = process.env.NEXT_PUBLIC_SANITY_DATASET;
export default defineCliConfig({
api: { projectId, dataset },
vite: (config) => {
return {
...config,
envPrefix: ["NEXT_", "SANITY_STUDIO_", "VITE_"],
};
},
});
export default defineCliConfig({ api: { projectId, dataset } });

View file

@ -0,0 +1,50 @@
"use client";
/**
* This config is used to set up Sanity Studio that's mounted on the `app/(sanity)/studio/[[...tool]]/page.tsx` route
*/
import { visionTool } from "@sanity/vision";
import { PluginOptions, defineConfig } from "sanity";
import { unsplashImageAsset } from "sanity-plugin-asset-source-unsplash";
import { presentationTool } from "sanity/presentation";
import { structureTool } from "sanity/structure";
import { apiVersion, dataset, projectId, studioUrl } from "@/sanity/lib/api";
import { locate } from "@/sanity/plugins/locate";
import { pageStructure, singletonPlugin } from "@/sanity/plugins/settings";
import { assistWithPresets } from "@/sanity/plugins/assist";
import author from "@/sanity/schemas/documents/author";
import post from "@/sanity/schemas/documents/post";
import settings from "@/sanity/schemas/singletons/settings";
export default defineConfig({
basePath: studioUrl,
projectId,
dataset,
schema: {
types: [
// Singletons
settings,
// Documents
post,
author,
],
},
plugins: [
presentationTool({
locate,
previewUrl: { previewMode: { enable: "/api/draft" } },
}),
structureTool({ structure: pageStructure([settings]) }),
// Configures the global "new document" button, and document actions, to suit the Settings document singleton
singletonPlugin([settings.name]),
// Add an image asset source for Unsplash
unsplashImageAsset(),
// Sets up AI Assist with preset prompts
// https://www.sanity.io/docs/ai-assist
assistWithPresets(),
// Vision lets you query your content with GROQ in the studio
// https://www.sanity.io/docs/the-vision-plugin
process.env.NODE_ENV === "development" &&
visionTool({ defaultApiVersion: apiVersion }),
].filter(Boolean) as PluginOptions[],
});

View file

@ -0,0 +1,33 @@
/**
* As this file is reused in several other files, try to keep it lean and small.
* Importing other npm packages here could lead to needlessly increasing the client bundle size, or end up in a server-only function that don't need it.
*/
function assertValue<T>(v: T | undefined, errorMessage: string): T {
if (v === undefined) {
throw new Error(errorMessage);
}
return v;
}
export const dataset = assertValue(
process.env.NEXT_PUBLIC_SANITY_DATASET,
"Missing environment variable: NEXT_PUBLIC_SANITY_DATASET",
);
export const projectId = assertValue(
process.env.NEXT_PUBLIC_SANITY_PROJECT_ID,
"Missing environment variable: NEXT_PUBLIC_SANITY_PROJECT_ID",
);
/**
* see https://www.sanity.io/docs/api-versioning for how versioning works
*/
export const apiVersion =
process.env.NEXT_PUBLIC_SANITY_API_VERSION || "2024-02-28";
/**
* Used to configure edit intent links, for Presentation Mode, as well as to configure where the Studio is mounted in the router.
*/
export const studioUrl = "/studio";

View file

@ -0,0 +1,22 @@
import { createClient } from "next-sanity";
import { apiVersion, dataset, projectId, studioUrl } from "@/sanity/lib/api";
export const client = createClient({
projectId,
dataset,
apiVersion,
useCdn: true,
perspective: "published",
stega: {
studioUrl,
logger: console,
filter: (props) => {
if (props.sourcePath.at(-1) === "title") {
return true;
}
return props.filterDefault(props);
},
},
});

View file

@ -0,0 +1,59 @@
/**
* Demo data used as placeholders and initial values for the blog
*/
export const title = "Blog.";
export const description = [
{
_key: "9f1a629887fd",
_type: "block",
children: [
{
_key: "4a58edd077880",
_type: "span",
marks: [],
text: "A statically generated blog example using ",
},
{
_key: "4a58edd077881",
_type: "span",
marks: ["ec5b66c9b1e0"],
text: "Next.js",
},
{
_key: "4a58edd077882",
_type: "span",
marks: [],
text: " and ",
},
{
_key: "4a58edd077883",
_type: "span",
marks: ["1f8991913ea8"],
text: "Sanity",
},
{
_key: "4a58edd077884",
_type: "span",
marks: [],
text: ".",
},
],
markDefs: [
{
_key: "ec5b66c9b1e0",
_type: "link",
href: "https://nextjs.org/",
},
{
_key: "1f8991913ea8",
_type: "link",
href: "https://sanity.io/",
},
],
style: "normal",
},
];
export const ogImageTitle = "A Next.js Blog with a Native Authoring Experience";

View file

@ -0,0 +1,51 @@
import type { ClientPerspective, QueryParams } from "next-sanity";
import { draftMode } from "next/headers";
import { client } from "@/sanity/lib/client";
import { token } from "@/sanity/lib/token";
/**
* Used to fetch data in Server Components, it has built in support for handling Draft Mode and perspectives.
* When using the "published" perspective then time-based revalidation is used, set to match the time-to-live on Sanity's API CDN (60 seconds)
* and will also fetch from the CDN.
* When using the "previewDrafts" perspective then the data is fetched from the live API and isn't cached, it will also fetch draft content that isn't published yet.
*/
export async function sanityFetch<QueryResponse>({
query,
params = {},
perspective = draftMode().isEnabled ? "previewDrafts" : "published",
/**
* Stega embedded Content Source Maps are used by Visual Editing by both the Sanity Presentation Tool and Vercel Visual Editing.
* The Sanity Presentation Tool will enable Draft Mode when loading up the live preview, and we use it as a signal for when to embed source maps.
* When outside of the Sanity Studio we also support the Vercel Toolbar Visual Editing feature, which is only enabled in production when it's a Vercel Preview Deployment.
*/
stega = perspective === "previewDrafts" ||
process.env.VERCEL_ENV === "preview",
}: {
query: string;
params?: QueryParams;
perspective?: Omit<ClientPerspective, "raw">;
stega?: boolean;
}) {
if (perspective === "previewDrafts") {
return client.fetch<QueryResponse>(query, params, {
stega,
perspective: "previewDrafts",
// The token is required to fetch draft content
token,
// The `previewDrafts` perspective isn't available on the API CDN
useCdn: false,
// And we can't cache the responses as it would slow down the live preview experience
next: { revalidate: 0 },
});
}
return client.fetch<QueryResponse>(query, params, {
stega,
perspective: "published",
// The `published` perspective is available on the API CDN
useCdn: true,
// Only enable Stega in production if it's a Vercel Preview Deployment, as the Vercel Toolbar supports Visual Editing
// When using the `published` perspective we use time-based revalidation to match the time-to-live on Sanity's API CDN (60 seconds)
next: { revalidate: 60 },
});
}

View file

@ -0,0 +1,61 @@
import { groq, type PortableTextBlock } from "next-sanity";
import type { Image } from "sanity";
export const settingsQuery = groq`*[_type == "settings"][0]`;
export interface SettingsQueryResponse {
title?: string;
description?: PortableTextBlock[];
footer?: PortableTextBlock[];
ogImage?: (Image & { alt?: string; metadataBase?: string }) | null;
}
export interface Author {
name: string;
picture?: (Image & { alt?: string | null }) | null;
}
export interface Post {
_id: string;
status: "draft" | "published";
title: string;
slug: string;
excerpt?: string | null;
coverImage?: (Image & { alt?: string }) | null;
date: string;
author?: Author | null;
}
const postFields = groq`
_id,
"status": select(_originalId in path("drafts.**") => "draft", "published"),
"title": coalesce(title, "Untitled"),
"slug": slug.current,
excerpt,
coverImage,
"date": coalesce(date, _updatedAt),
"author": author->{"name": coalesce(name, "Anonymous"), picture},
`;
export const heroQuery = groq`*[_type == "post" && defined(slug.current)] | order(date desc, _updatedAt desc) [0] {
content,
${postFields}
}`;
export type HeroQueryResponse =
| (Post & {
content?: PortableTextBlock[] | null;
})
| null;
export const moreStoriesQuery = groq`*[_type == "post" && _id != $skip && defined(slug.current)] | order(date desc, _updatedAt desc) [0...$limit] {
${postFields}
}`;
export type MoreStoriesQueryResponse = Post[] | null;
export const postQuery = groq`*[_type == "post" && slug.current == $slug] [0] {
content,
${postFields}
}`;
export type PostQueryResponse =
| (Post & {
content?: PortableTextBlock[] | null;
})
| null;

View file

@ -0,0 +1,15 @@
import "server-only";
import { experimental_taintUniqueValue } from "react";
export const token = process.env.SANITY_API_READ_TOKEN;
if (!token) {
throw new Error("Missing SANITY_API_READ_TOKEN");
}
experimental_taintUniqueValue(
"Do not pass the sanity API read token to the client.",
process,
token,
);

View file

@ -0,0 +1,37 @@
import createImageUrlBuilder from "@sanity/image-url";
import { dataset, projectId } from "@/sanity/lib/api";
const imageBuilder = createImageUrlBuilder({
projectId: projectId || "",
dataset: dataset || "",
});
export const urlForImage = (source: any) => {
// Ensure that source image contains a valid reference
if (!source?.asset?._ref) {
return undefined;
}
return imageBuilder?.image(source).auto("format").fit("max");
};
export function resolveOpenGraphImage(image: any, width = 1200, height = 627) {
if (!image) return;
const url = urlForImage(image)?.width(1200).height(627).fit("crop").url();
if (!url) return;
return { url, alt: image?.alt as string, width, height };
}
export function resolveHref(
documentType?: string,
slug?: string,
): string | undefined {
switch (documentType) {
case "post":
return slug ? `/posts/${slug}` : undefined;
default:
console.warn("Invalid document type:", documentType);
return undefined;
}
}

View file

@ -0,0 +1,265 @@
/**
* Sets up the AI Assist plugin with preset prompts for content creation
*/
import { assist } from "@sanity/assist";
import postType from "../schemas/documents/post";
export const assistWithPresets = () =>
assist({
__presets: {
[postType.name]: {
fields: [
{
/**
* Creates Portable Text `content` blocks from the `title` field
*/
path: "content",
instructions: [
{
_key: "preset-instruction-1",
title: "Generate sample content",
icon: "block-content",
prompt: [
{
_key: "86e70087d4d5",
markDefs: [],
children: [
{
_type: "span",
marks: [],
text: "Given the draft title ",
_key: "6b5d5d6a63cf0",
},
{
path: "title",
_type: "sanity.assist.instruction.fieldRef",
_key: "0132742d463b",
},
{
_type: "span",
marks: [],
text: " of a blog post, generate a comprehensive and engaging sample content that spans the length of one to two A4 pages. The content should be structured, informative, and tailored to the subject matter implied by the title, whether it be travel, software engineering, fashion, politics, or any other theme. The text will be displayed below the ",
_key: "a02c9ab4eb2d",
},
{
_type: "sanity.assist.instruction.fieldRef",
_key: "f208ef240062",
path: "title",
},
{
text: " and doesn't need to repeat it in the text. The generated text should include the following elements:",
_key: "8ecfa74a8487",
_type: "span",
marks: [],
},
],
_type: "block",
style: "normal",
},
{
style: "normal",
_key: "e4dded41ea89",
markDefs: [],
children: [
{
_type: "span",
marks: [],
text: "1. Introduction: A brief paragraph that captures the essence of the blog post, hooks the reader with intriguing insights, and outlines the purpose of the post.",
_key: "cc5ef44a2fb5",
},
],
_type: "block",
},
{
style: "normal",
_key: "585e8de2fe35",
markDefs: [],
children: [
{
_type: "span",
marks: [],
text: "2. Main Body:",
_key: "fab36eb7c541",
},
],
_type: "block",
},
{
_type: "block",
style: "normal",
_key: "e96b89ef6357",
markDefs: [],
children: [
{
_type: "span",
marks: [],
text: "- For thematic consistency, divide the body into several sections with subheadings that explore different facets of the topic.",
_key: "b685a310a0ff",
},
],
},
{
children: [
{
marks: [],
text: "- Include engaging and informative content such as personal anecdotes (for travel or fashion blogs), technical explanations or tutorials (for software engineering blogs), satirical or humorous observations (for shitposting), or well-argued positions (for political blogs).",
_key: "c7468d106c91",
_type: "span",
},
],
_type: "block",
style: "normal",
_key: "ce4acdb00da9",
markDefs: [],
},
{
_type: "block",
style: "normal",
_key: "fb4572e65833",
markDefs: [],
children: [
{
_type: "span",
marks: [],
text: "- ",
_key: "5358f261dce4",
},
{
_type: "span",
marks: [],
text: " observations (for shitposting), or well-argued positions (for political blogs).",
_key: "50792c6d0f77",
},
],
},
{
children: [
{
marks: [],
text: "Where applicable, incorporate bullet points or numbered lists to break down complex information, steps in a process, or key highlights.",
_key: "3b891d8c1dde0",
_type: "span",
},
],
_type: "block",
style: "normal",
_key: "9364b67074ce",
markDefs: [],
},
{
_key: "a6ba7579cd66",
markDefs: [],
children: [
{
_type: "span",
marks: [],
text: "3. Conclusion: Summarize the main points discussed in the post, offer final thoughts or calls to action, and invite readers to engage with the content through comments or social media sharing.",
_key: "1280f11d499d",
},
],
_type: "block",
style: "normal",
},
{
style: "normal",
_key: "719a79eb4c1c",
markDefs: [],
children: [
{
marks: [],
text: "4. Engagement Prompts: Conclude with questions or prompts that encourage readers to share their experiences, opinions, or questions related to the blog post's topic, but keep in mind there is no Comments field below the blog post.",
_key: "f1512086bab6",
_type: "span",
},
],
_type: "block",
},
{
_type: "block",
style: "normal",
_key: "4a1c586fd44a",
markDefs: [],
children: [
{
marks: [],
text: "Ensure the generated content maintains a balance between being informative and entertaining, to capture the interest of a wide audience. The sample content should serve as a solid foundation that can be further customized or expanded upon by the blog author to finalize the post.",
_key: "697bbd03cb110",
_type: "span",
},
],
},
{
children: [
{
marks: [],
text: 'Don\'t prefix each section with "Introduction", "Main Body", "Conclusion" or "Engagement Prompts"',
_key: "d20bb9a03b0d",
_type: "span",
},
],
_type: "block",
style: "normal",
_key: "b072b3c62c3c",
markDefs: [],
},
],
},
],
},
{
/**
* Summarize content into the `excerpt` field
*/
path: "excerpt",
instructions: [
{
_key: "preset-instruction-2",
title: "Summarize content",
icon: "blockquote",
prompt: [
{
markDefs: [],
children: [
{
_key: "650a0dcc327d",
_type: "span",
marks: [],
text: "Create a short excerpt based on ",
},
{
path: "content",
_type: "sanity.assist.instruction.fieldRef",
_key: "c62d14c73496",
},
{
_key: "38e043efa606",
_type: "span",
marks: [],
text: " that doesn't repeat what's already in the ",
},
{
path: "title",
_type: "sanity.assist.instruction.fieldRef",
_key: "445e62dda246",
},
{
_key: "98cce773915e",
_type: "span",
marks: [],
text: " . Consider the UI has limited horizontal space and try to avoid too many line breaks and make it as short, terse and brief as possible. At best a single sentence, at most two sentences.",
},
],
_type: "block",
style: "normal",
_key: "392c618784b0",
},
],
},
],
},
],
},
},
});

View file

@ -0,0 +1,95 @@
import { map, Observable } from "rxjs";
import type {
DocumentLocation,
DocumentLocationResolver,
DocumentLocationsState,
} from "sanity/presentation";
import { resolveHref } from "@/sanity/lib/utils";
const homeLocation = {
title: "Home",
href: "/",
} satisfies DocumentLocation;
export const locate: DocumentLocationResolver = (params, context) => {
if (params.type === "settings") {
const doc$ = context.documentStore.listenQuery(
`*[_type == "post" && defined(slug.current)]{title,slug}`,
{},
{ perspective: "previewDrafts" },
) as Observable<
| {
slug: { current: string };
title: string | null;
}[]
| null
>;
return doc$.pipe(
map((docs) => {
console.log(docs);
return {
message: "This document is used on all pages",
tone: "caution",
locations: docs?.length
? [
homeLocation,
...docs
.map((doc) => ({
title: doc?.title || "Untitled",
href: resolveHref("post", doc?.slug?.current)!,
}))
.filter((doc) => doc.href !== undefined),
]
: [],
} satisfies DocumentLocationsState;
}),
);
}
if (params.type === "post" || params.type === "author") {
const doc$ = context.documentStore.listenQuery(
`*[defined(slug.current) && _id==$id || references($id)]{_type,slug,title}`,
params,
{ perspective: "previewDrafts" },
) as Observable<
| {
_type: string;
slug: { current: string };
title?: string | null;
}[]
| null
>;
return doc$.pipe(
map((docs) => {
switch (params.type) {
case "author":
case "post":
return {
locations: docs?.length
? [
homeLocation,
...docs
.map((doc) => {
const href = resolveHref(doc._type, doc?.slug?.current);
return {
title: doc?.title || "Untitled",
href: href!,
};
})
.filter((doc) => doc.href !== undefined),
]
: [],
} satisfies DocumentLocationsState;
default:
return {
message: "Unable to map document type to locations",
tone: "critical",
} satisfies DocumentLocationsState;
}
}),
);
}
return null;
};

View file

@ -0,0 +1,65 @@
/**
* This plugin contains all the logic for setting up the singletons
*/
import { definePlugin, type DocumentDefinition } from "sanity";
import { type StructureResolver } from "sanity/structure";
export const singletonPlugin = definePlugin((types: string[]) => {
return {
name: "singletonPlugin",
document: {
// Hide 'Singletons (such as Settings)' from new document options
// https://user-images.githubusercontent.com/81981/195728798-e0c6cf7e-d442-4e58-af3a-8cd99d7fcc28.png
newDocumentOptions: (prev, { creationContext, ...rest }) => {
if (creationContext.type === "global") {
return prev.filter(
(templateItem) => !types.includes(templateItem.templateId),
);
}
return prev;
},
// Removes the "duplicate" action on the Singletons (such as Home)
actions: (prev, { schemaType }) => {
if (types.includes(schemaType)) {
return prev.filter(({ action }) => action !== "duplicate");
}
return prev;
},
},
};
});
// The StructureResolver is how we're changing the DeskTool structure to linking to document (named Singleton)
// like how "Home" is handled.
export const pageStructure = (
typeDefArray: DocumentDefinition[],
): StructureResolver => {
return (S) => {
// Goes through all of the singletons that were provided and translates them into something the
// Structure tool can understand
const singletonItems = typeDefArray.map((typeDef) => {
return S.listItem()
.title(typeDef.title!)
.icon(typeDef.icon)
.child(
S.editor()
.id(typeDef.name)
.schemaType(typeDef.name)
.documentId(typeDef.name),
);
});
// The default root list items (except custom ones)
const defaultListItems = S.documentTypeListItems().filter(
(listItem) =>
!typeDefArray.find((singleton) => singleton.name === listItem.getId()),
);
return S.list()
.title("Content")
.items([...singletonItems, S.divider(), ...defaultListItems]);
};
};

View file

@ -0,0 +1,38 @@
import { UserIcon } from "@sanity/icons";
import { defineField, defineType } from "sanity";
export default defineType({
name: "author",
title: "Author",
icon: UserIcon,
type: "document",
fields: [
defineField({
name: "name",
title: "Name",
type: "string",
validation: (rule) => rule.required(),
}),
defineField({
name: "picture",
title: "Picture",
type: "image",
fields: [
{
name: "alt",
type: "string",
title: "Alternative text",
description: "Important for SEO and accessiblity.",
validation: (rule) => rule.required(),
},
],
options: {
hotspot: true,
aiAssist: {
imageDescriptionField: "alt",
},
},
validation: (rule) => rule.required(),
}),
],
});

View file

@ -0,0 +1,104 @@
import { DocumentTextIcon } from "@sanity/icons";
import { format, parseISO } from "date-fns";
import { defineField, defineType } from "sanity";
import authorType from "./author";
/**
* This file is the schema definition for a post.
*
* Here you'll be able to edit the different fields that appear when you
* create or edit a post in the studio.
*
* Here you can see the different schema types that are available:
https://www.sanity.io/docs/schema-types
*/
export default defineType({
name: "post",
title: "Post",
icon: DocumentTextIcon,
type: "document",
fields: [
defineField({
name: "title",
title: "Title",
type: "string",
validation: (rule) => rule.required(),
}),
defineField({
name: "slug",
title: "Slug",
type: "slug",
description: "A slug is required for the post to show up in the preview",
options: {
source: "title",
maxLength: 96,
isUnique: (value, context) => context.defaultIsUnique(value, context),
},
validation: (rule) => rule.required(),
}),
defineField({
name: "content",
title: "Content",
type: "array",
of: [{ type: "block" }],
}),
defineField({
name: "excerpt",
title: "Excerpt",
type: "text",
}),
defineField({
name: "coverImage",
title: "Cover Image",
type: "image",
options: {
hotspot: true,
aiAssist: {
imageDescriptionField: "alt",
},
},
fields: [
{
name: "alt",
type: "string",
title: "Alternative text",
description: "Important for SEO and accessiblity.",
validation: (rule) => rule.required(),
},
],
validation: (rule) => rule.required(),
}),
defineField({
name: "date",
title: "Date",
type: "datetime",
initialValue: () => new Date().toISOString(),
}),
defineField({
name: "author",
title: "Author",
type: "reference",
to: [{ type: authorType.name }],
}),
],
preview: {
select: {
title: "title",
author: "author.name",
date: "date",
media: "coverImage",
},
prepare({ title, media, author, date }) {
const subtitles = [
author && `by ${author}`,
date && `on ${format(parseISO(date), "LLL d, yyyy")}`,
].filter(Boolean);
return { title, media, subtitle: subtitles.join(" ") };
},
},
});

View file

@ -0,0 +1,122 @@
import { CogIcon } from "@sanity/icons";
import { defineArrayMember, defineField, defineType } from "sanity";
import * as demo from "@/sanity/lib/demo";
export default defineType({
name: "settings",
title: "Settings",
type: "document",
icon: CogIcon,
fields: [
defineField({
name: "title",
description: "This field is the title of your blog.",
title: "Title",
type: "string",
initialValue: demo.title,
validation: (rule) => rule.required(),
}),
defineField({
name: "description",
description:
"Used both for the <meta> description tag for SEO, and the blog subheader.",
title: "Description",
type: "array",
initialValue: demo.description,
of: [
defineArrayMember({
type: "block",
options: {},
styles: [],
lists: [],
marks: {
decorators: [],
annotations: [
defineField({
type: "object",
name: "link",
fields: [
{
type: "string",
name: "href",
title: "URL",
validation: (rule) => rule.required(),
},
],
}),
],
},
}),
],
}),
defineField({
name: "footer",
description:
"This is a block of text that will be displayed at the bottom of the page.",
title: "Footer Info",
type: "array",
of: [
defineArrayMember({
type: "block",
marks: {
annotations: [
{
name: "link",
type: "object",
title: "Link",
fields: [
{
name: "href",
type: "url",
title: "Url",
},
],
},
],
},
}),
],
}),
defineField({
name: "ogImage",
title: "Open Graph Image",
type: "image",
description: "Displayed on social cards and search engine results.",
options: {
hotspot: true,
aiAssist: {
imageDescriptionField: "alt",
},
},
fields: [
defineField({
name: "alt",
description: "Important for accessibility and SEO.",
title: "Alternative text",
type: "string",
validation: (rule) => rule.required(),
}),
defineField({
name: "metadataBase",
type: "url",
description: (
<a
href="https://nextjs.org/docs/app/api-reference/functions/generate-metadata#metadatabase"
rel="noreferrer noopener"
>
More information
</a>
),
}),
],
}),
],
preview: {
prepare() {
return {
title: "Settings",
};
},
},
});

View file

@ -1,35 +0,0 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
.yarn/install-state.gz
# testing
/coverage
# production
/dist
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# local env files
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
.sanity

View file

@ -1,9 +0,0 @@
const fs = require("fs");
if (fs.existsSync("../.env")) {
fs.copyFileSync("../.env", ".env.development");
} else if (fs.existsSync("../.env.local")) {
fs.copyFileSync("../.env.local", ".env.development");
} else {
throw new Error("No .env or .env.local file found at root of the project");
}

View file

@ -1,27 +0,0 @@
{
"private": true,
"scripts": {
"start": "sanity dev",
"dev": "sanity dev",
"build": "sanity build",
"cors:add": "npx sanity cors add",
"deploy": "sanity deploy",
"prestart": "npm run env",
"predeploy": "npm run env",
"env": "node copyEnv.js"
},
"dependencies": {
"sanity": "^3.2.6",
"@sanity/vision": "^3.2.6",
"prop-types": "^15.7",
"react": "^18",
"react-dom": "^18",
"sanity-plugin-asset-source-unsplash": "^1.0.6",
"styled-components": "^5.2.0"
},
"devDependencies": {
"autoprefixer": "^10.4.13",
"postcss": "^8.4.21",
"tailwindcss": "^3.2.4"
}
}

View file

@ -1 +0,0 @@
User-specific packages can be placed here

View file

@ -1,21 +0,0 @@
let productionUrl;
try {
productionUrl = new URL(
import.meta.env.SANITY_STUDIO_PREVIEW_URL || "http://localhost:3000",
);
} catch (err) {
console.error("Invalid productionUrl", err);
}
export function resolveProductionUrl(prev, { document }) {
if (!productionUrl || !document.slug?.current) {
return prev;
}
const searchParams = new URLSearchParams();
searchParams.set(
"secret",
import.meta.env.SANITY_STUDIO_PREVIEW_SECRET || "",
);
searchParams.set("slug", document.slug.current);
return `${productionUrl.origin}/api/preview?${searchParams}`;
}

View file

@ -1,36 +0,0 @@
import { visionTool } from "@sanity/vision";
import { defineConfig } from "sanity";
import { deskTool } from "sanity/desk";
import { unsplashImageAsset } from "sanity-plugin-asset-source-unsplash";
import { resolveProductionUrl } from "./resolveProductionUrl";
import { author } from "./schemas/author";
import { post } from "./schemas/post";
const title =
import.meta.env.NEXT_PUBLIC_SANITY_PROJECT_TITLE ||
"Next.js Blog with Sanity.io";
const projectId = import.meta.env.NEXT_PUBLIC_SANITY_PROJECT_ID;
const dataset = import.meta.env.NEXT_PUBLIC_SANITY_DATASET;
export default defineConfig({
basePath: "/",
projectId: projectId || "",
dataset: dataset || "",
title,
schema: {
// If you want more content types, you can add them to this array
types: [author, post],
},
document: {
productionUrl: resolveProductionUrl,
},
plugins: [
deskTool({}),
// Add an image asset source for Unsplash
unsplashImageAsset(),
// Vision lets you query your content with GROQ in the studio
// https://www.sanity.io/docs/the-vision-plugin
visionTool(),
],
});

View file

@ -1,20 +0,0 @@
export const author = {
name: "author",
title: "Author",
type: "document",
fields: [
{
name: "name",
title: "Name",
type: "string",
validation: (Rule) => Rule.required(),
},
{
name: "picture",
title: "Picture",
type: "image",
options: { hotspot: true },
validation: (Rule) => Rule.required(),
},
],
};

View file

@ -1,64 +0,0 @@
export const post = {
name: "post",
title: "Post",
type: "document",
fields: [
{
name: "title",
title: "Title",
type: "string",
validation: (Rule) => Rule.required(),
},
{
name: "slug",
title: "Slug",
type: "slug",
options: {
source: "title",
maxLength: 96,
},
validation: (Rule) => Rule.required(),
},
{
name: "content",
title: "Content",
type: "array",
of: [{ type: "block" }],
},
{
name: "excerpt",
title: "Excerpt",
type: "string",
},
{
name: "coverImage",
title: "Cover Image",
type: "image",
options: {
hotspot: true,
},
},
{
name: "date",
title: "Date",
type: "datetime",
},
{
name: "author",
title: "Author",
type: "reference",
to: [{ type: "author" }],
},
],
preview: {
select: {
title: "title",
author: "author.name",
media: "coverImage",
},
prepare(selection) {
const { author } = selection;
return { ...selection, subtitle: author && `by ${author}` };
},
},
};

View file

@ -1 +0,0 @@
Files placed here will be served by the Sanity server under the `/static`-prefix

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -1,6 +0,0 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ["./components/**/*.{js,ts,jsx,tsx}"],
theme: {},
plugins: [],
};

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: [],
};

Some files were not shown because too many files have changed in this diff Show more