├── .env.local.example ├── .eslintrc.json ├── .github ├── renovate.json └── workflows │ ├── ci.yml │ ├── lock.yml │ ├── prettier.yml │ └── preview.yml ├── .gitignore ├── README.md ├── app ├── api │ ├── disable-draft │ │ └── route.ts │ └── draft │ │ └── route.ts ├── layout.tsx └── studio │ └── [[...index]] │ └── page.tsx ├── components ├── global │ ├── Footer.tsx │ ├── Navbar.tsx │ └── SiteMeta.tsx ├── pages │ ├── home │ │ ├── HomePage.tsx │ │ ├── HomePageHead.tsx │ │ ├── HomePagePreview.tsx │ │ └── ProjectListItem.tsx │ ├── page │ │ ├── Page.tsx │ │ ├── PageHead.tsx │ │ └── PagePreview.tsx │ └── project │ │ ├── ProjectPage.tsx │ │ ├── ProjectPageHead.tsx │ │ └── ProjectPreview.tsx ├── preview │ └── PreviewProvider.tsx └── shared │ ├── CustomPortableText.tsx │ ├── Header.tsx │ ├── ImageBox.tsx │ ├── Layout.tsx │ ├── ScrollUp.tsx │ ├── TimelineItem.tsx │ └── TimelineSection.tsx ├── intro-template ├── cover.png └── index.tsx ├── lib ├── demo.data.ts ├── sanity.api.ts ├── sanity.client.ts ├── sanity.image.ts ├── sanity.links.ts └── sanity.queries.ts ├── netlify.toml ├── next-env.d.ts ├── next.config.mjs ├── package-lock.json ├── package.json ├── pages ├── [slug].tsx ├── _app.tsx ├── _document.tsx ├── index.tsx └── projects │ └── [slug].tsx ├── plugins ├── locate.ts └── settings.tsx ├── postcss.config.js ├── public └── favicon │ ├── android-chrome-192x192.png │ ├── android-chrome-512x512.png │ ├── apple-touch-icon.png │ ├── browserconfig.xml │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── favicon.ico │ ├── mstile-150x150.png │ └── site.webmanifest ├── sanity.cli.ts ├── sanity.config.ts ├── schemas ├── documents │ ├── page.ts │ └── project.ts ├── objects │ ├── duration │ │ ├── DurationInput.tsx │ │ └── index.ts │ ├── milestone.ts │ ├── timeline.ts │ └── youtube.tsx └── singletons │ ├── home.ts │ └── settings.ts ├── styles └── index.css ├── tailwind.config.js ├── tsconfig.json └── types └── index.ts /.env.local.example: -------------------------------------------------------------------------------- 1 | # Defaults, used by ./intro-template and can be deleted if the component is removed 2 | NEXT_PUBLIC_VERCEL_GIT_REPO_OWNER="sanity-io" 3 | NEXT_PUBLIC_VERCEL_GIT_PROVIDER="github" 4 | NEXT_PUBLIC_VERCEL_GIT_REPO_SLUG="sanity-template-vercel-visual-editing" 5 | 6 | # Required, find them on https://manage.sanity.io 7 | NEXT_PUBLIC_SANITY_PROJECT_ID= 8 | NEXT_PUBLIC_SANITY_DATASET= 9 | SANITY_API_READ_TOKEN= 10 | # Optional, useful if you plan to add API functions that can write to your dataset your dataset 11 | SANITY_API_WRITE_TOKEN= 12 | 13 | # Optional, can be used to change the Studio title in the navbar and differentiate between production and staging environments for your editors 14 | # NEXT_PUBLIC_SANITY_PROJECT_TITLE="My Project" 15 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next", 3 | "plugins": ["simple-import-sort"], 4 | "rules": { 5 | "simple-import-sort/imports": "warn", 6 | "simple-import-sort/exports": "warn", 7 | "react-hooks/exhaustive-deps": "error" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "description": "Check the readme: https://github.com/sanity-io/renovate-presets/blob/main/ecosystem/README.md", 3 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 4 | "extends": [ 5 | "github>sanity-io/renovate-config:starter-template", 6 | ":reviewer(team:ecosystem)" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | merge_group: 5 | pull_request: 6 | push: 7 | branches: [main] 8 | workflow_dispatch: 9 | 10 | concurrency: 11 | group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} 12 | cancel-in-progress: true 13 | 14 | jobs: 15 | build: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v4 19 | - uses: actions/setup-node@v4 20 | with: 21 | cache: npm 22 | node-version: lts/* 23 | - run: npm ci 24 | - run: npm run type-check 25 | - run: npm run lint -- --max-warnings 0 26 | -------------------------------------------------------------------------------- /.github/workflows/lock.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: 'Lock Threads' 3 | 4 | on: 5 | schedule: 6 | - cron: '0 0 * * *' 7 | workflow_dispatch: 8 | 9 | permissions: 10 | issues: write 11 | pull-requests: write 12 | 13 | concurrency: 14 | group: ${{ github.workflow }} 15 | cancel-in-progress: true 16 | 17 | jobs: 18 | action: 19 | runs-on: ubuntu-latest 20 | steps: 21 | - uses: dessant/lock-threads@1bf7ec25051fe7c00bdd17e6a7cf3d7bfb7dc771 # v5 22 | with: 23 | issue-inactive-days: 0 24 | pr-inactive-days: 0 25 | -------------------------------------------------------------------------------- /.github/workflows/prettier.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Prettier 3 | 4 | on: 5 | push: 6 | branches: [main] 7 | workflow_dispatch: 8 | 9 | concurrency: 10 | group: ${{ github.workflow }}-${{ github.ref_name }} 11 | cancel-in-progress: true 12 | 13 | permissions: 14 | contents: read 15 | 16 | jobs: 17 | run: 18 | name: Can the code be prettier? 🤔 19 | runs-on: ubuntu-latest 20 | # workflow_dispatch always lets you select the branch ref, even though in this case we only ever want to run the action on `main` this we need an if check 21 | if: ${{ github.ref_name == 'main' }} 22 | steps: 23 | - uses: actions/checkout@v4 24 | - uses: actions/setup-node@v4 25 | with: 26 | cache: npm 27 | node-version: lts/* 28 | - run: npm ci --ignore-scripts --only-dev 29 | - uses: actions/cache@v4 30 | with: 31 | path: node_modules/.cache/prettier/.prettier-cache 32 | key: prettier-${{ hashFiles('package-lock.json') }}-${{ hashFiles('.gitignore') }} 33 | - run: npm run format 34 | - run: git restore .github/workflows 35 | - uses: tibdex/github-app-token@3beb63f4bd073e61482598c45c71c1019b59b73a # v2 36 | id: generate-token 37 | with: 38 | app_id: ${{ secrets.ECOSPARK_APP_ID }} 39 | private_key: ${{ secrets.ECOSPARK_APP_PRIVATE_KEY }} 40 | - uses: peter-evans/create-pull-request@a4f52f8033a6168103c2538976c07b467e8163bc # v6 41 | with: 42 | author: github-actions <41898282+github-actions[bot]@users.noreply.github.com> 43 | body: I ran `npm run format` 🧑‍💻 44 | branch: actions/prettier-if-needed 45 | commit-message: 'chore(prettier): 🤖 ✨' 46 | labels: 🤖 bot 47 | title: 'chore(prettier): 🤖 ✨' 48 | token: ${{ steps.generate-token.outputs.token }} 49 | -------------------------------------------------------------------------------- /.github/workflows/preview.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Sync main to preview 3 | 4 | on: 5 | push: 6 | branches: 7 | - main 8 | 9 | concurrency: 10 | group: ${{ github.workflow }}-${{ github.ref_name }} 11 | cancel-in-progress: true 12 | 13 | permissions: 14 | contents: read 15 | 16 | jobs: 17 | run: 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: actions/checkout@v4 21 | - uses: tibdex/github-app-token@3beb63f4bd073e61482598c45c71c1019b59b73a # v2 22 | id: generate-token 23 | with: 24 | app_id: ${{ secrets.ECOSPARK_APP_ID }} 25 | private_key: ${{ secrets.ECOSPARK_APP_PRIVATE_KEY }} 26 | - uses: connor-baer/action-sync-branch@main 27 | with: 28 | branch: preview 29 | token: ${{ steps.generate-token.outputs.token }} 30 | force: true 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /studio/node_modules 6 | /.pnp 7 | .pnp.js 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | /studio/dist 19 | 20 | # misc 21 | .DS_Store 22 | *.pem 23 | .vscode 24 | 25 | # debug 26 | npm-debug.log* 27 | yarn-debug.log* 28 | yarn-error.log* 29 | .pnpm-debug.log* 30 | 31 | # local env files 32 | .env*.local 33 | 34 | #Intellij 35 | .idea 36 | *.iml 37 | 38 | # vercel 39 | .vercel 40 | 41 | # typescript 42 | *.tsbuildinfo 43 | 44 | # Env files created by scripts for working locally 45 | .env 46 | studio/.env.development 47 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Vercel Visual Editing Demo 2 | 3 | ## [Live demo](https://sanity-template-vercel-visual-editing-git-preview.sanity.build/) 4 | 5 | > **Note** 6 | > 7 | > [Vercel Visual Editing][visual-editing] is available on [Vercel's Pro and Enterprise plans][vercel-enterprise] and on all Sanity plans. 8 | 9 | This starter is preconfigured to support [Visual Editing][visual-editing-intro], a new feature that enables you to make changes using Vercel's draft mode and new edit functionality. It is a statically generated personal website that uses [Next.js][nextjs] for the frontend and [Sanity][sanity-homepage] to handle its content. 10 | 11 | It comes with a native Sanity Studio that offers features like real-time collaboration, instant side-by-side content previews, and intuitive editing. 12 | 13 | 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. 14 | 15 | You can use this starter to kick-start a personal website to learn more about Visual Editing or other awesome Sanity features. 16 | 17 | [![Deploy with Vercel](https://vercel.com/button)][vercel-deploy] 18 | 19 | ## Features 20 | 21 | - A performant, static personal personal website with editable projects 22 | - A native and customizable authoring environment, accessible on `yourpersonalwebsite.com/studio` 23 | - Real-time and collaborative content editing with fine-grained revision history 24 | - Support for block content and the most advanced custom fields capability in the industry 25 | - Free Sanity project with unlimited admin users, free content updates, and pay-as-you-go for API overages 26 | - A project with starter-friendly and not too heavy-handed TypeScript and Tailwind.css 27 | 28 | ## Table of Contents 29 | 30 | - [Features](#features) 31 | - [Table of Contents](#table-of-contents) 32 | - [Project Overview](#project-overview) 33 | - [Important files and folders](#important-files-and-folders) 34 | - [Configuration](#configuration) 35 | - [Step 1. Set up the environment](#step-1-set-up-the-environment) 36 | - [Step 2. Set up the project locally](#step-2-set-up-the-project-locally) 37 | - [Step 3. Run Next.js locally in development mode](#step-3-run-nextjs-locally-in-development-mode) 38 | - [Step 4. Deploy to production](#step-4-deploy-to-production) 39 | - [Questions and Answers](#questions-and-answers) 40 | - [It doesn't work! Where can I get help?](#it-doesnt-work-where-can-i-get-help) 41 | - [How can I remove the "Next steps" block from my personal site?](#how-can-i-remove-the-next-steps-block-from-my-personal-site) 42 | - [Next steps](#next-steps) 43 | 44 | ## Project Overview 45 | 46 | | [Personal Website](https://template-vercel-visual-editing.sanity.build) | [Studio](https://template-vercel-visual-editing.sanity.build/studio) | 47 | | ------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | 48 | | ![Personal Website](https://user-images.githubusercontent.com/6951139/206395107-e58a796d-13a9-400a-94b6-31cb5df054ab.png) | ![Sanity Studio](https://user-images.githubusercontent.com/6951139/206395521-8a5f103d-4a0c-4da8-aff5-d2a1961fb2c0.png) | 49 | 50 | ### Important files and folders 51 | 52 | | File(s) | Description | 53 | | ------------------------------------------- | ------------------------------------------------------- | 54 | | `sanity.config.ts` | Config file for Sanity Studio | 55 | | `sanity.cli.ts` | Config file for Sanity CLI | 56 | | `/pages/studio/[[...index]]/page.tsx` | Where Sanity Studio is mounted | 57 | | `/schemas` | Where Sanity Studio gets its content types from | 58 | | `/plugins` | Where the advanced Sanity Studio customization is setup | 59 | | `/lib/sanity.api.ts`,`/lib/sanity.image.ts` | Configuration for the Sanity Content Lake client | 60 | 61 | ## Configuration 62 | 63 | ### Step 1. Set up the environment 64 | 65 | Use the Deploy Button below. It will let you deploy the starter using [Vercel](https://vercel.com?utm_source=github&utm_medium=readme&utm_campaign=next-sanity-example) as well as connect it to your Sanity Content Lake using [the Sanity Vercel Integration][integration]. 66 | 67 | [![Deploy with Vercel](https://vercel.com/button)][vercel-deploy] 68 | 69 | ### Step 2. Set up the project locally 70 | 71 | [Clone the repository](https://docs.github.com/en/repositories/creating-and-managing-repositories/cloning-a-repository) that was created for you on your GitHub account. Once cloned, run the following command from the project's root directory: 72 | 73 | ```bash 74 | npx vercel link 75 | ``` 76 | 77 | Download the environment variables needed to connect Next.js and the Studio to your Sanity project: 78 | 79 | ```bash 80 | npx vercel env pull 81 | ``` 82 | 83 | ### Step 3. Run Next.js locally in development mode 84 | 85 | ```bash 86 | npm install && npm run dev 87 | ``` 88 | 89 | When you run this development server, the changes you make in your frontend and studio configuration will be applied live using hot reloading. 90 | 91 | Your personal website should be up and running on [http://localhost:3000][localhost-3000]! You can create and edit content on [http://localhost:3000/studio][localhost-3000-studio]. 92 | 93 | ### Step 4. Deploy to production 94 | 95 | To deploy your changes to production you use `git`: 96 | 97 | ```bash 98 | git add . 99 | git commit 100 | git push 101 | ``` 102 | 103 | Alternatively, you can deploy without a `git` hosting provider using the Vercel CLI: 104 | 105 | ```bash 106 | npx vercel --prod 107 | ``` 108 | 109 | ## Questions and Answers 110 | 111 | ### How do I enable Visual Editing on my own Vercel project? 112 | 113 | [Read our guide.][visual-editing] 114 | 115 | ### It doesn't work! Where can I get help? 116 | 117 | In case of any issues or questions, you can post: 118 | 119 | - [GitHub Discussions for Next.js][vercel-github] 120 | - [Sanity's GitHub Discussions][sanity-github] 121 | - [Sanity's Community Slack][sanity-community] 122 | 123 | ### How can I remove the "Next steps" block from my personal website? 124 | 125 | You can remove it by deleting the `IntroTemplate` component in `/components/shared/Layout.tsx` and 126 | the `/intro-template` directory. 127 | 128 | ## Next steps 129 | 130 | - [Join our Slack community to ask questions and get help][sanity-community] 131 | - [How to edit my content structure?][sanity-schema-types] 132 | - [How to query content?][sanity-groq] 133 | - [What is content modelling?][sanity-content-modelling] 134 | 135 | [vercel-deploy]: https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fsanity-io%2Fsanity-template-vercel-visual-editing&project-name=sanity-template-vercel-visual-editing&repository-name=sanity-template-vercel-visual-editing&demo-title=Visual%20Editing%20Demo&demo-description=A%20Sanity-powered%20personal%20website%20that%20showcases%20Visual%20Editing%20on%20Vercel.&demo-url=https%3A%2F%2Ftemplate-vercel-visual-editing.sanity.build%2F&demo-image=https%3A%2F%2Fuser-images.githubusercontent.com%2F81981%2F235943631-9c0cd33b-6534-4f82-98f3-641f72970590.png&integration-ids=oac_hb2LITYajhRQ0i4QznmKH7gx&external-id=nextjs;template=sanity-template-vercel-visual-editing 136 | [integration]: https://www.sanity.io/docs/vercel-integration?utm_source=github.com&utm_medium=referral&utm_campaign=nextjs-v3vercelstarter 137 | [`.env.local.example`]: .env.local.example 138 | [nextjs]: https://github.com/vercel/next.js 139 | [sanity-create]: https://www.sanity.io/get-started/create-project?utm_source=github.com&utm_medium=referral&utm_campaign=nextjs-v3vercelstarter 140 | [sanity-deployment]: https://www.sanity.io/docs/deployment?utm_source=github.com&utm_medium=referral&utm_campaign=nextjs-v3vercelstarter 141 | [sanity-homepage]: https://www.sanity.io?utm_source=github.com&utm_medium=referral&utm_campaign=nextjs-v3vercelstarter 142 | [sanity-community]: https://slack.sanity.io/ 143 | [sanity-schema-types]: https://www.sanity.io/docs/schema-types?utm_source=github.com&utm_medium=referral&utm_campaign=nextjs-v3vercelstarter 144 | [sanity-github]: https://github.com/sanity-io/sanity/discussions 145 | [sanity-groq]: https://www.sanity.io/docs/groq?utm_source=github.com&utm_medium=referral&utm_campaign=nextjs-v3vercelstarter 146 | [sanity-content-modelling]: https://www.sanity.io/docs/content-modelling?utm_source=github.com&utm_medium=referral&utm_campaign=nextjs-v3vercelstarter 147 | [sanity-webhooks]: https://www.sanity.io/docs/webhooks?utm_source=github.com&utm_medium=referral&utm_campaign=nextjs-v3vercelstarter 148 | [localhost-3000]: http://localhost:3000 149 | [localhost-3000-studio]: http://localhost:3000/studio 150 | [vercel]: https://vercel.com 151 | [vercel-github]: https://github.com/vercel/next.js/discussions 152 | [app-dir]: https://beta.nextjs.org/docs/routing/fundamentals#the-app-directory 153 | [visual-editing]: https://www.sanity.io/docs/vercel-visual-editing?utm_source=github.com&utm_medium=referral&utm_campaign=may-vercel-launch 154 | [visual-editing-intro]: https://www.sanity.io/blog/visual-editing-sanity-vercel?utm_source=github.com&utm_medium=referral&utm_campaign=may-vercel-launch 155 | [sales-cta]: https://www.sanity.io/contact/sales?utm_source=github.com&utm_medium=referral&utm_campaign=may-vercel-launch 156 | [enterprise-cta]: https://www.sanity.io/enterprise?utm_source=github.com&utm_medium=referral&utm_campaign=may-vercel-launch 157 | [vercel-enterprise]: https://vercel.com/docs/accounts/plans/enterprise 158 | -------------------------------------------------------------------------------- /app/api/disable-draft/route.ts: -------------------------------------------------------------------------------- 1 | import { draftMode } from 'next/headers' 2 | import { NextRequest, NextResponse } from 'next/server' 3 | 4 | export function GET(request: NextRequest) { 5 | draftMode().disable() 6 | const url = new URL(request.nextUrl) 7 | return NextResponse.redirect(new URL('/', url.origin)) 8 | } 9 | -------------------------------------------------------------------------------- /app/api/draft/route.ts: -------------------------------------------------------------------------------- 1 | import { validatePreviewUrl } from '@sanity/preview-url-secret' 2 | import { readToken } from 'lib/sanity.api' 3 | import { getClient } from 'lib/sanity.client' 4 | import { draftMode } from 'next/headers' 5 | import { redirect } from 'next/navigation' 6 | 7 | const clientWithToken = getClient().withConfig({ token: readToken }) 8 | 9 | export async function GET(request: Request) { 10 | const { isValid, redirectTo = '/' } = await validatePreviewUrl( 11 | clientWithToken, 12 | request.url, 13 | ) 14 | if (!isValid) { 15 | return new Response('Invalid secret', { status: 401 }) 16 | } 17 | 18 | draftMode().enable() 19 | 20 | redirect(redirectTo) 21 | } 22 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import 'tailwindcss/tailwind.css' 2 | 3 | import { IBM_Plex_Mono, Inter, PT_Serif } from 'next/font/google' 4 | 5 | const serif = PT_Serif({ 6 | variable: '--font-serif', 7 | style: ['normal', 'italic'], 8 | subsets: ['latin'], 9 | weight: ['400', '700'], 10 | }) 11 | const sans = Inter({ 12 | variable: '--font-sans', 13 | subsets: ['latin'], 14 | // @todo: understand why extrabold (800) isn't being respected when explicitly specified in this weight array 15 | // weight: ['500', '700', '800'], 16 | }) 17 | const mono = IBM_Plex_Mono({ 18 | variable: '--font-mono', 19 | subsets: ['latin'], 20 | weight: ['500', '700'], 21 | }) 22 | 23 | export default async function RootLayout({ 24 | children, 25 | }: { 26 | children: React.ReactNode 27 | }) { 28 | return ( 29 | 33 | {children} 34 | 35 | ) 36 | } 37 | -------------------------------------------------------------------------------- /app/studio/[[...index]]/page.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * This route is responsible for the built-in authoring environment using Sanity Studio v3. 3 | * All routes under /studio will be handled by this file using Next.js' catch-all routes: 4 | * https://nextjs.org/docs/routing/dynamic-routes#catch-all-routes 5 | * 6 | * You can learn more about the next-sanity package here: 7 | * https://github.com/sanity-io/next-sanity 8 | */ 9 | 10 | import { NextStudio } from 'next-sanity/studio' 11 | import config from 'sanity.config' 12 | 13 | export const dynamic = 'force-static' 14 | 15 | export { metadata, viewport } from 'next-sanity/studio' 16 | 17 | export default function StudioPage() { 18 | return ( 19 | 26 | ) 27 | } 28 | -------------------------------------------------------------------------------- /components/global/Footer.tsx: -------------------------------------------------------------------------------- 1 | import type { PortableTextBlock } from '@portabletext/types' 2 | import { CustomPortableText } from 'components/shared/CustomPortableText' 3 | 4 | export function Footer({ footer }: { footer: PortableTextBlock[] }) { 5 | return ( 6 | 14 | ) 15 | } 16 | -------------------------------------------------------------------------------- /components/global/Navbar.tsx: -------------------------------------------------------------------------------- 1 | import { resolveHref } from 'lib/sanity.links' 2 | import Link from 'next/link' 3 | import { MenuItem } from 'types' 4 | 5 | interface NavbarProps { 6 | menuItems?: MenuItem[] 7 | } 8 | 9 | export function Navbar({ menuItems }: NavbarProps) { 10 | return ( 11 |
12 | 17 | Home 18 | 19 | {menuItems && 20 | menuItems.map((menuItem) => { 21 | const href = resolveHref(menuItem?._type, menuItem?.slug) 22 | if (!href) { 23 | return null 24 | } 25 | return ( 26 | 35 | {menuItem.title} 36 | 37 | ) 38 | })} 39 |
40 | ) 41 | } 42 | -------------------------------------------------------------------------------- /components/global/SiteMeta.tsx: -------------------------------------------------------------------------------- 1 | import * as demo from 'lib/demo.data' 2 | import { urlForImage } from 'lib/sanity.image' 3 | import Head from 'next/head' 4 | import type { Image } from 'sanity' 5 | 6 | /** 7 | * All the shared stuff that goes into on `(personal)` routes, can be be imported by `head.tsx` files in the /app dir or wrapped in a component in the /pages dir. 8 | */ 9 | export function SiteMeta({ 10 | baseTitle, 11 | description, 12 | image, 13 | title, 14 | }: { 15 | baseTitle?: string 16 | description?: string 17 | image?: Image 18 | title?: string 19 | }) { 20 | const metaTitle = [ 21 | ...(title ? [title] : []), 22 | ...(baseTitle ? [baseTitle] : []), 23 | ].join(' | ') 24 | 25 | const imageUrl = 26 | image && urlForImage(image)?.width(1200).height(627).fit('crop').url() 27 | 28 | return ( 29 | 30 | {metaTitle || demo.title} 31 | 32 | 37 | 43 | 49 | 50 | 51 | 52 | 53 | 54 | {description && ( 55 | 56 | )} 57 | {imageUrl && } 58 | 59 | ) 60 | } 61 | -------------------------------------------------------------------------------- /components/pages/home/HomePage.tsx: -------------------------------------------------------------------------------- 1 | import { ProjectListItem } from 'components/pages/home/ProjectListItem' 2 | import { Header } from 'components/shared/Header' 3 | import Layout from 'components/shared/Layout' 4 | import ScrollUp from 'components/shared/ScrollUp' 5 | import { resolveHref } from 'lib/sanity.links' 6 | import Link from 'next/link' 7 | import type { HomePagePayload } from 'types' 8 | import { SettingsPayload } from 'types' 9 | 10 | import HomePageHead from './HomePageHead' 11 | 12 | export interface HomePageProps { 13 | settings?: SettingsPayload 14 | page?: HomePagePayload 15 | preview?: boolean 16 | } 17 | 18 | export function HomePage({ page, settings, preview }: HomePageProps) { 19 | const { overview, showcaseProjects, title = 'Personal website' } = page ?? {} 20 | 21 | return ( 22 | <> 23 | 24 | 25 | 26 |
27 | {/* Header */} 28 | {title &&
} 29 | {/* Showcase projects */} 30 | {showcaseProjects && showcaseProjects.length > 0 && ( 31 |
32 | {showcaseProjects.map((project, key) => { 33 | const href = resolveHref(project._type, project.slug) 34 | if (!href) { 35 | return null 36 | } 37 | return ( 38 | 39 | 40 | 41 | ) 42 | })} 43 |
44 | )} 45 | 46 | {/* Workaround: scroll to top on route change */} 47 | 48 |
49 |
50 | 51 | ) 52 | } 53 | -------------------------------------------------------------------------------- /components/pages/home/HomePageHead.tsx: -------------------------------------------------------------------------------- 1 | import { toPlainText } from '@portabletext/react' 2 | import { SiteMeta } from 'components/global/SiteMeta' 3 | import { HomePagePayload, SettingsPayload } from 'types' 4 | 5 | export interface HomePageHeadProps { 6 | settings?: SettingsPayload 7 | page?: HomePagePayload 8 | } 9 | 10 | export default function HomePageHead({ settings, page }: HomePageHeadProps) { 11 | return ( 12 | 17 | ) 18 | } 19 | -------------------------------------------------------------------------------- /components/pages/home/HomePagePreview.tsx: -------------------------------------------------------------------------------- 1 | import { homePageQuery } from 'lib/sanity.queries' 2 | import { useLiveQuery } from 'next-sanity/preview' 3 | import type { HomePagePayload } from 'types' 4 | 5 | import { HomePage, HomePageProps } from './HomePage' 6 | 7 | export default function HomePagePreview({ 8 | page: initialPage, 9 | settings, 10 | }: HomePageProps) { 11 | const [page] = useLiveQuery( 12 | initialPage, 13 | homePageQuery, 14 | ) 15 | 16 | if (!page) { 17 | return ( 18 |
19 | Please start editing your Home document to see the preview! 20 |
21 | ) 22 | } 23 | 24 | return 25 | } 26 | -------------------------------------------------------------------------------- /components/pages/home/ProjectListItem.tsx: -------------------------------------------------------------------------------- 1 | import { CustomPortableText } from 'components/shared/CustomPortableText' 2 | import ImageBox from 'components/shared/ImageBox' 3 | import type { ShowcaseProject } from 'types' 4 | 5 | interface ProjectProps { 6 | project: ShowcaseProject 7 | odd: number 8 | } 9 | 10 | export function ProjectListItem(props: ProjectProps) { 11 | const { project, odd } = props 12 | 13 | return ( 14 |
19 |
20 | 25 |
26 |
27 | 28 |
29 |
30 | ) 31 | } 32 | 33 | function TextBox({ project }: { project: ShowcaseProject }) { 34 | return ( 35 |
36 |
37 | {/* Title */} 38 |
39 | {project.title} 40 |
41 | {/* Overview */} 42 |
43 | 44 |
45 |
46 | {/* Tags */} 47 |
48 | {project.tags?.map((tag, key) => ( 49 |
50 | #{tag} 51 |
52 | ))} 53 |
54 |
55 | ) 56 | } 57 | -------------------------------------------------------------------------------- /components/pages/page/Page.tsx: -------------------------------------------------------------------------------- 1 | import { CustomPortableText } from 'components/shared/CustomPortableText' 2 | import { Header } from 'components/shared/Header' 3 | import Layout from 'components/shared/Layout' 4 | import ScrollUp from 'components/shared/ScrollUp' 5 | import type { PagePayload, SettingsPayload } from 'types' 6 | 7 | import PageHead from './PageHead' 8 | 9 | export interface PageProps { 10 | page: PagePayload | undefined 11 | settings: SettingsPayload | undefined 12 | homePageTitle: string | undefined 13 | preview?: boolean 14 | } 15 | 16 | export function Page({ page, settings, homePageTitle, preview }: PageProps) { 17 | // Default to an empty object to allow previews on non-existent documents 18 | const { body, overview, title } = page || {} 19 | 20 | return ( 21 | <> 22 | 23 | 24 | 25 |
26 |
27 | {/* Header */} 28 |
29 | 30 | {/* Body */} 31 | {body && ( 32 | 36 | )} 37 | 38 | {/* Workaround: scroll to top on route change */} 39 | 40 |
41 |
42 |
43 | 44 | 45 | ) 46 | } 47 | -------------------------------------------------------------------------------- /components/pages/page/PageHead.tsx: -------------------------------------------------------------------------------- 1 | import { toPlainText } from '@portabletext/react' 2 | import { SiteMeta } from 'components/global/SiteMeta' 3 | import { PagePayload, SettingsPayload } from 'types' 4 | 5 | export interface PageHeadProps { 6 | title: string | undefined 7 | page: PagePayload | undefined 8 | settings: SettingsPayload | undefined 9 | } 10 | 11 | export default function PageHead({ title, page, settings }: PageHeadProps) { 12 | return ( 13 | 19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /components/pages/page/PagePreview.tsx: -------------------------------------------------------------------------------- 1 | import { pagesBySlugQuery } from 'lib/sanity.queries' 2 | import { useLiveQuery } from 'next-sanity/preview' 3 | import type { PagePayload } from 'types' 4 | 5 | import { Page, PageProps } from './Page' 6 | 7 | export default function PagePreview({ 8 | page: initialPage, 9 | settings, 10 | homePageTitle, 11 | }: PageProps) { 12 | const [page] = useLiveQuery( 13 | initialPage, 14 | pagesBySlugQuery, 15 | { 16 | slug: initialPage.slug, 17 | }, 18 | ) 19 | 20 | return ( 21 | 27 | ) 28 | } 29 | -------------------------------------------------------------------------------- /components/pages/project/ProjectPage.tsx: -------------------------------------------------------------------------------- 1 | import { CustomPortableText } from 'components/shared/CustomPortableText' 2 | import { Header } from 'components/shared/Header' 3 | import ImageBox from 'components/shared/ImageBox' 4 | import ScrollUp from 'components/shared/ScrollUp' 5 | import Link from 'next/link' 6 | import type { ProjectPayload, SettingsPayload } from 'types' 7 | 8 | import Layout from '../../shared/Layout' 9 | import ProjectPageHead from './ProjectPageHead' 10 | 11 | export interface ProjectPageProps { 12 | project: ProjectPayload | undefined 13 | settings: SettingsPayload | undefined 14 | homePageTitle: string | undefined 15 | preview?: boolean 16 | } 17 | 18 | export function ProjectPage({ 19 | project, 20 | settings, 21 | homePageTitle, 22 | preview, 23 | }: ProjectPageProps) { 24 | // Default to an empty object to allow previews on non-existent documents 25 | const { 26 | client, 27 | coverImage, 28 | description, 29 | duration, 30 | overview, 31 | site, 32 | tags, 33 | title, 34 | } = project || {} 35 | 36 | const startYear = new Date(duration?.start).getFullYear() 37 | const endYear = duration?.end ? new Date(duration?.end).getFullYear() : 'Now' 38 | 39 | return ( 40 | <> 41 | 42 | 43 | 44 |
45 |
46 | {/* Header */} 47 |
48 | 49 |
50 | {/* Image */} 51 | 56 | 57 |
58 | {/* Duration */} 59 | {!!(startYear && endYear) && ( 60 |
61 |
Duration
62 |
{`${startYear} - ${endYear}`}
63 |
64 | )} 65 | 66 | {/* Client */} 67 | {client && ( 68 |
69 |
Client
70 |
{client}
71 |
72 | )} 73 | 74 | {/* Site */} 75 | {site && ( 76 |
77 |
Site
78 | {site && ( 79 | 84 | {site} 85 | 86 | )} 87 |
88 | )} 89 | 90 | {/* Tags */} 91 |
92 |
Tags
93 |
94 | {tags?.map((tag, key) => ( 95 |
96 | #{tag} 97 |
98 | ))} 99 |
100 |
101 |
102 |
103 | 104 | {/* Description */} 105 | {description && ( 106 | 110 | )} 111 | {/* Workaround: scroll to top on route change */} 112 | 113 |
114 |
115 |
116 | 117 | 118 | ) 119 | } 120 | -------------------------------------------------------------------------------- /components/pages/project/ProjectPageHead.tsx: -------------------------------------------------------------------------------- 1 | import { toPlainText } from '@portabletext/react' 2 | import { SiteMeta } from 'components/global/SiteMeta' 3 | import { ProjectPayload } from 'types' 4 | 5 | export interface ProjectPageHeadProps { 6 | project: ProjectPayload | undefined 7 | title: string | undefined 8 | } 9 | 10 | export default function ProjectPageHead({ 11 | project, 12 | title, 13 | }: ProjectPageHeadProps) { 14 | return ( 15 | 21 | ) 22 | } 23 | -------------------------------------------------------------------------------- /components/pages/project/ProjectPreview.tsx: -------------------------------------------------------------------------------- 1 | import { projectBySlugQuery } from 'lib/sanity.queries' 2 | import { useLiveQuery } from 'next-sanity/preview' 3 | import type { ProjectPayload } from 'types' 4 | 5 | import { ProjectPage, ProjectPageProps } from './ProjectPage' 6 | 7 | export default function ProjectPreview({ 8 | settings, 9 | project: initialProject, 10 | homePageTitle, 11 | }: ProjectPageProps) { 12 | const [project] = useLiveQuery( 13 | initialProject, 14 | projectBySlugQuery, 15 | { 16 | slug: initialProject.slug, 17 | }, 18 | ) 19 | 20 | return ( 21 | 27 | ) 28 | } 29 | -------------------------------------------------------------------------------- /components/preview/PreviewProvider.tsx: -------------------------------------------------------------------------------- 1 | import { getClient } from 'lib/sanity.client' 2 | import { LiveQueryProvider } from 'next-sanity/preview' 3 | import { useMemo } from 'react' 4 | 5 | export default function PreviewProvider({ 6 | children, 7 | token, 8 | }: { 9 | children: React.ReactNode 10 | token: string 11 | }) { 12 | const client = useMemo(() => getClient({ token }), [token]) 13 | return ( 14 | 15 | {children} 16 | 17 | ) 18 | } 19 | -------------------------------------------------------------------------------- /components/shared/CustomPortableText.tsx: -------------------------------------------------------------------------------- 1 | import { PortableText, PortableTextComponents } from '@portabletext/react' 2 | import type { PortableTextBlock } from '@portabletext/types' 3 | import ImageBox from 'components/shared/ImageBox' 4 | import { TimelineSection } from 'components/shared/TimelineSection' 5 | import getYouTubeId from 'get-youtube-id' 6 | import LiteYouTubeEmbed from 'react-lite-youtube-embed' 7 | import type { Image } from 'sanity' 8 | 9 | export function CustomPortableText({ 10 | paragraphClasses, 11 | value, 12 | }: { 13 | paragraphClasses?: string 14 | value: PortableTextBlock[] 15 | }) { 16 | const components: PortableTextComponents = { 17 | block: { 18 | normal: ({ children }) => { 19 | return

{children}

20 | }, 21 | }, 22 | marks: { 23 | link: ({ children, value }) => { 24 | return ( 25 | 30 | {children} 31 | 32 | ) 33 | }, 34 | }, 35 | types: { 36 | image: ({ 37 | value, 38 | }: { 39 | value: Image & { alt?: string; caption?: string } 40 | }) => { 41 | return ( 42 |
43 | 48 | {value?.caption && ( 49 |
50 | {value.caption} 51 |
52 | )} 53 |
54 | ) 55 | }, 56 | timeline: ({ value }) => { 57 | const { items } = value || {} 58 | return 59 | }, 60 | youtube: ({ value }) => { 61 | const { url, title, aspectHeight, aspectWidth } = value 62 | const id = getYouTubeId(url) 63 | return ( 64 | 70 | ) 71 | }, 72 | }, 73 | } 74 | 75 | return 76 | } 77 | -------------------------------------------------------------------------------- /components/shared/Header.tsx: -------------------------------------------------------------------------------- 1 | import { CustomPortableText } from 'components/shared/CustomPortableText' 2 | 3 | interface HeaderProps { 4 | centered?: boolean 5 | description?: any[] 6 | title?: string 7 | } 8 | export function Header(props: HeaderProps) { 9 | const { title, description, centered = false } = props 10 | if (!description && !title) { 11 | return null 12 | } 13 | return ( 14 |
15 | {/* Title */} 16 |
17 | {title} 18 |
19 | {/* Description */} 20 | {description && ( 21 |
22 | 23 |
24 | )} 25 |
26 | ) 27 | } 28 | -------------------------------------------------------------------------------- /components/shared/ImageBox.tsx: -------------------------------------------------------------------------------- 1 | import { urlForImage } from 'lib/sanity.image' 2 | import Image from 'next/image' 3 | 4 | interface ImageBoxProps { 5 | image?: { asset?: any } 6 | alt?: string 7 | width?: number 8 | height?: number 9 | size?: string 10 | classesWrapper?: string 11 | } 12 | 13 | export default function ImageBox({ 14 | image, 15 | alt = 'Cover image', 16 | width = 3500, 17 | height = 2000, 18 | size = '100vw', 19 | classesWrapper, 20 | }: ImageBoxProps) { 21 | const imageUrl = 22 | image && urlForImage(image)?.height(height).width(width).fit('crop').url() 23 | 24 | return ( 25 |
28 | {imageUrl && ( 29 | {alt} 37 | )} 38 |
39 | ) 40 | } 41 | -------------------------------------------------------------------------------- /components/shared/Layout.tsx: -------------------------------------------------------------------------------- 1 | import { Footer } from 'components/global/Footer' 2 | import { Navbar } from 'components/global/Navbar' 3 | import IntroTemplate from 'intro-template' 4 | import { SettingsPayload } from 'types' 5 | 6 | const fallbackSettings: SettingsPayload = { 7 | menuItems: [], 8 | footer: [], 9 | } 10 | 11 | export interface LayoutProps { 12 | children: React.ReactNode 13 | settings: SettingsPayload | undefined 14 | preview?: boolean 15 | } 16 | 17 | export default function Layout({ 18 | children, 19 | settings = fallbackSettings, 20 | }: LayoutProps) { 21 | return ( 22 |
23 | 24 |
{children}
25 |
26 | 27 |
28 | ) 29 | } 30 | -------------------------------------------------------------------------------- /components/shared/ScrollUp.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react' 2 | 3 | /** 4 | * Workaround to force pages to scroll to the top when navigating with ``. 5 | * Delete this once this issue is resolved in Next 13 6 | * https://github.com/vercel/next.js/issues/42492 7 | */ 8 | 9 | export default function ScrollUp() { 10 | useEffect(() => window.document.scrollingElement?.scrollTo(0, 0), []) 11 | 12 | return null 13 | } 14 | -------------------------------------------------------------------------------- /components/shared/TimelineItem.tsx: -------------------------------------------------------------------------------- 1 | import ImageBox from 'components/shared/ImageBox' 2 | import type { MilestoneItem } from 'types' 3 | 4 | export function TimelineItem({ 5 | isLast, 6 | milestone, 7 | }: { 8 | isLast: boolean 9 | milestone: MilestoneItem 10 | }) { 11 | const { description, duration, image, tags, title } = milestone 12 | const startYear = duration?.start 13 | ? new Date(duration.start).getFullYear() 14 | : undefined 15 | const endYear = duration?.end ? new Date(duration.end).getFullYear() : 'Now' 16 | 17 | return ( 18 |
19 |
20 | {/* Thumbnail */} 21 |
25 | 32 |
33 | {/* Vertical line */} 34 | {!isLast &&
} 35 |
36 |
37 | {/* Title */} 38 |
{title}
39 | {/* Tags */} 40 |
41 | {tags?.map((tag, key) => ( 42 | 43 | {tag} 44 | 45 | 46 | ))} 47 | {startYear} - {endYear} 48 |
49 | {/* Description */} 50 |
{description}
51 |
52 |
53 | ) 54 | } 55 | -------------------------------------------------------------------------------- /components/shared/TimelineSection.tsx: -------------------------------------------------------------------------------- 1 | import { TimelineItem } from 'components/shared/TimelineItem' 2 | import type { MilestoneItem } from 'types' 3 | 4 | interface TimelineItem { 5 | title: string 6 | milestones: MilestoneItem[] 7 | } 8 | 9 | export function TimelineSection({ timelines }: { timelines: TimelineItem[] }) { 10 | return ( 11 |
12 | {timelines?.map((timeline, key) => { 13 | const { title, milestones } = timeline 14 | return ( 15 |
16 |
{title}
17 | 18 | {milestones?.map((experience, index) => ( 19 |
20 | 24 |
25 | ))} 26 |
27 | ) 28 | })} 29 |
30 | ) 31 | } 32 | -------------------------------------------------------------------------------- /intro-template/cover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sanity-io/sanity-template-vercel-visual-editing/581d00075c8c2d53cfe391b83ff06d51c5e68725/intro-template/cover.png -------------------------------------------------------------------------------- /intro-template/index.tsx: -------------------------------------------------------------------------------- 1 | import Image from 'next/image' 2 | import Link from 'next/link' 3 | import { usePathname } from 'next/navigation' 4 | import { memo, useEffect, useState } from 'react' 5 | 6 | import cover from './cover.png' 7 | 8 | export default memo(function IntroTemplate() { 9 | const [studioURL, setStudioURL] = useState(null) 10 | const [isLocalHost, setIsLocalhost] = useState(false) 11 | const pathname = usePathname() 12 | 13 | const hasEnvFile = process.env.NEXT_PUBLIC_SANITY_PROJECT_ID 14 | const hasRepoEnvVars = 15 | process.env.NEXT_PUBLIC_VERCEL_GIT_PROVIDER && 16 | process.env.NEXT_PUBLIC_VERCEL_GIT_REPO_OWNER && 17 | process.env.NEXT_PUBLIC_VERCEL_GIT_REPO_SLUG 18 | const repoURL = `https://${process.env.NEXT_PUBLIC_VERCEL_GIT_PROVIDER}.com/${process.env.NEXT_PUBLIC_VERCEL_GIT_REPO_OWNER}/${process.env.NEXT_PUBLIC_VERCEL_GIT_REPO_SLUG}` 19 | const removeBlockURL = hasRepoEnvVars 20 | ? `https://${process.env.NEXT_PUBLIC_VERCEL_GIT_PROVIDER}.com/${process.env.NEXT_PUBLIC_VERCEL_GIT_REPO_OWNER}/${process.env.NEXT_PUBLIC_VERCEL_GIT_REPO_SLUG}/blob/main/README.md#user-content-how-can-i-remove-the-next-steps-block-from-my-personal-website` 21 | : `https://github.com/sanity-io/sanity-template-vercel-visual-editing#user-content-how-can-i-remove-the-next-steps-block-from-my-personal-website` 22 | 23 | const [hasUTMtags, setHasUTMtags] = useState(false) 24 | 25 | useEffect(() => { 26 | if (typeof window !== 'undefined') { 27 | setStudioURL(`${window.location.origin}/studio`) 28 | setIsLocalhost(window.location.hostname === 'localhost') 29 | setHasUTMtags(window.location.search.includes('utm')) 30 | } 31 | }, []) 32 | 33 | // Only display this on the home page 34 | if (pathname !== '/') { 35 | return null 36 | } 37 | 38 | if (hasUTMtags || !studioURL) { 39 | return null 40 | } 41 | 42 | return ( 43 |
44 |
45 |
46 | An illustration of a browser window, a terminal window, the Sanity.io logo and the NextJS logo 50 |
51 | 52 |
53 |
54 | 55 |
56 |

57 | Next steps 58 |

59 | 60 | {!hasEnvFile && ( 61 |
65 | {`It looks like you haven't set up the local environment variables.`} 66 |

67 | 75 | {`Here's how to set them up locally`} 76 | 77 |

78 |
79 | )} 80 | 81 |
    82 | 86 |
    87 | Create a schema 88 |
    89 | 90 | {isLocalHost ? ( 91 |
    92 | Start editing your content structure in 93 |
    94 |
    sanity.config.ts
    95 |
    96 |
    97 | ) : ( 98 | <> 99 |
    100 | Your code can be found at 101 | 107 | {repoURL} 108 | 109 |
    110 | 111 | 121 | 122 | )} 123 |
124 | } 125 | /> 126 | 127 | 131 |
132 | Create content with Sanity Studio 133 |
134 |
135 | Your Sanity Studio is deployed at 136 | 140 | {studioURL} 141 | 142 |
143 | 144 |
145 | 149 | Go to Sanity Studio 150 | 151 |
152 |
153 | } 154 | /> 155 | 156 | 160 |
161 | Learn more and get help 162 |
163 |
    164 |
  • 165 | 169 |
  • 170 |
  • 171 | 175 |
  • 176 |
  • 177 | 181 |
  • 182 |
