├── .env.local.example ├── .eslintrc.json ├── .github ├── CODEOWNERS ├── renovate.json └── workflows │ ├── ci.yml │ ├── lock.yml │ ├── prettier.yml │ └── validate.yml ├── .gitignore ├── README.md ├── app ├── (personal) │ ├── DraftModeToast.tsx │ ├── [slug] │ │ └── page.tsx │ ├── client-functions.ts │ ├── layout.tsx │ ├── page.tsx │ ├── projects │ │ └── [slug] │ │ │ └── page.tsx │ └── server-functions.ts ├── api │ └── draft-mode │ │ └── enable │ │ └── route.ts ├── apple-icon.png ├── favicon.ico ├── globals.css ├── icon.png ├── layout.tsx └── studio │ └── [[...index]] │ └── page.tsx ├── components ├── CustomPortableText.tsx ├── Header.tsx ├── HomePage.tsx ├── ImageBox.tsx ├── Navbar.tsx ├── OptimisticSortOrder │ ├── index.client.tsx │ └── index.tsx ├── ProjectListItem.tsx ├── TimelineItem.tsx └── TimelineSection.tsx ├── intro-template ├── cover.png └── index.tsx ├── netlify.toml ├── next-env.d.ts ├── next.config.ts ├── package-lock.json ├── package.json ├── postcss.config.js ├── prettier.config.cjs ├── sanity.cli.ts ├── sanity.config.ts ├── sanity.types.ts ├── sanity ├── lib │ ├── api.ts │ ├── client.ts │ ├── live.ts │ ├── queries.ts │ ├── token.ts │ └── utils.ts ├── plugins │ ├── resolve.ts │ └── settings.tsx └── schemas │ ├── documents │ ├── page.ts │ └── project.ts │ ├── objects │ ├── duration │ │ ├── DurationInput.tsx │ │ └── index.ts │ ├── milestone.ts │ └── timeline.ts │ └── singletons │ ├── home.ts │ └── settings.ts ├── schema.json ├── styles └── index.css ├── tailwind.config.js ├── tsconfig.json ├── types └── index.ts └── vercel-installation-instructions.md /.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="template-nextjs-personal-website" 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 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="Next.js Personal Website with Sanity.io" 15 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next", 3 | "plugins": ["react-compiler"], 4 | "rules": { 5 | "react-hooks/exhaustive-deps": "error", 6 | "react-compiler/react-compiler": "warn" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @sanity-io/ecosystem 2 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "description": "Check the readme: https://github.com/sanity-io/renovate-config#readme", 3 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 4 | "extends": ["github>sanity-io/renovate-config:starter-template"] 5 | } 6 | -------------------------------------------------------------------------------- /.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: actions/create-github-app-token@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@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7 41 | with: 42 | body: I ran `npm run format` 🧑‍💻 43 | branch: actions/prettier-if-needed 44 | commit-message: "chore(prettier): 🤖 ✨" 45 | labels: 🤖 bot 46 | sign-commits: true 47 | title: "chore(prettier): 🤖 ✨" 48 | token: ${{ steps.generate-token.outputs.token }} 49 | -------------------------------------------------------------------------------- /.github/workflows/validate.yml: -------------------------------------------------------------------------------- 1 | name: Validate Template 2 | on: push 3 | 4 | jobs: 5 | validate: 6 | runs-on: ubuntu-latest 7 | steps: 8 | - uses: actions/checkout@v4 9 | - name: Validate Sanity Template 10 | uses: sanity-io/template-validator@v2 11 | -------------------------------------------------------------------------------- /.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 | /public/studio/static 20 | 21 | # misc 22 | .DS_Store 23 | *.pem 24 | .vscode 25 | .idea/ 26 | *.iml 27 | 28 | # debug 29 | npm-debug.log* 30 | yarn-debug.log* 31 | yarn-error.log* 32 | .pnpm-debug.log* 33 | 34 | # local env files 35 | .env*.local 36 | 37 | # vercel 38 | .vercel 39 | 40 | # typescript 41 | *.tsbuildinfo 42 | 43 | # Env files created by scripts for working locally 44 | .env 45 | studio/.env.development 46 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # A Next.js Personal Website with a Native Authoring Experience 2 | 3 | This starter is a statically generated personal website that uses [Next.js][nextjs] for the frontend and [Sanity][sanity-homepage] to handle its content. It comes with a native Sanity Studio that offers features like real-time collaboration and visual editing with live updates using [Presentation][presentation]. 4 | 5 | The Studio connects to Sanity Content Lake, which gives you hosted content APIs with a flexible query language, on-demand image transformations, powerful patching, and more. You can use this starter to kick-start a personal website or learn these technologies. 6 | 7 | ## Features 8 | 9 | - A performant, static personal website with editable projects 10 | - A native and customizable authoring environment, accessible on `yourpersonalwebsite.com/studio` 11 | - Real-time and collaborative content editing with fine-grained revision history 12 | - Side-by-side instant content preview that works across your whole site 13 | - Support for block content and the most advanced custom fields capability in the industry 14 | - Sanity Live Revalidation; no need to wait for a rebuild to publish new content 15 | - Free Sanity project with unlimited admin users, free content updates, and pay-as-you-go for API overages 16 | - A project with starter-friendly and not too heavy-handed TypeScript and Tailwind.css 17 | 18 | ## Table of Contents 19 | 20 | - [Features](#features) 21 | - [Table of Contents](#table-of-contents) 22 | - [Project Overview](#project-overview) 23 | - [Important files and folders](#important-files-and-folders) 24 | - [ Getting Started](#configuration) 25 | - [Step 1. Initialize template with Sanity CLI](#initialize-template-with-sanity-cli) 26 | - [Step 2. Run app locally in development mode](#run-app-locally-in-development-mode) 27 | - [Step 3. Open the app and sign in to the Studio](#open-the-app-and-sign-in-to-the-studio) 28 | - [Adding content with Sanity](#adding-content-with-sanity) 29 | - [Step 1. Publish your first document](#publish-your-first-document) 30 | - [Step 2. Extending the Sanity schema](#extending-the-sanity-schema) 31 | - [Deploying your application and inviting editors]() 32 | - [Step 1. Deploy Next.js app to Vercel](#deploy-next.js-app-to-vercel) 33 | - [Step 2. Invite a collaborator](#invite-a-collaborator) 34 | - [Questions and Answers](#questions-and-answers) 35 | - [It doesn't work! Where can I get help?](#it-doesnt-work-where-can-i-get-help) 36 | - [How can I remove the "Next steps" block from my personal site?](#how-can-i-remove-the-next-steps-block-from-my-personal-website) 37 | - [Next steps](#next-steps) 38 | 39 | ## Project Overview 40 | 41 | | [Personal Website](https://template-nextjs-personal-website.sanity.build/) | [Studio](https://template-nextjs-personal-website.sanity.build/studio) | 42 | | ------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | 43 | | ![Personal Website](https://user-images.githubusercontent.com/6951139/206395107-e58a796d-13a9-400a-94b6-31cb5df054ab.png) | ![Sanity Studio](https://user-images.githubusercontent.com/6951139/206395521-8a5f103d-4a0c-4da8-aff5-d2a1961fb2c0.png) | 44 | 45 | ### Important files and folders 46 | 47 | | File(s) | Description | 48 | | -------------------------------------------- | ------------------------------------------------------- | 49 | | `sanity.config.ts` | Config file for Sanity Studio | 50 | | `sanity.cli.ts` | Config file for Sanity CLI | 51 | | `/app/studio/[[...tool]]/Studio.tsx` | Where Sanity Studio is mounted | 52 | | `/app/api/draft-mode/enable/route.ts` | Serverless route for triggering Draft mode | 53 | | `/sanity/schemas` | Where Sanity Studio gets its content types from | 54 | | `/sanity/plugins` | Where the advanced Sanity Studio customization is setup | 55 | | `/sanity/lib/api.ts`,`/sanity/lib/client.ts` | Configuration for the Sanity Content Lake client | 56 | 57 | ## Getting Started 58 | 59 | ### Installing the template 60 | 61 | We will take a look at installing this template with the Sanity CLI, running locally, and lastly deploying to Vercel. If you'd rather start by deploying to Vercel, please instead reference the instructions in [`vercel-installation-instructions.md`](./vercel-installation-instructions.md) 62 | 63 | #### 1. Initialize template with Sanity CLI 64 | 65 | Run the command in your Terminal to initialize this template on your local computer. 66 | 67 | See the documentation if you are [having issues with the CLI](https://www.sanity.io/help/cli-errors). 68 | 69 | ```shell 70 | npm create sanity@latest -- --template sanity-io/template-nextjs-personal-website 71 | ``` 72 | 73 | #### 2. Run app locally in development mode 74 | 75 | Navigate to the template directory using `cd `, and start the development servers by running the following command 76 | 77 | ```shell 78 | npm run dev 79 | ``` 80 | 81 | #### 3. Open the app and sign in to the Studio 82 | 83 | Open the Next.js app running locally in your browser on [http://localhost:3000](http://localhost:3000). 84 | 85 | Open the Studio by navigating to the `/studio` route [http://localhost:3000/studio](http://localhost:3000/studio). You should now see a screen prompting you to log in to the Studio. Use the same service (Google, GitHub, or email) that you used when you logged in to the CLI. 86 | 87 | ### Adding content with Sanity 88 | 89 | #### 1. Publish your first document 90 | 91 | The template comes pre-defined with a schema containing `Page` and `Project` document types. 92 | 93 | From the Studio, click "+ Create" and select the `Project` document type. Go ahead and create and publish the document. 94 | 95 | Your content should now appear in your Next.js app ([http://localhost:3000](http://localhost:3000)) as well as in the Studio on the "Presentation" Tab 96 | 97 | #### 2. Extending the Sanity schema 98 | 99 | The schema for the `Post` document type is defined in the `studio/src/schemaTypes/post.ts` file. You can [add more document types](https://www.sanity.io/docs/schema-types) to the schema to suit your needs. 100 | 101 | ### Deploying your application and inviting editors 102 | 103 | #### 1. Deploy Next.js app to Vercel 104 | 105 | Your app is still only running on your local computer. It's time to deploy and get it into the hands of other content editors. 106 | 107 | You have the freedom to deploy your Next.js app to your hosting provider of choice. With Vercel and GitHub being a popular choice, we'll cover the basics of that approach. 108 | 109 | 1. Create a GitHub repository from this project. [Learn more](https://docs.github.com/en/migrations/importing-source-code/using-the-command-line-to-import-source-code/adding-locally-hosted-code-to-github). 110 | 2. Create a new Vercel project and connect it to your Github repository. 111 | 3. Configure your Environment Variables. 112 | 113 | #### 2. Invite a collaborator 114 | 115 | Now that you’ve deployed your Next.js application and Sanity Studio, you can optionally invite a collaborator to your Studio. Open up [Manage](https://www.sanity.io/manage), select your project and click "Invite project members" 116 | 117 | They will be able to access the deployed Studio, where you can collaborate together on creating content. 118 | 119 | ## Questions and Answers 120 | 121 | ### It doesn't work! Where can I get help? 122 | 123 | In case of any issues or questions, you can post: 124 | 125 | - [GitHub Discussions for Next.js][vercel-github] 126 | - [Sanity's GitHub Discussions][sanity-github] 127 | - [Sanity's Community Slack][sanity-community] 128 | 129 | ### How can I remove the "Next steps" block from my personal website? 130 | 131 | You can remove it by deleting the `IntroTemplate` component in `/app/(personal)/layout.tsx`. 132 | 133 | ## Next steps 134 | 135 | - [Join our Slack community to ask questions and get help][sanity-community] 136 | - [How to edit my content structure?][sanity-schema-types] 137 | - [How to query content?][sanity-groq] 138 | - [What is content modelling?][sanity-content-modelling] 139 | 140 | [vercel-deploy]: https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fsanity-io%2Ftemplate-nextjs-personal-website&project-name=nextjs-personal-website&repository-name=nextjs-personal-website&demo-title=Personal+Website+with+Built-in+Content+Editing&demo-description=A+Sanity-powered+personal+website+with+built-in+content+editing+and+instant+previews.+Uses+App+Router.&demo-url=https%3A%2F%2Ftemplate-nextjs-personal-website.sanity.build%2F&demo-image=https%3A%2F%2Fuser-images.githubusercontent.com%2F6951139%2F206395107-e58a796d-13a9-400a-94b6-31cb5df054ab.png&integration-ids=oac_hb2LITYajhRQ0i4QznmKH7gx&external-id=nextjs%3Btemplate%3Dtemplate-nextjs-personal-website 141 | [integration]: https://www.sanity.io/docs/vercel-integration?utm_source=github.com&utm_medium=referral&utm_campaign=nextjs-v3vercelstarter 142 | [`.env.local.example`]: .env.local.example 143 | [nextjs]: https://github.com/vercel/next.js 144 | [sanity-create]: https://www.sanity.io/get-started/create-project?utm_source=github.com&utm_medium=referral&utm_campaign=nextjs-v3vercelstarter 145 | [sanity-deployment]: https://www.sanity.io/docs/deployment?utm_source=github.com&utm_medium=referral&utm_campaign=nextjs-v3vercelstarter 146 | [sanity-homepage]: https://www.sanity.io?utm_source=github.com&utm_medium=referral&utm_campaign=nextjs-v3vercelstarter 147 | [sanity-community]: https://slack.sanity.io/ 148 | [sanity-schema-types]: https://www.sanity.io/docs/schema-types?utm_source=github.com&utm_medium=referral&utm_campaign=nextjs-v3vercelstarter 149 | [sanity-github]: https://github.com/sanity-io/sanity/discussions 150 | [sanity-groq]: https://www.sanity.io/docs/groq?utm_source=github.com&utm_medium=referral&utm_campaign=nextjs-v3vercelstarter 151 | [sanity-content-modelling]: https://www.sanity.io/docs/content-modelling?utm_source=github.com&utm_medium=referral&utm_campaign=nextjs-v3vercelstarter 152 | [sanity-webhooks]: https://www.sanity.io/docs/webhooks?utm_source=github.com&utm_medium=referral&utm_campaign=nextjs-v3vercelstarter 153 | [localhost-3000]: http://localhost:3000 154 | [localhost-3000-studio]: http://localhost:3000/studio 155 | [vercel]: https://vercel.com 156 | [vercel-github]: https://github.com/vercel/next.js/discussions 157 | [personal-website-pages]: https://github.com/sanity-io/template-nextjs-personal-website 158 | [presentation]: https://www.sanity.io/docs/presentation 159 | -------------------------------------------------------------------------------- /app/(personal)/DraftModeToast.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import {useDraftModeEnvironment, useIsPresentationTool} from 'next-sanity/hooks' 4 | import {useRouter} from 'next/navigation' 5 | import {useEffect, useTransition} from 'react' 6 | import {toast} from 'sonner' 7 | import {disableDraftMode} from './server-functions' 8 | 9 | export function DraftModeToast() { 10 | const isPresentationTool = useIsPresentationTool() 11 | const env = useDraftModeEnvironment() 12 | const router = useRouter() 13 | const [pending, startTransition] = useTransition() 14 | 15 | useEffect(() => { 16 | if (isPresentationTool === false) { 17 | /** 18 | * We delay the toast in case we're inside Presentation Tool 19 | */ 20 | const toastId = toast('Draft Mode Enabled', { 21 | id: 'draft-mode-toast', 22 | description: 23 | env === 'live' 24 | ? 'Content is live, refreshing automatically' 25 | : 'Refresh manually to see changes', 26 | duration: Infinity, 27 | action: { 28 | label: 'Disable', 29 | onClick: () => 30 | startTransition(async () => { 31 | await disableDraftMode() 32 | startTransition(() => router.refresh()) 33 | }), 34 | }, 35 | }) 36 | return () => { 37 | toast.dismiss(toastId) 38 | } 39 | } 40 | }, [env, router, isPresentationTool]) 41 | 42 | useEffect(() => { 43 | if (pending) { 44 | const toastId = toast.loading('Disabling draft mode...') 45 | return () => { 46 | toast.dismiss(toastId) 47 | } 48 | } 49 | }, [pending]) 50 | 51 | return null 52 | } 53 | -------------------------------------------------------------------------------- /app/(personal)/[slug]/page.tsx: -------------------------------------------------------------------------------- 1 | import {CustomPortableText} from '@/components/CustomPortableText' 2 | import {Header} from '@/components/Header' 3 | import {sanityFetch} from '@/sanity/lib/live' 4 | import {pagesBySlugQuery, slugsByTypeQuery} from '@/sanity/lib/queries' 5 | import type {Metadata, ResolvingMetadata} from 'next' 6 | import {toPlainText, type PortableTextBlock} from 'next-sanity' 7 | import {draftMode} from 'next/headers' 8 | import {notFound} from 'next/navigation' 9 | 10 | type Props = { 11 | params: Promise<{slug: string}> 12 | } 13 | 14 | export async function generateMetadata( 15 | {params}: Props, 16 | parent: ResolvingMetadata, 17 | ): Promise { 18 | const {data: page} = await sanityFetch({ 19 | query: pagesBySlugQuery, 20 | params, 21 | stega: false, 22 | }) 23 | 24 | return { 25 | title: page?.title, 26 | description: page?.overview ? toPlainText(page.overview) : (await parent).description, 27 | } 28 | } 29 | 30 | export async function generateStaticParams() { 31 | const {data} = await sanityFetch({ 32 | query: slugsByTypeQuery, 33 | params: {type: 'page'}, 34 | stega: false, 35 | perspective: 'published', 36 | }) 37 | return data 38 | } 39 | 40 | export default async function PageSlugRoute({params}: Props) { 41 | const {data} = await sanityFetch({query: pagesBySlugQuery, params}) 42 | 43 | // Only show the 404 page if we're in production, when in draft mode we might be about to create a page on this slug, and live reload won't work on the 404 route 44 | if (!data?._id && !(await draftMode()).isEnabled) { 45 | notFound() 46 | } 47 | 48 | const {body, overview, title} = data ?? {} 49 | 50 | return ( 51 |
52 |
53 | {/* Header */} 54 |
61 | 62 | {/* Body */} 63 | {body && ( 64 | 71 | )} 72 |
73 |
74 |
75 | ) 76 | } 77 | -------------------------------------------------------------------------------- /app/(personal)/client-functions.ts: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import {isCorsOriginError} from 'next-sanity' 4 | import {toast} from 'sonner' 5 | 6 | export function handleError(error: unknown) { 7 | if (isCorsOriginError(error)) { 8 | const {addOriginUrl} = error 9 | toast.error(`Sanity Live couldn't connect`, { 10 | description: `Your origin is blocked by CORS policy`, 11 | duration: Infinity, 12 | action: addOriginUrl 13 | ? { 14 | label: 'Manage', 15 | onClick: () => window.open(addOriginUrl.toString(), '_blank'), 16 | } 17 | : undefined, 18 | }) 19 | } else if (error instanceof Error) { 20 | console.error(error) 21 | toast.error(error.name, {description: error.message, duration: Infinity}) 22 | } else { 23 | console.error(error) 24 | toast.error('Unknown error', { 25 | description: 'Check the console for more details', 26 | duration: Infinity, 27 | }) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /app/(personal)/layout.tsx: -------------------------------------------------------------------------------- 1 | import '@/styles/index.css' 2 | import {CustomPortableText} from '@/components/CustomPortableText' 3 | import {Navbar} from '@/components/Navbar' 4 | import IntroTemplate from '@/intro-template' 5 | import {sanityFetch, SanityLive} from '@/sanity/lib/live' 6 | import {homePageQuery, settingsQuery} from '@/sanity/lib/queries' 7 | import {urlForOpenGraphImage} from '@/sanity/lib/utils' 8 | import type {Metadata, Viewport} from 'next' 9 | import {toPlainText, VisualEditing, type PortableTextBlock} from 'next-sanity' 10 | import {draftMode} from 'next/headers' 11 | import {Suspense} from 'react' 12 | import {Toaster} from 'sonner' 13 | import {handleError} from './client-functions' 14 | import {DraftModeToast} from './DraftModeToast' 15 | 16 | export async function generateMetadata(): Promise { 17 | const [{data: settings}, {data: homePage}] = await Promise.all([ 18 | sanityFetch({query: settingsQuery, stega: false}), 19 | sanityFetch({query: homePageQuery, stega: false}), 20 | ]) 21 | 22 | const ogImage = urlForOpenGraphImage( 23 | // @ts-expect-error - @TODO update @sanity/image-url types so it's compatible 24 | settings?.ogImage, 25 | ) 26 | return { 27 | title: homePage?.title 28 | ? { 29 | template: `%s | ${homePage.title}`, 30 | default: homePage.title || 'Personal website', 31 | } 32 | : undefined, 33 | description: homePage?.overview ? toPlainText(homePage.overview) : undefined, 34 | openGraph: { 35 | images: ogImage ? [ogImage] : [], 36 | }, 37 | } 38 | } 39 | 40 | export const viewport: Viewport = { 41 | themeColor: '#000', 42 | } 43 | 44 | export default async function IndexRoute({children}: {children: React.ReactNode}) { 45 | const {data} = await sanityFetch({query: settingsQuery}) 46 | return ( 47 | <> 48 |
49 | 50 |
{children}
51 |
52 | {data?.footer && ( 53 | 60 | )} 61 |
62 | 63 | 64 | 65 |
66 | 67 | 68 | {(await draftMode()).isEnabled && ( 69 | <> 70 | 71 | 72 | 73 | )} 74 | 75 | ) 76 | } 77 | -------------------------------------------------------------------------------- /app/(personal)/page.tsx: -------------------------------------------------------------------------------- 1 | import {HomePage} from '@/components/HomePage' 2 | import {studioUrl} from '@/sanity/lib/api' 3 | import {sanityFetch} from '@/sanity/lib/live' 4 | import {homePageQuery} from '@/sanity/lib/queries' 5 | import Link from 'next/link' 6 | 7 | export default async function IndexRoute() { 8 | const {data} = await sanityFetch({query: homePageQuery}) 9 | 10 | if (!data) { 11 | return ( 12 |
13 | You don’t have a homepage yet,{' '} 14 | 15 | create one now 16 | 17 | ! 18 |
19 | ) 20 | } 21 | 22 | return 23 | } 24 | -------------------------------------------------------------------------------- /app/(personal)/projects/[slug]/page.tsx: -------------------------------------------------------------------------------- 1 | import {CustomPortableText} from '@/components/CustomPortableText' 2 | import {Header} from '@/components/Header' 3 | import ImageBox from '@/components/ImageBox' 4 | import {studioUrl} from '@/sanity/lib/api' 5 | import {sanityFetch} from '@/sanity/lib/live' 6 | import {projectBySlugQuery, slugsByTypeQuery} from '@/sanity/lib/queries' 7 | import {urlForOpenGraphImage} from '@/sanity/lib/utils' 8 | import type {Metadata, ResolvingMetadata} from 'next' 9 | import {createDataAttribute, toPlainText} from 'next-sanity' 10 | import {draftMode} from 'next/headers' 11 | import Link from 'next/link' 12 | import {notFound} from 'next/navigation' 13 | 14 | type Props = { 15 | params: Promise<{slug: string}> 16 | } 17 | 18 | export async function generateMetadata( 19 | {params}: Props, 20 | parent: ResolvingMetadata, 21 | ): Promise { 22 | const {data: project} = await sanityFetch({ 23 | query: projectBySlugQuery, 24 | params, 25 | stega: false, 26 | }) 27 | const ogImage = urlForOpenGraphImage( 28 | // @ts-expect-error - @TODO update @sanity/image-url types so it's compatible 29 | project?.coverImage, 30 | ) 31 | 32 | return { 33 | title: project?.title, 34 | description: project?.overview ? toPlainText(project.overview) : (await parent).description, 35 | openGraph: ogImage 36 | ? { 37 | images: [ogImage, ...((await parent).openGraph?.images || [])], 38 | } 39 | : {}, 40 | } 41 | } 42 | 43 | export async function generateStaticParams() { 44 | const {data} = await sanityFetch({ 45 | query: slugsByTypeQuery, 46 | params: {type: 'project'}, 47 | stega: false, 48 | perspective: 'published', 49 | }) 50 | return data 51 | } 52 | 53 | export default async function ProjectSlugRoute({params}: Props) { 54 | const {data} = await sanityFetch({query: projectBySlugQuery, params}) 55 | 56 | // Only show the 404 page if we're in production, when in draft mode we might be about to create a project on this slug, and live reload won't work on the 404 route 57 | if (!data?._id && !(await draftMode()).isEnabled) { 58 | notFound() 59 | } 60 | 61 | const dataAttribute = 62 | data?._id && data._type 63 | ? createDataAttribute({ 64 | baseUrl: studioUrl, 65 | id: data._id, 66 | type: data._type, 67 | }) 68 | : null 69 | 70 | // Default to an empty object to allow previews on non-existent documents 71 | const {client, coverImage, description, duration, overview, site, tags, title} = data ?? {} 72 | 73 | const startYear = new Date(duration?.start!).getFullYear() 74 | const endYear = duration?.end ? new Date(duration?.end).getFullYear() : 'Now' 75 | 76 | return ( 77 |
78 |
79 | {/* Header */} 80 |
87 | 88 |
89 | {/* Image */} 90 | 97 | 98 |
99 | {/* Duration */} 100 | {!!(startYear && endYear) && ( 101 |
102 |
Duration
103 |
104 | {startYear} 105 | {' - '} 106 | {endYear} 107 |
108 |
109 | )} 110 | 111 | {/* Client */} 112 | {client && ( 113 |
114 |
Client
115 |
{client}
116 |
117 | )} 118 | 119 | {/* Site */} 120 | {site && ( 121 |
122 |
Site
123 | {site && ( 124 | 125 | {site} 126 | 127 | )} 128 |
129 | )} 130 | 131 | {/* Tags */} 132 |
133 |
Tags
134 |
135 | {tags?.map((tag, key) => ( 136 |
137 | #{tag} 138 |
139 | ))} 140 |
141 |
142 |
143 |
144 | 145 | {/* Description */} 146 | {description && ( 147 | 154 | )} 155 |
156 |
157 |
158 | ) 159 | } 160 | -------------------------------------------------------------------------------- /app/(personal)/server-functions.ts: -------------------------------------------------------------------------------- 1 | 'use server' 2 | 3 | import {draftMode} from 'next/headers' 4 | 5 | export async function disableDraftMode() { 6 | 'use server' 7 | await Promise.allSettled([ 8 | (await draftMode()).disable(), 9 | // Simulate a delay to show the loading state 10 | new Promise((resolve) => setTimeout(resolve, 1000)), 11 | ]) 12 | } 13 | -------------------------------------------------------------------------------- /app/api/draft-mode/enable/route.ts: -------------------------------------------------------------------------------- 1 | import {client} from '@/sanity/lib/client' 2 | import {token} from '@/sanity/lib/token' 3 | import {defineEnableDraftMode} from 'next-sanity/draft-mode' 4 | 5 | export const {GET} = defineEnableDraftMode({ 6 | client: client.withConfig({token}), 7 | }) 8 | -------------------------------------------------------------------------------- /app/apple-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sanity-io/template-nextjs-personal-website/4f677caaf45349d4ad54c12f5936123aa213cd72/app/apple-icon.png -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sanity-io/template-nextjs-personal-website/4f677caaf45349d4ad54c12f5936123aa213cd72/app/favicon.ico -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /app/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sanity-io/template-nextjs-personal-website/4f677caaf45349d4ad54c12f5936123aa213cd72/app/icon.png -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import './globals.css' 2 | import {IBM_Plex_Mono, Inter, PT_Serif} from 'next/font/google' 3 | 4 | const serif = PT_Serif({ 5 | variable: '--font-serif', 6 | style: ['normal', 'italic'], 7 | subsets: ['latin'], 8 | weight: ['400', '700'], 9 | }) 10 | const sans = Inter({ 11 | variable: '--font-sans', 12 | subsets: ['latin'], 13 | // @todo: understand why extrabold (800) isn't being respected when explicitly specified in this weight array 14 | // weight: ['500', '700', '800'], 15 | }) 16 | const mono = IBM_Plex_Mono({ 17 | variable: '--font-mono', 18 | subsets: ['latin'], 19 | weight: ['500', '700'], 20 | }) 21 | 22 | export default async function RootLayout({children}: {children: React.ReactNode}) { 23 | return ( 24 | 25 | {children} 26 | 27 | ) 28 | } 29 | -------------------------------------------------------------------------------- /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 config from '@/sanity.config' 11 | import {NextStudio} from 'next-sanity/studio' 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 | } 20 | -------------------------------------------------------------------------------- /components/CustomPortableText.tsx: -------------------------------------------------------------------------------- 1 | import ImageBox from '@/components/ImageBox' 2 | import {TimelineSection} from '@/components/TimelineSection' 3 | import type {PathSegment, StudioPathLike} from '@sanity/client/csm' 4 | import {PortableText, type PortableTextBlock, type PortableTextComponents} from 'next-sanity' 5 | import type {Image} from 'sanity' 6 | 7 | export function CustomPortableText({ 8 | id, 9 | type, 10 | path, 11 | paragraphClasses, 12 | value, 13 | }: { 14 | id: string | null 15 | type: string | null 16 | path: PathSegment[] 17 | paragraphClasses?: string 18 | value: PortableTextBlock[] 19 | }) { 20 | const components: PortableTextComponents = { 21 | block: { 22 | normal: ({children}) => { 23 | return

{children}

24 | }, 25 | }, 26 | marks: { 27 | link: ({children, value}) => { 28 | return ( 29 | 34 | {children} 35 | 36 | ) 37 | }, 38 | }, 39 | types: { 40 | image: ({value}: {value: Image & {alt?: string; caption?: string}}) => { 41 | return ( 42 |
43 | 44 | {value?.caption && ( 45 |
{value.caption}
46 | )} 47 |
48 | ) 49 | }, 50 | timeline: ({value}) => { 51 | const {items, _key} = value || {} 52 | return ( 53 | 60 | ) 61 | }, 62 | }, 63 | } 64 | 65 | return 66 | } 67 | -------------------------------------------------------------------------------- /components/Header.tsx: -------------------------------------------------------------------------------- 1 | import {CustomPortableText} from '@/components/CustomPortableText' 2 | import type {PathSegment} from 'sanity' 3 | 4 | interface HeaderProps { 5 | id: string | null 6 | type: string | null 7 | path: PathSegment[] 8 | centered?: boolean 9 | description?: null | any[] 10 | title?: string | null 11 | } 12 | export function Header(props: HeaderProps) { 13 | const {id, type, path, title, description, centered = false} = props 14 | if (!description && !title) { 15 | return null 16 | } 17 | return ( 18 |
19 | {/* Title */} 20 | {title &&
{title}
} 21 | {/* Description */} 22 | {description && ( 23 |
24 | 25 |
26 | )} 27 |
28 | ) 29 | } 30 | -------------------------------------------------------------------------------- /components/HomePage.tsx: -------------------------------------------------------------------------------- 1 | import {Header} from '@/components/Header' 2 | import {OptimisticSortOrder} from '@/components/OptimisticSortOrder' 3 | import {ProjectListItem} from '@/components/ProjectListItem' 4 | import type {HomePageQueryResult} from '@/sanity.types' 5 | import {studioUrl} from '@/sanity/lib/api' 6 | import {resolveHref} from '@/sanity/lib/utils' 7 | import {createDataAttribute} from 'next-sanity' 8 | import {draftMode} from 'next/headers' 9 | import Link from 'next/link' 10 | 11 | export interface HomePageProps { 12 | data: HomePageQueryResult | null 13 | } 14 | 15 | export async function HomePage({data}: HomePageProps) { 16 | // Default to an empty object to allow previews on non-existent documents 17 | const {overview = [], showcaseProjects = [], title = ''} = data ?? {} 18 | 19 | const dataAttribute = 20 | data?._id && data?._type 21 | ? createDataAttribute({ 22 | baseUrl: studioUrl, 23 | id: data._id, 24 | type: data._type, 25 | }) 26 | : null 27 | 28 | return ( 29 |
30 | {/* Header */} 31 | {title && ( 32 |
40 | )} 41 | {/* Showcase projects */} 42 |
43 | 44 | {showcaseProjects && 45 | showcaseProjects.length > 0 && 46 | showcaseProjects.map((project) => { 47 | const href = resolveHref(project?._type, project?.slug) 48 | if (!href) { 49 | return null 50 | } 51 | return ( 52 | 58 | 59 | 60 | ) 61 | })} 62 | 63 |
64 |
65 | ) 66 | } 67 | -------------------------------------------------------------------------------- /components/ImageBox.tsx: -------------------------------------------------------------------------------- 1 | import {urlForImage} from '@/sanity/lib/utils' 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 | 'data-sanity'?: string 12 | } 13 | 14 | export default function ImageBox({ 15 | image, 16 | alt = 'Cover image', 17 | width = 3500, 18 | height = 2000, 19 | size = '100vw', 20 | classesWrapper, 21 | ...props 22 | }: ImageBoxProps) { 23 | const imageUrl = image && urlForImage(image)?.height(height).width(width).fit('crop').url() 24 | 25 | return ( 26 |
30 | {imageUrl && ( 31 | {alt} 39 | )} 40 |
41 | ) 42 | } 43 | -------------------------------------------------------------------------------- /components/Navbar.tsx: -------------------------------------------------------------------------------- 1 | import {OptimisticSortOrder} from '@/components/OptimisticSortOrder' 2 | import type {SettingsQueryResult} from '@/sanity.types' 3 | import {studioUrl} from '@/sanity/lib/api' 4 | import {resolveHref} from '@/sanity/lib/utils' 5 | import {createDataAttribute, stegaClean} from 'next-sanity' 6 | import Link from 'next/link' 7 | 8 | interface NavbarProps { 9 | data: SettingsQueryResult 10 | } 11 | export function Navbar(props: NavbarProps) { 12 | const {data} = props 13 | const dataAttribute = 14 | data?._id && data?._type 15 | ? createDataAttribute({ 16 | baseUrl: studioUrl, 17 | id: data._id, 18 | type: data._type, 19 | }) 20 | : null 21 | return ( 22 |
26 | 27 | {data?.menuItems?.map((menuItem) => { 28 | const href = resolveHref(menuItem?._type, menuItem?.slug) 29 | if (!href) { 30 | return null 31 | } 32 | return ( 33 | 44 | {stegaClean(menuItem.title)} 45 | 46 | ) 47 | })} 48 | 49 |
50 | ) 51 | } 52 | -------------------------------------------------------------------------------- /components/OptimisticSortOrder/index.client.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import type {AllSanitySchemaTypes} from '@/sanity.types' 4 | import type {SanityDocument} from '@sanity/client' 5 | import {type StudioPathLike} from '@sanity/client/csm' 6 | import {get} from '@sanity/util/paths' 7 | import {useOptimistic} from 'next-sanity/hooks' 8 | import {Children, isValidElement} from 'react' 9 | 10 | export interface OptimisticSortOrderProps { 11 | children: React.ReactNode 12 | /** 13 | * The id is needed to enable the optimistic state reducer to know if the document being mutated is relevant to the action 14 | */ 15 | id: string 16 | /** 17 | * Where from the source document we're applying optimistic state 18 | */ 19 | path: StudioPathLike 20 | } 21 | 22 | /** 23 | * This component is used to apply optimistic state to a list of children. It is used to 24 | * provide a smooth user experience when reordering items in a list. The component 25 | * expects the children to have a unique key prop, and will reorder the children based 26 | * on the optimistic state. 27 | */ 28 | 29 | export default function OptimisticSortOrder(props: OptimisticSortOrderProps) { 30 | const {children, id, path} = props 31 | const childrenLength = Children.count(children) 32 | 33 | const optimistic = useOptimistic>( 34 | null, 35 | (state, action) => { 36 | if (action.id !== id) return state 37 | const value = get(action.document, path) as {_key: string}[] 38 | if (!value) { 39 | console.error('No value found for path', path, 'in document', action.document) 40 | return state 41 | } 42 | return value.map(({_key}) => _key) 43 | }, 44 | ) 45 | 46 | if (optimistic) { 47 | if (optimistic.length < childrenLength) { 48 | // If the optimistic state is shorter than children, then we don't have enough data to accurately reorder the children so we bail 49 | return children 50 | } 51 | 52 | const cache = new Map() 53 | Children.forEach(children, (child) => { 54 | if (!isValidElement(child) || !child.key) return 55 | cache.set(child.key, child) 56 | }) 57 | return optimistic.map((key) => cache.get(key)) 58 | } 59 | 60 | return children 61 | } 62 | -------------------------------------------------------------------------------- /components/OptimisticSortOrder/index.tsx: -------------------------------------------------------------------------------- 1 | import {draftMode} from 'next/headers' 2 | import {lazy, Suspense} from 'react' 3 | import type {OptimisticSortOrderProps} from './index.client' 4 | 5 | const LazyOptimisticSortOrder = lazy(() => import('./index.client')) 6 | 7 | /** 8 | * Optimistic sort ordering is only used when editing the website from Sanity Studio, so it's only actually loaded in Draft Mode. 9 | */ 10 | 11 | export async function OptimisticSortOrder( 12 | props: Omit & {id?: string | null}, 13 | ) { 14 | const {children, id, path} = props 15 | 16 | if (!id) { 17 | return children 18 | } 19 | const {isEnabled} = await draftMode() 20 | if (!isEnabled) { 21 | return children 22 | } 23 | 24 | return ( 25 | 26 | 27 | {children} 28 | 29 | 30 | ) 31 | } 32 | -------------------------------------------------------------------------------- /components/ProjectListItem.tsx: -------------------------------------------------------------------------------- 1 | import {CustomPortableText} from '@/components/CustomPortableText' 2 | import ImageBox from '@/components/ImageBox' 3 | import type {ShowcaseProject} from '@/types' 4 | import type {PortableTextBlock} from 'next-sanity' 5 | 6 | interface ProjectProps { 7 | project: ShowcaseProject 8 | } 9 | 10 | export function ProjectListItem(props: ProjectProps) { 11 | const {project} = props 12 | 13 | return ( 14 | <> 15 |
16 | 21 |
22 |
23 | 24 |
25 | 26 | ) 27 | } 28 | 29 | function TextBox({project}: {project: ShowcaseProject}) { 30 | console.log(project) 31 | return ( 32 |
33 |
34 | {/* Title */} 35 |
36 | {project.title} 37 |
38 | {/* Overview */} 39 |
40 | 46 |
47 |
48 | {/* Tags */} 49 |
50 | {project.tags?.map((tag, key) => ( 51 |
52 | #{tag} 53 |
54 | ))} 55 |
56 |
57 | ) 58 | } 59 | -------------------------------------------------------------------------------- /components/TimelineItem.tsx: -------------------------------------------------------------------------------- 1 | import ImageBox from '@/components/ImageBox' 2 | import type {MilestoneItem} from '@/types' 3 | 4 | export function TimelineItem({milestone}: {milestone: MilestoneItem}) { 5 | const {description, duration, image, tags, title} = milestone 6 | const startYear = duration?.start ? new Date(duration.start).getFullYear() : undefined 7 | const endYear = duration?.end ? new Date(duration.end).getFullYear() : 'Now' 8 | 9 | return ( 10 |
11 |
12 | {/* Thumbnail */} 13 |
17 | 24 |
25 | {/* Vertical line */} 26 |
27 |
28 |
29 | {/* Title */} 30 |
{title}
31 | {/* Tags */} 32 |
33 | {tags?.map((tag, key) => ( 34 | 35 | {tag} 36 | 37 | 38 | ))} 39 | {startYear} - {endYear} 40 |
41 | {/* Description */} 42 |
{description}
43 |
44 |
45 | ) 46 | } 47 | -------------------------------------------------------------------------------- /components/TimelineSection.tsx: -------------------------------------------------------------------------------- 1 | import {TimelineItem} from '@/components/TimelineItem' 2 | import {studioUrl} from '@/sanity/lib/api' 3 | import type {MilestoneItem} from '@/types' 4 | import type {StudioPathLike} from '@sanity/client/csm' 5 | import {createDataAttribute, stegaClean, type CreateDataAttribute} from 'next-sanity' 6 | import {OptimisticSortOrder} from './OptimisticSortOrder' 7 | 8 | interface TimelineItem { 9 | _key: string 10 | title: string 11 | milestones: MilestoneItem[] 12 | } 13 | 14 | export function TimelineSection({ 15 | timelines, 16 | id, 17 | type, 18 | path, 19 | }: { 20 | timelines: TimelineItem[] 21 | id: string | null 22 | type: string | null 23 | path: StudioPathLike 24 | }) { 25 | const dataAttribute = 26 | id && type 27 | ? createDataAttribute({ 28 | baseUrl: studioUrl, 29 | id, 30 | type, 31 | path, 32 | }) 33 | : null 34 | 35 | return ( 36 |
40 | 41 | {timelines?.map((timeline) => { 42 | const {title, milestones, _key} = timeline 43 | return ( 44 |
49 |
{stegaClean(title)}
50 | 51 | {milestones?.map((experience) => ( 52 |
57 | 58 |
59 | ))} 60 |
61 |
62 | ) 63 | })} 64 |
65 |
66 | ) 67 | } 68 | -------------------------------------------------------------------------------- /intro-template/cover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sanity-io/template-nextjs-personal-website/4f677caaf45349d4ad54c12f5936123aa213cd72/intro-template/cover.png -------------------------------------------------------------------------------- /intro-template/index.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import {studioUrl} from '@/sanity/lib/api' 4 | import Image from 'next/image' 5 | import Link from 'next/link' 6 | import {usePathname} from 'next/navigation' 7 | import {useSyncExternalStore} from 'react' 8 | import cover from './cover.png' 9 | 10 | const subscribe = () => () => {} 11 | function useAfterHydration( 12 | getSnapshot: () => Snapshot, 13 | serverSnapshot: Snapshot, 14 | ): Snapshot { 15 | return useSyncExternalStore(subscribe, getSnapshot, () => serverSnapshot) 16 | } 17 | 18 | export default function IntroTemplate() { 19 | const studioURL = useAfterHydration(() => `${location.origin}${studioUrl}`, null) 20 | const isLocalHost = useAfterHydration(() => window.location.hostname === 'localhost', false) 21 | const hasUTMtags = useAfterHydration(() => window.location.search.includes('utm'), false) 22 | const pathname = usePathname() 23 | 24 | const hasEnvFile = process.env.NEXT_PUBLIC_SANITY_PROJECT_ID 25 | const hasRepoEnvVars = 26 | process.env.NEXT_PUBLIC_VERCEL_GIT_PROVIDER && 27 | process.env.NEXT_PUBLIC_VERCEL_GIT_REPO_OWNER && 28 | process.env.NEXT_PUBLIC_VERCEL_GIT_REPO_SLUG 29 | 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}` 30 | const removeBlockURL = hasRepoEnvVars 31 | ? `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#how-can-i-remove-the-next-steps-block-from-my-app` 32 | : `https://github.com/sanity-io/template-nextjs-clean#how-can-i-remove-the-next-steps-block-from-my-app` 33 | 34 | // Only display this on the home page 35 | if (pathname !== '/') { 36 | return null 37 | } 38 | 39 | if (hasUTMtags || !studioURL) { 40 | return null 41 | } 42 | 43 | return ( 44 |
45 |
46 |
47 | An illustration of a browser window, a terminal window, the Sanity.io logo and the NextJS logo 51 |
52 | 53 |
54 |
55 | 56 |
57 |

Next steps

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

63 | 71 | {`Here's how to set them up locally`} 72 | 73 |

74 |
75 | )} 76 | 77 |
    78 | 82 |
    Create a schema
    83 | 84 | {isLocalHost ? ( 85 |
    86 | Start editing your content structure in 87 |
    88 |
    sanity.config.ts
    89 |
    90 |
    91 | ) : ( 92 | <> 93 |
    94 | Your code can be found at 95 | 101 | {repoURL} 102 | 103 |
    104 | 105 | 115 | 116 | )} 117 |
