├── .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 | [][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 | |  |  |
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 | [][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 |
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 |
54 |
55 |
56 |
57 | Next steps
58 |
59 |
60 | {!hasEnvFile && (
61 |
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 |
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 |
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 |
--------------------------------------------------------------------------------