183 |
184 | } 185 | /> 186 | 187 |
188 | 189 |
190 |
191 |
192 |
193 | ) 194 | }) 195 | 196 | function Box({ 197 | circleTitle, 198 | element, 199 | }: { 200 | circleTitle: string 201 | element: JSX.Element 202 | }) { 203 | return ( 204 |
  • 205 |
    206 |
    207 | {circleTitle} 208 |
    209 |
    210 | {element} 211 |
  • 212 | ) 213 | } 214 | 215 | function BlueLink({ href, text }: { href: string; text: string }) { 216 | return ( 217 | 223 | {text} 224 | 225 | ) 226 | } 227 | 228 | const RemoveBlock = ({ url }: { url: string }) => ( 229 | 235 | How to remove this block? 236 | 237 | ) 238 | 239 | function getGitProvider() { 240 | switch (process.env.NEXT_PUBLIC_VERCEL_GIT_PROVIDER) { 241 | case 'gitlab': 242 | return 'GitLab' 243 | case 'bitbucket': 244 | return 'Bitbucket' 245 | default: 246 | return 'GitHub' 247 | } 248 | } 249 | -------------------------------------------------------------------------------- /lib/demo.data.ts: -------------------------------------------------------------------------------- 1 | // All the demo data that used as fallbacks when there's nothing in the dataset yet 2 | 3 | export const title = 'Personal website' 4 | -------------------------------------------------------------------------------- /lib/sanity.api.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * As this file is reused in several other files, try to keep it lean and small. 3 | * 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. 4 | */ 5 | 6 | export const dataset = assertValue( 7 | process.env.NEXT_PUBLIC_SANITY_DATASET, 8 | 'Missing environment variable: NEXT_PUBLIC_SANITY_DATASET', 9 | ) 10 | 11 | export const projectId = assertValue( 12 | process.env.NEXT_PUBLIC_SANITY_PROJECT_ID, 13 | 'Missing environment variable: NEXT_PUBLIC_SANITY_PROJECT_ID', 14 | ) 15 | 16 | export const readToken = process.env.SANITY_API_READ_TOKEN || '' 17 | 18 | // see https://www.sanity.io/docs/api-versioning for how versioning works 19 | export const apiVersion = 20 | process.env.NEXT_PUBLIC_SANITY_API_VERSION || '2023-06-21' 21 | 22 | function assertValue(v: T | undefined, errorMessage: string): T { 23 | if (v === undefined) { 24 | throw new Error(errorMessage) 25 | } 26 | 27 | return v 28 | } 29 | 30 | // The route that hosts the Studio, used for the embedded Studio routing as well as Visual Editing 31 | export const basePath = '/studio' 32 | -------------------------------------------------------------------------------- /lib/sanity.client.ts: -------------------------------------------------------------------------------- 1 | import { apiVersion, basePath, dataset, projectId } from 'lib/sanity.api' 2 | import { createClient } from 'next-sanity' 3 | 4 | export function getClient(preview?: { token: string }) { 5 | const client = createClient({ 6 | projectId, 7 | dataset, 8 | apiVersion, 9 | useCdn: false, 10 | perspective: 'published', 11 | stega: { 12 | enabled: 13 | process.env.NEXT_PUBLIC_VERCEL_ENV === 'preview' || 14 | typeof preview?.token === 'string', 15 | studioUrl: basePath, 16 | logger: console, 17 | filter: (props) => { 18 | if (typeof props.sourcePath.at(-1) === 'number') { 19 | return false 20 | } 21 | if (props.sourcePath.at(0) === 'duration') { 22 | return false 23 | } 24 | switch (props.sourcePath.at(-1)) { 25 | case 'site': 26 | return false 27 | } 28 | return props.filterDefault(props) 29 | }, 30 | }, 31 | }) 32 | if (preview) { 33 | if (!preview.token) { 34 | throw new Error('You must provide a token to preview drafts') 35 | } 36 | return client.withConfig({ 37 | token: preview.token, 38 | useCdn: false, 39 | ignoreBrowserTokenWarning: true, 40 | perspective: 'previewDrafts', 41 | }) 42 | } 43 | return client 44 | } 45 | -------------------------------------------------------------------------------- /lib/sanity.image.ts: -------------------------------------------------------------------------------- 1 | import createImageUrlBuilder from '@sanity/image-url' 2 | import { dataset, projectId } from 'lib/sanity.api' 3 | import type { Image } from 'sanity' 4 | 5 | const imageBuilder = createImageUrlBuilder({ 6 | projectId: projectId || '', 7 | dataset: dataset || '', 8 | }) 9 | 10 | export const urlForImage = (source: Image) => { 11 | // Ensure that source image contains a valid reference 12 | if (!source?.asset?._ref) { 13 | return undefined 14 | } 15 | 16 | return imageBuilder?.image(source).auto('format').fit('max') 17 | } 18 | -------------------------------------------------------------------------------- /lib/sanity.links.ts: -------------------------------------------------------------------------------- 1 | export function resolveHref( 2 | documentType?: string, 3 | slug?: string, 4 | ): string | undefined { 5 | switch (documentType) { 6 | case 'home': 7 | return '/' 8 | case 'page': 9 | return slug ? `/${slug}` : undefined 10 | case 'project': 11 | return slug ? `/projects/${slug}` : undefined 12 | default: 13 | console.warn('Invalid document type:', documentType) 14 | return undefined 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /lib/sanity.queries.ts: -------------------------------------------------------------------------------- 1 | import { groq } from 'next-sanity' 2 | 3 | export const homePageQuery = groq` 4 | *[_type == "home"][0]{ 5 | _id, 6 | footer, 7 | overview, 8 | showcaseProjects[]->{ 9 | _type, 10 | coverImage, 11 | overview, 12 | "slug": slug.current, 13 | tags, 14 | title, 15 | }, 16 | title, 17 | } 18 | ` 19 | 20 | export const homePageTitleQuery = groq` 21 | *[_type == "home"][0].title 22 | ` 23 | 24 | export const pagesBySlugQuery = groq` 25 | *[_type == "page" && slug.current == $slug][0] { 26 | _id, 27 | body, 28 | overview, 29 | title, 30 | "slug": slug.current, 31 | } 32 | ` 33 | 34 | export const projectBySlugQuery = groq` 35 | *[_type == "project" && slug.current == $slug][0] { 36 | _id, 37 | client, 38 | coverImage, 39 | description, 40 | duration, 41 | overview, 42 | site, 43 | "slug": slug.current, 44 | tags, 45 | title, 46 | } 47 | ` 48 | 49 | export const projectPaths = groq` 50 | *[_type == "project" && slug.current != null].slug.current 51 | ` 52 | 53 | export const pagePaths = groq` 54 | *[_type == "page" && slug.current != null].slug.current 55 | ` 56 | 57 | export const settingsQuery = groq` 58 | *[_type == "settings"][0]{ 59 | footer, 60 | menuItems[]->{ 61 | _type, 62 | "slug": slug.current, 63 | title 64 | }, 65 | ogImage, 66 | } 67 | ` 68 | -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | [template] 2 | incoming-hooks = ["Sanity"] 3 | 4 | [template.environment] 5 | NEXT_PUBLIC_SANITY_PROJECT_ID="Your Sanity Project Id" 6 | NEXT_PUBLIC_SANITY_DATASET="Your Sanity Dataset" 7 | SANITY_API_WRITE_TOKEN="Your Sanity API Write Token" 8 | SANITY_API_READ_TOKEN="Your Sanity API Read Token" -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | /// 4 | 5 | // NOTE: This file should not be edited 6 | // see https://nextjs.org/docs/basic-features/typescript for more information. 7 | -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const config = { 3 | images: { 4 | remotePatterns: [ 5 | { hostname: 'cdn.sanity.io' }, 6 | { hostname: 'source.unsplash.com' }, 7 | ], 8 | }, 9 | typescript: { 10 | // Set this to false if you want production builds to abort if there's type errors 11 | ignoreBuildErrors: process.env.VERCEL_ENV === 'production', 12 | }, 13 | eslint: { 14 | /// Set this to false if you want production builds to abort if there's lint errors 15 | ignoreDuringBuilds: process.env.VERCEL_ENV === 'production', 16 | }, 17 | } 18 | 19 | export default config 20 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sanity-template-vercel-visual-editing", 3 | "private": true, 4 | "scripts": { 5 | "build": "next build", 6 | "dev": "next", 7 | "format": "npx prettier --write . --ignore-path .gitignore", 8 | "lint": "next lint -- --ignore-path .gitignore", 9 | "lint:fix": "npm run format && npm run lint -- --fix", 10 | "start": "next start", 11 | "type-check": "tsc --noEmit" 12 | }, 13 | "prettier": { 14 | "semi": false, 15 | "singleQuote": true 16 | }, 17 | "dependencies": { 18 | "@portabletext/react": "3.0.11", 19 | "@sanity/client": "6.15.1", 20 | "@sanity/demo": "2.0.0", 21 | "@sanity/icons": "2.10.3", 22 | "@sanity/image-url": "1.0.2", 23 | "@sanity/preview-url-secret": "1.6.4", 24 | "@sanity/vision": "3.31.0", 25 | "@sanity/visual-editing": "1.6.0", 26 | "@tailwindcss/typography": "0.5.10", 27 | "@vercel/og": "0.6.2", 28 | "classnames": "2.5.1", 29 | "get-youtube-id": "1.0.1", 30 | "intl-segmenter-polyfill": "0.4.4", 31 | "next": "14.1.2", 32 | "next-google-fonts": "2.2.0", 33 | "next-sanity": "8.1.3", 34 | "react": "18.2.0", 35 | "react-dom": "18.2.0", 36 | "react-is": "18.2.0", 37 | "react-lite-youtube-embed": "2.4.0", 38 | "sanity": "3.31.0", 39 | "sanity-plugin-asset-source-unsplash": "1.1.2", 40 | "styled-components": "6.1.8" 41 | }, 42 | "devDependencies": { 43 | "@types/react": "18.2.63", 44 | "autoprefixer": "10.4.18", 45 | "eslint": "8.57.0", 46 | "eslint-config-next": "14.1.2", 47 | "eslint-plugin-simple-import-sort": "12.0.0", 48 | "postcss": "8.4.35", 49 | "prettier": "3.2.5", 50 | "prettier-plugin-packagejson": "2.4.12", 51 | "prettier-plugin-tailwindcss": "0.5.11", 52 | "tailwindcss": "3.4.1", 53 | "typescript": "5.3.3" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /pages/[slug].tsx: -------------------------------------------------------------------------------- 1 | import { Page } from 'components/pages/page/Page' 2 | import PagePreview from 'components/pages/page/PagePreview' 3 | import { readToken } from 'lib/sanity.api' 4 | import { getClient } from 'lib/sanity.client' 5 | import { resolveHref } from 'lib/sanity.links' 6 | import { 7 | homePageTitleQuery, 8 | pagePaths, 9 | pagesBySlugQuery, 10 | settingsQuery, 11 | } from 'lib/sanity.queries' 12 | import type { GetStaticProps } from 'next' 13 | import { PagePayload, SettingsPayload } from 'types' 14 | 15 | import type { SharedPageProps } from './_app' 16 | 17 | interface PageProps extends SharedPageProps { 18 | page?: PagePayload 19 | settings?: SettingsPayload 20 | homePageTitle?: string 21 | } 22 | 23 | interface Query { 24 | [key: string]: string 25 | } 26 | 27 | export default function ProjectSlugRoute(props: PageProps) { 28 | const { homePageTitle, settings, page, draftMode } = props 29 | 30 | if (draftMode) { 31 | return ( 32 | 37 | ) 38 | } 39 | 40 | return 41 | } 42 | 43 | export const getStaticProps: GetStaticProps = async (ctx) => { 44 | const { draftMode = false, params = {} } = ctx 45 | const client = getClient(draftMode ? { token: readToken } : undefined) 46 | 47 | const [settings, page, homePageTitle] = await Promise.all([ 48 | client.fetch(settingsQuery), 49 | client.fetch(pagesBySlugQuery, { 50 | slug: params.slug, 51 | }), 52 | client.fetch(homePageTitleQuery), 53 | ]) 54 | 55 | if (!page) { 56 | return { 57 | notFound: true, 58 | revalidate: 1, // nonexistant slug might be created later 59 | } 60 | } 61 | 62 | return { 63 | props: { 64 | page, 65 | settings: settings ?? {}, 66 | homePageTitle: homePageTitle ?? undefined, 67 | draftMode, 68 | token: draftMode ? readToken : null, 69 | }, 70 | revalidate: 10, 71 | } 72 | } 73 | 74 | export const getStaticPaths = async () => { 75 | const client = getClient() 76 | const paths = await client.fetch(pagePaths) 77 | 78 | return { 79 | paths: paths?.map((slug) => resolveHref('page', slug)) || [], 80 | fallback: true, // check if slug created since last build 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import 'react-lite-youtube-embed/dist/LiteYouTubeEmbed.css' 2 | import 'styles/index.css' 3 | 4 | import { VisualEditing } from '@sanity/visual-editing/next-pages-router' 5 | import { AppProps } from 'next/app' 6 | import { IBM_Plex_Mono, Inter, PT_Serif } from 'next/font/google' 7 | import { lazy, useSyncExternalStore } from 'react' 8 | 9 | export interface SharedPageProps { 10 | draftMode: boolean 11 | token: string 12 | } 13 | 14 | const PreviewProvider = lazy(() => import('components/preview/PreviewProvider')) 15 | 16 | const mono = IBM_Plex_Mono({ 17 | variable: '--font-mono', 18 | subsets: ['latin'], 19 | weight: ['500', '700'], 20 | }) 21 | 22 | const sans = Inter({ 23 | variable: '--font-sans', 24 | subsets: ['latin'], 25 | weight: ['500', '700', '800'], 26 | }) 27 | 28 | const serif = PT_Serif({ 29 | variable: '--font-serif', 30 | style: ['normal', 'italic'], 31 | subsets: ['latin'], 32 | weight: ['400', '700'], 33 | }) 34 | 35 | const subscribe = () => () => {} 36 | 37 | export default function App({ 38 | Component, 39 | pageProps, 40 | }: AppProps) { 41 | const { draftMode, token } = pageProps 42 | const isMaybeInsidePresentation = useSyncExternalStore( 43 | subscribe, 44 | () => 45 | window !== parent || 46 | !!opener || 47 | process.env.NEXT_PUBLIC_SANITY_VISUAL_EDITING === 'true', 48 | () => process.env.NEXT_PUBLIC_SANITY_VISUAL_EDITING === 'true', 49 | ) 50 | return ( 51 | <> 52 | 61 | 62 | {draftMode ? ( 63 | 64 | 65 | 66 | ) : ( 67 | 68 | )} 69 | 70 | {isMaybeInsidePresentation && } 71 | 72 | ) 73 | } 74 | -------------------------------------------------------------------------------- /pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import { Head, Html, Main, NextScript } from 'next/document' 2 | 3 | export default function Document() { 4 | return ( 5 | 6 | 7 | 8 |
    9 | 10 | 11 | 12 | ) 13 | } 14 | -------------------------------------------------------------------------------- /pages/index.tsx: -------------------------------------------------------------------------------- 1 | import { HomePage } from 'components/pages/home/HomePage' 2 | import HomePagePreview from 'components/pages/home/HomePagePreview' 3 | import { readToken } from 'lib/sanity.api' 4 | import { getClient } from 'lib/sanity.client' 5 | import { homePageQuery, settingsQuery } from 'lib/sanity.queries' 6 | import { GetStaticProps } from 'next' 7 | import { HomePagePayload, SettingsPayload } from 'types' 8 | 9 | import type { SharedPageProps } from './_app' 10 | 11 | interface PageProps extends SharedPageProps { 12 | page: HomePagePayload 13 | settings: SettingsPayload 14 | } 15 | 16 | interface Query { 17 | [key: string]: string 18 | } 19 | 20 | export default function IndexPage(props: PageProps) { 21 | const { page, settings, draftMode } = props 22 | 23 | if (draftMode) { 24 | return 25 | } 26 | 27 | return 28 | } 29 | 30 | const fallbackPage: HomePagePayload = { 31 | title: '', 32 | overview: [], 33 | showcaseProjects: [], 34 | } 35 | 36 | export const getStaticProps: GetStaticProps = async (ctx) => { 37 | const { draftMode = false } = ctx 38 | const client = getClient(draftMode ? { token: readToken } : undefined) 39 | 40 | const [settings, page] = await Promise.all([ 41 | client.fetch(settingsQuery), 42 | client.fetch(homePageQuery), 43 | ]) 44 | 45 | return { 46 | props: { 47 | page: page ?? fallbackPage, 48 | settings: settings ?? {}, 49 | draftMode, 50 | token: draftMode ? readToken : null, 51 | }, 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /pages/projects/[slug].tsx: -------------------------------------------------------------------------------- 1 | import { ProjectPage } from 'components/pages/project/ProjectPage' 2 | import ProjectPreview from 'components/pages/project/ProjectPreview' 3 | import { readToken } from 'lib/sanity.api' 4 | import { getClient } from 'lib/sanity.client' 5 | import { resolveHref } from 'lib/sanity.links' 6 | import { 7 | homePageTitleQuery, 8 | projectBySlugQuery, 9 | projectPaths, 10 | settingsQuery, 11 | } from 'lib/sanity.queries' 12 | import { GetStaticProps } from 'next' 13 | import { ProjectPayload, SettingsPayload } from 'types' 14 | 15 | import type { SharedPageProps } from '../_app' 16 | 17 | interface PageProps extends SharedPageProps { 18 | project?: ProjectPayload 19 | settings?: SettingsPayload 20 | homePageTitle?: string 21 | } 22 | 23 | interface Query { 24 | [key: string]: string 25 | } 26 | 27 | export default function ProjectSlugRoute(props: PageProps) { 28 | const { homePageTitle, settings, project, draftMode } = props 29 | 30 | if (draftMode) { 31 | return ( 32 | 37 | ) 38 | } 39 | 40 | return ( 41 | 46 | ) 47 | } 48 | 49 | export const getStaticProps: GetStaticProps = async (ctx) => { 50 | const { draftMode = false, params = {} } = ctx 51 | const client = getClient(draftMode ? { token: readToken } : undefined) 52 | 53 | const [settings, project, homePageTitle] = await Promise.all([ 54 | client.fetch(settingsQuery), 55 | client.fetch(projectBySlugQuery, { 56 | slug: params.slug, 57 | }), 58 | client.fetch(homePageTitleQuery), 59 | ]) 60 | 61 | if (!project) { 62 | return { 63 | notFound: true, 64 | revalidate: 1, // nonexistant slug might be created later 65 | } 66 | } 67 | 68 | return { 69 | props: { 70 | project, 71 | settings: settings ?? {}, 72 | homePageTitle: homePageTitle ?? undefined, 73 | draftMode, 74 | token: draftMode ? readToken : null, 75 | }, 76 | revalidate: 10, 77 | } 78 | } 79 | 80 | export const getStaticPaths = async () => { 81 | const client = getClient() 82 | const paths = await client.fetch(projectPaths) 83 | 84 | return { 85 | paths: paths?.map((slug) => resolveHref('project', slug)) || [], 86 | fallback: true, // check if slug created since last build 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /plugins/locate.ts: -------------------------------------------------------------------------------- 1 | import { resolveHref } from 'lib/sanity.links' 2 | import { map, Observable } from 'rxjs' 3 | import { 4 | DocumentLocationResolver, 5 | DocumentLocationsState, 6 | } from 'sanity/presentation' 7 | 8 | export const locate: DocumentLocationResolver = (params, context) => { 9 | if (params.type === 'settings') { 10 | return { 11 | message: 'This document is used on all pages', 12 | tone: 'caution', 13 | } satisfies DocumentLocationsState 14 | } 15 | 16 | if ( 17 | params.type === 'home' || 18 | params.type === 'page' || 19 | params.type === 'project' 20 | ) { 21 | const doc$ = context.documentStore.listenQuery( 22 | `*[_id==$id || references($id)]{_type,slug,title}`, 23 | params, 24 | { perspective: 'previewDrafts' }, 25 | ) as Observable< 26 | | { 27 | _type: string 28 | slug: { current: string } 29 | title: string | null 30 | }[] 31 | | null 32 | > 33 | return doc$.pipe( 34 | map((docs) => { 35 | const isReferencedBySettings = docs?.some( 36 | (doc) => doc._type === 'settings', 37 | ) 38 | switch (params.type) { 39 | case 'home': 40 | return { 41 | locations: [ 42 | { 43 | title: 44 | docs?.find((doc) => doc._type === 'home')?.title || 'Home', 45 | href: resolveHref(params.type)!, 46 | }, 47 | ], 48 | tone: 'positive', 49 | message: 'This document is used to render the front page', 50 | } satisfies DocumentLocationsState 51 | case 'page': 52 | return { 53 | locations: docs 54 | ?.map((doc) => { 55 | const href = resolveHref(doc._type, doc?.slug?.current) 56 | return { 57 | title: doc?.title || 'Untitled', 58 | href: href!, 59 | } 60 | }) 61 | .filter((doc) => doc.href !== undefined), 62 | tone: isReferencedBySettings ? 'positive' : 'critical', 63 | message: isReferencedBySettings 64 | ? 'The top menu is linking to this page' 65 | : "The top menu isn't linking to this page. It can still be accessed if the visitor knows the URL.", 66 | } satisfies DocumentLocationsState 67 | case 'project': 68 | return { 69 | locations: docs 70 | ?.map((doc) => { 71 | const href = resolveHref(doc._type, doc?.slug?.current) 72 | return { 73 | title: doc?.title || 'Untitled', 74 | href: href!, 75 | } 76 | }) 77 | .filter((doc) => doc.href !== undefined), 78 | tone: isReferencedBySettings ? 'caution' : undefined, 79 | message: isReferencedBySettings 80 | ? 'This document is used on all pages as it is in the top menu' 81 | : undefined, 82 | } satisfies DocumentLocationsState 83 | default: 84 | return { 85 | message: 'Unable to map document type to locations', 86 | tone: 'critical', 87 | } satisfies DocumentLocationsState 88 | } 89 | }), 90 | ) 91 | } 92 | 93 | return null 94 | } 95 | -------------------------------------------------------------------------------- /plugins/settings.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * This plugin contains all the logic for setting up the singletons 3 | */ 4 | 5 | import { type DocumentDefinition } from 'sanity' 6 | import { type StructureResolver } from 'sanity/structure' 7 | 8 | export const singletonPlugin = (types: string[]) => { 9 | return { 10 | name: 'singletonPlugin', 11 | document: { 12 | // Hide 'Singletons (such as Home)' from new document options 13 | // https://user-images.githubusercontent.com/81981/195728798-e0c6cf7e-d442-4e58-af3a-8cd99d7fcc28.png 14 | newDocumentOptions: (prev, { creationContext }) => { 15 | if (creationContext.type === 'global') { 16 | return prev.filter( 17 | (templateItem) => !types.includes(templateItem.templateId), 18 | ) 19 | } 20 | 21 | return prev 22 | }, 23 | // Removes the "duplicate" action on the Singletons (such as Home) 24 | actions: (prev, { schemaType }) => { 25 | if (types.includes(schemaType)) { 26 | return prev.filter(({ action }) => action !== 'duplicate') 27 | } 28 | 29 | return prev 30 | }, 31 | }, 32 | } 33 | } 34 | 35 | // The StructureResolver is how we're changing the DeskTool structure to linking to document (named Singleton) 36 | // like how "Home" is handled. 37 | export const pageStructure = ( 38 | typeDefArray: DocumentDefinition[], 39 | ): StructureResolver => { 40 | return (S) => { 41 | // Goes through all of the singletons that were provided and translates them into something the 42 | // Desktool can understand 43 | const singletonItems = typeDefArray.map((typeDef) => { 44 | return S.listItem() 45 | .title(typeDef.title) 46 | .icon(typeDef.icon) 47 | .child( 48 | S.editor() 49 | .id(typeDef.name) 50 | .schemaType(typeDef.name) 51 | .documentId(typeDef.name) 52 | .views([S.view.form()]), 53 | ) 54 | }) 55 | 56 | // The default root list items (except custom ones) 57 | const defaultListItems = S.documentTypeListItems().filter( 58 | (listItem) => 59 | !typeDefArray.find((singleton) => singleton.name === listItem.getId()), 60 | ) 61 | 62 | return S.list() 63 | .title('Content') 64 | .items([...singletonItems, S.divider(), ...defaultListItems]) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | // If you want to use other PostCSS plugins, see the following: 2 | // https://tailwindcss.com/docs/using-with-preprocessors 3 | module.exports = { 4 | plugins: { 5 | tailwindcss: {}, 6 | autoprefixer: {}, 7 | }, 8 | } 9 | -------------------------------------------------------------------------------- /public/favicon/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sanity-io/sanity-template-vercel-visual-editing/581d00075c8c2d53cfe391b83ff06d51c5e68725/public/favicon/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/favicon/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sanity-io/sanity-template-vercel-visual-editing/581d00075c8c2d53cfe391b83ff06d51c5e68725/public/favicon/android-chrome-512x512.png -------------------------------------------------------------------------------- /public/favicon/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sanity-io/sanity-template-vercel-visual-editing/581d00075c8c2d53cfe391b83ff06d51c5e68725/public/favicon/apple-touch-icon.png -------------------------------------------------------------------------------- /public/favicon/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #000000 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /public/favicon/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sanity-io/sanity-template-vercel-visual-editing/581d00075c8c2d53cfe391b83ff06d51c5e68725/public/favicon/favicon-16x16.png -------------------------------------------------------------------------------- /public/favicon/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sanity-io/sanity-template-vercel-visual-editing/581d00075c8c2d53cfe391b83ff06d51c5e68725/public/favicon/favicon-32x32.png -------------------------------------------------------------------------------- /public/favicon/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sanity-io/sanity-template-vercel-visual-editing/581d00075c8c2d53cfe391b83ff06d51c5e68725/public/favicon/favicon.ico -------------------------------------------------------------------------------- /public/favicon/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sanity-io/sanity-template-vercel-visual-editing/581d00075c8c2d53cfe391b83ff06d51c5e68725/public/favicon/mstile-150x150.png -------------------------------------------------------------------------------- /public/favicon/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Next.js", 3 | "short_name": "Next.js", 4 | "icons": [ 5 | { 6 | "src": "/favicons/android-chrome-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "/favicons/android-chrome-512x512.png", 12 | "sizes": "512x512", 13 | "type": "image/png" 14 | } 15 | ], 16 | "theme_color": "#000000", 17 | "background_color": "#000000", 18 | "display": "standalone" 19 | } 20 | -------------------------------------------------------------------------------- /sanity.cli.ts: -------------------------------------------------------------------------------- 1 | import { loadEnvConfig } from '@next/env' 2 | import { defineCliConfig } from 'sanity/cli' 3 | 4 | const dev = process.env.NODE_ENV !== 'production' 5 | loadEnvConfig(__dirname, dev, { info: () => null, error: console.error }) 6 | 7 | // @TODO report top-level await bug 8 | // Using a dynamic import here as `loadEnvConfig` needs to run before this file is loaded 9 | // const { projectId, dataset } = await import('lib/sanity.api') 10 | const projectId = process.env.NEXT_PUBLIC_SANITY_PROJECT_ID 11 | const dataset = process.env.NEXT_PUBLIC_SANITY_DATASET 12 | 13 | export default defineCliConfig({ api: { projectId, dataset } }) 14 | -------------------------------------------------------------------------------- /sanity.config.ts: -------------------------------------------------------------------------------- 1 | 'use client' 2 | /** 3 | * This config is used to set up Sanity Studio that's mounted on the `/pages/studio/[[...index]].tsx` route 4 | */ 5 | import { visionTool } from '@sanity/vision' 6 | import { apiVersion, basePath, dataset, projectId } from 'lib/sanity.api' 7 | import { locate } from 'plugins/locate' 8 | import { pageStructure, singletonPlugin } from 'plugins/settings' 9 | import { defineConfig } from 'sanity' 10 | import { presentationTool } from 'sanity/presentation' 11 | import { structureTool } from 'sanity/structure' 12 | import { unsplashImageAsset } from 'sanity-plugin-asset-source-unsplash' 13 | import page from 'schemas/documents/page' 14 | import project from 'schemas/documents/project' 15 | import duration from 'schemas/objects/duration' 16 | import milestone from 'schemas/objects/milestone' 17 | import timeline from 'schemas/objects/timeline' 18 | import youtube from 'schemas/objects/youtube' 19 | import home from 'schemas/singletons/home' 20 | import settings from 'schemas/singletons/settings' 21 | import { debugSecrets } from '@sanity/preview-url-secret/sanity-plugin-debug-secrets' 22 | 23 | const title = process.env.NEXT_PUBLIC_SANITY_PROJECT_TITLE 24 | 25 | export default defineConfig({ 26 | basePath, 27 | projectId: projectId || '', 28 | dataset: dataset || '', 29 | title, 30 | schema: { 31 | // If you want more content types, you can add them to this array 32 | types: [ 33 | // Singletons 34 | home, 35 | settings, 36 | // Documents 37 | duration, 38 | page, 39 | project, 40 | // Objects 41 | milestone, 42 | timeline, 43 | youtube, 44 | ], 45 | }, 46 | plugins: [ 47 | presentationTool({ 48 | locate, 49 | previewUrl: { 50 | previewMode: { 51 | enable: '/api/draft', 52 | }, 53 | }, 54 | }), 55 | structureTool({ 56 | structure: pageStructure([home, settings]), 57 | }), 58 | // Configures the global "new document" button, and document actions, to suit the Settings document singleton 59 | singletonPlugin([home.name, settings.name]), 60 | // Add an image asset source for Unsplash 61 | unsplashImageAsset(), 62 | // Vision lets you query your content with GROQ in the studio 63 | // https://www.sanity.io/docs/the-vision-plugin 64 | visionTool({ defaultApiVersion: apiVersion }), 65 | // See url preview secrets in the schema for debugging 66 | process.env.NODE_ENV === 'development' && debugSecrets(), 67 | ].filter(Boolean), 68 | }) 69 | -------------------------------------------------------------------------------- /schemas/documents/page.ts: -------------------------------------------------------------------------------- 1 | import { DocumentIcon, ImageIcon } from '@sanity/icons' 2 | import { defineArrayMember, defineField, defineType } from 'sanity' 3 | 4 | export default defineType({ 5 | type: 'document', 6 | name: 'page', 7 | title: 'Page', 8 | icon: DocumentIcon, 9 | fields: [ 10 | defineField({ 11 | type: 'string', 12 | name: 'title', 13 | title: 'Title', 14 | validation: (rule) => rule.required(), 15 | }), 16 | defineField({ 17 | type: 'slug', 18 | name: 'slug', 19 | title: 'Slug', 20 | options: { 21 | source: 'title', 22 | }, 23 | validation: (rule) => rule.required(), 24 | }), 25 | defineField({ 26 | name: 'overview', 27 | description: 28 | 'Used both for the description tag for SEO, and the personal website subheader.', 29 | title: 'Overview', 30 | type: 'array', 31 | of: [ 32 | // Paragraphs 33 | defineArrayMember({ 34 | lists: [], 35 | marks: { 36 | annotations: [], 37 | decorators: [ 38 | { 39 | title: 'Italic', 40 | value: 'em', 41 | }, 42 | { 43 | title: 'Strong', 44 | value: 'strong', 45 | }, 46 | ], 47 | }, 48 | styles: [], 49 | type: 'block', 50 | }), 51 | ], 52 | validation: (rule) => rule.max(155).required(), 53 | }), 54 | defineField({ 55 | type: 'array', 56 | name: 'body', 57 | title: 'Body', 58 | description: 59 | "This is where you can write the page's content. Including custom blocks like timelines for more a more visual display of information.", 60 | of: [ 61 | // Paragraphs 62 | defineArrayMember({ 63 | type: 'block', 64 | marks: { 65 | annotations: [ 66 | { 67 | name: 'link', 68 | type: 'object', 69 | title: 'Link', 70 | fields: [ 71 | { 72 | name: 'href', 73 | type: 'url', 74 | title: 'Url', 75 | }, 76 | ], 77 | }, 78 | ], 79 | }, 80 | styles: [], 81 | }), 82 | // Custom blocks 83 | defineArrayMember({ 84 | name: 'timeline', 85 | type: 'timeline', 86 | }), 87 | defineField({ 88 | type: 'image', 89 | icon: ImageIcon, 90 | name: 'image', 91 | title: 'Image', 92 | options: { 93 | hotspot: true, 94 | }, 95 | preview: { 96 | select: { 97 | imageUrl: 'asset.url', 98 | title: 'caption', 99 | }, 100 | }, 101 | fields: [ 102 | defineField({ 103 | title: 'Caption', 104 | name: 'caption', 105 | type: 'string', 106 | }), 107 | defineField({ 108 | name: 'alt', 109 | type: 'string', 110 | title: 'Alt text', 111 | description: 112 | 'Alternative text for screenreaders. Falls back on caption if not set', 113 | }), 114 | ], 115 | }), 116 | defineField({ type: 'youtube' as any }), 117 | ], 118 | }), 119 | ], 120 | preview: { 121 | select: { 122 | title: 'title', 123 | }, 124 | prepare({ title }) { 125 | return { 126 | subtitle: 'Page', 127 | title, 128 | } 129 | }, 130 | }, 131 | }) 132 | -------------------------------------------------------------------------------- /schemas/documents/project.ts: -------------------------------------------------------------------------------- 1 | import { DocumentIcon, ImageIcon } from '@sanity/icons' 2 | import { defineArrayMember, defineField, defineType } from 'sanity' 3 | 4 | export default defineType({ 5 | name: 'project', 6 | title: 'Project', 7 | type: 'document', 8 | icon: DocumentIcon, 9 | // Uncomment below to have edits publish automatically as you type 10 | // liveEdit: true, 11 | fields: [ 12 | defineField({ 13 | name: 'title', 14 | description: 'This field is the title of your project.', 15 | title: 'Title', 16 | type: 'string', 17 | validation: (rule) => rule.required(), 18 | }), 19 | defineField({ 20 | name: 'slug', 21 | title: 'Slug', 22 | type: 'slug', 23 | options: { 24 | source: 'title', 25 | maxLength: 96, 26 | isUnique: (value, context) => context.defaultIsUnique(value, context), 27 | }, 28 | validation: (rule) => rule.required(), 29 | }), 30 | defineField({ 31 | name: 'overview', 32 | description: 33 | 'Used both for the description tag for SEO, and project subheader.', 34 | title: 'Overview', 35 | type: 'array', 36 | of: [ 37 | // Paragraphs 38 | defineArrayMember({ 39 | lists: [], 40 | marks: { 41 | annotations: [], 42 | decorators: [ 43 | { 44 | title: 'Italic', 45 | value: 'em', 46 | }, 47 | { 48 | title: 'Strong', 49 | value: 'strong', 50 | }, 51 | ], 52 | }, 53 | styles: [], 54 | type: 'block', 55 | }), 56 | ], 57 | validation: (rule) => rule.max(155).required(), 58 | }), 59 | defineField({ 60 | name: 'coverImage', 61 | title: 'Cover Image', 62 | description: 63 | 'This image will be used as the cover image for the project. If you choose to add it to the show case projects, this is the image displayed in the list within the homepage.', 64 | type: 'image', 65 | options: { 66 | hotspot: true, 67 | }, 68 | validation: (rule) => rule.required(), 69 | }), 70 | defineField({ 71 | name: 'duration', 72 | title: 'Duration', 73 | type: 'duration', 74 | }), 75 | defineField({ 76 | name: 'client', 77 | title: 'Client', 78 | type: 'string', 79 | }), 80 | defineField({ 81 | name: 'site', 82 | title: 'Site', 83 | type: 'url', 84 | }), 85 | defineField({ 86 | name: 'tags', 87 | title: 'Tags', 88 | type: 'array', 89 | of: [{ type: 'string' }], 90 | options: { 91 | layout: 'tags', 92 | }, 93 | }), 94 | defineField({ 95 | name: 'description', 96 | title: 'Project Description', 97 | type: 'array', 98 | of: [ 99 | defineArrayMember({ 100 | type: 'block', 101 | marks: { 102 | annotations: [ 103 | { 104 | name: 'link', 105 | type: 'object', 106 | title: 'Link', 107 | fields: [ 108 | { 109 | name: 'href', 110 | type: 'url', 111 | title: 'Url', 112 | }, 113 | ], 114 | }, 115 | ], 116 | }, 117 | styles: [], 118 | }), 119 | // Custom blocks 120 | defineArrayMember({ 121 | name: 'timeline', 122 | type: 'timeline', 123 | }), 124 | defineField({ 125 | type: 'image', 126 | icon: ImageIcon, 127 | name: 'image', 128 | title: 'Image', 129 | options: { 130 | hotspot: true, 131 | }, 132 | preview: { 133 | select: { 134 | imageUrl: 'asset.url', 135 | title: 'caption', 136 | }, 137 | }, 138 | fields: [ 139 | defineField({ 140 | title: 'Caption', 141 | name: 'caption', 142 | type: 'string', 143 | }), 144 | defineField({ 145 | name: 'alt', 146 | type: 'string', 147 | title: 'Alt text', 148 | description: 149 | 'Alternative text for screenreaders. Falls back on caption if not set', 150 | }), 151 | ], 152 | }), 153 | defineField({ type: 'youtube' as any }), 154 | ], 155 | }), 156 | ], 157 | }) 158 | -------------------------------------------------------------------------------- /schemas/objects/duration/DurationInput.tsx: -------------------------------------------------------------------------------- 1 | import { ArrowRightIcon } from '@sanity/icons' 2 | import { Box, Flex, Text } from '@sanity/ui' 3 | import { useCallback, useMemo } from 'react' 4 | import { 5 | FieldMember, 6 | MemberField, 7 | ObjectInputProps, 8 | RenderFieldCallback, 9 | } from 'sanity' 10 | 11 | export function DurationInput(props: ObjectInputProps) { 12 | const { members, renderInput, renderItem, renderPreview } = props 13 | 14 | const fieldMembers = useMemo( 15 | () => members.filter((mem) => mem.kind === 'field') as FieldMember[], 16 | [members], 17 | ) 18 | 19 | const start = fieldMembers.find((mem) => mem.name === 'start') 20 | const end = fieldMembers.find((mem) => mem.name === 'end') 21 | 22 | const renderField: RenderFieldCallback = useCallback( 23 | (props) => props.children, 24 | [], 25 | ) 26 | 27 | const renderProps = useMemo( 28 | () => ({ renderField, renderInput, renderItem, renderPreview }), 29 | [renderField, renderInput, renderItem, renderPreview], 30 | ) 31 | 32 | return ( 33 | 34 | 35 | {start && } 36 | 37 | 38 | 39 | 40 | 41 | 42 | {end && } 43 | 44 | ) 45 | } 46 | -------------------------------------------------------------------------------- /schemas/objects/duration/index.ts: -------------------------------------------------------------------------------- 1 | import { defineField } from 'sanity' 2 | 3 | import { DurationInput } from './DurationInput' 4 | 5 | export default defineField({ 6 | type: 'object', 7 | name: 'duration', 8 | title: 'Duration', 9 | components: { 10 | input: DurationInput, 11 | }, 12 | fields: [ 13 | defineField({ 14 | type: 'datetime', 15 | name: 'start', 16 | title: 'Start', 17 | }), 18 | defineField({ 19 | type: 'datetime', 20 | name: 'end', 21 | title: 'End', 22 | }), 23 | ], 24 | }) 25 | -------------------------------------------------------------------------------- /schemas/objects/milestone.ts: -------------------------------------------------------------------------------- 1 | import { defineField, defineType } from 'sanity' 2 | 3 | export default defineType({ 4 | name: 'milestone', 5 | title: 'Milestone', 6 | type: 'object', 7 | fields: [ 8 | defineField({ 9 | type: 'string', 10 | name: 'title', 11 | title: 'Title', 12 | validation: (rule) => rule.required(), 13 | }), 14 | defineField({ 15 | type: 'string', 16 | name: 'description', 17 | title: 'Description', 18 | }), 19 | defineField({ 20 | name: 'image', 21 | title: 'Image', 22 | type: 'image', 23 | description: "This image will be used as the milestone's cover image.", 24 | options: { 25 | hotspot: true, 26 | }, 27 | }), 28 | defineField({ 29 | name: 'tags', 30 | title: 'Tags', 31 | type: 'array', 32 | description: 33 | 'Tags to help categorize the milestone. For example: name of the university course, name of the project, the position you held within the project etc. ', 34 | of: [{ type: 'string' }], 35 | options: { 36 | layout: 'tags', 37 | }, 38 | }), 39 | defineField({ 40 | type: 'duration', 41 | name: 'duration', 42 | title: 'Duration', 43 | validation: (rule) => rule.required(), 44 | }), 45 | ], 46 | preview: { 47 | select: { 48 | duration: 'duration', 49 | image: 'image', 50 | title: 'title', 51 | }, 52 | prepare({ duration, image, title }) { 53 | return { 54 | media: image, 55 | subtitle: [ 56 | duration?.start && new Date(duration.start).getFullYear(), 57 | duration?.end && new Date(duration.end).getFullYear(), 58 | ] 59 | .filter(Boolean) 60 | .join(' - '), 61 | title, 62 | } 63 | }, 64 | }, 65 | }) 66 | -------------------------------------------------------------------------------- /schemas/objects/timeline.ts: -------------------------------------------------------------------------------- 1 | import { defineField, defineType } from 'sanity' 2 | 3 | export default defineType({ 4 | name: 'timeline', 5 | title: 'Timeline', 6 | type: 'object', 7 | fields: [ 8 | { 9 | name: 'items', 10 | title: 'Items', 11 | description: 12 | "Allows for creating a number of timelines (max 2) for displaying in the page's body", 13 | type: 'array', 14 | validation: (Rule) => Rule.max(2), 15 | of: [ 16 | { 17 | name: 'item', 18 | title: 'Item', 19 | type: 'object', 20 | fields: [ 21 | defineField({ 22 | name: 'title', 23 | title: 'Title', 24 | type: 'string', 25 | }), 26 | { 27 | name: 'milestones', 28 | title: 'Milestones', 29 | type: 'array', 30 | of: [ 31 | defineField({ 32 | name: 'milestone', 33 | title: 'Milestone', 34 | type: 'milestone', 35 | }), 36 | ], 37 | }, 38 | ], 39 | preview: { 40 | select: { 41 | items: 'milestones', 42 | title: 'title', 43 | }, 44 | prepare({ items, title }) { 45 | const hasItems = items && items.length > 0 46 | const milestoneNames = 47 | hasItems && items.map((timeline) => timeline.title).join(', ') 48 | 49 | return { 50 | subtitle: hasItems 51 | ? `${milestoneNames} (${items.length} item${ 52 | items.length > 1 ? 's' : '' 53 | })` 54 | : 'No milestones', 55 | title, 56 | } 57 | }, 58 | }, 59 | }, 60 | ], 61 | }, 62 | ], 63 | preview: { 64 | select: { 65 | items: 'items', 66 | }, 67 | prepare({ items }: { items: { title: string }[] }) { 68 | const hasItems = items && items.length > 0 69 | const timelineNames = 70 | hasItems && items.map((timeline) => timeline.title).join(', ') 71 | 72 | return { 73 | title: 'Timelines', 74 | subtitle: hasItems 75 | ? `${timelineNames} (${items.length} item${ 76 | items.length > 1 ? 's' : '' 77 | })` 78 | : 'No timelines', 79 | } 80 | }, 81 | }, 82 | }) 83 | -------------------------------------------------------------------------------- /schemas/objects/youtube.tsx: -------------------------------------------------------------------------------- 1 | import getYouTubeId from 'get-youtube-id' 2 | import LiteYouTubeEmbed from 'react-lite-youtube-embed' 3 | import { defineField, defineType } from 'sanity' 4 | 5 | export default defineType({ 6 | type: 'object', 7 | icon: ( 8 | 14 | 18 | 19 | ), 20 | name: 'youtube', 21 | title: 'YouTube Embed', 22 | fields: [ 23 | defineField({ 24 | name: 'url', 25 | title: 'URL of the video', 26 | description: "Paste in the URL and we'll figure out the rest", 27 | type: 'url', 28 | }), 29 | defineField({ 30 | name: 'title', 31 | title: 'Video title / headline', 32 | description: '⚡ Optional but highly encouraged for accessibility & SEO.', 33 | type: 'string', 34 | }), 35 | defineField({ 36 | name: 'aspectWidth', 37 | title: 'Aspect Width', 38 | type: 'number', 39 | initialValue: 16, 40 | }), 41 | defineField({ 42 | name: 'aspectHeight', 43 | title: 'Aspect Height', 44 | type: 'number', 45 | initialValue: 9, 46 | }), 47 | ], 48 | preview: { 49 | select: { 50 | url: 'url', 51 | title: 'title', 52 | }, 53 | }, 54 | components: { 55 | preview: (props) => { 56 | const { 57 | // @ts-expect-error 58 | url, 59 | title, 60 | // @ts-expect-error 61 | aspectHeight, 62 | // @ts-expect-error 63 | aspectWidth, 64 | renderDefault, 65 | } = props 66 | if (!url) { 67 | return
    Missing YouTube URL
    68 | } 69 | const id = getYouTubeId(url) 70 | return ( 71 |
    72 | {renderDefault({ ...props, title: 'YouTube Embed' })} 73 | 80 |
    81 | ) 82 | }, 83 | }, 84 | }) 85 | -------------------------------------------------------------------------------- /schemas/singletons/home.ts: -------------------------------------------------------------------------------- 1 | import { HomeIcon } from '@sanity/icons' 2 | import { defineArrayMember, defineField, defineType } from 'sanity' 3 | 4 | export default defineType({ 5 | name: 'home', 6 | title: 'Home', 7 | type: 'document', 8 | icon: HomeIcon, 9 | // Uncomment below to have edits publish automatically as you type 10 | // liveEdit: true, 11 | fields: [ 12 | defineField({ 13 | name: 'title', 14 | description: 'This field is the title of your personal website.', 15 | title: 'Title', 16 | type: 'string', 17 | validation: (rule) => rule.required(), 18 | }), 19 | defineField({ 20 | name: 'overview', 21 | description: 22 | 'Used both for the description tag for SEO, and the personal website subheader.', 23 | title: 'Description', 24 | type: 'array', 25 | of: [ 26 | // Paragraphs 27 | defineArrayMember({ 28 | lists: [], 29 | marks: { 30 | annotations: [ 31 | { 32 | name: 'link', 33 | type: 'object', 34 | title: 'Link', 35 | fields: [ 36 | { 37 | name: 'href', 38 | type: 'url', 39 | title: 'Url', 40 | }, 41 | ], 42 | }, 43 | ], 44 | decorators: [ 45 | { 46 | title: 'Italic', 47 | value: 'em', 48 | }, 49 | { 50 | title: 'Strong', 51 | value: 'strong', 52 | }, 53 | ], 54 | }, 55 | styles: [], 56 | type: 'block', 57 | }), 58 | ], 59 | validation: (rule) => rule.max(155).required(), 60 | }), 61 | defineField({ 62 | name: 'showcaseProjects', 63 | title: 'Showcase projects', 64 | description: 65 | 'These are the projects that will appear first on your landing page.', 66 | type: 'array', 67 | of: [ 68 | defineArrayMember({ 69 | type: 'reference', 70 | to: [{ type: 'project' }], 71 | }), 72 | ], 73 | }), 74 | ], 75 | preview: { 76 | select: { 77 | title: 'title', 78 | }, 79 | prepare({ title }) { 80 | return { 81 | subtitle: 'Home', 82 | title, 83 | } 84 | }, 85 | }, 86 | }) 87 | -------------------------------------------------------------------------------- /schemas/singletons/settings.ts: -------------------------------------------------------------------------------- 1 | import { CogIcon } from '@sanity/icons' 2 | import { defineArrayMember, defineField, defineType } from 'sanity' 3 | 4 | export default defineType({ 5 | name: 'settings', 6 | title: 'Settings', 7 | type: 'document', 8 | icon: CogIcon, 9 | // Uncomment below to have edits publish automatically as you type 10 | // liveEdit: true, 11 | fields: [ 12 | defineField({ 13 | name: 'menuItems', 14 | title: 'Menu Item list', 15 | description: 'Links displayed on the header of your site.', 16 | type: 'array', 17 | of: [ 18 | { 19 | title: 'Reference', 20 | type: 'reference', 21 | to: [ 22 | { 23 | type: 'home', 24 | }, 25 | { 26 | type: 'page', 27 | }, 28 | { 29 | type: 'project', 30 | }, 31 | ], 32 | }, 33 | ], 34 | }), 35 | defineField({ 36 | name: 'footer', 37 | description: 38 | 'This is a block of text that will be displayed at the bottom of the page.', 39 | title: 'Footer Info', 40 | type: 'array', 41 | of: [ 42 | defineArrayMember({ 43 | type: 'block', 44 | marks: { 45 | annotations: [ 46 | { 47 | name: 'link', 48 | type: 'object', 49 | title: 'Link', 50 | fields: [ 51 | { 52 | name: 'href', 53 | type: 'url', 54 | title: 'Url', 55 | }, 56 | ], 57 | }, 58 | ], 59 | }, 60 | }), 61 | ], 62 | }), 63 | defineField({ 64 | name: 'ogImage', 65 | title: 'Open Graph Image', 66 | type: 'image', 67 | description: 'Displayed on social cards and search engine results.', 68 | options: { 69 | hotspot: true, 70 | }, 71 | }), 72 | ], 73 | preview: { 74 | prepare() { 75 | return { 76 | title: 'Menu Items', 77 | } 78 | }, 79 | }, 80 | }) 81 | -------------------------------------------------------------------------------- /styles/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | html, 6 | body, 7 | #__next { 8 | height: 100%; 9 | } 10 | 11 | body { 12 | -webkit-font-smoothing: antialiased; 13 | margin: 0; 14 | } 15 | 16 | html { 17 | -webkit-font-smoothing: antialiased; 18 | -moz-osx-font-smoothing: grayscale; 19 | overflow-x: hidden; 20 | } 21 | 22 | p:not(:last-child) { 23 | margin-bottom: 0.875rem; 24 | } 25 | 26 | ol, 27 | ul { 28 | margin-left: 1rem; 29 | } 30 | 31 | ol { 32 | list-style-type: disc; 33 | } 34 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | const { theme } = require('@sanity/demo/tailwind') 2 | 3 | /** @type {import('tailwindcss').Config} */ 4 | module.exports = { 5 | content: [ 6 | './app/**/*.{js,ts,jsx,tsx}', 7 | './components/**/*.{js,ts,jsx,tsx}', 8 | './intro-template/**/*.{js,ts,jsx,tsx}', 9 | ], 10 | theme: { 11 | ...theme, 12 | // Overriding fontFamily to use @next/font loaded families 13 | fontFamily: { 14 | mono: 'var(--font-mono)', 15 | sans: 'var(--font-sans)', 16 | serif: 'var(--font-serif)', 17 | }, 18 | }, 19 | plugins: [require('@tailwindcss/typography')], 20 | } 21 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "target": "ES2017", 5 | "lib": ["dom", "dom.iterable", "esnext"], 6 | "allowJs": true, 7 | "skipLibCheck": true, 8 | "strict": false, 9 | "forceConsistentCasingInFileNames": true, 10 | "noEmit": true, 11 | "incremental": true, 12 | "esModuleInterop": true, 13 | "module": "esnext", 14 | "moduleResolution": "node", 15 | "resolveJsonModule": true, 16 | "isolatedModules": true, 17 | "jsx": "preserve", 18 | "plugins": [ 19 | { 20 | "name": "next" 21 | } 22 | ], 23 | "strictNullChecks": false 24 | }, 25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 26 | "exclude": ["node_modules"] 27 | } 28 | -------------------------------------------------------------------------------- /types/index.ts: -------------------------------------------------------------------------------- 1 | import type { PortableTextBlock } from '@portabletext/types' 2 | import type { Image } from 'sanity' 3 | 4 | export interface MenuItem { 5 | _type: string 6 | slug?: string 7 | title?: string 8 | } 9 | 10 | export interface MilestoneItem { 11 | description?: string 12 | duration?: { 13 | start?: string 14 | end?: string 15 | } 16 | image?: Image 17 | tags?: string[] 18 | title?: string 19 | } 20 | 21 | export interface ShowcaseProject { 22 | _type: string 23 | coverImage?: Image 24 | overview?: PortableTextBlock[] 25 | slug?: string 26 | tags?: string[] 27 | title?: string 28 | } 29 | 30 | // Page payloads 31 | 32 | export interface HomePagePayload { 33 | footer?: PortableTextBlock[] 34 | overview?: PortableTextBlock[] 35 | showcaseProjects?: ShowcaseProject[] 36 | title?: string 37 | } 38 | 39 | export interface PagePayload { 40 | body?: PortableTextBlock[] 41 | name?: string 42 | overview?: PortableTextBlock[] 43 | title?: string 44 | slug?: string 45 | } 46 | 47 | export interface ProjectPayload { 48 | client?: string 49 | coverImage?: Image 50 | description?: PortableTextBlock[] 51 | duration?: { 52 | start?: string 53 | end?: string 54 | } 55 | overview?: PortableTextBlock[] 56 | site?: string 57 | slug: string 58 | tags?: string[] 59 | title?: string 60 | } 61 | 62 | export interface SettingsPayload { 63 | footer?: PortableTextBlock[] 64 | menuItems?: MenuItem[] 65 | ogImage?: Image 66 | } 67 | --------------------------------------------------------------------------------