118 | } 119 | /> 120 | 121 | 125 |
126 | Create content with Sanity Studio 127 |
128 |
129 | Your Sanity Studio is deployed at 130 | 131 | {studioURL} 132 | 133 |
134 | 135 |
136 | 140 | Go to Sanity Studio 141 | 142 |
143 |
144 | } 145 | /> 146 | 147 | 151 |
Learn more and get help
152 |
    153 |
  • 154 | 155 |
  • 156 |
  • 157 | 158 |
  • 159 |
  • 160 | 161 |
  • 162 |
163 |
164 | } 165 | /> 166 | 167 |
168 | 169 |
170 |
171 |
172 |
173 | ) 174 | } 175 | 176 | function Box({circleTitle, element}: {circleTitle: string; element: React.JSX.Element}) { 177 | return ( 178 |
  • 179 |
    180 |
    181 | {circleTitle} 182 |
    183 |
    184 | {element} 185 |
  • 186 | ) 187 | } 188 | 189 | function BlueLink({href, text}: {href: string; text: string}) { 190 | return ( 191 | 197 | {text} 198 | 199 | ) 200 | } 201 | 202 | const RemoveBlock = ({url}: {url: string}) => ( 203 | 204 | How to remove this block? 205 | 206 | ) 207 | 208 | function getGitProvider() { 209 | switch (process.env.NEXT_PUBLIC_VERCEL_GIT_PROVIDER) { 210 | case 'gitlab': 211 | return 'GitLab' 212 | case 'bitbucket': 213 | return 'Bitbucket' 214 | default: 215 | return 'GitHub' 216 | } 217 | } 218 | -------------------------------------------------------------------------------- /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 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. 6 | -------------------------------------------------------------------------------- /next.config.ts: -------------------------------------------------------------------------------- 1 | import {NextConfig} from 'next' 2 | 3 | const config: NextConfig = { 4 | // Helps catch bugs 5 | reactStrictMode: true, 6 | experimental: { 7 | // Speeds up performance by automatically generating useMemo and useCallback in client components 8 | reactCompiler: true, 9 | }, 10 | images: { 11 | remotePatterns: [{hostname: 'cdn.sanity.io'}], 12 | }, 13 | typescript: { 14 | // Set this to false if you want production builds to abort if there's type errors 15 | ignoreBuildErrors: process.env.VERCEL_ENV === 'production', 16 | }, 17 | eslint: { 18 | /// Set this to false if you want production builds to abort if there's lint errors 19 | ignoreDuringBuilds: process.env.VERCEL_ENV === 'production', 20 | }, 21 | logging: { 22 | fetches: { 23 | fullUrl: true, 24 | }, 25 | }, 26 | env: { 27 | // Matches the behavior of `sanity dev` which sets styled-components to use the fastest way of inserting CSS rules in both dev and production. It's default behavior is to disable it in dev mode. 28 | SC_DISABLE_SPEEDY: 'false', 29 | }, 30 | } 31 | 32 | export default config 33 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sanity-template-template-nextjs-personal-website", 3 | "private": true, 4 | "scripts": { 5 | "build": "next build && sanity manifest extract --path public/studio/static", 6 | "predev": "npm run typegen", 7 | "dev": "next --turbopack", 8 | "format": "npx prettier --write . --ignore-path .gitignore", 9 | "lint": "next lint .", 10 | "lint:fix": "npm run format && npm run lint -- --fix", 11 | "start": "next start", 12 | "type-check": "tsc --noEmit", 13 | "typegen": "sanity schema extract && sanity typegen generate" 14 | }, 15 | "dependencies": { 16 | "@next/env": "15.3.3", 17 | "@sanity/client": "7.4.1", 18 | "@sanity/demo": "2.0.0", 19 | "@sanity/icons": "3.7.0", 20 | "@sanity/image-url": "1.1.0", 21 | "@sanity/ui": "2.15.18", 22 | "@sanity/util": "3.91.0", 23 | "@sanity/vision": "3.91.0", 24 | "@tailwindcss/typography": "0.5.16", 25 | "classnames": "2.5.1", 26 | "date-fns": "4.1.0", 27 | "next": "15.3.3", 28 | "next-sanity": "9.12.0", 29 | "react": "19.1.0", 30 | "react-dom": "19.1.0", 31 | "react-live-transitions": "0.2.0", 32 | "rxjs": "7.8.2", 33 | "sanity": "3.91.0", 34 | "sanity-plugin-asset-source-unsplash": "3.0.3", 35 | "server-only": "0.0.1", 36 | "sonner": "2.0.5", 37 | "styled-components": "6.1.18" 38 | }, 39 | "devDependencies": { 40 | "@ianvs/prettier-plugin-sort-imports": "4.4.2", 41 | "@sanity/prettier-config": "1.0.3", 42 | "@types/react": "19.1.6", 43 | "autoprefixer": "10.4.21", 44 | "babel-plugin-react-compiler": "beta", 45 | "eslint": "9.28.0", 46 | "eslint-config-next": "15.3.3", 47 | "eslint-plugin-react-compiler": "beta", 48 | "postcss": "8.5.4", 49 | "prettier": "3.5.3", 50 | "prettier-plugin-tailwindcss": "0.6.12", 51 | "tailwindcss": "3.4.17", 52 | "typescript": "5.8.3" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /prettier.config.cjs: -------------------------------------------------------------------------------- 1 | const preset = require('@sanity/prettier-config') 2 | 3 | module.exports = { 4 | ...preset, 5 | plugins: [ 6 | ...preset.plugins, 7 | 'prettier-plugin-tailwindcss', 8 | '@ianvs/prettier-plugin-sort-imports', 9 | ], 10 | } 11 | -------------------------------------------------------------------------------- /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 | /** 4 | * This config is used to set up Sanity Studio that's mounted on the `app/studio/[[...index]]/page.tsx` route 5 | */ 6 | import {apiVersion, dataset, projectId, studioUrl} from '@/sanity/lib/api' 7 | import * as resolve from '@/sanity/plugins/resolve' 8 | import {pageStructure, singletonPlugin} from '@/sanity/plugins/settings' 9 | import page from '@/sanity/schemas/documents/page' 10 | import project from '@/sanity/schemas/documents/project' 11 | import duration from '@/sanity/schemas/objects/duration' 12 | import milestone from '@/sanity/schemas/objects/milestone' 13 | import timeline from '@/sanity/schemas/objects/timeline' 14 | import home from '@/sanity/schemas/singletons/home' 15 | import settings from '@/sanity/schemas/singletons/settings' 16 | import {visionTool} from '@sanity/vision' 17 | import {defineConfig} from 'sanity' 18 | import {unsplashImageAsset} from 'sanity-plugin-asset-source-unsplash' 19 | import {presentationTool} from 'sanity/presentation' 20 | import {structureTool} from 'sanity/structure' 21 | 22 | const title = 23 | process.env.NEXT_PUBLIC_SANITY_PROJECT_TITLE || 'Next.js Personal Website with Sanity.io' 24 | 25 | export default defineConfig({ 26 | basePath: studioUrl, 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 | ], 44 | }, 45 | plugins: [ 46 | structureTool({ 47 | structure: pageStructure([home, settings]), 48 | }), 49 | presentationTool({ 50 | resolve, 51 | previewUrl: {previewMode: {enable: '/api/draft-mode/enable'}}, 52 | }), 53 | // Configures the global "new document" button, and document actions, to suit the Settings document singleton 54 | singletonPlugin([home.name, settings.name]), 55 | // Add an image asset source for Unsplash 56 | unsplashImageAsset(), 57 | // Vision lets you query your content with GROQ in the studio 58 | // https://www.sanity.io/docs/the-vision-plugin 59 | visionTool({defaultApiVersion: apiVersion}), 60 | ], 61 | }) 62 | -------------------------------------------------------------------------------- /sanity.types.ts: -------------------------------------------------------------------------------- 1 | // Query TypeMap 2 | import '@sanity/client' 3 | 4 | /** 5 | * --------------------------------------------------------------------------------- 6 | * This file has been generated by Sanity TypeGen. 7 | * Command: `sanity typegen generate` 8 | * 9 | * Any modifications made directly to this file will be overwritten the next time 10 | * the TypeScript definitions are generated. Please make changes to the Sanity 11 | * schema definitions and/or GROQ queries if you need to update these types. 12 | * 13 | * For more information on how to use Sanity TypeGen, visit the official documentation: 14 | * https://www.sanity.io/docs/sanity-typegen 15 | * --------------------------------------------------------------------------------- 16 | */ 17 | 18 | // Source: schema.json 19 | export type Timeline = { 20 | _type: 'timeline' 21 | items?: Array<{ 22 | title?: string 23 | milestones?: Array< 24 | { 25 | _key: string 26 | } & Milestone 27 | > 28 | _type: 'item' 29 | _key: string 30 | }> 31 | } 32 | 33 | export type Milestone = { 34 | _type: 'milestone' 35 | title?: string 36 | description?: string 37 | image?: { 38 | asset?: { 39 | _ref: string 40 | _type: 'reference' 41 | _weak?: boolean 42 | [internalGroqTypeReferenceTo]?: 'sanity.imageAsset' 43 | } 44 | media?: unknown 45 | hotspot?: SanityImageHotspot 46 | crop?: SanityImageCrop 47 | _type: 'image' 48 | } 49 | tags?: Array 50 | duration?: Duration 51 | } 52 | 53 | export type Project = { 54 | _id: string 55 | _type: 'project' 56 | _createdAt: string 57 | _updatedAt: string 58 | _rev: string 59 | title?: string 60 | slug?: Slug 61 | overview?: Array<{ 62 | children?: Array<{ 63 | marks?: Array 64 | text?: string 65 | _type: 'span' 66 | _key: string 67 | }> 68 | style?: 'normal' 69 | listItem?: never 70 | markDefs?: null 71 | level?: number 72 | _type: 'block' 73 | _key: string 74 | }> 75 | coverImage?: { 76 | asset?: { 77 | _ref: string 78 | _type: 'reference' 79 | _weak?: boolean 80 | [internalGroqTypeReferenceTo]?: 'sanity.imageAsset' 81 | } 82 | media?: unknown 83 | hotspot?: SanityImageHotspot 84 | crop?: SanityImageCrop 85 | _type: 'image' 86 | } 87 | duration?: Duration 88 | client?: string 89 | site?: string 90 | tags?: Array 91 | description?: Array< 92 | | { 93 | children?: Array<{ 94 | marks?: Array 95 | text?: string 96 | _type: 'span' 97 | _key: string 98 | }> 99 | style?: 'normal' 100 | listItem?: 'bullet' | 'number' 101 | markDefs?: Array<{ 102 | href?: string 103 | _type: 'link' 104 | _key: string 105 | }> 106 | level?: number 107 | _type: 'block' 108 | _key: string 109 | } 110 | | ({ 111 | _key: string 112 | } & Timeline) 113 | | { 114 | asset?: { 115 | _ref: string 116 | _type: 'reference' 117 | _weak?: boolean 118 | [internalGroqTypeReferenceTo]?: 'sanity.imageAsset' 119 | } 120 | media?: unknown 121 | hotspot?: SanityImageHotspot 122 | crop?: SanityImageCrop 123 | caption?: string 124 | alt?: string 125 | _type: 'image' 126 | _key: string 127 | } 128 | > 129 | } 130 | 131 | export type Page = { 132 | _id: string 133 | _type: 'page' 134 | _createdAt: string 135 | _updatedAt: string 136 | _rev: string 137 | title?: string 138 | slug?: Slug 139 | overview?: Array<{ 140 | children?: Array<{ 141 | marks?: Array 142 | text?: string 143 | _type: 'span' 144 | _key: string 145 | }> 146 | style?: 'normal' 147 | listItem?: never 148 | markDefs?: null 149 | level?: number 150 | _type: 'block' 151 | _key: string 152 | }> 153 | body?: Array< 154 | | { 155 | children?: Array<{ 156 | marks?: Array 157 | text?: string 158 | _type: 'span' 159 | _key: string 160 | }> 161 | style?: 'normal' 162 | listItem?: 'bullet' | 'number' 163 | markDefs?: Array<{ 164 | href?: string 165 | _type: 'link' 166 | _key: string 167 | }> 168 | level?: number 169 | _type: 'block' 170 | _key: string 171 | } 172 | | ({ 173 | _key: string 174 | } & Timeline) 175 | | { 176 | asset?: { 177 | _ref: string 178 | _type: 'reference' 179 | _weak?: boolean 180 | [internalGroqTypeReferenceTo]?: 'sanity.imageAsset' 181 | } 182 | media?: unknown 183 | hotspot?: SanityImageHotspot 184 | crop?: SanityImageCrop 185 | caption?: string 186 | alt?: string 187 | _type: 'image' 188 | _key: string 189 | } 190 | > 191 | } 192 | 193 | export type Duration = { 194 | _type: 'duration' 195 | start?: string 196 | end?: string 197 | } 198 | 199 | export type Settings = { 200 | _id: string 201 | _type: 'settings' 202 | _createdAt: string 203 | _updatedAt: string 204 | _rev: string 205 | menuItems?: Array< 206 | | { 207 | _ref: string 208 | _type: 'reference' 209 | _weak?: boolean 210 | [internalGroqTypeReferenceTo]?: 'home' 211 | } 212 | | { 213 | _ref: string 214 | _type: 'reference' 215 | _weak?: boolean 216 | [internalGroqTypeReferenceTo]?: 'page' 217 | } 218 | | { 219 | _ref: string 220 | _type: 'reference' 221 | _weak?: boolean 222 | [internalGroqTypeReferenceTo]?: 'project' 223 | } 224 | > 225 | footer?: Array<{ 226 | children?: Array<{ 227 | marks?: Array 228 | text?: string 229 | _type: 'span' 230 | _key: string 231 | }> 232 | style?: 'normal' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'blockquote' 233 | listItem?: 'bullet' | 'number' 234 | markDefs?: Array<{ 235 | href?: string 236 | _type: 'link' 237 | _key: string 238 | }> 239 | level?: number 240 | _type: 'block' 241 | _key: string 242 | }> 243 | ogImage?: { 244 | asset?: { 245 | _ref: string 246 | _type: 'reference' 247 | _weak?: boolean 248 | [internalGroqTypeReferenceTo]?: 'sanity.imageAsset' 249 | } 250 | media?: unknown 251 | hotspot?: SanityImageHotspot 252 | crop?: SanityImageCrop 253 | _type: 'image' 254 | } 255 | } 256 | 257 | export type Home = { 258 | _id: string 259 | _type: 'home' 260 | _createdAt: string 261 | _updatedAt: string 262 | _rev: string 263 | title?: string 264 | overview?: Array<{ 265 | children?: Array<{ 266 | marks?: Array 267 | text?: string 268 | _type: 'span' 269 | _key: string 270 | }> 271 | style?: 'normal' 272 | listItem?: never 273 | markDefs?: Array<{ 274 | href?: string 275 | _type: 'link' 276 | _key: string 277 | }> 278 | level?: number 279 | _type: 'block' 280 | _key: string 281 | }> 282 | showcaseProjects?: Array<{ 283 | _ref: string 284 | _type: 'reference' 285 | _weak?: boolean 286 | _key: string 287 | [internalGroqTypeReferenceTo]?: 'project' 288 | }> 289 | } 290 | 291 | export type SanityImagePaletteSwatch = { 292 | _type: 'sanity.imagePaletteSwatch' 293 | background?: string 294 | foreground?: string 295 | population?: number 296 | title?: string 297 | } 298 | 299 | export type SanityImagePalette = { 300 | _type: 'sanity.imagePalette' 301 | darkMuted?: SanityImagePaletteSwatch 302 | lightVibrant?: SanityImagePaletteSwatch 303 | darkVibrant?: SanityImagePaletteSwatch 304 | vibrant?: SanityImagePaletteSwatch 305 | dominant?: SanityImagePaletteSwatch 306 | lightMuted?: SanityImagePaletteSwatch 307 | muted?: SanityImagePaletteSwatch 308 | } 309 | 310 | export type SanityImageDimensions = { 311 | _type: 'sanity.imageDimensions' 312 | height?: number 313 | width?: number 314 | aspectRatio?: number 315 | } 316 | 317 | export type SanityImageHotspot = { 318 | _type: 'sanity.imageHotspot' 319 | x?: number 320 | y?: number 321 | height?: number 322 | width?: number 323 | } 324 | 325 | export type SanityImageCrop = { 326 | _type: 'sanity.imageCrop' 327 | top?: number 328 | bottom?: number 329 | left?: number 330 | right?: number 331 | } 332 | 333 | export type SanityFileAsset = { 334 | _id: string 335 | _type: 'sanity.fileAsset' 336 | _createdAt: string 337 | _updatedAt: string 338 | _rev: string 339 | originalFilename?: string 340 | label?: string 341 | title?: string 342 | description?: string 343 | altText?: string 344 | sha1hash?: string 345 | extension?: string 346 | mimeType?: string 347 | size?: number 348 | assetId?: string 349 | uploadId?: string 350 | path?: string 351 | url?: string 352 | source?: SanityAssetSourceData 353 | } 354 | 355 | export type SanityImageAsset = { 356 | _id: string 357 | _type: 'sanity.imageAsset' 358 | _createdAt: string 359 | _updatedAt: string 360 | _rev: string 361 | originalFilename?: string 362 | label?: string 363 | title?: string 364 | description?: string 365 | altText?: string 366 | sha1hash?: string 367 | extension?: string 368 | mimeType?: string 369 | size?: number 370 | assetId?: string 371 | uploadId?: string 372 | path?: string 373 | url?: string 374 | metadata?: SanityImageMetadata 375 | source?: SanityAssetSourceData 376 | } 377 | 378 | export type SanityImageMetadata = { 379 | _type: 'sanity.imageMetadata' 380 | location?: Geopoint 381 | dimensions?: SanityImageDimensions 382 | palette?: SanityImagePalette 383 | lqip?: string 384 | blurHash?: string 385 | hasAlpha?: boolean 386 | isOpaque?: boolean 387 | } 388 | 389 | export type Geopoint = { 390 | _type: 'geopoint' 391 | lat?: number 392 | lng?: number 393 | alt?: number 394 | } 395 | 396 | export type Slug = { 397 | _type: 'slug' 398 | current?: string 399 | source?: string 400 | } 401 | 402 | export type SanityAssetSourceData = { 403 | _type: 'sanity.assetSourceData' 404 | name?: string 405 | id?: string 406 | url?: string 407 | } 408 | 409 | export type AllSanitySchemaTypes = 410 | | Timeline 411 | | Milestone 412 | | Project 413 | | Page 414 | | Duration 415 | | Settings 416 | | Home 417 | | SanityImagePaletteSwatch 418 | | SanityImagePalette 419 | | SanityImageDimensions 420 | | SanityImageHotspot 421 | | SanityImageCrop 422 | | SanityFileAsset 423 | | SanityImageAsset 424 | | SanityImageMetadata 425 | | Geopoint 426 | | Slug 427 | | SanityAssetSourceData 428 | export declare const internalGroqTypeReferenceTo: unique symbol 429 | // Source: ./sanity/lib/queries.ts 430 | // Variable: homePageQuery 431 | // Query: *[_type == "home"][0]{ _id, _type, overview, showcaseProjects[]{ _key, ...@->{ _id, _type, coverImage, overview, "slug": slug.current, tags, title, } }, title, } 432 | export type HomePageQueryResult = { 433 | _id: string 434 | _type: 'home' 435 | overview: Array<{ 436 | children?: Array<{ 437 | marks?: Array 438 | text?: string 439 | _type: 'span' 440 | _key: string 441 | }> 442 | style?: 'normal' 443 | listItem?: never 444 | markDefs?: Array<{ 445 | href?: string 446 | _type: 'link' 447 | _key: string 448 | }> 449 | level?: number 450 | _type: 'block' 451 | _key: string 452 | }> | null 453 | showcaseProjects: Array<{ 454 | _key: string 455 | _id: string 456 | _type: 'project' 457 | coverImage: { 458 | asset?: { 459 | _ref: string 460 | _type: 'reference' 461 | _weak?: boolean 462 | [internalGroqTypeReferenceTo]?: 'sanity.imageAsset' 463 | } 464 | media?: unknown 465 | hotspot?: SanityImageHotspot 466 | crop?: SanityImageCrop 467 | _type: 'image' 468 | } | null 469 | overview: Array<{ 470 | children?: Array<{ 471 | marks?: Array 472 | text?: string 473 | _type: 'span' 474 | _key: string 475 | }> 476 | style?: 'normal' 477 | listItem?: never 478 | markDefs?: null 479 | level?: number 480 | _type: 'block' 481 | _key: string 482 | }> | null 483 | slug: string | null 484 | tags: Array | null 485 | title: string | null 486 | }> | null 487 | title: string | null 488 | } | null 489 | // Variable: pagesBySlugQuery 490 | // Query: *[_type == "page" && slug.current == $slug][0] { _id, _type, body, overview, title, "slug": slug.current, } 491 | export type PagesBySlugQueryResult = { 492 | _id: string 493 | _type: 'page' 494 | body: Array< 495 | | ({ 496 | _key: string 497 | } & Timeline) 498 | | { 499 | children?: Array<{ 500 | marks?: Array 501 | text?: string 502 | _type: 'span' 503 | _key: string 504 | }> 505 | style?: 'normal' 506 | listItem?: 'bullet' | 'number' 507 | markDefs?: Array<{ 508 | href?: string 509 | _type: 'link' 510 | _key: string 511 | }> 512 | level?: number 513 | _type: 'block' 514 | _key: string 515 | } 516 | | { 517 | asset?: { 518 | _ref: string 519 | _type: 'reference' 520 | _weak?: boolean 521 | [internalGroqTypeReferenceTo]?: 'sanity.imageAsset' 522 | } 523 | media?: unknown 524 | hotspot?: SanityImageHotspot 525 | crop?: SanityImageCrop 526 | caption?: string 527 | alt?: string 528 | _type: 'image' 529 | _key: string 530 | } 531 | > | null 532 | overview: Array<{ 533 | children?: Array<{ 534 | marks?: Array 535 | text?: string 536 | _type: 'span' 537 | _key: string 538 | }> 539 | style?: 'normal' 540 | listItem?: never 541 | markDefs?: null 542 | level?: number 543 | _type: 'block' 544 | _key: string 545 | }> | null 546 | title: string | null 547 | slug: string | null 548 | } | null 549 | // Variable: projectBySlugQuery 550 | // Query: *[_type == "project" && slug.current == $slug][0] { _id, _type, client, coverImage, description, duration, overview, site, "slug": slug.current, tags, title, } 551 | export type ProjectBySlugQueryResult = { 552 | _id: string 553 | _type: 'project' 554 | client: string | null 555 | coverImage: { 556 | asset?: { 557 | _ref: string 558 | _type: 'reference' 559 | _weak?: boolean 560 | [internalGroqTypeReferenceTo]?: 'sanity.imageAsset' 561 | } 562 | media?: unknown 563 | hotspot?: SanityImageHotspot 564 | crop?: SanityImageCrop 565 | _type: 'image' 566 | } | null 567 | description: Array< 568 | | ({ 569 | _key: string 570 | } & Timeline) 571 | | { 572 | children?: Array<{ 573 | marks?: Array 574 | text?: string 575 | _type: 'span' 576 | _key: string 577 | }> 578 | style?: 'normal' 579 | listItem?: 'bullet' | 'number' 580 | markDefs?: Array<{ 581 | href?: string 582 | _type: 'link' 583 | _key: string 584 | }> 585 | level?: number 586 | _type: 'block' 587 | _key: string 588 | } 589 | | { 590 | asset?: { 591 | _ref: string 592 | _type: 'reference' 593 | _weak?: boolean 594 | [internalGroqTypeReferenceTo]?: 'sanity.imageAsset' 595 | } 596 | media?: unknown 597 | hotspot?: SanityImageHotspot 598 | crop?: SanityImageCrop 599 | caption?: string 600 | alt?: string 601 | _type: 'image' 602 | _key: string 603 | } 604 | > | null 605 | duration: Duration | null 606 | overview: Array<{ 607 | children?: Array<{ 608 | marks?: Array 609 | text?: string 610 | _type: 'span' 611 | _key: string 612 | }> 613 | style?: 'normal' 614 | listItem?: never 615 | markDefs?: null 616 | level?: number 617 | _type: 'block' 618 | _key: string 619 | }> | null 620 | site: string | null 621 | slug: string | null 622 | tags: Array | null 623 | title: string | null 624 | } | null 625 | // Variable: settingsQuery 626 | // Query: *[_type == "settings"][0]{ _id, _type, footer, menuItems[]{ _key, ...@->{ _type, "slug": slug.current, title } }, ogImage, } 627 | export type SettingsQueryResult = { 628 | _id: string 629 | _type: 'settings' 630 | footer: Array<{ 631 | children?: Array<{ 632 | marks?: Array 633 | text?: string 634 | _type: 'span' 635 | _key: string 636 | }> 637 | style?: 'blockquote' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'normal' 638 | listItem?: 'bullet' | 'number' 639 | markDefs?: Array<{ 640 | href?: string 641 | _type: 'link' 642 | _key: string 643 | }> 644 | level?: number 645 | _type: 'block' 646 | _key: string 647 | }> | null 648 | menuItems: Array< 649 | | { 650 | _key: null 651 | _type: 'home' 652 | slug: null 653 | title: string | null 654 | } 655 | | { 656 | _key: null 657 | _type: 'page' 658 | slug: string | null 659 | title: string | null 660 | } 661 | | { 662 | _key: null 663 | _type: 'project' 664 | slug: string | null 665 | title: string | null 666 | } 667 | > | null 668 | ogImage: { 669 | asset?: { 670 | _ref: string 671 | _type: 'reference' 672 | _weak?: boolean 673 | [internalGroqTypeReferenceTo]?: 'sanity.imageAsset' 674 | } 675 | media?: unknown 676 | hotspot?: SanityImageHotspot 677 | crop?: SanityImageCrop 678 | _type: 'image' 679 | } | null 680 | } | null 681 | // Variable: slugsByTypeQuery 682 | // Query: *[_type == $type && defined(slug.current)]{"slug": slug.current} 683 | export type SlugsByTypeQueryResult = Array<{ 684 | slug: string | null 685 | }> 686 | 687 | declare module '@sanity/client' { 688 | interface SanityQueries { 689 | '\n *[_type == "home"][0]{\n _id,\n _type,\n overview,\n showcaseProjects[]{\n _key,\n ...@->{\n _id,\n _type,\n coverImage,\n overview,\n "slug": slug.current,\n tags,\n title,\n }\n },\n title,\n }\n': HomePageQueryResult 690 | '\n *[_type == "page" && slug.current == $slug][0] {\n _id,\n _type,\n body,\n overview,\n title,\n "slug": slug.current,\n }\n': PagesBySlugQueryResult 691 | '\n *[_type == "project" && slug.current == $slug][0] {\n _id,\n _type,\n client,\n coverImage,\n description,\n duration,\n overview,\n site,\n "slug": slug.current,\n tags,\n title,\n }\n': ProjectBySlugQueryResult 692 | '\n *[_type == "settings"][0]{\n _id,\n _type,\n footer,\n menuItems[]{\n _key,\n ...@->{\n _type,\n "slug": slug.current,\n title\n }\n },\n ogImage,\n }\n': SettingsQueryResult 693 | '\n *[_type == $type && defined(slug.current)]{"slug": slug.current}\n': SlugsByTypeQueryResult 694 | } 695 | } 696 | -------------------------------------------------------------------------------- /sanity/lib/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 | // see https://www.sanity.io/docs/api-versioning for how versioning works 17 | export const apiVersion = process.env.NEXT_PUBLIC_SANITY_API_VERSION || '2025-02-27' 18 | 19 | function assertValue(v: T | undefined, errorMessage: string): T { 20 | if (v === undefined) { 21 | throw new Error(errorMessage) 22 | } 23 | 24 | return v 25 | } 26 | /** 27 | * Used to configure edit intent links, for Presentation Mode, as well as to configure where the Studio is mounted in the router. 28 | */ 29 | export const studioUrl = '/studio' 30 | -------------------------------------------------------------------------------- /sanity/lib/client.ts: -------------------------------------------------------------------------------- 1 | import {apiVersion, dataset, projectId, studioUrl} from '@/sanity/lib/api' 2 | import {createClient} from 'next-sanity' 3 | 4 | export const client = createClient({ 5 | projectId, 6 | dataset, 7 | apiVersion, 8 | useCdn: true, 9 | perspective: 'published', 10 | stega: { 11 | studioUrl, 12 | logger: console, 13 | filter: (props) => { 14 | if (props.sourcePath.at(-1) === 'title') { 15 | return true 16 | } 17 | 18 | return props.filterDefault(props) 19 | }, 20 | }, 21 | }) 22 | 23 | console.warn( 24 | 'This template is using stega to embed Content Source Maps, see more information here: https://www.sanity.io/docs/loaders-and-overlays#26cf681fadd4', 25 | ) 26 | -------------------------------------------------------------------------------- /sanity/lib/live.ts: -------------------------------------------------------------------------------- 1 | import {defineLive} from 'next-sanity' 2 | import {client} from './client' 3 | import {token} from './token' 4 | 5 | export const {SanityLive, sanityFetch} = defineLive({ 6 | client, 7 | serverToken: token, 8 | browserToken: token, 9 | }) 10 | -------------------------------------------------------------------------------- /sanity/lib/queries.ts: -------------------------------------------------------------------------------- 1 | import {defineQuery} from 'next-sanity' 2 | 3 | export const homePageQuery = defineQuery(` 4 | *[_type == "home"][0]{ 5 | _id, 6 | _type, 7 | overview, 8 | showcaseProjects[]{ 9 | _key, 10 | ...@->{ 11 | _id, 12 | _type, 13 | coverImage, 14 | overview, 15 | "slug": slug.current, 16 | tags, 17 | title, 18 | } 19 | }, 20 | title, 21 | } 22 | `) 23 | 24 | export const pagesBySlugQuery = defineQuery(` 25 | *[_type == "page" && slug.current == $slug][0] { 26 | _id, 27 | _type, 28 | body, 29 | overview, 30 | title, 31 | "slug": slug.current, 32 | } 33 | `) 34 | 35 | export const projectBySlugQuery = defineQuery(` 36 | *[_type == "project" && slug.current == $slug][0] { 37 | _id, 38 | _type, 39 | client, 40 | coverImage, 41 | description, 42 | duration, 43 | overview, 44 | site, 45 | "slug": slug.current, 46 | tags, 47 | title, 48 | } 49 | `) 50 | 51 | export const settingsQuery = defineQuery(` 52 | *[_type == "settings"][0]{ 53 | _id, 54 | _type, 55 | footer, 56 | menuItems[]{ 57 | _key, 58 | ...@->{ 59 | _type, 60 | "slug": slug.current, 61 | title 62 | } 63 | }, 64 | ogImage, 65 | } 66 | `) 67 | 68 | export const slugsByTypeQuery = defineQuery(` 69 | *[_type == $type && defined(slug.current)]{"slug": slug.current} 70 | `) 71 | -------------------------------------------------------------------------------- /sanity/lib/token.ts: -------------------------------------------------------------------------------- 1 | import 'server-only' 2 | 3 | export const token = process.env.SANITY_API_READ_TOKEN 4 | 5 | if (!token) { 6 | throw new Error('Missing SANITY_API_READ_TOKEN') 7 | } 8 | -------------------------------------------------------------------------------- /sanity/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import {dataset, projectId} from '@/sanity/lib/api' 2 | import createImageUrlBuilder from '@sanity/image-url' 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 | null | undefined) => { 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 | 19 | export function urlForOpenGraphImage(image: Image | null | undefined) { 20 | return urlForImage(image)?.width(1200).height(627).fit('crop').url() 21 | } 22 | 23 | export function resolveHref(documentType?: string, slug?: string | null): string | undefined { 24 | switch (documentType) { 25 | case 'home': 26 | return '/' 27 | case 'page': 28 | return slug ? `/${slug}` : undefined 29 | case 'project': 30 | return slug ? `/projects/${slug}` : undefined 31 | default: 32 | console.warn('Invalid document type:', documentType) 33 | return undefined 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /sanity/plugins/resolve.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Sets up the Presentation Resolver API, 3 | * see https://www.sanity.io/docs/presentation-resolver-api for more information. 4 | */ 5 | 6 | import {resolveHref} from '@/sanity/lib/utils' 7 | import {defineDocuments, defineLocations} from 'sanity/presentation' 8 | 9 | export const mainDocuments = defineDocuments([ 10 | { 11 | route: '/projects/:slug', 12 | filter: `_type == "project" && slug.current == $slug`, 13 | }, 14 | { 15 | route: '/:slug', 16 | filter: `_type == "page" && slug.current == $slug`, 17 | }, 18 | ]) 19 | 20 | export const locations = { 21 | settings: defineLocations({ 22 | message: 'This document is used on all pages', 23 | tone: 'caution', 24 | }), 25 | home: defineLocations({ 26 | message: 'This document is used to render the front page', 27 | tone: 'positive', 28 | locations: [{title: 'Home', href: resolveHref('home')!}], 29 | }), 30 | project: defineLocations({ 31 | select: {title: 'title', slug: 'slug.current'}, 32 | resolve: (doc) => ({ 33 | locations: [ 34 | { 35 | title: doc?.title || 'Untitled', 36 | href: resolveHref('project', doc?.slug)!, 37 | }, 38 | ], 39 | }), 40 | }), 41 | page: defineLocations({ 42 | select: {title: 'title', slug: 'slug.current'}, 43 | resolve: (doc) => ({ 44 | locations: [ 45 | { 46 | title: doc?.title || 'Untitled', 47 | href: resolveHref('page', doc?.slug)!, 48 | }, 49 | ], 50 | }), 51 | }), 52 | } 53 | -------------------------------------------------------------------------------- /sanity/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((templateItem) => !types.includes(templateItem.templateId)) 17 | } 18 | 19 | return prev 20 | }, 21 | // Removes the "duplicate" action on the Singletons (such as Home) 22 | actions: (prev, {schemaType}) => { 23 | if (types.includes(schemaType)) { 24 | return prev.filter(({action}) => action !== 'duplicate') 25 | } 26 | 27 | return prev 28 | }, 29 | }, 30 | } 31 | } 32 | 33 | // The StructureResolver is how we're changing the DeskTool structure to linking to document (named Singleton) 34 | // like how "Home" is handled. 35 | export const pageStructure = (typeDefArray: DocumentDefinition[]): StructureResolver => { 36 | return (S) => { 37 | // Goes through all of the singletons that were provided and translates them into something the 38 | // Desktool can understand 39 | const singletonItems = typeDefArray.map((typeDef) => { 40 | return S.listItem() 41 | .title(typeDef.title!) 42 | .icon(typeDef.icon) 43 | .child(S.editor().id(typeDef.name).schemaType(typeDef.name).documentId(typeDef.name)) 44 | }) 45 | 46 | // The default root list items (except custom ones) 47 | const defaultListItems = S.documentTypeListItems().filter( 48 | (listItem) => !typeDefArray.find((singleton) => singleton.name === listItem.getId()), 49 | ) 50 | 51 | return S.list() 52 | .title('Content') 53 | .items([...singletonItems, S.divider(), ...defaultListItems]) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /sanity/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 | media: 'asset', 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: 'Alternative text for screenreaders. Falls back on caption if not set', 112 | }), 113 | ], 114 | }), 115 | ], 116 | }), 117 | ], 118 | preview: { 119 | select: { 120 | title: 'title', 121 | }, 122 | prepare({title}) { 123 | return { 124 | subtitle: 'Page', 125 | title, 126 | } 127 | }, 128 | }, 129 | }) 130 | -------------------------------------------------------------------------------- /sanity/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: 'Used both for the description tag for SEO, and project subheader.', 33 | title: 'Overview', 34 | type: 'array', 35 | of: [ 36 | // Paragraphs 37 | defineArrayMember({ 38 | lists: [], 39 | marks: { 40 | annotations: [], 41 | decorators: [ 42 | { 43 | title: 'Italic', 44 | value: 'em', 45 | }, 46 | { 47 | title: 'Strong', 48 | value: 'strong', 49 | }, 50 | ], 51 | }, 52 | styles: [], 53 | type: 'block', 54 | }), 55 | ], 56 | validation: (rule) => rule.max(155).required(), 57 | }), 58 | defineField({ 59 | name: 'coverImage', 60 | title: 'Cover Image', 61 | description: 62 | '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.', 63 | type: 'image', 64 | options: { 65 | hotspot: true, 66 | }, 67 | validation: (rule) => rule.required(), 68 | }), 69 | defineField({ 70 | name: 'duration', 71 | title: 'Duration', 72 | type: 'duration', 73 | }), 74 | defineField({ 75 | name: 'client', 76 | title: 'Client', 77 | type: 'string', 78 | }), 79 | defineField({ 80 | name: 'site', 81 | title: 'Site', 82 | type: 'url', 83 | }), 84 | defineField({ 85 | name: 'tags', 86 | title: 'Tags', 87 | type: 'array', 88 | of: [{type: 'string'}], 89 | options: { 90 | layout: 'tags', 91 | }, 92 | }), 93 | defineField({ 94 | name: 'description', 95 | title: 'Project Description', 96 | type: 'array', 97 | of: [ 98 | defineArrayMember({ 99 | type: 'block', 100 | marks: { 101 | annotations: [ 102 | { 103 | name: 'link', 104 | type: 'object', 105 | title: 'Link', 106 | fields: [ 107 | { 108 | name: 'href', 109 | type: 'url', 110 | title: 'Url', 111 | }, 112 | ], 113 | }, 114 | ], 115 | }, 116 | styles: [], 117 | }), 118 | // Custom blocks 119 | defineArrayMember({ 120 | name: 'timeline', 121 | type: 'timeline', 122 | }), 123 | defineField({ 124 | type: 'image', 125 | icon: ImageIcon, 126 | name: 'image', 127 | title: 'Image', 128 | options: { 129 | hotspot: true, 130 | }, 131 | preview: { 132 | select: { 133 | media: 'asset', 134 | title: 'caption', 135 | }, 136 | }, 137 | fields: [ 138 | defineField({ 139 | title: 'Caption', 140 | name: 'caption', 141 | type: 'string', 142 | }), 143 | defineField({ 144 | name: 'alt', 145 | type: 'string', 146 | title: 'Alt text', 147 | description: 'Alternative text for screenreaders. Falls back on caption if not set', 148 | }), 149 | ], 150 | }), 151 | ], 152 | }), 153 | ], 154 | }) 155 | -------------------------------------------------------------------------------- /sanity/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 {FieldMember, MemberField, ObjectInputProps, RenderFieldCallback} from 'sanity' 5 | 6 | export function DurationInput(props: ObjectInputProps) { 7 | const {members, renderInput, renderItem, renderPreview} = props 8 | 9 | const fieldMembers = members.filter((mem) => mem.kind === 'field') as FieldMember[] 10 | const start = fieldMembers.find((mem) => mem.name === 'start') 11 | const end = fieldMembers.find((mem) => mem.name === 'end') 12 | 13 | const renderField: RenderFieldCallback = useCallback((props) => props.children, []) 14 | 15 | const renderProps = useMemo( 16 | () => ({renderField, renderInput, renderItem, renderPreview}), 17 | [renderField, renderInput, renderItem, renderPreview], 18 | ) 19 | 20 | return ( 21 | 22 | {start && } 23 | 24 | 25 | 26 | 27 | 28 | {end && } 29 | 30 | ) 31 | } 32 | -------------------------------------------------------------------------------- /sanity/schemas/objects/duration/index.ts: -------------------------------------------------------------------------------- 1 | import {defineField} from 'sanity' 2 | import {DurationInput} from './DurationInput' 3 | 4 | export default defineField({ 5 | type: 'object', 6 | name: 'duration', 7 | title: 'Duration', 8 | components: { 9 | input: DurationInput, 10 | }, 11 | fields: [ 12 | defineField({ 13 | type: 'datetime', 14 | name: 'start', 15 | title: 'Start', 16 | }), 17 | defineField({ 18 | type: 'datetime', 19 | name: 'end', 20 | title: 'End', 21 | }), 22 | ], 23 | }) 24 | -------------------------------------------------------------------------------- /sanity/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 | -------------------------------------------------------------------------------- /sanity/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 = hasItems && items.map((timeline) => timeline.title).join(', ') 47 | 48 | return { 49 | subtitle: hasItems 50 | ? `${milestoneNames} (${items.length} item${items.length > 1 ? 's' : ''})` 51 | : 'No milestones', 52 | title, 53 | } 54 | }, 55 | }, 56 | }, 57 | ], 58 | }, 59 | ], 60 | preview: { 61 | select: { 62 | items: 'items', 63 | }, 64 | prepare({items}: {items: {title: string}[]}) { 65 | const hasItems = items && items.length > 0 66 | const timelineNames = hasItems && items.map((timeline) => timeline.title).join(', ') 67 | 68 | return { 69 | title: 'Timelines', 70 | subtitle: hasItems 71 | ? `${timelineNames} (${items.length} item${items.length > 1 ? 's' : ''})` 72 | : 'No timelines', 73 | } 74 | }, 75 | }, 76 | }) 77 | -------------------------------------------------------------------------------- /sanity/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: 'These are the projects that will appear first on your landing page.', 65 | type: 'array', 66 | of: [ 67 | defineArrayMember({ 68 | type: 'reference', 69 | to: [{type: 'project'}], 70 | }), 71 | ], 72 | }), 73 | ], 74 | preview: { 75 | select: { 76 | title: 'title', 77 | }, 78 | prepare({title}) { 79 | return { 80 | subtitle: 'Home', 81 | title, 82 | } 83 | }, 84 | }, 85 | }) 86 | -------------------------------------------------------------------------------- /sanity/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: 'This is a block of text that will be displayed at the bottom of the page.', 38 | title: 'Footer Info', 39 | type: 'array', 40 | of: [ 41 | defineArrayMember({ 42 | type: 'block', 43 | marks: { 44 | annotations: [ 45 | { 46 | name: 'link', 47 | type: 'object', 48 | title: 'Link', 49 | fields: [ 50 | { 51 | name: 'href', 52 | type: 'url', 53 | title: 'Url', 54 | }, 55 | ], 56 | }, 57 | ], 58 | }, 59 | }), 60 | ], 61 | }), 62 | defineField({ 63 | name: 'ogImage', 64 | title: 'Open Graph Image', 65 | type: 'image', 66 | description: 'Displayed on social cards and search engine results.', 67 | options: { 68 | hotspot: true, 69 | }, 70 | }), 71 | ], 72 | preview: { 73 | prepare() { 74 | return { 75 | title: 'Settings', 76 | subtitle: 'Menu Items, Footer Info, and Open Graph Image', 77 | } 78 | }, 79 | }, 80 | }) 81 | -------------------------------------------------------------------------------- /schema.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "timeline", 4 | "type": "type", 5 | "value": { 6 | "type": "object", 7 | "attributes": { 8 | "_type": { 9 | "type": "objectAttribute", 10 | "value": { 11 | "type": "string", 12 | "value": "timeline" 13 | } 14 | }, 15 | "items": { 16 | "type": "objectAttribute", 17 | "value": { 18 | "type": "array", 19 | "of": { 20 | "type": "object", 21 | "attributes": { 22 | "title": { 23 | "type": "objectAttribute", 24 | "value": { 25 | "type": "string" 26 | }, 27 | "optional": true 28 | }, 29 | "milestones": { 30 | "type": "objectAttribute", 31 | "value": { 32 | "type": "array", 33 | "of": { 34 | "type": "object", 35 | "attributes": { 36 | "_key": { 37 | "type": "objectAttribute", 38 | "value": { 39 | "type": "string" 40 | } 41 | } 42 | }, 43 | "rest": { 44 | "type": "inline", 45 | "name": "milestone" 46 | } 47 | } 48 | }, 49 | "optional": true 50 | }, 51 | "_type": { 52 | "type": "objectAttribute", 53 | "value": { 54 | "type": "string", 55 | "value": "item" 56 | } 57 | } 58 | }, 59 | "rest": { 60 | "type": "object", 61 | "attributes": { 62 | "_key": { 63 | "type": "objectAttribute", 64 | "value": { 65 | "type": "string" 66 | } 67 | } 68 | } 69 | } 70 | } 71 | }, 72 | "optional": true 73 | } 74 | } 75 | } 76 | }, 77 | { 78 | "name": "milestone", 79 | "type": "type", 80 | "value": { 81 | "type": "object", 82 | "attributes": { 83 | "_type": { 84 | "type": "objectAttribute", 85 | "value": { 86 | "type": "string", 87 | "value": "milestone" 88 | } 89 | }, 90 | "title": { 91 | "type": "objectAttribute", 92 | "value": { 93 | "type": "string" 94 | }, 95 | "optional": true 96 | }, 97 | "description": { 98 | "type": "objectAttribute", 99 | "value": { 100 | "type": "string" 101 | }, 102 | "optional": true 103 | }, 104 | "image": { 105 | "type": "objectAttribute", 106 | "value": { 107 | "type": "object", 108 | "attributes": { 109 | "asset": { 110 | "type": "objectAttribute", 111 | "value": { 112 | "type": "object", 113 | "attributes": { 114 | "_ref": { 115 | "type": "objectAttribute", 116 | "value": { 117 | "type": "string" 118 | } 119 | }, 120 | "_type": { 121 | "type": "objectAttribute", 122 | "value": { 123 | "type": "string", 124 | "value": "reference" 125 | } 126 | }, 127 | "_weak": { 128 | "type": "objectAttribute", 129 | "value": { 130 | "type": "boolean" 131 | }, 132 | "optional": true 133 | } 134 | }, 135 | "dereferencesTo": "sanity.imageAsset" 136 | }, 137 | "optional": true 138 | }, 139 | "media": { 140 | "type": "objectAttribute", 141 | "value": { 142 | "type": "unknown" 143 | }, 144 | "optional": true 145 | }, 146 | "hotspot": { 147 | "type": "objectAttribute", 148 | "value": { 149 | "type": "inline", 150 | "name": "sanity.imageHotspot" 151 | }, 152 | "optional": true 153 | }, 154 | "crop": { 155 | "type": "objectAttribute", 156 | "value": { 157 | "type": "inline", 158 | "name": "sanity.imageCrop" 159 | }, 160 | "optional": true 161 | }, 162 | "_type": { 163 | "type": "objectAttribute", 164 | "value": { 165 | "type": "string", 166 | "value": "image" 167 | } 168 | } 169 | } 170 | }, 171 | "optional": true 172 | }, 173 | "tags": { 174 | "type": "objectAttribute", 175 | "value": { 176 | "type": "array", 177 | "of": { 178 | "type": "string" 179 | } 180 | }, 181 | "optional": true 182 | }, 183 | "duration": { 184 | "type": "objectAttribute", 185 | "value": { 186 | "type": "inline", 187 | "name": "duration" 188 | }, 189 | "optional": true 190 | } 191 | } 192 | } 193 | }, 194 | { 195 | "name": "project", 196 | "type": "document", 197 | "attributes": { 198 | "_id": { 199 | "type": "objectAttribute", 200 | "value": { 201 | "type": "string" 202 | } 203 | }, 204 | "_type": { 205 | "type": "objectAttribute", 206 | "value": { 207 | "type": "string", 208 | "value": "project" 209 | } 210 | }, 211 | "_createdAt": { 212 | "type": "objectAttribute", 213 | "value": { 214 | "type": "string" 215 | } 216 | }, 217 | "_updatedAt": { 218 | "type": "objectAttribute", 219 | "value": { 220 | "type": "string" 221 | } 222 | }, 223 | "_rev": { 224 | "type": "objectAttribute", 225 | "value": { 226 | "type": "string" 227 | } 228 | }, 229 | "title": { 230 | "type": "objectAttribute", 231 | "value": { 232 | "type": "string" 233 | }, 234 | "optional": true 235 | }, 236 | "slug": { 237 | "type": "objectAttribute", 238 | "value": { 239 | "type": "inline", 240 | "name": "slug" 241 | }, 242 | "optional": true 243 | }, 244 | "overview": { 245 | "type": "objectAttribute", 246 | "value": { 247 | "type": "array", 248 | "of": { 249 | "type": "object", 250 | "attributes": { 251 | "children": { 252 | "type": "objectAttribute", 253 | "value": { 254 | "type": "array", 255 | "of": { 256 | "type": "object", 257 | "attributes": { 258 | "marks": { 259 | "type": "objectAttribute", 260 | "value": { 261 | "type": "array", 262 | "of": { 263 | "type": "string" 264 | } 265 | }, 266 | "optional": true 267 | }, 268 | "text": { 269 | "type": "objectAttribute", 270 | "value": { 271 | "type": "string" 272 | }, 273 | "optional": true 274 | }, 275 | "_type": { 276 | "type": "objectAttribute", 277 | "value": { 278 | "type": "string", 279 | "value": "span" 280 | } 281 | } 282 | }, 283 | "rest": { 284 | "type": "object", 285 | "attributes": { 286 | "_key": { 287 | "type": "objectAttribute", 288 | "value": { 289 | "type": "string" 290 | } 291 | } 292 | } 293 | } 294 | } 295 | }, 296 | "optional": true 297 | }, 298 | "style": { 299 | "type": "objectAttribute", 300 | "value": { 301 | "type": "union", 302 | "of": [ 303 | { 304 | "type": "string", 305 | "value": "normal" 306 | } 307 | ] 308 | }, 309 | "optional": true 310 | }, 311 | "listItem": { 312 | "type": "objectAttribute", 313 | "value": { 314 | "type": "union", 315 | "of": [] 316 | }, 317 | "optional": true 318 | }, 319 | "markDefs": { 320 | "type": "objectAttribute", 321 | "value": { 322 | "type": "null" 323 | }, 324 | "optional": true 325 | }, 326 | "level": { 327 | "type": "objectAttribute", 328 | "value": { 329 | "type": "number" 330 | }, 331 | "optional": true 332 | }, 333 | "_type": { 334 | "type": "objectAttribute", 335 | "value": { 336 | "type": "string", 337 | "value": "block" 338 | } 339 | } 340 | }, 341 | "rest": { 342 | "type": "object", 343 | "attributes": { 344 | "_key": { 345 | "type": "objectAttribute", 346 | "value": { 347 | "type": "string" 348 | } 349 | } 350 | } 351 | } 352 | } 353 | }, 354 | "optional": true 355 | }, 356 | "coverImage": { 357 | "type": "objectAttribute", 358 | "value": { 359 | "type": "object", 360 | "attributes": { 361 | "asset": { 362 | "type": "objectAttribute", 363 | "value": { 364 | "type": "object", 365 | "attributes": { 366 | "_ref": { 367 | "type": "objectAttribute", 368 | "value": { 369 | "type": "string" 370 | } 371 | }, 372 | "_type": { 373 | "type": "objectAttribute", 374 | "value": { 375 | "type": "string", 376 | "value": "reference" 377 | } 378 | }, 379 | "_weak": { 380 | "type": "objectAttribute", 381 | "value": { 382 | "type": "boolean" 383 | }, 384 | "optional": true 385 | } 386 | }, 387 | "dereferencesTo": "sanity.imageAsset" 388 | }, 389 | "optional": true 390 | }, 391 | "media": { 392 | "type": "objectAttribute", 393 | "value": { 394 | "type": "unknown" 395 | }, 396 | "optional": true 397 | }, 398 | "hotspot": { 399 | "type": "objectAttribute", 400 | "value": { 401 | "type": "inline", 402 | "name": "sanity.imageHotspot" 403 | }, 404 | "optional": true 405 | }, 406 | "crop": { 407 | "type": "objectAttribute", 408 | "value": { 409 | "type": "inline", 410 | "name": "sanity.imageCrop" 411 | }, 412 | "optional": true 413 | }, 414 | "_type": { 415 | "type": "objectAttribute", 416 | "value": { 417 | "type": "string", 418 | "value": "image" 419 | } 420 | } 421 | } 422 | }, 423 | "optional": true 424 | }, 425 | "duration": { 426 | "type": "objectAttribute", 427 | "value": { 428 | "type": "inline", 429 | "name": "duration" 430 | }, 431 | "optional": true 432 | }, 433 | "client": { 434 | "type": "objectAttribute", 435 | "value": { 436 | "type": "string" 437 | }, 438 | "optional": true 439 | }, 440 | "site": { 441 | "type": "objectAttribute", 442 | "value": { 443 | "type": "string" 444 | }, 445 | "optional": true 446 | }, 447 | "tags": { 448 | "type": "objectAttribute", 449 | "value": { 450 | "type": "array", 451 | "of": { 452 | "type": "string" 453 | } 454 | }, 455 | "optional": true 456 | }, 457 | "description": { 458 | "type": "objectAttribute", 459 | "value": { 460 | "type": "array", 461 | "of": { 462 | "type": "union", 463 | "of": [ 464 | { 465 | "type": "object", 466 | "attributes": { 467 | "children": { 468 | "type": "objectAttribute", 469 | "value": { 470 | "type": "array", 471 | "of": { 472 | "type": "object", 473 | "attributes": { 474 | "marks": { 475 | "type": "objectAttribute", 476 | "value": { 477 | "type": "array", 478 | "of": { 479 | "type": "string" 480 | } 481 | }, 482 | "optional": true 483 | }, 484 | "text": { 485 | "type": "objectAttribute", 486 | "value": { 487 | "type": "string" 488 | }, 489 | "optional": true 490 | }, 491 | "_type": { 492 | "type": "objectAttribute", 493 | "value": { 494 | "type": "string", 495 | "value": "span" 496 | } 497 | } 498 | }, 499 | "rest": { 500 | "type": "object", 501 | "attributes": { 502 | "_key": { 503 | "type": "objectAttribute", 504 | "value": { 505 | "type": "string" 506 | } 507 | } 508 | } 509 | } 510 | } 511 | }, 512 | "optional": true 513 | }, 514 | "style": { 515 | "type": "objectAttribute", 516 | "value": { 517 | "type": "union", 518 | "of": [ 519 | { 520 | "type": "string", 521 | "value": "normal" 522 | } 523 | ] 524 | }, 525 | "optional": true 526 | }, 527 | "listItem": { 528 | "type": "objectAttribute", 529 | "value": { 530 | "type": "union", 531 | "of": [ 532 | { 533 | "type": "string", 534 | "value": "bullet" 535 | }, 536 | { 537 | "type": "string", 538 | "value": "number" 539 | } 540 | ] 541 | }, 542 | "optional": true 543 | }, 544 | "markDefs": { 545 | "type": "objectAttribute", 546 | "value": { 547 | "type": "array", 548 | "of": { 549 | "type": "object", 550 | "attributes": { 551 | "href": { 552 | "type": "objectAttribute", 553 | "value": { 554 | "type": "string" 555 | }, 556 | "optional": true 557 | }, 558 | "_type": { 559 | "type": "objectAttribute", 560 | "value": { 561 | "type": "string", 562 | "value": "link" 563 | } 564 | } 565 | }, 566 | "rest": { 567 | "type": "object", 568 | "attributes": { 569 | "_key": { 570 | "type": "objectAttribute", 571 | "value": { 572 | "type": "string" 573 | } 574 | } 575 | } 576 | } 577 | } 578 | }, 579 | "optional": true 580 | }, 581 | "level": { 582 | "type": "objectAttribute", 583 | "value": { 584 | "type": "number" 585 | }, 586 | "optional": true 587 | }, 588 | "_type": { 589 | "type": "objectAttribute", 590 | "value": { 591 | "type": "string", 592 | "value": "block" 593 | } 594 | } 595 | }, 596 | "rest": { 597 | "type": "object", 598 | "attributes": { 599 | "_key": { 600 | "type": "objectAttribute", 601 | "value": { 602 | "type": "string" 603 | } 604 | } 605 | } 606 | } 607 | }, 608 | { 609 | "type": "object", 610 | "attributes": { 611 | "_key": { 612 | "type": "objectAttribute", 613 | "value": { 614 | "type": "string" 615 | } 616 | } 617 | }, 618 | "rest": { 619 | "type": "inline", 620 | "name": "timeline" 621 | } 622 | }, 623 | { 624 | "type": "object", 625 | "attributes": { 626 | "asset": { 627 | "type": "objectAttribute", 628 | "value": { 629 | "type": "object", 630 | "attributes": { 631 | "_ref": { 632 | "type": "objectAttribute", 633 | "value": { 634 | "type": "string" 635 | } 636 | }, 637 | "_type": { 638 | "type": "objectAttribute", 639 | "value": { 640 | "type": "string", 641 | "value": "reference" 642 | } 643 | }, 644 | "_weak": { 645 | "type": "objectAttribute", 646 | "value": { 647 | "type": "boolean" 648 | }, 649 | "optional": true 650 | } 651 | }, 652 | "dereferencesTo": "sanity.imageAsset" 653 | }, 654 | "optional": true 655 | }, 656 | "media": { 657 | "type": "objectAttribute", 658 | "value": { 659 | "type": "unknown" 660 | }, 661 | "optional": true 662 | }, 663 | "hotspot": { 664 | "type": "objectAttribute", 665 | "value": { 666 | "type": "inline", 667 | "name": "sanity.imageHotspot" 668 | }, 669 | "optional": true 670 | }, 671 | "crop": { 672 | "type": "objectAttribute", 673 | "value": { 674 | "type": "inline", 675 | "name": "sanity.imageCrop" 676 | }, 677 | "optional": true 678 | }, 679 | "caption": { 680 | "type": "objectAttribute", 681 | "value": { 682 | "type": "string" 683 | }, 684 | "optional": true 685 | }, 686 | "alt": { 687 | "type": "objectAttribute", 688 | "value": { 689 | "type": "string" 690 | }, 691 | "optional": true 692 | }, 693 | "_type": { 694 | "type": "objectAttribute", 695 | "value": { 696 | "type": "string", 697 | "value": "image" 698 | } 699 | } 700 | }, 701 | "rest": { 702 | "type": "object", 703 | "attributes": { 704 | "_key": { 705 | "type": "objectAttribute", 706 | "value": { 707 | "type": "string" 708 | } 709 | } 710 | } 711 | } 712 | } 713 | ] 714 | } 715 | }, 716 | "optional": true 717 | } 718 | } 719 | }, 720 | { 721 | "name": "page", 722 | "type": "document", 723 | "attributes": { 724 | "_id": { 725 | "type": "objectAttribute", 726 | "value": { 727 | "type": "string" 728 | } 729 | }, 730 | "_type": { 731 | "type": "objectAttribute", 732 | "value": { 733 | "type": "string", 734 | "value": "page" 735 | } 736 | }, 737 | "_createdAt": { 738 | "type": "objectAttribute", 739 | "value": { 740 | "type": "string" 741 | } 742 | }, 743 | "_updatedAt": { 744 | "type": "objectAttribute", 745 | "value": { 746 | "type": "string" 747 | } 748 | }, 749 | "_rev": { 750 | "type": "objectAttribute", 751 | "value": { 752 | "type": "string" 753 | } 754 | }, 755 | "title": { 756 | "type": "objectAttribute", 757 | "value": { 758 | "type": "string" 759 | }, 760 | "optional": true 761 | }, 762 | "slug": { 763 | "type": "objectAttribute", 764 | "value": { 765 | "type": "inline", 766 | "name": "slug" 767 | }, 768 | "optional": true 769 | }, 770 | "overview": { 771 | "type": "objectAttribute", 772 | "value": { 773 | "type": "array", 774 | "of": { 775 | "type": "object", 776 | "attributes": { 777 | "children": { 778 | "type": "objectAttribute", 779 | "value": { 780 | "type": "array", 781 | "of": { 782 | "type": "object", 783 | "attributes": { 784 | "marks": { 785 | "type": "objectAttribute", 786 | "value": { 787 | "type": "array", 788 | "of": { 789 | "type": "string" 790 | } 791 | }, 792 | "optional": true 793 | }, 794 | "text": { 795 | "type": "objectAttribute", 796 | "value": { 797 | "type": "string" 798 | }, 799 | "optional": true 800 | }, 801 | "_type": { 802 | "type": "objectAttribute", 803 | "value": { 804 | "type": "string", 805 | "value": "span" 806 | } 807 | } 808 | }, 809 | "rest": { 810 | "type": "object", 811 | "attributes": { 812 | "_key": { 813 | "type": "objectAttribute", 814 | "value": { 815 | "type": "string" 816 | } 817 | } 818 | } 819 | } 820 | } 821 | }, 822 | "optional": true 823 | }, 824 | "style": { 825 | "type": "objectAttribute", 826 | "value": { 827 | "type": "union", 828 | "of": [ 829 | { 830 | "type": "string", 831 | "value": "normal" 832 | } 833 | ] 834 | }, 835 | "optional": true 836 | }, 837 | "listItem": { 838 | "type": "objectAttribute", 839 | "value": { 840 | "type": "union", 841 | "of": [] 842 | }, 843 | "optional": true 844 | }, 845 | "markDefs": { 846 | "type": "objectAttribute", 847 | "value": { 848 | "type": "null" 849 | }, 850 | "optional": true 851 | }, 852 | "level": { 853 | "type": "objectAttribute", 854 | "value": { 855 | "type": "number" 856 | }, 857 | "optional": true 858 | }, 859 | "_type": { 860 | "type": "objectAttribute", 861 | "value": { 862 | "type": "string", 863 | "value": "block" 864 | } 865 | } 866 | }, 867 | "rest": { 868 | "type": "object", 869 | "attributes": { 870 | "_key": { 871 | "type": "objectAttribute", 872 | "value": { 873 | "type": "string" 874 | } 875 | } 876 | } 877 | } 878 | } 879 | }, 880 | "optional": true 881 | }, 882 | "body": { 883 | "type": "objectAttribute", 884 | "value": { 885 | "type": "array", 886 | "of": { 887 | "type": "union", 888 | "of": [ 889 | { 890 | "type": "object", 891 | "attributes": { 892 | "children": { 893 | "type": "objectAttribute", 894 | "value": { 895 | "type": "array", 896 | "of": { 897 | "type": "object", 898 | "attributes": { 899 | "marks": { 900 | "type": "objectAttribute", 901 | "value": { 902 | "type": "array", 903 | "of": { 904 | "type": "string" 905 | } 906 | }, 907 | "optional": true 908 | }, 909 | "text": { 910 | "type": "objectAttribute", 911 | "value": { 912 | "type": "string" 913 | }, 914 | "optional": true 915 | }, 916 | "_type": { 917 | "type": "objectAttribute", 918 | "value": { 919 | "type": "string", 920 | "value": "span" 921 | } 922 | } 923 | }, 924 | "rest": { 925 | "type": "object", 926 | "attributes": { 927 | "_key": { 928 | "type": "objectAttribute", 929 | "value": { 930 | "type": "string" 931 | } 932 | } 933 | } 934 | } 935 | } 936 | }, 937 | "optional": true 938 | }, 939 | "style": { 940 | "type": "objectAttribute", 941 | "value": { 942 | "type": "union", 943 | "of": [ 944 | { 945 | "type": "string", 946 | "value": "normal" 947 | } 948 | ] 949 | }, 950 | "optional": true 951 | }, 952 | "listItem": { 953 | "type": "objectAttribute", 954 | "value": { 955 | "type": "union", 956 | "of": [ 957 | { 958 | "type": "string", 959 | "value": "bullet" 960 | }, 961 | { 962 | "type": "string", 963 | "value": "number" 964 | } 965 | ] 966 | }, 967 | "optional": true 968 | }, 969 | "markDefs": { 970 | "type": "objectAttribute", 971 | "value": { 972 | "type": "array", 973 | "of": { 974 | "type": "object", 975 | "attributes": { 976 | "href": { 977 | "type": "objectAttribute", 978 | "value": { 979 | "type": "string" 980 | }, 981 | "optional": true 982 | }, 983 | "_type": { 984 | "type": "objectAttribute", 985 | "value": { 986 | "type": "string", 987 | "value": "link" 988 | } 989 | } 990 | }, 991 | "rest": { 992 | "type": "object", 993 | "attributes": { 994 | "_key": { 995 | "type": "objectAttribute", 996 | "value": { 997 | "type": "string" 998 | } 999 | } 1000 | } 1001 | } 1002 | } 1003 | }, 1004 | "optional": true 1005 | }, 1006 | "level": { 1007 | "type": "objectAttribute", 1008 | "value": { 1009 | "type": "number" 1010 | }, 1011 | "optional": true 1012 | }, 1013 | "_type": { 1014 | "type": "objectAttribute", 1015 | "value": { 1016 | "type": "string", 1017 | "value": "block" 1018 | } 1019 | } 1020 | }, 1021 | "rest": { 1022 | "type": "object", 1023 | "attributes": { 1024 | "_key": { 1025 | "type": "objectAttribute", 1026 | "value": { 1027 | "type": "string" 1028 | } 1029 | } 1030 | } 1031 | } 1032 | }, 1033 | { 1034 | "type": "object", 1035 | "attributes": { 1036 | "_key": { 1037 | "type": "objectAttribute", 1038 | "value": { 1039 | "type": "string" 1040 | } 1041 | } 1042 | }, 1043 | "rest": { 1044 | "type": "inline", 1045 | "name": "timeline" 1046 | } 1047 | }, 1048 | { 1049 | "type": "object", 1050 | "attributes": { 1051 | "asset": { 1052 | "type": "objectAttribute", 1053 | "value": { 1054 | "type": "object", 1055 | "attributes": { 1056 | "_ref": { 1057 | "type": "objectAttribute", 1058 | "value": { 1059 | "type": "string" 1060 | } 1061 | }, 1062 | "_type": { 1063 | "type": "objectAttribute", 1064 | "value": { 1065 | "type": "string", 1066 | "value": "reference" 1067 | } 1068 | }, 1069 | "_weak": { 1070 | "type": "objectAttribute", 1071 | "value": { 1072 | "type": "boolean" 1073 | }, 1074 | "optional": true 1075 | } 1076 | }, 1077 | "dereferencesTo": "sanity.imageAsset" 1078 | }, 1079 | "optional": true 1080 | }, 1081 | "media": { 1082 | "type": "objectAttribute", 1083 | "value": { 1084 | "type": "unknown" 1085 | }, 1086 | "optional": true 1087 | }, 1088 | "hotspot": { 1089 | "type": "objectAttribute", 1090 | "value": { 1091 | "type": "inline", 1092 | "name": "sanity.imageHotspot" 1093 | }, 1094 | "optional": true 1095 | }, 1096 | "crop": { 1097 | "type": "objectAttribute", 1098 | "value": { 1099 | "type": "inline", 1100 | "name": "sanity.imageCrop" 1101 | }, 1102 | "optional": true 1103 | }, 1104 | "caption": { 1105 | "type": "objectAttribute", 1106 | "value": { 1107 | "type": "string" 1108 | }, 1109 | "optional": true 1110 | }, 1111 | "alt": { 1112 | "type": "objectAttribute", 1113 | "value": { 1114 | "type": "string" 1115 | }, 1116 | "optional": true 1117 | }, 1118 | "_type": { 1119 | "type": "objectAttribute", 1120 | "value": { 1121 | "type": "string", 1122 | "value": "image" 1123 | } 1124 | } 1125 | }, 1126 | "rest": { 1127 | "type": "object", 1128 | "attributes": { 1129 | "_key": { 1130 | "type": "objectAttribute", 1131 | "value": { 1132 | "type": "string" 1133 | } 1134 | } 1135 | } 1136 | } 1137 | } 1138 | ] 1139 | } 1140 | }, 1141 | "optional": true 1142 | } 1143 | } 1144 | }, 1145 | { 1146 | "name": "duration", 1147 | "type": "type", 1148 | "value": { 1149 | "type": "object", 1150 | "attributes": { 1151 | "_type": { 1152 | "type": "objectAttribute", 1153 | "value": { 1154 | "type": "string", 1155 | "value": "duration" 1156 | } 1157 | }, 1158 | "start": { 1159 | "type": "objectAttribute", 1160 | "value": { 1161 | "type": "string" 1162 | }, 1163 | "optional": true 1164 | }, 1165 | "end": { 1166 | "type": "objectAttribute", 1167 | "value": { 1168 | "type": "string" 1169 | }, 1170 | "optional": true 1171 | } 1172 | } 1173 | } 1174 | }, 1175 | { 1176 | "name": "settings", 1177 | "type": "document", 1178 | "attributes": { 1179 | "_id": { 1180 | "type": "objectAttribute", 1181 | "value": { 1182 | "type": "string" 1183 | } 1184 | }, 1185 | "_type": { 1186 | "type": "objectAttribute", 1187 | "value": { 1188 | "type": "string", 1189 | "value": "settings" 1190 | } 1191 | }, 1192 | "_createdAt": { 1193 | "type": "objectAttribute", 1194 | "value": { 1195 | "type": "string" 1196 | } 1197 | }, 1198 | "_updatedAt": { 1199 | "type": "objectAttribute", 1200 | "value": { 1201 | "type": "string" 1202 | } 1203 | }, 1204 | "_rev": { 1205 | "type": "objectAttribute", 1206 | "value": { 1207 | "type": "string" 1208 | } 1209 | }, 1210 | "menuItems": { 1211 | "type": "objectAttribute", 1212 | "value": { 1213 | "type": "array", 1214 | "of": { 1215 | "type": "union", 1216 | "of": [ 1217 | { 1218 | "type": "object", 1219 | "attributes": { 1220 | "_ref": { 1221 | "type": "objectAttribute", 1222 | "value": { 1223 | "type": "string" 1224 | } 1225 | }, 1226 | "_type": { 1227 | "type": "objectAttribute", 1228 | "value": { 1229 | "type": "string", 1230 | "value": "reference" 1231 | } 1232 | }, 1233 | "_weak": { 1234 | "type": "objectAttribute", 1235 | "value": { 1236 | "type": "boolean" 1237 | }, 1238 | "optional": true 1239 | } 1240 | }, 1241 | "dereferencesTo": "home" 1242 | }, 1243 | { 1244 | "type": "object", 1245 | "attributes": { 1246 | "_ref": { 1247 | "type": "objectAttribute", 1248 | "value": { 1249 | "type": "string" 1250 | } 1251 | }, 1252 | "_type": { 1253 | "type": "objectAttribute", 1254 | "value": { 1255 | "type": "string", 1256 | "value": "reference" 1257 | } 1258 | }, 1259 | "_weak": { 1260 | "type": "objectAttribute", 1261 | "value": { 1262 | "type": "boolean" 1263 | }, 1264 | "optional": true 1265 | } 1266 | }, 1267 | "dereferencesTo": "page" 1268 | }, 1269 | { 1270 | "type": "object", 1271 | "attributes": { 1272 | "_ref": { 1273 | "type": "objectAttribute", 1274 | "value": { 1275 | "type": "string" 1276 | } 1277 | }, 1278 | "_type": { 1279 | "type": "objectAttribute", 1280 | "value": { 1281 | "type": "string", 1282 | "value": "reference" 1283 | } 1284 | }, 1285 | "_weak": { 1286 | "type": "objectAttribute", 1287 | "value": { 1288 | "type": "boolean" 1289 | }, 1290 | "optional": true 1291 | } 1292 | }, 1293 | "dereferencesTo": "project" 1294 | } 1295 | ] 1296 | } 1297 | }, 1298 | "optional": true 1299 | }, 1300 | "footer": { 1301 | "type": "objectAttribute", 1302 | "value": { 1303 | "type": "array", 1304 | "of": { 1305 | "type": "object", 1306 | "attributes": { 1307 | "children": { 1308 | "type": "objectAttribute", 1309 | "value": { 1310 | "type": "array", 1311 | "of": { 1312 | "type": "object", 1313 | "attributes": { 1314 | "marks": { 1315 | "type": "objectAttribute", 1316 | "value": { 1317 | "type": "array", 1318 | "of": { 1319 | "type": "string" 1320 | } 1321 | }, 1322 | "optional": true 1323 | }, 1324 | "text": { 1325 | "type": "objectAttribute", 1326 | "value": { 1327 | "type": "string" 1328 | }, 1329 | "optional": true 1330 | }, 1331 | "_type": { 1332 | "type": "objectAttribute", 1333 | "value": { 1334 | "type": "string", 1335 | "value": "span" 1336 | } 1337 | } 1338 | }, 1339 | "rest": { 1340 | "type": "object", 1341 | "attributes": { 1342 | "_key": { 1343 | "type": "objectAttribute", 1344 | "value": { 1345 | "type": "string" 1346 | } 1347 | } 1348 | } 1349 | } 1350 | } 1351 | }, 1352 | "optional": true 1353 | }, 1354 | "style": { 1355 | "type": "objectAttribute", 1356 | "value": { 1357 | "type": "union", 1358 | "of": [ 1359 | { 1360 | "type": "string", 1361 | "value": "normal" 1362 | }, 1363 | { 1364 | "type": "string", 1365 | "value": "h1" 1366 | }, 1367 | { 1368 | "type": "string", 1369 | "value": "h2" 1370 | }, 1371 | { 1372 | "type": "string", 1373 | "value": "h3" 1374 | }, 1375 | { 1376 | "type": "string", 1377 | "value": "h4" 1378 | }, 1379 | { 1380 | "type": "string", 1381 | "value": "h5" 1382 | }, 1383 | { 1384 | "type": "string", 1385 | "value": "h6" 1386 | }, 1387 | { 1388 | "type": "string", 1389 | "value": "blockquote" 1390 | } 1391 | ] 1392 | }, 1393 | "optional": true 1394 | }, 1395 | "listItem": { 1396 | "type": "objectAttribute", 1397 | "value": { 1398 | "type": "union", 1399 | "of": [ 1400 | { 1401 | "type": "string", 1402 | "value": "bullet" 1403 | }, 1404 | { 1405 | "type": "string", 1406 | "value": "number" 1407 | } 1408 | ] 1409 | }, 1410 | "optional": true 1411 | }, 1412 | "markDefs": { 1413 | "type": "objectAttribute", 1414 | "value": { 1415 | "type": "array", 1416 | "of": { 1417 | "type": "object", 1418 | "attributes": { 1419 | "href": { 1420 | "type": "objectAttribute", 1421 | "value": { 1422 | "type": "string" 1423 | }, 1424 | "optional": true 1425 | }, 1426 | "_type": { 1427 | "type": "objectAttribute", 1428 | "value": { 1429 | "type": "string", 1430 | "value": "link" 1431 | } 1432 | } 1433 | }, 1434 | "rest": { 1435 | "type": "object", 1436 | "attributes": { 1437 | "_key": { 1438 | "type": "objectAttribute", 1439 | "value": { 1440 | "type": "string" 1441 | } 1442 | } 1443 | } 1444 | } 1445 | } 1446 | }, 1447 | "optional": true 1448 | }, 1449 | "level": { 1450 | "type": "objectAttribute", 1451 | "value": { 1452 | "type": "number" 1453 | }, 1454 | "optional": true 1455 | }, 1456 | "_type": { 1457 | "type": "objectAttribute", 1458 | "value": { 1459 | "type": "string", 1460 | "value": "block" 1461 | } 1462 | } 1463 | }, 1464 | "rest": { 1465 | "type": "object", 1466 | "attributes": { 1467 | "_key": { 1468 | "type": "objectAttribute", 1469 | "value": { 1470 | "type": "string" 1471 | } 1472 | } 1473 | } 1474 | } 1475 | } 1476 | }, 1477 | "optional": true 1478 | }, 1479 | "ogImage": { 1480 | "type": "objectAttribute", 1481 | "value": { 1482 | "type": "object", 1483 | "attributes": { 1484 | "asset": { 1485 | "type": "objectAttribute", 1486 | "value": { 1487 | "type": "object", 1488 | "attributes": { 1489 | "_ref": { 1490 | "type": "objectAttribute", 1491 | "value": { 1492 | "type": "string" 1493 | } 1494 | }, 1495 | "_type": { 1496 | "type": "objectAttribute", 1497 | "value": { 1498 | "type": "string", 1499 | "value": "reference" 1500 | } 1501 | }, 1502 | "_weak": { 1503 | "type": "objectAttribute", 1504 | "value": { 1505 | "type": "boolean" 1506 | }, 1507 | "optional": true 1508 | } 1509 | }, 1510 | "dereferencesTo": "sanity.imageAsset" 1511 | }, 1512 | "optional": true 1513 | }, 1514 | "media": { 1515 | "type": "objectAttribute", 1516 | "value": { 1517 | "type": "unknown" 1518 | }, 1519 | "optional": true 1520 | }, 1521 | "hotspot": { 1522 | "type": "objectAttribute", 1523 | "value": { 1524 | "type": "inline", 1525 | "name": "sanity.imageHotspot" 1526 | }, 1527 | "optional": true 1528 | }, 1529 | "crop": { 1530 | "type": "objectAttribute", 1531 | "value": { 1532 | "type": "inline", 1533 | "name": "sanity.imageCrop" 1534 | }, 1535 | "optional": true 1536 | }, 1537 | "_type": { 1538 | "type": "objectAttribute", 1539 | "value": { 1540 | "type": "string", 1541 | "value": "image" 1542 | } 1543 | } 1544 | } 1545 | }, 1546 | "optional": true 1547 | } 1548 | } 1549 | }, 1550 | { 1551 | "name": "home", 1552 | "type": "document", 1553 | "attributes": { 1554 | "_id": { 1555 | "type": "objectAttribute", 1556 | "value": { 1557 | "type": "string" 1558 | } 1559 | }, 1560 | "_type": { 1561 | "type": "objectAttribute", 1562 | "value": { 1563 | "type": "string", 1564 | "value": "home" 1565 | } 1566 | }, 1567 | "_createdAt": { 1568 | "type": "objectAttribute", 1569 | "value": { 1570 | "type": "string" 1571 | } 1572 | }, 1573 | "_updatedAt": { 1574 | "type": "objectAttribute", 1575 | "value": { 1576 | "type": "string" 1577 | } 1578 | }, 1579 | "_rev": { 1580 | "type": "objectAttribute", 1581 | "value": { 1582 | "type": "string" 1583 | } 1584 | }, 1585 | "title": { 1586 | "type": "objectAttribute", 1587 | "value": { 1588 | "type": "string" 1589 | }, 1590 | "optional": true 1591 | }, 1592 | "overview": { 1593 | "type": "objectAttribute", 1594 | "value": { 1595 | "type": "array", 1596 | "of": { 1597 | "type": "object", 1598 | "attributes": { 1599 | "children": { 1600 | "type": "objectAttribute", 1601 | "value": { 1602 | "type": "array", 1603 | "of": { 1604 | "type": "object", 1605 | "attributes": { 1606 | "marks": { 1607 | "type": "objectAttribute", 1608 | "value": { 1609 | "type": "array", 1610 | "of": { 1611 | "type": "string" 1612 | } 1613 | }, 1614 | "optional": true 1615 | }, 1616 | "text": { 1617 | "type": "objectAttribute", 1618 | "value": { 1619 | "type": "string" 1620 | }, 1621 | "optional": true 1622 | }, 1623 | "_type": { 1624 | "type": "objectAttribute", 1625 | "value": { 1626 | "type": "string", 1627 | "value": "span" 1628 | } 1629 | } 1630 | }, 1631 | "rest": { 1632 | "type": "object", 1633 | "attributes": { 1634 | "_key": { 1635 | "type": "objectAttribute", 1636 | "value": { 1637 | "type": "string" 1638 | } 1639 | } 1640 | } 1641 | } 1642 | } 1643 | }, 1644 | "optional": true 1645 | }, 1646 | "style": { 1647 | "type": "objectAttribute", 1648 | "value": { 1649 | "type": "union", 1650 | "of": [ 1651 | { 1652 | "type": "string", 1653 | "value": "normal" 1654 | } 1655 | ] 1656 | }, 1657 | "optional": true 1658 | }, 1659 | "listItem": { 1660 | "type": "objectAttribute", 1661 | "value": { 1662 | "type": "union", 1663 | "of": [] 1664 | }, 1665 | "optional": true 1666 | }, 1667 | "markDefs": { 1668 | "type": "objectAttribute", 1669 | "value": { 1670 | "type": "array", 1671 | "of": { 1672 | "type": "object", 1673 | "attributes": { 1674 | "href": { 1675 | "type": "objectAttribute", 1676 | "value": { 1677 | "type": "string" 1678 | }, 1679 | "optional": true 1680 | }, 1681 | "_type": { 1682 | "type": "objectAttribute", 1683 | "value": { 1684 | "type": "string", 1685 | "value": "link" 1686 | } 1687 | } 1688 | }, 1689 | "rest": { 1690 | "type": "object", 1691 | "attributes": { 1692 | "_key": { 1693 | "type": "objectAttribute", 1694 | "value": { 1695 | "type": "string" 1696 | } 1697 | } 1698 | } 1699 | } 1700 | } 1701 | }, 1702 | "optional": true 1703 | }, 1704 | "level": { 1705 | "type": "objectAttribute", 1706 | "value": { 1707 | "type": "number" 1708 | }, 1709 | "optional": true 1710 | }, 1711 | "_type": { 1712 | "type": "objectAttribute", 1713 | "value": { 1714 | "type": "string", 1715 | "value": "block" 1716 | } 1717 | } 1718 | }, 1719 | "rest": { 1720 | "type": "object", 1721 | "attributes": { 1722 | "_key": { 1723 | "type": "objectAttribute", 1724 | "value": { 1725 | "type": "string" 1726 | } 1727 | } 1728 | } 1729 | } 1730 | } 1731 | }, 1732 | "optional": true 1733 | }, 1734 | "showcaseProjects": { 1735 | "type": "objectAttribute", 1736 | "value": { 1737 | "type": "array", 1738 | "of": { 1739 | "type": "object", 1740 | "attributes": { 1741 | "_ref": { 1742 | "type": "objectAttribute", 1743 | "value": { 1744 | "type": "string" 1745 | } 1746 | }, 1747 | "_type": { 1748 | "type": "objectAttribute", 1749 | "value": { 1750 | "type": "string", 1751 | "value": "reference" 1752 | } 1753 | }, 1754 | "_weak": { 1755 | "type": "objectAttribute", 1756 | "value": { 1757 | "type": "boolean" 1758 | }, 1759 | "optional": true 1760 | } 1761 | }, 1762 | "dereferencesTo": "project", 1763 | "rest": { 1764 | "type": "object", 1765 | "attributes": { 1766 | "_key": { 1767 | "type": "objectAttribute", 1768 | "value": { 1769 | "type": "string" 1770 | } 1771 | } 1772 | } 1773 | } 1774 | } 1775 | }, 1776 | "optional": true 1777 | } 1778 | } 1779 | }, 1780 | { 1781 | "name": "sanity.imagePaletteSwatch", 1782 | "type": "type", 1783 | "value": { 1784 | "type": "object", 1785 | "attributes": { 1786 | "_type": { 1787 | "type": "objectAttribute", 1788 | "value": { 1789 | "type": "string", 1790 | "value": "sanity.imagePaletteSwatch" 1791 | } 1792 | }, 1793 | "background": { 1794 | "type": "objectAttribute", 1795 | "value": { 1796 | "type": "string" 1797 | }, 1798 | "optional": true 1799 | }, 1800 | "foreground": { 1801 | "type": "objectAttribute", 1802 | "value": { 1803 | "type": "string" 1804 | }, 1805 | "optional": true 1806 | }, 1807 | "population": { 1808 | "type": "objectAttribute", 1809 | "value": { 1810 | "type": "number" 1811 | }, 1812 | "optional": true 1813 | }, 1814 | "title": { 1815 | "type": "objectAttribute", 1816 | "value": { 1817 | "type": "string" 1818 | }, 1819 | "optional": true 1820 | } 1821 | } 1822 | } 1823 | }, 1824 | { 1825 | "name": "sanity.imagePalette", 1826 | "type": "type", 1827 | "value": { 1828 | "type": "object", 1829 | "attributes": { 1830 | "_type": { 1831 | "type": "objectAttribute", 1832 | "value": { 1833 | "type": "string", 1834 | "value": "sanity.imagePalette" 1835 | } 1836 | }, 1837 | "darkMuted": { 1838 | "type": "objectAttribute", 1839 | "value": { 1840 | "type": "inline", 1841 | "name": "sanity.imagePaletteSwatch" 1842 | }, 1843 | "optional": true 1844 | }, 1845 | "lightVibrant": { 1846 | "type": "objectAttribute", 1847 | "value": { 1848 | "type": "inline", 1849 | "name": "sanity.imagePaletteSwatch" 1850 | }, 1851 | "optional": true 1852 | }, 1853 | "darkVibrant": { 1854 | "type": "objectAttribute", 1855 | "value": { 1856 | "type": "inline", 1857 | "name": "sanity.imagePaletteSwatch" 1858 | }, 1859 | "optional": true 1860 | }, 1861 | "vibrant": { 1862 | "type": "objectAttribute", 1863 | "value": { 1864 | "type": "inline", 1865 | "name": "sanity.imagePaletteSwatch" 1866 | }, 1867 | "optional": true 1868 | }, 1869 | "dominant": { 1870 | "type": "objectAttribute", 1871 | "value": { 1872 | "type": "inline", 1873 | "name": "sanity.imagePaletteSwatch" 1874 | }, 1875 | "optional": true 1876 | }, 1877 | "lightMuted": { 1878 | "type": "objectAttribute", 1879 | "value": { 1880 | "type": "inline", 1881 | "name": "sanity.imagePaletteSwatch" 1882 | }, 1883 | "optional": true 1884 | }, 1885 | "muted": { 1886 | "type": "objectAttribute", 1887 | "value": { 1888 | "type": "inline", 1889 | "name": "sanity.imagePaletteSwatch" 1890 | }, 1891 | "optional": true 1892 | } 1893 | } 1894 | } 1895 | }, 1896 | { 1897 | "name": "sanity.imageDimensions", 1898 | "type": "type", 1899 | "value": { 1900 | "type": "object", 1901 | "attributes": { 1902 | "_type": { 1903 | "type": "objectAttribute", 1904 | "value": { 1905 | "type": "string", 1906 | "value": "sanity.imageDimensions" 1907 | } 1908 | }, 1909 | "height": { 1910 | "type": "objectAttribute", 1911 | "value": { 1912 | "type": "number" 1913 | }, 1914 | "optional": true 1915 | }, 1916 | "width": { 1917 | "type": "objectAttribute", 1918 | "value": { 1919 | "type": "number" 1920 | }, 1921 | "optional": true 1922 | }, 1923 | "aspectRatio": { 1924 | "type": "objectAttribute", 1925 | "value": { 1926 | "type": "number" 1927 | }, 1928 | "optional": true 1929 | } 1930 | } 1931 | } 1932 | }, 1933 | { 1934 | "name": "sanity.imageHotspot", 1935 | "type": "type", 1936 | "value": { 1937 | "type": "object", 1938 | "attributes": { 1939 | "_type": { 1940 | "type": "objectAttribute", 1941 | "value": { 1942 | "type": "string", 1943 | "value": "sanity.imageHotspot" 1944 | } 1945 | }, 1946 | "x": { 1947 | "type": "objectAttribute", 1948 | "value": { 1949 | "type": "number" 1950 | }, 1951 | "optional": true 1952 | }, 1953 | "y": { 1954 | "type": "objectAttribute", 1955 | "value": { 1956 | "type": "number" 1957 | }, 1958 | "optional": true 1959 | }, 1960 | "height": { 1961 | "type": "objectAttribute", 1962 | "value": { 1963 | "type": "number" 1964 | }, 1965 | "optional": true 1966 | }, 1967 | "width": { 1968 | "type": "objectAttribute", 1969 | "value": { 1970 | "type": "number" 1971 | }, 1972 | "optional": true 1973 | } 1974 | } 1975 | } 1976 | }, 1977 | { 1978 | "name": "sanity.imageCrop", 1979 | "type": "type", 1980 | "value": { 1981 | "type": "object", 1982 | "attributes": { 1983 | "_type": { 1984 | "type": "objectAttribute", 1985 | "value": { 1986 | "type": "string", 1987 | "value": "sanity.imageCrop" 1988 | } 1989 | }, 1990 | "top": { 1991 | "type": "objectAttribute", 1992 | "value": { 1993 | "type": "number" 1994 | }, 1995 | "optional": true 1996 | }, 1997 | "bottom": { 1998 | "type": "objectAttribute", 1999 | "value": { 2000 | "type": "number" 2001 | }, 2002 | "optional": true 2003 | }, 2004 | "left": { 2005 | "type": "objectAttribute", 2006 | "value": { 2007 | "type": "number" 2008 | }, 2009 | "optional": true 2010 | }, 2011 | "right": { 2012 | "type": "objectAttribute", 2013 | "value": { 2014 | "type": "number" 2015 | }, 2016 | "optional": true 2017 | } 2018 | } 2019 | } 2020 | }, 2021 | { 2022 | "name": "sanity.fileAsset", 2023 | "type": "document", 2024 | "attributes": { 2025 | "_id": { 2026 | "type": "objectAttribute", 2027 | "value": { 2028 | "type": "string" 2029 | } 2030 | }, 2031 | "_type": { 2032 | "type": "objectAttribute", 2033 | "value": { 2034 | "type": "string", 2035 | "value": "sanity.fileAsset" 2036 | } 2037 | }, 2038 | "_createdAt": { 2039 | "type": "objectAttribute", 2040 | "value": { 2041 | "type": "string" 2042 | } 2043 | }, 2044 | "_updatedAt": { 2045 | "type": "objectAttribute", 2046 | "value": { 2047 | "type": "string" 2048 | } 2049 | }, 2050 | "_rev": { 2051 | "type": "objectAttribute", 2052 | "value": { 2053 | "type": "string" 2054 | } 2055 | }, 2056 | "originalFilename": { 2057 | "type": "objectAttribute", 2058 | "value": { 2059 | "type": "string" 2060 | }, 2061 | "optional": true 2062 | }, 2063 | "label": { 2064 | "type": "objectAttribute", 2065 | "value": { 2066 | "type": "string" 2067 | }, 2068 | "optional": true 2069 | }, 2070 | "title": { 2071 | "type": "objectAttribute", 2072 | "value": { 2073 | "type": "string" 2074 | }, 2075 | "optional": true 2076 | }, 2077 | "description": { 2078 | "type": "objectAttribute", 2079 | "value": { 2080 | "type": "string" 2081 | }, 2082 | "optional": true 2083 | }, 2084 | "altText": { 2085 | "type": "objectAttribute", 2086 | "value": { 2087 | "type": "string" 2088 | }, 2089 | "optional": true 2090 | }, 2091 | "sha1hash": { 2092 | "type": "objectAttribute", 2093 | "value": { 2094 | "type": "string" 2095 | }, 2096 | "optional": true 2097 | }, 2098 | "extension": { 2099 | "type": "objectAttribute", 2100 | "value": { 2101 | "type": "string" 2102 | }, 2103 | "optional": true 2104 | }, 2105 | "mimeType": { 2106 | "type": "objectAttribute", 2107 | "value": { 2108 | "type": "string" 2109 | }, 2110 | "optional": true 2111 | }, 2112 | "size": { 2113 | "type": "objectAttribute", 2114 | "value": { 2115 | "type": "number" 2116 | }, 2117 | "optional": true 2118 | }, 2119 | "assetId": { 2120 | "type": "objectAttribute", 2121 | "value": { 2122 | "type": "string" 2123 | }, 2124 | "optional": true 2125 | }, 2126 | "uploadId": { 2127 | "type": "objectAttribute", 2128 | "value": { 2129 | "type": "string" 2130 | }, 2131 | "optional": true 2132 | }, 2133 | "path": { 2134 | "type": "objectAttribute", 2135 | "value": { 2136 | "type": "string" 2137 | }, 2138 | "optional": true 2139 | }, 2140 | "url": { 2141 | "type": "objectAttribute", 2142 | "value": { 2143 | "type": "string" 2144 | }, 2145 | "optional": true 2146 | }, 2147 | "source": { 2148 | "type": "objectAttribute", 2149 | "value": { 2150 | "type": "inline", 2151 | "name": "sanity.assetSourceData" 2152 | }, 2153 | "optional": true 2154 | } 2155 | } 2156 | }, 2157 | { 2158 | "name": "sanity.imageAsset", 2159 | "type": "document", 2160 | "attributes": { 2161 | "_id": { 2162 | "type": "objectAttribute", 2163 | "value": { 2164 | "type": "string" 2165 | } 2166 | }, 2167 | "_type": { 2168 | "type": "objectAttribute", 2169 | "value": { 2170 | "type": "string", 2171 | "value": "sanity.imageAsset" 2172 | } 2173 | }, 2174 | "_createdAt": { 2175 | "type": "objectAttribute", 2176 | "value": { 2177 | "type": "string" 2178 | } 2179 | }, 2180 | "_updatedAt": { 2181 | "type": "objectAttribute", 2182 | "value": { 2183 | "type": "string" 2184 | } 2185 | }, 2186 | "_rev": { 2187 | "type": "objectAttribute", 2188 | "value": { 2189 | "type": "string" 2190 | } 2191 | }, 2192 | "originalFilename": { 2193 | "type": "objectAttribute", 2194 | "value": { 2195 | "type": "string" 2196 | }, 2197 | "optional": true 2198 | }, 2199 | "label": { 2200 | "type": "objectAttribute", 2201 | "value": { 2202 | "type": "string" 2203 | }, 2204 | "optional": true 2205 | }, 2206 | "title": { 2207 | "type": "objectAttribute", 2208 | "value": { 2209 | "type": "string" 2210 | }, 2211 | "optional": true 2212 | }, 2213 | "description": { 2214 | "type": "objectAttribute", 2215 | "value": { 2216 | "type": "string" 2217 | }, 2218 | "optional": true 2219 | }, 2220 | "altText": { 2221 | "type": "objectAttribute", 2222 | "value": { 2223 | "type": "string" 2224 | }, 2225 | "optional": true 2226 | }, 2227 | "sha1hash": { 2228 | "type": "objectAttribute", 2229 | "value": { 2230 | "type": "string" 2231 | }, 2232 | "optional": true 2233 | }, 2234 | "extension": { 2235 | "type": "objectAttribute", 2236 | "value": { 2237 | "type": "string" 2238 | }, 2239 | "optional": true 2240 | }, 2241 | "mimeType": { 2242 | "type": "objectAttribute", 2243 | "value": { 2244 | "type": "string" 2245 | }, 2246 | "optional": true 2247 | }, 2248 | "size": { 2249 | "type": "objectAttribute", 2250 | "value": { 2251 | "type": "number" 2252 | }, 2253 | "optional": true 2254 | }, 2255 | "assetId": { 2256 | "type": "objectAttribute", 2257 | "value": { 2258 | "type": "string" 2259 | }, 2260 | "optional": true 2261 | }, 2262 | "uploadId": { 2263 | "type": "objectAttribute", 2264 | "value": { 2265 | "type": "string" 2266 | }, 2267 | "optional": true 2268 | }, 2269 | "path": { 2270 | "type": "objectAttribute", 2271 | "value": { 2272 | "type": "string" 2273 | }, 2274 | "optional": true 2275 | }, 2276 | "url": { 2277 | "type": "objectAttribute", 2278 | "value": { 2279 | "type": "string" 2280 | }, 2281 | "optional": true 2282 | }, 2283 | "metadata": { 2284 | "type": "objectAttribute", 2285 | "value": { 2286 | "type": "inline", 2287 | "name": "sanity.imageMetadata" 2288 | }, 2289 | "optional": true 2290 | }, 2291 | "source": { 2292 | "type": "objectAttribute", 2293 | "value": { 2294 | "type": "inline", 2295 | "name": "sanity.assetSourceData" 2296 | }, 2297 | "optional": true 2298 | } 2299 | } 2300 | }, 2301 | { 2302 | "name": "sanity.imageMetadata", 2303 | "type": "type", 2304 | "value": { 2305 | "type": "object", 2306 | "attributes": { 2307 | "_type": { 2308 | "type": "objectAttribute", 2309 | "value": { 2310 | "type": "string", 2311 | "value": "sanity.imageMetadata" 2312 | } 2313 | }, 2314 | "location": { 2315 | "type": "objectAttribute", 2316 | "value": { 2317 | "type": "inline", 2318 | "name": "geopoint" 2319 | }, 2320 | "optional": true 2321 | }, 2322 | "dimensions": { 2323 | "type": "objectAttribute", 2324 | "value": { 2325 | "type": "inline", 2326 | "name": "sanity.imageDimensions" 2327 | }, 2328 | "optional": true 2329 | }, 2330 | "palette": { 2331 | "type": "objectAttribute", 2332 | "value": { 2333 | "type": "inline", 2334 | "name": "sanity.imagePalette" 2335 | }, 2336 | "optional": true 2337 | }, 2338 | "lqip": { 2339 | "type": "objectAttribute", 2340 | "value": { 2341 | "type": "string" 2342 | }, 2343 | "optional": true 2344 | }, 2345 | "blurHash": { 2346 | "type": "objectAttribute", 2347 | "value": { 2348 | "type": "string" 2349 | }, 2350 | "optional": true 2351 | }, 2352 | "hasAlpha": { 2353 | "type": "objectAttribute", 2354 | "value": { 2355 | "type": "boolean" 2356 | }, 2357 | "optional": true 2358 | }, 2359 | "isOpaque": { 2360 | "type": "objectAttribute", 2361 | "value": { 2362 | "type": "boolean" 2363 | }, 2364 | "optional": true 2365 | } 2366 | } 2367 | } 2368 | }, 2369 | { 2370 | "name": "geopoint", 2371 | "type": "type", 2372 | "value": { 2373 | "type": "object", 2374 | "attributes": { 2375 | "_type": { 2376 | "type": "objectAttribute", 2377 | "value": { 2378 | "type": "string", 2379 | "value": "geopoint" 2380 | } 2381 | }, 2382 | "lat": { 2383 | "type": "objectAttribute", 2384 | "value": { 2385 | "type": "number" 2386 | }, 2387 | "optional": true 2388 | }, 2389 | "lng": { 2390 | "type": "objectAttribute", 2391 | "value": { 2392 | "type": "number" 2393 | }, 2394 | "optional": true 2395 | }, 2396 | "alt": { 2397 | "type": "objectAttribute", 2398 | "value": { 2399 | "type": "number" 2400 | }, 2401 | "optional": true 2402 | } 2403 | } 2404 | } 2405 | }, 2406 | { 2407 | "name": "slug", 2408 | "type": "type", 2409 | "value": { 2410 | "type": "object", 2411 | "attributes": { 2412 | "_type": { 2413 | "type": "objectAttribute", 2414 | "value": { 2415 | "type": "string", 2416 | "value": "slug" 2417 | } 2418 | }, 2419 | "current": { 2420 | "type": "objectAttribute", 2421 | "value": { 2422 | "type": "string" 2423 | }, 2424 | "optional": true 2425 | }, 2426 | "source": { 2427 | "type": "objectAttribute", 2428 | "value": { 2429 | "type": "string" 2430 | }, 2431 | "optional": true 2432 | } 2433 | } 2434 | } 2435 | }, 2436 | { 2437 | "name": "sanity.assetSourceData", 2438 | "type": "type", 2439 | "value": { 2440 | "type": "object", 2441 | "attributes": { 2442 | "_type": { 2443 | "type": "objectAttribute", 2444 | "value": { 2445 | "type": "string", 2446 | "value": "sanity.assetSourceData" 2447 | } 2448 | }, 2449 | "name": { 2450 | "type": "objectAttribute", 2451 | "value": { 2452 | "type": "string" 2453 | }, 2454 | "optional": true 2455 | }, 2456 | "id": { 2457 | "type": "objectAttribute", 2458 | "value": { 2459 | "type": "string" 2460 | }, 2461 | "optional": true 2462 | }, 2463 | "url": { 2464 | "type": "objectAttribute", 2465 | "value": { 2466 | "type": "string" 2467 | }, 2468 | "optional": true 2469 | } 2470 | } 2471 | } 2472 | } 2473 | ] 2474 | -------------------------------------------------------------------------------- /styles/index.css: -------------------------------------------------------------------------------- 1 | html { 2 | -webkit-font-smoothing: antialiased; 3 | -moz-osx-font-smoothing: grayscale; 4 | overflow-x: hidden; 5 | } 6 | 7 | p:not(:last-child) { 8 | margin-bottom: 0.875rem; 9 | } 10 | 11 | ol, 12 | ul { 13 | margin-left: 1rem; 14 | } 15 | 16 | ol { 17 | list-style-type: disc; 18 | } 19 | -------------------------------------------------------------------------------- /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 | "target": "ES2017", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": false, 8 | "strictNullChecks": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "noEmit": true, 11 | "esModuleInterop": true, 12 | "module": "esnext", 13 | "moduleResolution": "bundler", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "jsx": "preserve", 17 | "incremental": true, 18 | "plugins": [ 19 | { 20 | "name": "next" 21 | } 22 | ], 23 | "paths": { 24 | "@/*": ["./*"] 25 | } 26 | }, 27 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 28 | "exclude": ["node_modules"] 29 | } 30 | -------------------------------------------------------------------------------- /types/index.ts: -------------------------------------------------------------------------------- 1 | import type {PortableTextBlock} from 'next-sanity' 2 | import type {Image} from 'sanity' 3 | 4 | export interface MilestoneItem { 5 | _key: string 6 | description?: string 7 | duration?: { 8 | start?: string 9 | end?: string 10 | } 11 | image?: Image 12 | tags?: string[] 13 | title?: string 14 | } 15 | 16 | export interface ShowcaseProject { 17 | _id: string 18 | _type: string 19 | coverImage?: Image 20 | overview?: PortableTextBlock[] 21 | slug?: string 22 | tags?: string[] 23 | title?: string 24 | } 25 | -------------------------------------------------------------------------------- /vercel-installation-instructions.md: -------------------------------------------------------------------------------- 1 | # Deploying with Vercel 2 | 3 | ## Table of Contents 4 | 5 | - [Configuration](#configuration) 6 | - [Step 1. Set up the environment](#step-1-set-up-the-environment) 7 | - [Step 2. Set up the project locally](#step-2-set-up-the-project-locally) 8 | - [Step 3. Run Next.js locally in development mode](#step-3-run-nextjs-locally-in-development-mode) 9 | - [Step 4. Deploy to production](#step-4-deploy-to-production) 10 | 11 | ## Deploying with Vercel 12 | 13 | ### Step 1. Set up the environment 14 | 15 | 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]. 16 | 17 | [![Deploy with Vercel](https://vercel.com/button)][vercel-deploy] 18 | 19 | ### Step 2. Set up the project locally 20 | 21 | [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: 22 | 23 | ```bash 24 | npx vercel link 25 | ``` 26 | 27 | Download the environment variables needed to connect Next.js and the Studio to your Sanity project: 28 | 29 | ```bash 30 | npx vercel env pull 31 | ``` 32 | 33 | ### Step 3. Run Next.js locally in development mode 34 | 35 | ```bash 36 | npm install && npm run dev 37 | ``` 38 | 39 | When you run this development server, the changes you make in your frontend and studio configuration will be applied live using hot reloading. 40 | 41 | 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]. 42 | 43 | ### Step 4. Deploy to production 44 | 45 | To deploy your changes to production you use `git`: 46 | 47 | ```bash 48 | git add . 49 | git commit 50 | git push 51 | ``` 52 | 53 | Alternatively, you can deploy without a `git` hosting provider using the Vercel CLI: 54 | 55 | ```bash 56 | npx vercel --prod 57 | ``` 58 | 59 | [vercel-deploy]: https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fsanity-io%2Ftemplate-nextjs-personal-website&project-name=nextjs-personal-website&repository-name=nextjs-personal-website&demo-title=Personal+Website+with+Built-in+Content+Editing&demo-description=A+Sanity-powered+personal+website+with+built-in+content+editing+and+instant+previews.+Uses+App+Router.&demo-url=https%3A%2F%2Ftemplate-nextjs-personal-website.sanity.build%2F&demo-image=https%3A%2F%2Fuser-images.githubusercontent.com%2F6951139%2F206395107-e58a796d-13a9-400a-94b6-31cb5df054ab.png&integration-ids=oac_hb2LITYajhRQ0i4QznmKH7gx&external-id=nextjs%3Btemplate%3Dtemplate-nextjs-personal-website 60 | --------------------------------------------------------------------------------