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
|
@ -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>
|
||||
|
|
|
@ -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}`}
|
||||
|
|
|
@ -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=
|
||||
|
|
4
examples/cms-sanity/.eslintrc
Normal file
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"extends": "next/core-web-vitals",
|
||||
"root": true
|
||||
}
|
2
examples/cms-sanity/.gitignore
vendored
|
@ -39,4 +39,4 @@ next-env.d.ts
|
|||
|
||||
# Env files created by scripts for working locally
|
||||
.env
|
||||
studio/.env.development
|
||||
.env.local
|
|
@ -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
|
||||
? What’s 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
|
||||
? What’s 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
|
||||
|
|
12
examples/cms-sanity/app/(blog)/actions.ts
Normal 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)),
|
||||
]);
|
||||
}
|
52
examples/cms-sanity/app/(blog)/alert-banner.tsx
Normal 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>
|
||||
);
|
||||
}
|
31
examples/cms-sanity/app/(blog)/avatar.tsx
Normal 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>
|
||||
);
|
||||
}
|
31
examples/cms-sanity/app/(blog)/cover-image.tsx
Normal 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>
|
||||
);
|
||||
}
|
9
examples/cms-sanity/app/(blog)/date.tsx
Normal 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>
|
||||
);
|
||||
}
|
115
examples/cms-sanity/app/(blog)/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
52
examples/cms-sanity/app/(blog)/more-stories.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
73
examples/cms-sanity/app/(blog)/onboarding.tsx
Normal 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>
|
||||
);
|
||||
}
|
116
examples/cms-sanity/app/(blog)/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
49
examples/cms-sanity/app/(blog)/portable-text.tsx
Normal 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>
|
||||
);
|
||||
}
|
118
examples/cms-sanity/app/(blog)/posts/[slug]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
BIN
examples/cms-sanity/app/(sanity)/apple-icon.png
Normal file
After Width: | Height: | Size: 3.6 KiB |
BIN
examples/cms-sanity/app/(sanity)/icon.ico
Normal file
After Width: | Height: | Size: 4.2 KiB |
BIN
examples/cms-sanity/app/(sanity)/icon.png
Normal file
After Width: | Height: | Size: 10 KiB |
7
examples/cms-sanity/app/(sanity)/icon.svg
Normal 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 |
23
examples/cms-sanity/app/(sanity)/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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} />;
|
||||
}
|
27
examples/cms-sanity/app/api/draft/route.tsx
Normal 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);
|
||||
}
|
BIN
examples/cms-sanity/app/favicon.ico
Normal file
After Width: | Height: | Size: 15 KiB |
|
@ -1,5 +1,3 @@
|
|||
/* purgecss start ignore */
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
/* purgecss end ignore */
|
||||
@tailwind utilities;
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -1,3 +0,0 @@
|
|||
export default function Container({ children }) {
|
||||
return <div className="container mx-auto px-5">{children}</div>;
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>;
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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 />;
|
||||
}
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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 />;
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -1,3 +0,0 @@
|
|||
export default function SectionSeparator() {
|
||||
return <hr className="border-accent-2 mt-28 mb-24" />;
|
||||
}
|
|
@ -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
|
||||
};
|
|
@ -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";
|
|
@ -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}
|
||||
}
|
||||
`;
|
|
@ -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,
|
||||
});
|
|
@ -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());
|
||||
}
|
|
@ -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 },
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,7 +0,0 @@
|
|||
import "../styles/index.css";
|
||||
|
||||
function MyApp({ Component, pageProps }) {
|
||||
return <Component {...pageProps} />;
|
||||
}
|
||||
|
||||
export default MyApp;
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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();
|
||||
}
|
|
@ -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}`);
|
||||
}
|
|
@ -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 });
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
};
|
||||
}
|
|
@ -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,
|
||||
};
|
||||
}
|
|
@ -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: {},
|
||||
|
|
Before Width: | Height: | Size: 4.7 KiB |
Before Width: | Height: | Size: 14 KiB |
Before Width: | Height: | Size: 1.3 KiB |
|
@ -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>
|
Before Width: | Height: | Size: 595 B |
Before Width: | Height: | Size: 880 B |
Before Width: | Height: | Size: 15 KiB |
Before Width: | Height: | Size: 3.5 KiB |
|
@ -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 |
|
@ -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"
|
||||
}
|
|
@ -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 } });
|
50
examples/cms-sanity/sanity.config.ts
Normal 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[],
|
||||
});
|
33
examples/cms-sanity/sanity/lib/api.ts
Normal 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";
|
22
examples/cms-sanity/sanity/lib/client.ts
Normal 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);
|
||||
},
|
||||
},
|
||||
});
|
59
examples/cms-sanity/sanity/lib/demo.ts
Normal 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";
|
51
examples/cms-sanity/sanity/lib/fetch.ts
Normal 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 },
|
||||
});
|
||||
}
|
61
examples/cms-sanity/sanity/lib/queries.ts
Normal 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;
|
15
examples/cms-sanity/sanity/lib/token.ts
Normal 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,
|
||||
);
|
37
examples/cms-sanity/sanity/lib/utils.ts
Normal 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;
|
||||
}
|
||||
}
|
265
examples/cms-sanity/sanity/plugins/assist.ts
Normal 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",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
95
examples/cms-sanity/sanity/plugins/locate.ts
Normal 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;
|
||||
};
|
65
examples/cms-sanity/sanity/plugins/settings.tsx
Normal 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]);
|
||||
};
|
||||
};
|
38
examples/cms-sanity/sanity/schemas/documents/author.ts
Normal 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(),
|
||||
}),
|
||||
],
|
||||
});
|
104
examples/cms-sanity/sanity/schemas/documents/post.ts
Normal 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(" ") };
|
||||
},
|
||||
},
|
||||
});
|
122
examples/cms-sanity/sanity/schemas/singletons/settings.tsx
Normal 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",
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
35
examples/cms-sanity/studio/.gitignore
vendored
|
@ -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
|
|
@ -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");
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -1 +0,0 @@
|
|||
User-specific packages can be placed here
|
|
@ -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}`;
|
||||
}
|
|
@ -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(),
|
||||
],
|
||||
});
|
|
@ -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(),
|
||||
},
|
||||
],
|
||||
};
|
|
@ -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}` };
|
||||
},
|
||||
},
|
||||
};
|
|
@ -1 +0,0 @@
|
|||
Files placed here will be served by the Sanity server under the `/static`-prefix
|
Before Width: | Height: | Size: 1.1 KiB |
|
@ -1,6 +0,0 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: ["./components/**/*.{js,ts,jsx,tsx}"],
|
||||
theme: {},
|
||||
plugins: [],
|
||||
};
|
|
@ -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: [],
|
||||
};
|