├── .env.example
├── .eslintrc.json
├── .git-blame-ignore-revs
├── .github
└── workflows
│ └── ci.yml
├── .gitignore
├── .husky
├── commit-msg
└── pre-commit
├── .npmrc
├── .prettierignore
├── .prettierrc.json
├── CONTRIBUTING.md
├── LICENSE
├── README.md
├── commitlint.config.js
├── components
├── Alert.tsx
├── ArticleIndex.tsx
├── ArticleIndexLifestyle.tsx
├── ArticlePage.tsx
├── ArticlePreview.tsx
├── AuthorPage.tsx
├── Avatar.tsx
├── Body.tsx
├── Container.tsx
├── CoverImage.tsx
├── Credits.tsx
├── Date.tsx
├── Figure.tsx
├── Footer.tsx
├── Header.tsx
├── Layout.tsx
├── LayoutLifestyle.tsx
├── MoreStories.tsx
├── Navbar.tsx
├── NavbarLifestyle.tsx
├── PreviewArticlePage.tsx
├── PreviewArticlePreview.tsx
├── PreviewAuthorPage.tsx
├── PreviewReviewPreview.tsx
├── PreviewSectionPage.tsx
├── SectionPage.tsx
├── SectionSeparator.tsx
├── Seo.tsx
├── SiteHeader.tsx
└── Title.tsx
├── lib
├── config.ts
├── constants.tsx
├── homeVariationsMiddleware.ts
├── next-seo.config.ts
├── queries
│ ├── article.ts
│ ├── index.ts
│ ├── newsletter.ts
│ ├── person.ts
│ └── section.ts
├── sanity.preview.ts
├── sanity.server.tsx
└── sanity.tsx
├── lint-staged.config.js
├── logo.tsx
├── middleware.ts
├── next-env.d.ts
├── next.config.js
├── next.lock
├── data
│ └── https_themer.sanity.build
│ │ └── api_hues_preset_tw-cyan_primary_b595f9_60a6d334a732789239e0
└── lock.json
├── package-lock.json
├── package.json
├── pages
├── [brand]
│ ├── articles
│ │ └── [slug].tsx
│ ├── authors
│ │ └── [slug].tsx
│ ├── home
│ │ └── [[...variant]].tsx
│ ├── index.tsx
│ └── sections
│ │ └── [slug].tsx
├── _app.tsx
├── _document.tsx
├── api
│ ├── dynamic-og.tsx
│ ├── exit-preview.tsx
│ ├── newsletter
│ │ └── preview.tsx
│ ├── preview.ts
│ └── revalidate.tsx
├── index.tsx
└── studio
│ └── [[...index]].tsx
├── plugins
├── PreviewPane
│ └── index.tsx
├── defaultConfig
│ ├── defaultDocumentNode.tsx
│ ├── index.ts
│ ├── mediaConfigPlugin.ts
│ └── structure
│ │ ├── index.ts
│ │ ├── lifestyleStructure.ts
│ │ ├── reviewStructure.ts
│ │ ├── techStructure.ts
│ │ └── utils.tsx
├── newsletter
│ ├── components
│ │ ├── EMSStatusWrapper.tsx
│ │ ├── InputWrappers.tsx
│ │ ├── NewsletterPreview.tsx
│ │ ├── SyncNewArticles.tsx
│ │ └── index.ts
│ ├── index.ts
│ ├── templates
│ │ ├── newsletter.mjml
│ │ └── newsletter.text.mjml.twig
│ └── utils
│ │ └── format.ts
├── productionUrl
│ ├── index.ts
│ └── utils
│ │ ├── buildPreviewUrl.ts
│ │ ├── getSecret.ts
│ │ ├── index.ts
│ │ └── useBuildPreviewUrl.ts
└── variations
│ └── index.tsx
├── postcss.config.js
├── public
└── favicon
│ ├── android-chrome-192x192.png
│ ├── android-chrome-512x512.png
│ ├── apple-touch-icon.png
│ ├── browserconfig.xml
│ ├── favicon-16x16.png
│ ├── favicon-32x32.png
│ ├── favicon.ico
│ ├── mstile-150x150.png
│ ├── safari-pinned-tab.svg
│ └── site.webmanifest
├── renovate.json
├── sanity.cli.ts
├── sanity.config.ts
├── schemas
├── article.ts
├── index.ts
├── newsletter.ts
├── objects
│ ├── articleReference.ts
│ ├── articleReferences.ts
│ ├── brand.ts
│ ├── contentRole.ts
│ ├── mainImage.ts
│ ├── minimalPortableText.ts
│ ├── podcastEpisode.tsx
│ ├── podcastReference.ts
│ ├── portableText.ts
│ ├── reviewReference.ts
│ ├── seo.ts
│ └── video.tsx
├── person.ts
├── podcast.ts
├── review.ts
├── section.ts
├── siteSettings.ts
└── utils
│ ├── getVariablePortableText.ts
│ ├── index.ts
│ └── referenceBrandHelpers.ts
├── styles
└── index.css
├── tailwind.config.js
├── themer.d.ts
├── tsconfig.json
├── types
└── index.tsx
├── utils
├── brand.ts
├── logError.ts
├── routing.ts
├── useArticleOrPreview.tsx
├── useRandom.ts
└── useSplitLifestyleArticles.ts
├── vite.d.ts
└── yalc.lock
/.env.example:
--------------------------------------------------------------------------------
1 | NEXT_PUBLIC_VERCEL_GIT_REPO_OWNER="sanity-io"
2 | NEXT_PUBLIC_VERCEL_GIT_PROVIDER="github"
3 | NEXT_PUBLIC_VERCEL_GIT_REPO_SLUG="demo-media-site-nextjs"
4 | NEXT_PUBLIC_BRAND="lifestyle"
5 |
6 | NEXT_PUBLIC_SANITY_PROJECT_ID=
7 | NEXT_PUBLIC_SANITY_DATASET=
8 | SANITY_API_READ_TOKEN=
9 | SANITY_REVALIDATE_SECRET=
10 |
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": ["next", "sanity/react", "sanity/typescript", "prettier"],
3 | "plugins": ["simple-import-sort"],
4 | "rules": {
5 | "simple-import-sort/imports": "warn",
6 | "simple-import-sort/exports": "warn",
7 | "react-hooks/exhaustive-deps": "error",
8 | "@next/next/no-page-custom-font": "off"
9 | },
10 | "globals": {
11 | "JSX": true,
12 | "NodeJS": true
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/.git-blame-ignore-revs:
--------------------------------------------------------------------------------
1 | # .git-blame-ignore-revs
2 | # initial de-lint, format by Rune
3 | 637d309b1ee82eed17e0d7926f27470d3cb4fb28
4 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on:
4 | pull_request:
5 | push:
6 | branches: [main]
7 | workflow_dispatch:
8 |
9 | concurrency:
10 | group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
11 | cancel-in-progress: true
12 |
13 | jobs:
14 | build:
15 | runs-on: ubuntu-latest
16 | steps:
17 | - uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # tag=v3
18 | - uses: actions/setup-node@8c91899e586c5b171469028077307d293428b516 # tag=v3
19 | with:
20 | node-version: lts/*
21 | cache: npm
22 | - run: npm ci
23 | - run: npm run type-check
24 | - run: npm run lint -- --max-warnings 0
25 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /studio/node_modules
6 | /.pnp
7 | .pnp.js
8 |
9 | # testing
10 | /coverage
11 |
12 | # next.js
13 | /.next/
14 | /out/
15 |
16 | # production
17 | /build
18 | /studio/dist
19 |
20 | # misc
21 | .DS_Store
22 | *.pem
23 |
24 | # debug
25 | npm-debug.log*
26 | yarn-debug.log*
27 | yarn-error.log*
28 | .pnpm-debug.log*
29 |
30 | # local env files
31 | .env*.local
32 |
33 | # vercel
34 | .vercel
35 |
36 | .idea
37 | .vscode
38 |
39 | # typescript
40 | *.tsbuildinfo
41 |
42 | # Env files created by scripts for working locally
43 | .env
44 | studio/.env.development
45 |
46 | #yalc
47 | .yalc
48 |
49 | #sanity
50 | .sanity*
51 | dist
52 |
--------------------------------------------------------------------------------
/.husky/commit-msg:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 | . "$(dirname -- "$0")/_/husky.sh"
3 |
4 | npx --no -- commitlint --edit ""
5 |
--------------------------------------------------------------------------------
/.husky/pre-commit:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 | . "$(dirname -- "$0")/_/husky.sh"
3 |
4 | npx lint-staged
5 |
--------------------------------------------------------------------------------
/.npmrc:
--------------------------------------------------------------------------------
1 | legacy-peer-deps=true
2 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | *.mjml
2 |
--------------------------------------------------------------------------------
/.prettierrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "printWidth": 80,
3 | "tabWidth": 2,
4 | "useTabs": false,
5 | "semi": false,
6 | "singleQuote": true,
7 | "trailingComma": "es5",
8 | "bracketSpacing": false,
9 | "plugins": ["prettier-plugin-tailwindcss"]
10 | }
11 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sanity-io/demo-media-site-nextjs/52f0b7ec86df5ab694361ebfdcfc63e305115926/CONTRIBUTING.md
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2016 - 2022 Sanity.io
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/commitlint.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | extends: ['@commitlint/config-conventional'],
3 | }
4 |
--------------------------------------------------------------------------------
/components/Alert.tsx:
--------------------------------------------------------------------------------
1 | import cn from 'classnames'
2 | import Link from 'next/link'
3 | import * as React from 'react'
4 |
5 | import {isLifestyle} from '../utils/brand'
6 | import Container from './Container'
7 |
8 | const isLifestyleBrand = isLifestyle()
9 |
10 | export default function Alert({preview}: {preview: boolean}) {
11 | return (
12 |
13 |
22 |
23 |
24 | {preview && (
25 | <>
26 | This page is a preview.{' '}
27 |
31 | Click here
32 | {' '}
33 | to exit preview mode.
34 | >
35 | )}
36 |
37 |
38 |
39 |
40 | )
41 | }
42 |
--------------------------------------------------------------------------------
/components/ArticleIndex.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import {useArticleOrPreview} from 'utils/useArticleOrPreview'
3 |
4 | import {Article, Review} from '../types'
5 |
6 | export default function ArticleIndex({
7 | articles,
8 | token,
9 | }: {
10 | articles: (Article | Review)[]
11 | token?: string
12 | }) {
13 | const displayedArticles = useArticleOrPreview(articles, 'tech', token)
14 | return (
15 |
16 | Articles
17 |
18 |
19 | {displayedArticles}
20 |
21 |
22 |
23 | )
24 | }
25 |
--------------------------------------------------------------------------------
/components/ArticleIndexLifestyle.tsx:
--------------------------------------------------------------------------------
1 | import clsx from 'clsx'
2 | import React, {useMemo} from 'react'
3 | import {useArticleOrPreview} from 'utils/useArticleOrPreview'
4 |
5 | import {Article, Review} from '../types'
6 | import {useSplitLifestyleArticles} from '../utils/useSplitLifestyleArticles'
7 |
8 | function StorySection({
9 | title,
10 | columns = 4,
11 | sectionType,
12 | children,
13 | }: {
14 | title?: string
15 | columns?: number
16 | sectionType?: 'featured' | 'normal'
17 | children: React.ReactNode
18 | }) {
19 | const gridClass = useMemo(() => {
20 | //per Fred, needed for correct borders to work in all breakpoints and light and dark
21 | const baseGridClass = `grid border-gray-200 dark:divide-gray-900 dark:border-gray-900 max-sm:divide-gray-200 max-sm:dark:divide-gray-900 max-sm:divide-y sm:grid-cols-2 sm:border sm:[&>*]:dark:border-gray-900 md:rounded md:grid-cols-${columns}`
22 | const unfeaturedClass = `sm:[&>:nth-child(-n+3):not(:last-child)]:border-b md:border md:[&>:not(:nth-child(3n))]:border-r`
23 | const featuredClass = `sm:[&>:not(:last-child):nth-child(odd)]:border-r sm:[&>:not(:last-child)]:border-b md:[&>:not(:last-child):not(:nth-child(4))]:border-r`
24 | return clsx(
25 | baseGridClass,
26 | sectionType === 'featured' ? featuredClass : unfeaturedClass
27 | )
28 | }, [columns, sectionType])
29 |
30 | return (
31 |
32 | {title && (
33 |
34 | ● {title}
35 |
36 | )}
37 |
38 |
41 |
42 | )
43 | }
44 |
45 | export function ArticleIndexLifestyle({
46 | articles,
47 | token,
48 | }: {
49 | articles: (Article | Review)[]
50 | token?: string
51 | }) {
52 | const {topArticles, restArticles} = useSplitLifestyleArticles(articles)
53 | const featured = useArticleOrPreview(
54 | topArticles,
55 | 'lifestyle',
56 | token,
57 | 'featured'
58 | )
59 | const unfeatured = useArticleOrPreview(
60 | restArticles,
61 | 'lifestyle',
62 | token,
63 | 'normal'
64 | )
65 |
66 | return (
67 |
68 | {Boolean(featured?.length) && (
69 | {featured}
70 | )}
71 | {Boolean(unfeatured?.length) && (
72 |
73 | {unfeatured}
74 |
75 | )}
76 |
77 | )
78 | }
79 |
--------------------------------------------------------------------------------
/components/ArticlePage.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 |
3 | import {Article} from '../types'
4 | import Body from './Body'
5 | import Container from './Container'
6 | import {PeopleProvider} from './Credits'
7 | import Header from './Header'
8 | import {Seo} from './Seo'
9 |
10 | interface ArticlePageProps {
11 | article?: Article
12 | }
13 |
14 | export default function ArticlePage({article}: ArticlePageProps) {
15 | return (
16 |
17 | {article && }
18 |
19 |
20 |
27 |
33 |
34 |
35 |
36 | )
37 | }
38 |
--------------------------------------------------------------------------------
/components/ArticlePreview.tsx:
--------------------------------------------------------------------------------
1 | import {PortableText} from '@portabletext/react'
2 | import cn from 'classnames'
3 | import {config} from 'lib/config'
4 | import Link from 'next/link'
5 | import * as React from 'react'
6 |
7 | import {ArticlePreviewProps} from '../types'
8 | import {getUrlForDocumentType} from '../utils/routing'
9 | import {useRandom} from '../utils/useRandom'
10 | import CoverImage from './CoverImage'
11 | import {PeopleList} from './Credits'
12 | import Date from './Date'
13 |
14 | const PREVIEW_TYPE_FEATURED_HIGHLIGHTED = 'featured-highlighted'
15 | const PREVIEW_TYPE_FEATURED_NORMAL = 'featured-normal'
16 | const PREVIEW_TYPE_NORMAL = 'normal'
17 | const IMAGE_TYPES = {
18 | [PREVIEW_TYPE_FEATURED_HIGHLIGHTED]: {
19 | aspectClass: 'aspect-square md:aspect-[26/9]',
20 | width: 2500,
21 | height: 900,
22 | },
23 | [PREVIEW_TYPE_FEATURED_NORMAL]: {
24 | aspectClass: 'aspect-square',
25 | width: 1000,
26 | height: 1000,
27 | },
28 | [PREVIEW_TYPE_NORMAL]: {
29 | aspectClass: 'aspect-[6/4.5]',
30 | width: 1000,
31 | height: 700,
32 | },
33 | }
34 |
35 | const CATS = [
36 | 'Superdrug',
37 | 'Beauty',
38 | 'Health',
39 | 'Lifestyle',
40 | 'Entertainment',
41 | 'Fashion',
42 | 'Christmas',
43 | ]
44 |
45 | export default function ArticlePreview({
46 | title,
47 | mainImage,
48 | date,
49 | intro,
50 | people,
51 | sections,
52 | isHighlighted,
53 | slug,
54 | sectionType,
55 | brandName,
56 | }: ArticlePreviewProps & {brandName?: string}) {
57 | let category = useRandom(CATS)
58 | category = sections?.[0]?.name || category
59 | const isLifestyleBrand = brandName === config.lifestyleBrand
60 |
61 | if (isLifestyleBrand) {
62 | const firstPerson = people?.[0] || {name: '', slug: ''}
63 | let imageSettings = IMAGE_TYPES[PREVIEW_TYPE_NORMAL]
64 | if (sectionType === 'featured') {
65 | if (isHighlighted) {
66 | imageSettings = IMAGE_TYPES[PREVIEW_TYPE_FEATURED_HIGHLIGHTED]
67 | } else {
68 | imageSettings = IMAGE_TYPES[PREVIEW_TYPE_FEATURED_NORMAL]
69 | }
70 | }
71 |
72 | const aspectClass = imageSettings.aspectClass
73 | const width = imageSettings.width
74 | const height = imageSettings.height
75 | return (
76 |
83 |
90 |
102 | {!isHighlighted && (
103 |
104 |
105 | {category}
106 |
107 |
108 | )}
109 |
110 |
116 | {isHighlighted && (
117 |
118 |
119 | {category}
120 |
121 |
122 | )}
123 |
130 |
134 | {title}
135 |
136 |
137 |
138 |
139 | {firstPerson.name && (
140 |
141 | by
142 |
143 |
150 | {firstPerson.name}
151 |
152 |
153 |
154 | )}
155 |
156 |
157 |
158 | )
159 | }
160 |
161 | return (
162 |
163 |
164 |
171 |
172 |
173 |
174 |
178 | {title}
179 |
180 |
181 |
182 | {intro && (
183 |
186 | )}
187 |
188 |
189 |
0 ? ' ● ' : ''}
191 | className="after:inline after:content-[attr(data-after)]"
192 | >
193 | {date && }
194 |
195 | {people && people?.length > 0 ?
: null}
196 |
197 |
198 |
199 | )
200 | }
201 |
--------------------------------------------------------------------------------
/components/AuthorPage.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 |
3 | import {Author} from '../types'
4 | import Body from './Body'
5 | import Container from './Container'
6 | import MoreStories from './MoreStories'
7 | import {Seo} from './Seo'
8 | import Title from './Title'
9 |
10 | export default function AuthorPage({author}: {author: Author}) {
11 | const {name, bio, articles} = author || {}
12 | return (
13 |
14 |
15 |
16 |
{name}
17 | {bio && }
18 |
19 | {articles && articles?.length > 0 && (
20 |
21 | )}
22 |
23 | )
24 | }
25 |
--------------------------------------------------------------------------------
/components/Avatar.tsx:
--------------------------------------------------------------------------------
1 | import Image from 'next/image'
2 | import * as React from 'react'
3 |
4 | import {urlForImage} from '../lib/sanity'
5 | import {Author} from '../types'
6 |
7 | export default function Avatar(props: Author) {
8 | const {name, image} = props
9 | return (
10 |
11 |
12 |
23 |
24 |
{name}
25 |
26 | )
27 | }
28 |
--------------------------------------------------------------------------------
/components/Body.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * This component uses Portable Text to render a post body.
3 | *
4 | * You can learn more about Portable Text on:
5 | * https://www.sanity.io/docs/block-content
6 | * https://github.com/portabletext/react-portabletext
7 | * https://portabletext.org/
8 | *
9 | */
10 | import {PortableText, PortableTextMarkComponentProps} from '@portabletext/react'
11 | import {config} from 'lib/config'
12 | import dynamic from 'next/dynamic'
13 | import Image from 'next/image'
14 | import Link from 'next/link'
15 | import * as React from 'react'
16 | import {logError} from 'utils/logError'
17 |
18 | import {urlForImage} from '../lib/sanity'
19 | import {Article, MainImage} from '../types'
20 | import {getUrlForDocumentType} from '../utils/routing'
21 | import {Credits, PeopleList, usePeople} from './Credits'
22 | import {Figure} from './Figure'
23 |
24 | const ReactPlayer = dynamic(() => import('react-player'), {ssr: false})
25 |
26 | type MediaNode = {
27 | _type: 'video' | 'podcast'
28 | url: string
29 | }
30 |
31 | const BodyImage = React.memo(function BodyImage({
32 | image,
33 | caption,
34 | alt,
35 | }: MainImage) {
36 | const photographers = usePeople('photographer')
37 | const hasPhotographers = photographers && photographers.length > 0
38 | const separator =
39 | caption && photographers && photographers.length > 0 && ' ● '
40 |
41 | if (!image || !image?.asset) {
42 | return
43 | }
44 |
45 | return (
46 |
49 | {caption}
50 | {separator}
51 | {hasPhotographers && (
52 | <>
53 | Photo:
54 | >
55 | )}
56 |
57 | }
58 | img={
59 |
67 | }
68 | />
69 | )
70 | })
71 |
72 | const components = {
73 | block: {
74 | normal: ({children}: {children?: React.ReactNode}) => {
75 | return {children}
76 | },
77 | },
78 | types: {
79 | article: ({value}: {value: Article}) => {
80 | const {title, slug} = value
81 | const url = getUrlForDocumentType('article', slug, value.brand)
82 | return (
83 |
84 |
85 | Read more:{' '}
86 |
87 | {title}
88 |
89 |
90 |
91 | )
92 | },
93 | mainImage: ({value}: {value: MainImage}) => {
94 | return
95 | },
96 | podcast: ({value}: {value: MediaNode}) => {
97 | const {url} = value
98 | return (
99 |
100 |
101 |
102 | )
103 | },
104 | video: ({value}: {value: MediaNode}) => {
105 | const {url} = value
106 | return (
107 |
108 |
109 |
110 | )
111 | },
112 | },
113 | marks: {
114 | articleLink: ({children}: PortableTextMarkComponentProps) => {
115 | return {children}
116 | },
117 | },
118 | }
119 |
120 | export default function Body({
121 | date,
122 | content,
123 | people,
124 | brand,
125 | }: {
126 | content: any
127 | date?: any
128 | people?: any
129 | brand?: 'tech' | 'lifestyle'
130 | }) {
131 | const isLifestyle = brand === config.lifestyleBrand
132 |
133 | return (
134 |
141 | {people && people.length &&
}
142 |
143 |
154 |
155 | )
156 | }
157 |
--------------------------------------------------------------------------------
/components/Container.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 |
3 | export default function Container({children}: {children: React.ReactNode}) {
4 | return {children}
5 | }
6 |
--------------------------------------------------------------------------------
/components/CoverImage.tsx:
--------------------------------------------------------------------------------
1 | import cn from 'classnames'
2 | import Image from 'next/image'
3 | import Link from 'next/link'
4 | import * as React from 'react'
5 |
6 | import {urlForImage} from '../lib/sanity'
7 | import {getUrlForDocumentType} from '../utils/routing'
8 |
9 | interface CoverImageProps {
10 | title: string
11 | className?: string
12 | wrapperClassName?: string
13 | aspectClass?: string
14 | slug?: string
15 | brand?: string
16 | image?: {
17 | alt?: string
18 | image: any
19 | }
20 | priority?: boolean
21 | width?: number
22 | height?: number
23 | }
24 |
25 | export default function CoverImage(props: CoverImageProps) {
26 | const {
27 | title,
28 | slug,
29 | brand,
30 | image: source,
31 | priority,
32 | className,
33 | wrapperClassName = '',
34 | aspectClass = 'aspect-[4/2] md:aspect-[3/2]',
35 | width,
36 | height,
37 | } = props
38 | const alt = source?.alt
39 | const image = source?.image?.asset?._ref ? (
40 |
45 |
57 |
58 | ) : (
59 |
60 | )
61 |
62 | return (
63 |
64 | {slug ? (
65 |
69 | {image}
70 |
71 | ) : (
72 | image
73 | )}
74 |
75 | )
76 | }
77 |
--------------------------------------------------------------------------------
/components/Credits.tsx:
--------------------------------------------------------------------------------
1 | import {config} from 'lib/config'
2 | import Image from 'next/image'
3 | import Link from 'next/link'
4 | import React, {createContext, useContext} from 'react'
5 |
6 | import {urlForImage} from '../lib/sanity'
7 | import {Article} from '../types'
8 | import {getUrlForDocumentType} from '../utils/routing'
9 | import Date from './Date'
10 |
11 | const PeopleContext = createContext([])
12 |
13 | export function PeopleProvider({
14 | people,
15 | children,
16 | }: {
17 | people: Article['people']
18 | children: React.ReactNode
19 | }) {
20 | return (
21 | {children}
22 | )
23 | }
24 |
25 | // Create a hook that uses the PeopleContext and filters the list of people based on their role
26 | export function usePeople(role: string) {
27 | try {
28 | const people = useContext(PeopleContext)
29 | return people?.filter((person) => person.role === role)
30 | } catch (e) {
31 | return []
32 | }
33 | }
34 |
35 | export function Credits({
36 | role = 'author',
37 | brandName,
38 | date,
39 | }: {
40 | role?: 'author' | 'photographer' | 'contributor' | 'copyEditor'
41 | date?: string
42 | brandName?: string
43 | }) {
44 | const people = usePeople(role)
45 | const isLifestyle = brandName === config.lifestyleBrand
46 | if (isLifestyle && people) {
47 | const [firstPerson] = people
48 |
49 | return (
50 |
51 |
52 | {firstPerson?.image && (
53 |
54 |
64 |
65 | )}
66 |
67 | {date && (
68 | <>
69 |
70 |
71 | >
72 | )}
73 | {firstPerson && (
74 |
75 | by
76 |
77 |
84 | {firstPerson.name}
85 |
86 |
87 |
88 | )}
89 |
90 |
91 |
92 | )
93 | }
94 |
95 | return (
96 |
97 | {date && (
98 |
0 ? ' ● ' : undefined}
100 | className="after:inline after:content-[attr(data-after)]"
101 | >
102 |
103 |
104 | )}
105 |
106 |
107 | )
108 | }
109 |
110 | export const PeopleList = ({people}: {people: Article['people']}) => {
111 | return (
112 | <>
113 | {people &&
114 | people?.length > 0 &&
115 | people.map((person) => (
116 |
121 |
122 | {person.name}
123 |
124 |
125 | ))}
126 | >
127 | )
128 | }
129 |
--------------------------------------------------------------------------------
/components/Date.tsx:
--------------------------------------------------------------------------------
1 | import {format, parseISO} from 'date-fns'
2 | import * as React from 'react'
3 |
4 | export default function Date({dateString}: {dateString: string}) {
5 | if (!dateString) return null
6 |
7 | const date = parseISO(dateString)
8 | return
9 | }
10 |
--------------------------------------------------------------------------------
/components/Figure.tsx:
--------------------------------------------------------------------------------
1 | import {ReactNode} from 'react'
2 | import * as React from 'react'
3 |
4 | export function Figure(props: {
5 | caption?: ReactNode
6 | className?: string
7 | img?: ReactNode
8 | }) {
9 | const {caption, className = '', img} = props
10 |
11 | return (
12 |
13 | {img}
14 |
15 |
16 | {caption}
17 |
18 |
19 | )
20 | }
21 |
--------------------------------------------------------------------------------
/components/Footer.tsx:
--------------------------------------------------------------------------------
1 | import {config} from 'lib/config'
2 | import Link from 'next/link'
3 | import {useRouter} from 'next/router'
4 | import * as React from 'react'
5 |
6 | export default function Footer({brandName}: {brandName: string}) {
7 | const router = useRouter()
8 | const isLandingPage = router.pathname === '/'
9 |
10 | if (brandName === config.lifestyleBrand) {
11 | return (
12 |
17 |
20 |
21 | ● Reach
22 |
23 |
24 |
25 | )
26 | }
27 |
28 | return (
29 |
34 |
37 |
38 | ● Media
39 |
40 |
41 |
42 | )
43 | }
44 |
--------------------------------------------------------------------------------
/components/Header.tsx:
--------------------------------------------------------------------------------
1 | import {PortableText} from '@portabletext/react'
2 | import {config} from 'lib/config'
3 | import Image from 'next/image'
4 | import Link from 'next/link'
5 | import React from 'react'
6 | import {logError} from 'utils/logError'
7 |
8 | import {urlForImage} from '../lib/sanity'
9 | import {Article, BrandSpecificProps, MainImage} from '../types'
10 | import {getUrlForDocumentType} from '../utils/routing'
11 | import {PeopleList, usePeople} from './Credits'
12 | import {Figure} from './Figure'
13 |
14 | type HeaderProps = Pick
15 |
16 | export default function Header(props: HeaderProps & BrandSpecificProps) {
17 | const {title, mainImage, intro, sections, brand} = props
18 |
19 | if (brand === config.lifestyleBrand) {
20 | return
21 | }
22 |
23 | return (
24 | <>
25 |
26 | {sections && sections?.length > 0 && (
27 |
28 | )}
29 |
30 |
31 | {title || 'Untitled'}
32 |
33 |
34 | {intro && (
35 |
38 | )}
39 |
40 | {mainImage && mainImage.image && mainImage.image.asset?._ref && (
41 |
48 | )}
49 | >
50 | )
51 | }
52 |
53 | type HeaderLifestyleProps = Pick
54 |
55 | export function HeaderLifestyle(props: HeaderLifestyleProps) {
56 | const {title, mainImage} = props
57 | return (
58 | <>
59 | {mainImage && (
60 |
66 | )}
67 |
68 |
69 | {title || 'Untitled'}
70 |
71 |
72 | >
73 | )
74 | }
75 |
76 | function MainCoverImage({
77 | title,
78 | mainImage,
79 | width = 2000,
80 | height = 1000,
81 | brandName = 'tech',
82 | }: {
83 | title: string
84 | width?: number
85 | height?: number
86 | mainImage: MainImage
87 | brandName?: string
88 | }) {
89 | const photographers = usePeople('photographer')
90 | const hasPhotographers = photographers && photographers.length > 0
91 | const separator =
92 | mainImage?.caption && photographers && photographers.length > 0 && ' ● '
93 |
94 | return (
95 | <>
96 |
100 | {mainImage.caption}
101 | {separator}
102 | {hasPhotographers && (
103 | <>
104 | Photo:
105 | >
106 | )}
107 |
108 | )
109 | }
110 | className={
111 | brandName === config.lifestyleBrand
112 | ? 'm-auto max-w-xl py-2'
113 | : 'm-auto max-w-5xl py-2'
114 | }
115 | img={
116 |
131 | }
132 | />
133 | >
134 | )
135 | }
136 |
137 | type SectionLinkProps = Pick
138 |
139 | function SectionLinks({sections}: SectionLinkProps) {
140 | return (
141 |
142 | {sections &&
143 | sections.map((section) =>
144 | section && section._id && section.slug ? (
145 |
155 | {section.name}
156 |
157 | ) : null
158 | )}
159 |
160 | )
161 | }
162 |
--------------------------------------------------------------------------------
/components/Layout.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 |
3 | import Alert from './Alert'
4 | import Footer from './Footer'
5 | import Navbar from './Navbar'
6 |
7 | export default function LayoutTech({
8 | preview,
9 | children,
10 | }: {
11 | preview: boolean
12 | children: React.ReactNode
13 | }) {
14 | return (
15 | <>
16 |
17 |
18 | {preview &&
}
19 |
{children}
20 |
21 |
22 | >
23 | )
24 | }
25 |
--------------------------------------------------------------------------------
/components/LayoutLifestyle.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 |
3 | import Alert from './Alert'
4 | import Footer from './Footer'
5 | import NavbarLifestyle from './NavbarLifestyle'
6 |
7 | export default function Layout({
8 | preview,
9 | children,
10 | }: {
11 | preview: boolean
12 | children: React.ReactNode
13 | }) {
14 | return (
15 | <>
16 |
17 |
18 | {preview &&
}
19 |
{children}
20 |
21 |
22 | >
23 | )
24 | }
25 |
--------------------------------------------------------------------------------
/components/MoreStories.tsx:
--------------------------------------------------------------------------------
1 | import {config} from 'lib/config'
2 | import React, {useMemo} from 'react'
3 |
4 | import {Article, isArticle, Review} from '../types'
5 | import {useSplitLifestyleArticles} from '../utils/useSplitLifestyleArticles'
6 | import ArticlePreview from './ArticlePreview'
7 |
8 | function StorySection({
9 | articles,
10 | title,
11 | columns = 4,
12 | sectionType,
13 | brandName,
14 | token,
15 | }: {
16 | articles?: (Article | Review)[]
17 | title?: string
18 | columns?: number
19 | sectionType?: 'featured' | 'normal'
20 | brandName?: string
21 | token?: string
22 | }) {
23 | const isLifestyleBrand = brandName === config.lifestyleBrand
24 | // Sorry for the mess, but this was the only way I could get the correct borders to work in all breakpoints, in light and dark mode :grimacing:
25 | const gridClass = useMemo(() => {
26 | return sectionType === 'featured'
27 | ? `grid border-gray-200 dark:divide-gray-900 dark:border-gray-900 max-sm:divide-gray-200 max-sm:divide-y max-sm:dark:divide-gray-900 border-b sm:grid-cols-2 sm:border sm:[&>*]:dark:border-gray-900 sm:[&>:not(:last-child):nth-child(odd)]:border-r sm:[&>:not(:last-child)]:border-b md:rounded md:grid-cols-${columns} md:[&>:not(:last-child):not(:nth-child(4))]:border-r`
28 | : `grid border-gray-200 dark:divide-gray-900 dark:border-gray-900 max-sm:divide-gray-200 max-sm:dark:divide-gray-900 max-sm:divide-y sm:grid-cols-2 sm:border sm:[&>*]:dark:border-gray-900 sm:[&>:nth-child(odd)]:border-r sm:[&>:nth-child(-n+3):not(:last-child)]:border-b md:rounded md:grid-cols-${columns} md:border md:[&>:not(:nth-child(3n))]:border-r`
29 | }, [columns, sectionType])
30 | if (isLifestyleBrand) {
31 | /* md:grid-cols-2 md:grid-cols-3 md:grid-cols-4 */
32 | return (
33 |
34 | {title && (
35 |
36 | ● {title}
37 |
38 | )}
39 |
40 |
41 |
42 | {articles?.map((article) => {
43 | const baseProps = {
44 | title: article?.title,
45 | mainImage: article?.mainImage,
46 | slug: article?.slug,
47 | intro: article?.intro,
48 | sectionType,
49 | isHighlighted: Boolean(article?.isHighlighted),
50 | brandName,
51 | }
52 |
53 | let additionalProps = {}
54 |
55 | if (isArticle(article)) {
56 | additionalProps = {
57 | date: article?.date,
58 | people: article?.people,
59 | sections: article?.sections,
60 | }
61 | }
62 |
63 | const props = {
64 | ...baseProps,
65 | ...additionalProps,
66 | }
67 |
68 | return
69 | })}
70 |
71 |
72 |
73 | )
74 | }
75 |
76 | return (
77 |
78 | Articles
79 |
80 |
81 | {articles?.map((article) => {
82 | const baseProps = {
83 | title: article?.title,
84 | mainImage: article?.mainImage,
85 | slug: article?.slug,
86 | intro: article?.intro,
87 | sectionType,
88 | isHighlighted: Boolean(article?.isHighlighted),
89 | brandName,
90 | }
91 |
92 | let additionalProps = {}
93 |
94 | if (isArticle(article)) {
95 | additionalProps = {
96 | date: article?.date,
97 | people: article?.people,
98 | sections: article?.sections,
99 | }
100 | }
101 |
102 | const props = {
103 | ...baseProps,
104 | ...additionalProps,
105 | }
106 |
107 | return
108 | })}
109 |
110 |
111 |
112 | )
113 | }
114 |
115 | export default function MoreStories({
116 | articles,
117 | brandName,
118 | token,
119 | }: {
120 | articles: (Article | Review)[]
121 | brandName?: string
122 | token?: string
123 | }) {
124 | const {topArticles, restArticles} = useSplitLifestyleArticles(articles)
125 | const isLifestyleBrand = brandName === config.lifestyleBrand
126 |
127 | if (isLifestyleBrand) {
128 | return (
129 |
130 | {Boolean(topArticles?.length) && (
131 | <>
132 |
138 | >
139 | )}
140 | {Boolean(restArticles?.length) && (
141 | <>
142 |
150 | >
151 | )}
152 |
153 | )
154 | }
155 |
156 | return (
157 |
158 | Articles
159 |
160 |
161 | {articles?.map((article) => {
162 | const baseProps = {
163 | title: article?.title,
164 | mainImage: article?.mainImage,
165 | slug: article?.slug,
166 | intro: article?.intro,
167 | isHighlighted: Boolean(article?.isHighlighted),
168 | brandName,
169 | }
170 |
171 | let additionalProps = {}
172 |
173 | if (isArticle(article)) {
174 | additionalProps = {
175 | date: article?.date,
176 | people: article?.people,
177 | sections: article?.sections,
178 | }
179 | }
180 |
181 | const props = {
182 | ...baseProps,
183 | ...additionalProps,
184 | }
185 |
186 | return
187 | })}
188 |
189 |
190 |
191 | )
192 | }
193 |
--------------------------------------------------------------------------------
/components/Navbar.tsx:
--------------------------------------------------------------------------------
1 | import Link from 'next/link'
2 | import {useRouter} from 'next/router'
3 | import * as React from 'react'
4 |
5 | export default function Navbar() {
6 | const router = useRouter()
7 | const isLandingPage = router.pathname === '/'
8 |
9 | return (
10 |
15 |
16 |
17 | ● Media
18 |
19 |
20 |
21 | )
22 | }
23 |
--------------------------------------------------------------------------------
/components/NavbarLifestyle.tsx:
--------------------------------------------------------------------------------
1 | import clsx from 'clsx'
2 | import Link from 'next/link'
3 | import {useRouter} from 'next/router'
4 | import * as React from 'react'
5 |
6 | const SECTIONS = [
7 | {name: 'Entertainment', href: '/lifestyle/sections/entertainment'},
8 | {name: 'Wellness', href: '/lifestyle/sections/wellness'},
9 | {name: 'Beauty', href: '/lifestyle/sections/beauty'},
10 | {name: 'Fashion', href: '/lifestyle/sections/fashion'},
11 | {name: 'Must have', href: '/lifestyle/sections/must-have'},
12 | ]
13 |
14 | const CLASS_NAMES = `uppercase antialiased transition ease-in-out duration-300 text-black-500 hover:text-purple-500`
15 | export default function NavbarLifestyle() {
16 | const router = useRouter()
17 |
18 | return (
19 |
20 |
21 |
22 | ● Reach
23 |
24 |
25 |
40 |
41 |
42 | )
43 | }
44 |
--------------------------------------------------------------------------------
/components/PreviewArticlePage.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 | import * as React from 'react'
3 |
4 | import {articleQuery} from '../lib/queries'
5 | import {usePreview} from '../lib/sanity.preview'
6 | import ArticlePage from './ArticlePage'
7 |
8 | interface Props {
9 | token: string
10 | slug: string
11 | brand: string
12 | }
13 |
14 | export default function PreviewArticlePage({token, slug, brand}: Props) {
15 | const {article} = usePreview(token, articleQuery, {slug, brand})
16 | return
17 | }
18 |
--------------------------------------------------------------------------------
/components/PreviewArticlePreview.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 | import * as React from 'react'
3 | import {Article} from 'types'
4 |
5 | import {articleQuery} from '../lib/queries'
6 | import {usePreview} from '../lib/sanity.preview'
7 | import ArticlePreview from './ArticlePreview'
8 |
9 | interface Props {
10 | brandName?: string
11 | token: string
12 | slug?: string
13 | sectionType?: 'featured' | 'normal'
14 | isHighlighted?: boolean
15 | }
16 |
17 | /* This is only used for index pages.
18 | * The reason we have a preview component for such a small part of the page
19 | * is so we can preview content from different datasets!
20 | * Take this approach with a grain of salt, we haven't tested it at scale.
21 | */
22 | export function PreviewArticlePreview({
23 | brandName,
24 | slug,
25 | token,
26 | sectionType,
27 | isHighlighted,
28 | }: Props) {
29 | const articleBody: Article = usePreview(token, articleQuery, {
30 | slug,
31 | brand: brandName,
32 | })?.article
33 |
34 | const props = {
35 | ...articleBody,
36 | brandName,
37 | sectionType,
38 | isHighlighted,
39 | }
40 |
41 | return
42 | }
43 |
--------------------------------------------------------------------------------
/components/PreviewAuthorPage.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 | import * as React from 'react'
3 |
4 | import {personBySlugQuery} from '../lib/queries'
5 | import {usePreview} from '../lib/sanity.preview'
6 | import AuthorPage from './AuthorPage'
7 |
8 | export default function PreviewAuthorPage({
9 | slug,
10 | token,
11 | }: {
12 | slug: string
13 | token: string
14 | }) {
15 | const author = usePreview(token, personBySlugQuery, {slug})
16 | return
17 | }
18 |
--------------------------------------------------------------------------------
/components/PreviewReviewPreview.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 | import {reviewConfig} from 'lib/config'
3 | import * as React from 'react'
4 | import {Review, Section} from 'types'
5 |
6 | import {reviewQuery} from '../lib/queries'
7 | import {useReviewPreview} from '../lib/sanity.preview'
8 | import ArticlePreview from './ArticlePreview'
9 |
10 | interface Props {
11 | token: string
12 | slug?: string
13 | brandName?: string
14 | sectionType?: 'featured' | 'normal'
15 | isHighlighted?: boolean
16 | title?: string
17 | sections?: Section[]
18 | }
19 |
20 | /* This is only used for index pages.
21 | * The reason we have a preview component for such a small part of the page
22 | * is so we can preview content from different datasets!
23 | * Take this approach with a grain of salt, we haven't tested it at scale.
24 | */
25 | export function PreviewReviewPreview({
26 | brandName,
27 | slug,
28 | token,
29 | sectionType,
30 | isHighlighted,
31 | title,
32 | sections,
33 | }: Props) {
34 | const review: Review = useReviewPreview(token, reviewQuery, {
35 | slug,
36 | })
37 | //for our urlBuilder logic, mark which dataset this is
38 | //please do this better later
39 | //@ts-ignore
40 | review.mainImage.image.asset._dataset = reviewConfig.sanity.dataset
41 |
42 | const props = {
43 | ...review,
44 | brandName,
45 | sectionType,
46 | isHighlighted,
47 | title: title || review.title,
48 | sections,
49 | }
50 |
51 | return
52 | }
53 |
--------------------------------------------------------------------------------
/components/PreviewSectionPage.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 | import * as React from 'react'
3 |
4 | import {sectionBySlugQuery} from '../lib/queries'
5 | import {usePreview} from '../lib/sanity.preview'
6 | import SectionPage from './SectionPage'
7 |
8 | export default function PreviewSectionPage({
9 | slug,
10 | brand,
11 | }: {
12 | slug: string
13 | brand: string
14 | }) {
15 | const section = usePreview(null, sectionBySlugQuery, {
16 | slug,
17 | brand,
18 | })
19 | return
20 | }
21 |
--------------------------------------------------------------------------------
/components/SectionPage.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import {Section} from 'types'
3 |
4 | import {isLifestyle} from '../utils/brand'
5 | import Container from './Container'
6 | import MoreStories from './MoreStories'
7 | import {Seo} from './Seo'
8 | import Title, {TitleLifeStyle} from './Title'
9 |
10 | const isLifestyleBrand = () => isLifestyle()
11 |
12 | export default function SectionPage({section}: {section: Section}) {
13 | const {name, articles} = section || {}
14 |
15 | if (isLifestyleBrand()) {
16 | return (
17 |
18 | {name}
19 |
20 |
21 | )
22 | }
23 |
24 | return (
25 |
26 |
27 |
28 |
29 |
{name}
30 |
31 | {articles && articles?.length > 0 && (
32 |
33 | )}
34 |
35 |
36 | )
37 | }
38 |
--------------------------------------------------------------------------------
/components/SectionSeparator.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 |
3 | export default function SectionSeparator() {
4 | return
5 | }
6 |
--------------------------------------------------------------------------------
/components/Seo.tsx:
--------------------------------------------------------------------------------
1 | import {toPlainText} from '@portabletext/react'
2 | import {config} from 'lib/config'
3 | import {urlForImage} from 'lib/sanity'
4 | import {NextSeo} from 'next-seo'
5 | import React from 'react'
6 | import {isArticle, isAuthor, SEODocumentType} from 'types'
7 | import {getUrlForDocumentType} from 'utils/routing'
8 |
9 | const createCanonicalUrl = (doc: SEODocumentType) => {
10 | const url =
11 | config.env === 'production'
12 | ? 'https://demo-media-site-nextjs.sanity.build'
13 | : 'http://localhost:3000'
14 | const {_type, brand, slug} = doc
15 | return url + getUrlForDocumentType(_type, slug, brand)
16 | }
17 |
18 | const createOpenGraphObject = (
19 | title: string,
20 | description: string,
21 | url: string,
22 | document: SEODocumentType
23 | ) => {
24 | //it would be nice if we could get all the images from,
25 | //for example, the entire body of a article. We can't demo
26 | //it at the moment, so we'll just use the main image
27 | const images = []
28 | if (document.seo?.image) {
29 | images.push({
30 | url: urlForImage(document.seo.image)
31 | .width(1200)
32 | .height(627)
33 | .fit('crop')
34 | .url(),
35 | })
36 | }
37 |
38 | if (isArticle(document) && document?.mainImage?.image?.asset?._ref) {
39 | images.push({
40 | url: urlForImage(document.mainImage.image)
41 | .width(1200)
42 | .height(627)
43 | .fit('crop')
44 | .url(),
45 | })
46 | }
47 |
48 | if (isAuthor(document) && document.image?.asset?._ref) {
49 | images.push({
50 | url: urlForImage(document.image)
51 | .width(1200)
52 | .height(627)
53 | .fit('crop')
54 | .url(),
55 | })
56 | }
57 |
58 | //we will do types at a later date, again with the better OG/card preview
59 | //and JSON-LD previews
60 | return {
61 | title,
62 | description,
63 | url,
64 | images,
65 | }
66 | }
67 |
68 | export const Seo = ({doc}: {doc: SEODocumentType}) => {
69 | const defaultTitle = isArticle(doc) ? doc.title : doc.name
70 | const title = doc.seo?.title || defaultTitle
71 | let defaultDescription = ''
72 |
73 | if (isAuthor(doc)) {
74 | defaultDescription = doc.bio ? toPlainText(doc.bio) : defaultDescription
75 | } else if (isArticle(doc)) {
76 | defaultDescription = doc.intro ? toPlainText(doc.intro) : defaultDescription
77 | } else {
78 | defaultDescription = `Check out more ${
79 | doc.name && `about ${doc.name}`
80 | } on the ${doc.brand} site.`
81 | }
82 |
83 | const description = doc.seo?.description || defaultDescription
84 | const canonicalUrl = createCanonicalUrl(doc)
85 |
86 | return (
87 |
93 | )
94 | }
95 |
--------------------------------------------------------------------------------
/components/SiteHeader.tsx:
--------------------------------------------------------------------------------
1 | import Link from 'next/link'
2 | import * as React from 'react'
3 |
4 | export default function SiteHeader({title}: {title: string}) {
5 | return (
6 |
7 |
8 | {title}
9 |
10 |
11 | )
12 | }
13 |
--------------------------------------------------------------------------------
/components/Title.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 |
3 | export default function Title({children}: {children: React.ReactNode}) {
4 | return (
5 |
6 | {children}
7 |
8 | )
9 | }
10 |
11 | export function TitleLifeStyle({children}: {children: React.ReactNode}) {
12 | return (
13 |
14 | ● {children}
15 |
16 | )
17 | }
18 |
--------------------------------------------------------------------------------
/lib/config.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-process-env */
2 | export type Config = {
3 | env: string
4 | brand: string
5 | lifestyleBrand: string
6 | git: {
7 | repoOwner?: string
8 | repoProvider?: string
9 | repoSlug?: string
10 | }
11 |
12 | sanity: {
13 | projectId: string
14 | dataset: string
15 | apiVersion: string
16 | projectTitle?: string
17 | useCdn?: boolean
18 | readToken?: string
19 | writeToken?: string
20 | previewSecretId: `${string}.${string}`
21 | }
22 | revalidateSecret?: string
23 | }
24 |
25 | export const config: Config = {
26 | env: process.env.NODE_ENV || 'development',
27 | brand: process.env.NEXT_PUBLIC_BRAND || 'tech',
28 | lifestyleBrand: process.env.NEXT_PUBLIC_LIFESTYLE_BRAND || 'lifestyle',
29 | git: {
30 | repoOwner: process.env.NEXT_PUBLIC_GIT_REPO_OWNER,
31 | repoProvider: process.env.NEXT_PUBLIC_GIT_REPO_PROVIDER,
32 | repoSlug: process.env.NEXT_PUBLIC_GIT_REPO_SLUG,
33 | },
34 | sanity: {
35 | projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID || 'ejk8qe4e',
36 | dataset: process.env.NEXT_PUBLIC_SANITY_DATASET || 'production',
37 | apiVersion: '2022-03-13',
38 | projectTitle: process.env.NEXT_PUBLIC_SANITY_PROJECT_TITLE,
39 | // useCdn == true gives fast, cheap responses using a globally distributed cache.
40 | // When in production the Sanity API is only queried on build-time, and on-demand when responding to webhooks.
41 | // Thus the data need to be fresh and API response time is less important.
42 | // When in development/working locally, it's more important to keep costs down as hot reloading can incurr a lot of API calls
43 | // And every page load calls getStaticProps.
44 | // To get the lowest latency, lowest cost, and latest data, use the Instant Preview mode
45 | // see https://www.sanity.io/docs/api-versioning for how versioning works
46 | useCdn:
47 | typeof document !== 'undefined' && process.env.NODE_ENV === 'production',
48 | readToken: process.env.SANITY_API_READ_TOKEN,
49 | writeToken: process.env.SANIY_API_WRITE_TOKEN,
50 | previewSecretId: `preview.secret`,
51 | },
52 | revalidateSecret: process.env.SANIY_REVALIDATE_SECRET,
53 | // This is the document id used for the preview secret that's stored in your dataset.
54 | // The secret protects against unauthorized access to your draft content and have a lifetime of 60 minutes, to protect against bruteforcing.
55 | }
56 |
57 | export const reviewConfig: Config = {
58 | ...config,
59 | sanity: {
60 | ...config.sanity,
61 | dataset: process.env.NEXT_PUBLIC_SANITY_REVIEW_DATASET || 'reviews',
62 | },
63 | }
64 |
--------------------------------------------------------------------------------
/lib/constants.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | FiFeather,
3 | FiHash,
4 | FiHeadphones,
5 | FiMail,
6 | FiStar,
7 | FiUser,
8 | } from 'react-icons/fi'
9 | import {IconType} from 'react-icons/lib'
10 | export type Brand = {
11 | name: string
12 | title: string
13 | structure: (SchemaItem | SchemaDivider | SchemaSiteSettings)[]
14 | }
15 |
16 | export type SchemaItem = {
17 | schemaType: string
18 | title: string
19 | icon?: IconType
20 | }
21 |
22 | export type SchemaDivider = 'divider'
23 | export type SchemaSiteSettings = 'siteSettings'
24 |
25 | export const CMS_NAME = 'Sanity'
26 | export const CMS_URL = 'https://sanity.io/'
27 | export const HOME_OG_IMAGE_URL =
28 | 'https://og-image.vercel.app/Next.js%20Blog%20Example%20with%20**Sanity**.png?theme=light&md=1&fontSize=75px&images=https%3A%2F%2Fassets.vercel.com%2Fimage%2Fupload%2Ffront%2Fassets%2Fdesign%2Fnextjs-black-logo.svg&images=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB2aWV3Qm94PSIwIDAgMTA1IDIyIiBmaWxsPSJub25lIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMWVtIj48dGl0bGU%2BU2FuaXR5PC90aXRsZT48cGF0aCBvcGFjaXR5PSIwLjciIGQ9Ik03OC4xNzkzIDcuOTkyNjFWMjEuMDAyOEg3My45MDMxVjEwLjIxMzhMNzguMTc5MyA3Ljk5MjYxWiIgZmlsbD0iY3VycmVudENvbG9yIj48L3BhdGg%2BPHBhdGggb3BhY2l0eT0iMC43IiBkPSJNMjAuOTUxMSAyMS4zM0wzMC45NDQgMTYuMTA1MUwyOS43MTIxIDEyLjkxNDFMMjMuMTMzMiAxNS45ODIxTDIwLjk1MTEgMjEuMzNaIiBmaWxsPSJjdXJyZW50Q29sb3IiPjwvcGF0aD48cGF0aCBvcGFjaXR5PSIwLjUiIGQ9Ik03My45MDMxIDEwLjIwMjdMODQuNzQ0MyA0LjY1NDc3TDgyLjkxMjYgMS41NTcxTDczLjkwMzEgNS45NTk5N1YxMC4yMDI3WiIgZmlsbD0iY3VycmVudENvbG9yIj48L3BhdGg%2BPHBhdGggb3BhY2l0eT0iMC43IiBkPSJNNDMuMzcwNSA2Ljk2MjMzVjIxLjAwMjhIMzkuMjkyN1YxLjAwNzE0TDQzLjM3MDUgNi45NjIzM1oiIGZpbGw9ImN1cnJlbnRDb2xvciI%2BPC9wYXRoPjxwYXRoIG9wYWNpdHk9IjAuNSIgZD0iTTI3LjEyOTkgNi4xODYxN0wyMC45NTExIDIxLjMzTDE3Ljc3MzEgMTguNTk0M0wyNS4xMzUzIDEuMDA3MTRMMjcuMTI5OSA2LjE4NjE3WiIgZmlsbD0iY3VycmVudENvbG9yIj48L3BhdGg%2BPHBhdGggZD0iTTI1LjEzNTMgMS4wMDcxNEgyOS4zNDc3TDM3LjEzODYgMjEuMDAyOEgzMi44MjY5TDI1LjEzNTMgMS4wMDcxNFoiIGZpbGw9ImN1cnJlbnRDb2xvciI%2BPC9wYXRoPjxwYXRoIGQ9Ik00NC4wMDEyIDEuMDA3MTRMNTIuOTgyNCAxNC42NjgyVjIxLjAwMjhMMzkuMjkyNyAxLjAwNzE0SDQ0LjAwMTJaIiBmaWxsPSJjdXJyZW50Q29sb3IiPjwvcGF0aD48cGF0aCBkPSJNNjQuOTE4MyAxLjAwNzE0SDYwLjY3MzlWMjEuMDA2M0g2NC45MTgzVjEuMDA3MTRaIiBmaWxsPSJjdXJyZW50Q29sb3IiPjwvcGF0aD48cGF0aCBkPSJNNzMuOTAzMSA0LjY1NDc0SDY3LjM3VjEuMDA3MTRIODIuNTg2N0w4NC43NDQzIDQuNjU0NzRINzguMTc5M0g3My45MDMxWiIgZmlsbD0iY3VycmVudENvbG9yIj48L3BhdGg%2BPHBhdGggb3BhY2l0eT0iMC41IiBkPSJNOTcuMjc1NCAxMy40MTUzVjIxLjAwMjhIOTMuMDYyOVYxMy40MTUzIiBmaWxsPSJjdXJyZW50Q29sb3IiPjwvcGF0aD48cGF0aCBkPSJNOTMuMDYyOSAxMy40MTUyTDEwMC4xOTEgMS4wMDcxNEgxMDQuNjY2TDk3LjI3NTQgMTMuNDE1Mkg5My4wNjI5WiIgZmlsbD0iY3VycmVudENvbG9yIj48L3BhdGg%2BPHBhdGggb3BhY2l0eT0iMC43IiBkPSJNOTMuMDYzIDEzLjQxNTJMODUuNzM2MyAxLjAwNzE0SDkwLjM0NTZMOTUuMzA5MiA5LjUxMDA4TDkzLjA2MyAxMy40MTUyWiIgZmlsbD0iY3VycmVudENvbG9yIj48L3BhdGg%2BPHBhdGggZD0iTTEuOTYxMjYgMy4zMTQ3OUMxLjk2MTI2IDYuMDk5MjEgMy43MTE0NSA3Ljc1NTk1IDcuMjE1MzYgOC42Mjk1NkwxMC45MjgzIDkuNDc1MzNDMTQuMjQ0NCAxMC4yMjM2IDE2LjI2MzkgMTIuMDgyMiAxNi4yNjM5IDE1LjExMDNDMTYuMjg5NyAxNi40Mjk1IDE1Ljg1MzEgMTcuNzE3MyAxNS4wMjc0IDE4Ljc1NzlDMTUuMDI3NCAxNS43MzY4IDEzLjQzNjcgMTQuMTA0NCA5LjU5OTcyIDEzLjEyMjlMNS45NTQwOSAxMi4zMDg1QzMuMDM0NzUgMTEuNjU0MSAwLjc4MTQ3OCAxMC4xMjYyIDAuNzgxNDc4IDYuODM3MDlDMC43NjYxMjMgNS41NjY5MyAxLjE4MTE2IDQuMzI3ODEgMS45NjEyNiAzLjMxNDc5IiBmaWxsPSJjdXJyZW50Q29sb3IiPjwvcGF0aD48cGF0aCBvcGFjaXR5PSIwLjciIGQ9Ik01Mi45ODI0IDEzLjY0MTVWMS4wMDcxNEg1Ny4wNjAyVjIxLjAwMjhINTIuOTgyNFYxMy42NDE1WiIgZmlsbD0iY3VycmVudENvbG9yIj48L3BhdGg%2BPHBhdGggb3BhY2l0eT0iMC43IiBkPSJNMTIuNzQ1OCAxNC4zNjg5QzE0LjMyOTQgMTUuMzY0MyAxNS4wMjM4IDE2Ljc1NjUgMTUuMDIzOCAxOC43NTQ0QzEzLjcxMyAyMC40MDQxIDExLjQxMDEgMjEuMzMgOC43MDMzMyAyMS4zM0M0LjE0NzE4IDIxLjMzIDAuOTU4NTc3IDE5LjEyNjggMC4yNSAxNS4yOTgySDQuNjI1NDdDNS4xODg3OCAxNy4wNTU5IDYuNjgwMzQgMTcuODcwMyA4LjY3MTQ0IDE3Ljg3MDNDMTEuMTAxOSAxNy44NzAzIDEyLjcxNzQgMTYuNTk2NCAxMi43NDkzIDE0LjM2MTkiIGZpbGw9ImN1cnJlbnRDb2xvciI%2BPC9wYXRoPjxwYXRoIG9wYWNpdHk9IjAuNyIgZD0iTTQuMjM1NjcgNy40NDI2N0MzLjUxMjUgNy4wMjA0NSAyLjkxOTIgNi40MTM3NSAyLjUxODczIDUuNjg2OTdDMi4xMTgyNyA0Ljk2MDE5IDEuOTI1NTggNC4xNDA0NSAxLjk2MTEzIDMuMzE0NzZDMy4yMjU5NCAxLjY3ODkxIDUuNDI2MDggMC42Nzk5OTMgOC4xMDgwNCAwLjY3OTk5M0MxMi43NDkyIDAuNjc5OTkzIDE1LjQzNDcgMy4wODg1MiAxNi4wOTcyIDYuNDc4NTZIMTEuODg4M0MxMS40MjQyIDUuMTQyMDMgMTAuMjYyMSA0LjEwMTM2IDguMTQzNDcgNC4xMDEzNkM1Ljg3OTU3IDQuMTAxMzYgNC4zMzQ4NyA1LjM5NjExIDQuMjQ2MjkgNy40NDI2NyIgZmlsbD0iY3VycmVudENvbG9yIj48L3BhdGg%2BPC9zdmc%2B&widths=undefined&widths=auto&heights=250&heights=150'
29 |
30 | export const SCHEMA_ITEMS_LIFESTYLE: (
31 | | SchemaItem
32 | | SchemaDivider
33 | | SchemaSiteSettings
34 | )[] = [
35 | 'siteSettings',
36 | 'divider',
37 | {schemaType: `article`, title: 'Articles', icon: FiFeather},
38 | 'divider',
39 | {schemaType: `person`, title: 'People', icon: FiUser},
40 | {schemaType: `section`, title: 'Sections', icon: FiHash},
41 | ]
42 |
43 | export const SCHEMA_ITEMS_TECH: (
44 | | SchemaItem
45 | | SchemaDivider
46 | | SchemaSiteSettings
47 | )[] = [
48 | 'siteSettings',
49 | 'divider',
50 | {schemaType: `article`, title: 'Articles', icon: FiFeather},
51 | {schemaType: `newsletter`, title: 'Newsletter', icon: FiMail},
52 | {schemaType: `podcast`, title: 'Podcast', icon: FiHeadphones},
53 | 'divider',
54 | {schemaType: `person`, title: 'People', icon: FiUser},
55 | {schemaType: `section`, title: 'Sections', icon: FiHash},
56 | ]
57 |
58 | export const SCHEMA_ITEMS_REVIEWS: (SchemaItem | SchemaDivider)[] = [
59 | {schemaType: `review`, title: 'Reviews', icon: FiStar},
60 | ]
61 |
62 | export const BRANDS: Brand[] = [
63 | {
64 | name: 'tech',
65 | title: 'Tech',
66 | structure: SCHEMA_ITEMS_TECH,
67 | },
68 | {
69 | name: 'lifestyle',
70 | title: 'Lifestyle',
71 | structure: SCHEMA_ITEMS_LIFESTYLE,
72 | },
73 | {
74 | name: 'reviews',
75 | title: 'Reviews',
76 | structure: SCHEMA_ITEMS_REVIEWS,
77 | },
78 | ]
79 |
--------------------------------------------------------------------------------
/lib/homeVariationsMiddleware.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Logic to fetch the available experiments and determine which to show to a user
3 | */
4 | import {NextMiddleware, NextRequest, NextResponse} from 'next/server'
5 |
6 | import {config} from './config'
7 |
8 | const {projectId, dataset, apiVersion} = config.sanity
9 |
10 | // this query must match the filter and order of the home page
11 | // but only needs to fetch the ones we are going to experiment on
12 | const query = `
13 | *[_type == "article" && defined(variations) && brand == $brand] | order(date desc, _updatedAt desc) [0...3] {
14 | _id,
15 | 'variations': variations[]._key
16 | }
17 | `
18 |
19 | interface Article {
20 | _id: string
21 | variations?: string[]
22 | }
23 |
24 | const urlQuery = encodeURIComponent(query)
25 |
26 | const queryUrl = (brand: string) =>
27 | new URL(
28 | `https://${projectId}.apicdn.sanity.io/v${apiVersion}/data/query/${dataset}?query=${urlQuery}&$brand="${brand}"`
29 | )
30 |
31 | // NOTE this calls the APICDN for every view of the homepage
32 | // Although Netlify has a way to rewrite HTML in edge middleware, and thus not call the API for every page view,
33 | // Vercel does not have such a capability, so we have to do this on every page view for now.
34 | const fetchArticles = async (brand: string) => {
35 | const response = await fetch(queryUrl(brand))
36 | const {result} = (await response.json()) as {result: Article[]}
37 | return result
38 | }
39 |
40 | export const homeMiddleware: NextMiddleware = async (request: NextRequest) => {
41 | const currentExperiments = JSON.parse(
42 | request.cookies.get('homeContent')?.value || '{}'
43 | )
44 | const brand = request.nextUrl.pathname.split('/')[1]
45 |
46 | const articles = await fetchArticles(brand)
47 |
48 | const newExperiments = {}
49 |
50 | articles.forEach((article) => {
51 | const currentVariant = currentExperiments[article._id]
52 |
53 | if (
54 | currentVariant &&
55 | (currentVariant === 'fallback' ||
56 | article.variations?.includes(currentVariant))
57 | ) {
58 | // preserve existing experiments
59 | //@ts-expect-error
60 | newExperiments[article._id] = currentExperiments[article._id]
61 | } else if (article.variations) {
62 | // add 1 to account for the fallback/baseline content!
63 | //@ts-expect-error
64 | newExperiments[article._id] =
65 | article.variations[
66 | Math.floor(Math.random() * (article.variations.length + 1))
67 | ] || 'fallback'
68 | }
69 | })
70 |
71 | const path = Object.entries(newExperiments)
72 | .map((item) => item.join(':'))
73 | .join('/')
74 |
75 | // console.log('newExperiments', newExperiments)
76 |
77 | const response = NextResponse.rewrite(
78 | new URL(`/${brand}/home/${path}`, request.url)
79 | )
80 |
81 | const newExperimentsHeaderValue = JSON.stringify(newExperiments)
82 | // console.log('header', newExperimentsHeaderValue)
83 |
84 | response.cookies.set('homeContent', newExperimentsHeaderValue)
85 |
86 | return response
87 | }
88 |
--------------------------------------------------------------------------------
/lib/next-seo.config.ts:
--------------------------------------------------------------------------------
1 | import {DefaultSeoProps} from 'next-seo'
2 |
3 | const envBasedConfig = (brand: string | undefined) =>
4 | brand === 'lifestyle'
5 | ? {
6 | titleTemplate: '%s | Lifestyle',
7 | defaultTitle: 'Lifestyle',
8 | openGraph: {
9 | type: 'website',
10 | locale: 'en',
11 | url: 'https://www.url.ie/',
12 | siteName: 'Lifestyle',
13 | },
14 | }
15 | : {
16 | titleTemplate: '%s | Tech',
17 | defaultTitle: 'Tech',
18 | openGraph: {
19 | type: 'website',
20 | locale: 'en',
21 | url: 'https://www.url.ie/',
22 | siteName: 'Tech',
23 | },
24 | }
25 |
26 | const config = (brand: string): DefaultSeoProps => ({
27 | dangerouslySetAllPagesToNoIndex: true,
28 | ...envBasedConfig(brand),
29 | description: 'A demo of the Sanity.io editorial workflow',
30 | additionalLinkTags: [
31 | {
32 | rel: 'apple-touch-icon',
33 | sizes: '180x180',
34 | href: '/favicon/apple-touch-icon.png',
35 | },
36 | {
37 | rel: 'icon',
38 | type: 'image/png',
39 | sizes: '32x32',
40 | href: '/favicon/favicon-32x32.png',
41 | },
42 | {
43 | rel: 'icon',
44 | type: 'image/png',
45 | sizes: '16x16',
46 | href: '/favicon/favicon-16x16.png',
47 | },
48 | {
49 | rel: 'manifest',
50 | href: '/favicon/site.webmanifest',
51 | },
52 | {
53 | rel: 'mask-icon',
54 | href: '/favicon/safari-pinned-tab.svg',
55 | color: '#000000',
56 | },
57 | {
58 | rel: 'shortcut icon',
59 | href: '/favicon/favicon.ico',
60 | },
61 | /*
62 |
63 | */
64 | ],
65 | additionalMetaTags: [
66 | {
67 | name: 'msapplication-TileColor',
68 | content: 'Jane Doe',
69 | },
70 | {
71 | name: 'msapplication-config',
72 | content: '/favicon/browserconfig.xml',
73 | },
74 | {
75 | name: 'theme-color',
76 | content: '#000',
77 | },
78 | ],
79 | })
80 |
81 | export default config
82 |
--------------------------------------------------------------------------------
/lib/queries/article.ts:
--------------------------------------------------------------------------------
1 | import groq from 'groq'
2 |
3 | export const articleContentFields = groq`
4 | "content": content[]{
5 | _type == 'articleReference' => @->{_type, _id, title, "slug": slug.current},
6 | _type != 'articleReference' => @,
7 | _type == 'podcastReference' => @->{_type, _id, "url": podcastEpisode.url },
8 | },
9 | `
10 |
11 | export const articleFields = groq`
12 | _id,
13 | _type,
14 | title,
15 | date,
16 | intro,
17 | brand,
18 | "summary": intro,
19 | seo,
20 | mainImage,
21 | "date": _updatedAt,
22 | "slug": slug.current,
23 | "people": people[]{ role, ...person->{name, image, bio, 'slug': slug.current} },
24 | "sections": sections[]->{name, _id, "slug": slug.current},
25 | `
26 |
27 | export const settingsQuery = groq`*[_type == "settings"][0]{title}`
28 |
29 | export const indexQuery = groq`
30 | {
31 | "featuredArticles": *[_type == 'siteSettings' && brand == $brand][0].featured[defined(_ref) || defined(review._ref)]{
32 | _type == 'articleReference' => @->,
33 | _type == 'reviewReference'=> {
34 | ...@.review->,
35 | @.review->soldOut => {
36 | "title": "SOLD OUT: " + coalesce(@.titleOverride, @.review->{title}.title)
37 | },
38 | @.review->soldOut == false => {
39 | "title": coalesce(@.titleOverride, @.review->{title}.title)
40 | },
41 | "sections": @.sections
42 | }
43 | },
44 | "recentArticles": *[_type == "article" && brand == $brand] | order(date desc, _createdAt desc) [0..10]
45 | } |
46 | {
47 | "featuredArticles": @.featuredArticles,
48 | "filteredRecent": @.recentArticles[!(_id in ^.featuredArticles[]._id)]
49 | } | {
50 | "combined": @.featuredArticles + @.filteredRecent
51 | }.combined[0..10]{
52 | ${articleFields}
53 | variations
54 | }`
55 |
56 | export const articleQuery = groq`
57 | {
58 | "article": *[_type == "article" && slug.current == $slug && brand == $brand] | order(_updatedAt desc) [0] {
59 | ${articleContentFields}
60 | ${articleFields}
61 | },
62 | "morePosts": *[_type == "article" && slug.current != $slug] | order(date desc, _updatedAt desc) [0...2] {
63 | content,
64 | ${articleFields}
65 | }
66 | }`
67 |
68 | export const articleSlugsQuery = groq`
69 | *[_type == "article" && defined(slug.current)][]{
70 | brand,
71 | "slug": slug.current,
72 | }
73 | `
74 |
75 | export const articleBySlugQuery = groq`
76 | *[_type == "article" && slug.current == $slug && brand == $brand][0] {
77 | ${articleFields}
78 | }
79 | `
80 |
81 | export const reviewQuery = groq`
82 | *[_type == "review" && slug.current == $slug][0] {
83 | ${articleFields}
84 | // mainImage->{..., image{..., asset->{..., '_dataset': 'reviews'}}},
85 | }
86 | `
87 |
--------------------------------------------------------------------------------
/lib/queries/index.ts:
--------------------------------------------------------------------------------
1 | export * from './article'
2 | export * from './person'
3 | export * from './section'
4 |
--------------------------------------------------------------------------------
/lib/queries/newsletter.ts:
--------------------------------------------------------------------------------
1 | import groq from 'groq'
2 |
3 | export const newsletterFields = groq`
4 | _id,
5 | title,
6 | subject,
7 | intro,
8 | hasCustomTextContent,
9 | "content": content[]{
10 | _type == 'articleReference' => @->{_type, _id, title, intro, "slug": slug.current, mainImage },
11 | _type == 'articleReferences' => @{_type, _key, references[]->{_type, _id, title, intro, "slug": slug.current, mainImage }},
12 | _type != 'articleReference' && _type != 'articleReferences' => @,
13 | },
14 | "date": _updatedAt,
15 | `
16 |
17 | export const settingsQuery = groq`*[_type == "settings"][0]{title}`
18 |
19 | export const indexQuery = groq`
20 | *[_type == "newsletter"] | order(date desc, _updatedAt desc) {
21 | ${newsletterFields}
22 | }`
23 |
24 | export const newsletterQuery = groq`
25 | {
26 | "newsletter": *[_type == "newsletter" && slug.current == $slug] | order(_updatedAt desc) [0] {
27 | content,
28 | ${newsletterFields}
29 | },
30 | "morePosts": *[_type == "newsletter" && slug.current != $slug] | order(date desc, _updatedAt desc) [0...2] {
31 | content,
32 | ${newsletterFields}
33 | }
34 | }`
35 |
36 | export const newsletterSlugsQuery = groq`
37 | *[_type == "newsletter" && defined(slug.current)][].slug.current
38 | `
39 |
40 | export const newsletterBySlugQuery = groq`
41 | *[_type == "newsletter" && slug.current == $slug][0] {
42 | ${newsletterFields}
43 | }
44 | `
45 |
46 | export const newsletterByIdQuery = groq`
47 | *[_type == "newsletter" && _id == $id][0] {
48 | ${newsletterFields}
49 | }
50 | `
51 | export const newslettersByIdQuery = groq`
52 | *[_type == "newsletter" && _id in $ids] {
53 | ${newsletterFields}
54 | }
55 | `
56 |
--------------------------------------------------------------------------------
/lib/queries/person.ts:
--------------------------------------------------------------------------------
1 | import groq from 'groq'
2 |
3 | import {articleFields} from './article'
4 |
5 | export const personFields = groq`
6 | _id,
7 | _type,
8 | name,
9 | bio,
10 | isStaff,
11 | seo,
12 | brand,
13 | "slug": slug.current,
14 | `
15 |
16 | export const personIndexQuery = groq`
17 | *[_type == "person"] | order(date desc, _updatedAt desc) {
18 | ${personFields}
19 | }`
20 |
21 | export const personBySlugQuery = groq`*[_type == "person" && slug.current == $slug] | order(_updatedAt desc) [0] {
22 | ${personFields}
23 | "articles": *[_type == 'article' && references(^._id)] | order(_updatedAt desc) {
24 | ${articleFields}
25 | }
26 | }`
27 |
28 | export const personSlugsQuery = groq`
29 | *[_type == "person" && defined(slug.current)][]{
30 | "slug": slug.current,
31 | brand
32 | }
33 | `
34 |
--------------------------------------------------------------------------------
/lib/queries/section.ts:
--------------------------------------------------------------------------------
1 | import groq from 'groq'
2 |
3 | import {articleFields} from './article'
4 |
5 | export const sectionFields = groq`
6 | _id,
7 | _type,
8 | name,
9 | brand,
10 | seo,
11 | "slug": slug.current,
12 | `
13 |
14 | export const sectionIndexQuery = groq`
15 | *[_type == "section"] | order(date desc, _updatedAt desc) {
16 | ${sectionFields}
17 | }`
18 |
19 | export const sectionBySlugQuery = groq`*[_type == "section" && slug.current == $slug && brand == $brand] | order(_updatedAt desc) [0] {
20 | ${sectionFields}
21 | "articles": *[_type == 'article' && references(^._id) && brand == $brand] | order(_updatedAt desc) {
22 | ${articleFields}
23 | }
24 | }`
25 |
26 | export const sectionSlugsQuery = groq`
27 | *[_type == "section" && defined(slug.current)][]{
28 | "slug": slug.current,
29 | brand
30 | }
31 | `
32 |
--------------------------------------------------------------------------------
/lib/sanity.preview.ts:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import {definePreview} from 'next-sanity/preview'
4 |
5 | import {config, reviewConfig} from './config'
6 |
7 | function onPublicAccessOnly() {
8 | throw new Error(`Unable to load preview as you're not logged in`)
9 | }
10 |
11 | const {projectId, dataset} = config.sanity
12 |
13 | export const usePreview = definePreview({
14 | projectId,
15 | dataset,
16 | onPublicAccessOnly,
17 | })
18 |
19 | const reviewProjectId = reviewConfig.sanity.projectId
20 | const reviewDataset = reviewConfig.sanity.dataset
21 |
22 | export const useReviewPreview = definePreview({
23 | projectId: reviewProjectId,
24 | dataset: reviewDataset,
25 | onPublicAccessOnly,
26 | })
27 |
--------------------------------------------------------------------------------
/lib/sanity.server.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * Server-side Sanity utilities. By having these in a separate file from the
3 | * utilities we use on the client side, we are able to tree-shake (remove)
4 | * code that is not used on the client side.
5 | */
6 | import {createClient} from 'next-sanity'
7 | import {SanityDocument} from 'sanity'
8 |
9 | import {config} from './config'
10 |
11 | export const getClient = (preview: boolean) =>
12 | preview
13 | ? createClient({
14 | projectId: config.sanity.projectId,
15 | dataset: config.sanity.dataset,
16 | useCdn: false,
17 | // Fallback to using the WRITE token until https://www.sanity.io/docs/vercel-integration starts shipping a READ token.
18 | // As this client only exists on the server and the token is never shared with the browser, we don't risk escalating permissions to untrustworthy users
19 | token: config.sanity.readToken || config.sanity.writeToken,
20 | apiVersion: config.sanity.apiVersion,
21 | })
22 | : createClient({
23 | projectId: config.sanity.projectId,
24 | dataset: config.sanity.dataset,
25 | apiVersion: config.sanity.apiVersion,
26 | useCdn: true,
27 | })
28 |
29 | export function overlayDrafts(docs: SanityDocument[]) {
30 | const documents = docs || []
31 | const overlayed = documents.reduce((map, doc) => {
32 | if (!doc._id) {
33 | throw new Error('Ensure that `_id` is included in query projection')
34 | }
35 |
36 | const isDraft = doc._id.startsWith('drafts.')
37 | const id = isDraft ? doc._id.slice(7) : doc._id
38 | return isDraft || !map.has(id) ? map.set(id, doc) : map
39 | }, new Map())
40 |
41 | return Array.from(overlayed.values())
42 | }
43 |
--------------------------------------------------------------------------------
/lib/sanity.tsx:
--------------------------------------------------------------------------------
1 | import createImageUrlBuilder from '@sanity/image-url'
2 | import {SanityAsset} from '@sanity/image-url/lib/types/types'
3 | import {CrossDatasetSource} from 'types'
4 |
5 | import {config, reviewConfig} from './config'
6 |
7 | export const imageBuilder = createImageUrlBuilder({
8 | projectId: config.sanity.projectId,
9 | dataset: config.sanity.dataset,
10 | })
11 |
12 | export const urlForImage = (source: SanityAsset | CrossDatasetSource) => {
13 | if (source?.asset?._dataset == 'reviews') {
14 | const reviewImageBuilder = createImageUrlBuilder({
15 | projectId: reviewConfig.sanity.projectId,
16 | dataset: reviewConfig.sanity.dataset,
17 | })
18 | return reviewImageBuilder.image(source).auto('format').fit('max')
19 | }
20 |
21 | return imageBuilder.image(source).auto('format').fit('max')
22 | }
23 |
--------------------------------------------------------------------------------
/lint-staged.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | '**/*.{js,jsx}': ['prettier --list-different', 'eslint'],
3 | '**/*.{ts,tsx}': [
4 | 'prettier --list-different',
5 | 'eslint',
6 | () => 'tsc --noEmit',
7 | ],
8 | }
9 |
--------------------------------------------------------------------------------
/logo.tsx:
--------------------------------------------------------------------------------
1 | import {Flex, Text} from '@sanity/ui'
2 | import React, {memo, useMemo} from 'react'
3 |
4 | export const Logo = memo(function Logo(props: {type?: string}) {
5 | const type = props?.type || 'tech'
6 | const settings: Record = useMemo(() => {
7 | return {
8 | lifestyle: {
9 | text: 'Lifestyle',
10 | background: '#8E64E8',
11 | },
12 | tech: {
13 | text: 'Technology',
14 | background: '#FFFFFF',
15 | },
16 | reviews: {
17 | text: 'Reviews',
18 | background: '#86DF9F',
19 | },
20 | }
21 | }, [])
22 |
23 | return (
24 |
25 |
37 | {settings?.[type].text}
38 |
39 | )
40 | })
41 |
42 | export function TechLogo() {
43 | return
44 | }
45 |
46 | export function LifestyleLogo() {
47 | return
48 | }
49 |
50 | export function ReviewsLogo() {
51 | return
52 | }
53 |
54 | export function TechWorkspaceLogo() {
55 | return
56 | }
57 |
58 | export function LifestyleWorkspaceLogo() {
59 | return
60 | }
61 |
62 | export function ReviewsWorkspaceLogo() {
63 | return
64 | }
65 |
66 | export function HighFashionWorkspaceLogo() {
67 | return
68 | }
69 |
70 | export function OutdoorsWorkspaceLogo() {
71 | return
72 | }
73 |
74 | export function GossipWorkspaceLogo() {
75 | return
76 | }
77 |
78 | export function EntertainmentWorkspaceLogo() {
79 | return
80 | }
81 | export const WorkspaceLogo = memo(function WorkspaceLogo(props: {
82 | type?: string
83 | }) {
84 | const type = props?.type || 'tech'
85 | const logos: Record = useMemo(() => {
86 | return {
87 | lifestyle: {
88 | background: '#BB9FF9',
89 | foreground: '#8E64E8',
90 | },
91 | tech: {
92 | background: '#111213',
93 | foreground: '#FFFFFF',
94 | },
95 | reviews: {
96 | background: '#86DF9F',
97 | foreground: '#119236',
98 | },
99 | }
100 | }, [])
101 |
102 | return (
103 |
116 | )
117 | })
118 |
--------------------------------------------------------------------------------
/middleware.ts:
--------------------------------------------------------------------------------
1 | // middleware.ts
2 | import {homeMiddleware} from 'lib/homeVariationsMiddleware'
3 | import {NextMiddleware} from 'next/server'
4 | import {NextResponse} from 'next/server'
5 |
6 | // This function can be marked `async` if using `await` inside
7 | export const middleware: NextMiddleware = async (request, event) => {
8 | try {
9 | const homeResponse = await homeMiddleware(request, event)
10 |
11 | if (homeResponse) {
12 | return homeResponse
13 | }
14 | } catch (e) {
15 | console.error('MIDDLEWARE ERROR', e)
16 | }
17 |
18 | // all else fails, send them to the "home" route with no experiments
19 | return NextResponse.rewrite(new URL('/home', request.url))
20 | }
21 |
22 | // See "Matching Paths" below to learn more
23 | export const config = {
24 | matcher: [],
25 | }
26 |
--------------------------------------------------------------------------------
/next-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 |
4 | // NOTE: This file should not be edited
5 | // see https://nextjs.org/docs/basic-features/typescript for more information.
6 |
--------------------------------------------------------------------------------
/next.config.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-process-env */
2 | /** @type {import('next').NextConfig} */
3 | module.exports = {
4 | experimental: {
5 | urlImports: ['https://themer.sanity.build/'],
6 | esmExternals: 'loose',
7 | },
8 | images: {
9 | remotePatterns: [
10 | {hostname: 'cdn.sanity.io'},
11 | {hostname: 'source.unsplash.com'},
12 | ],
13 | },
14 | typescript: {
15 | // Set this to false if you want production builds to abort if there's type errors
16 | ignoreBuildErrors: process.env.VERCEL_ENV === 'production',
17 | },
18 | eslint: {
19 | /// Set this to false if you want production builds to abort if there's lint errors
20 | ignoreDuringBuilds: process.env.VERCEL_ENV === 'production',
21 | },
22 | webpack: (config) => {
23 | config.experiments = {...config.experiments, topLevelAwait: true}
24 | return config
25 | },
26 | }
27 |
--------------------------------------------------------------------------------
/next.lock/lock.json:
--------------------------------------------------------------------------------
1 | {
2 | "https://themer.sanity.build/api/hues?preset=tw-cyan&primary=b595f9": {
3 | "integrity": "sha512-pyblcarEU/ZhOw8jtTAvn2t16TPWc2ttphF/zkZLiktus1ezA08LUVCRiZiZm7IjtL1dMzVwWbTOg+12vLq2qA==",
4 | "contentType": "application/javascript; charset=utf-8"
5 | },
6 | "version": 1
7 | }
8 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "private": true,
3 | "description": "Sanity.io demo media site using Next.js",
4 | "homepage": "https://github.com/sanity-io/demo-media-site-nextjs#readme",
5 | "bugs": {
6 | "url": "https://github.com/sanity-io/demo-media-site-nextjs/issues"
7 | },
8 | "repository": {
9 | "url": "https://github.com/sanity-io/demo-media-site-nextjs"
10 | },
11 | "license": "MIT",
12 | "author": "Sanity.io ",
13 | "scripts": {
14 | "build": "next build",
15 | "dev": "next",
16 | "format": "npx prettier --write . --ignore-path .gitignore",
17 | "lint": "eslint . --ext .js,.ts,.jsx,.tsx --ignore-path .gitignore",
18 | "lint:fix": "npm run format && npm run lint -- --fix",
19 | "lint:next": "next lint -- --ignore-path .gitignore",
20 | "prepare": "husky install",
21 | "start": "next start",
22 | "type-check": "tsc --noEmit"
23 | },
24 | "dependencies": {
25 | "@portabletext/react": "^3.0.2",
26 | "@portabletext/to-html": "^2.0.0",
27 | "@sanity/client": "6.1.2",
28 | "@sanity/dashboard": "^3.1.4",
29 | "@sanity/demo": "^1.0.2",
30 | "@sanity/image-url": "^1.0.2",
31 | "@sanity/scheduled-publishing": "^1.1.1",
32 | "@sanity/vision": "^3.20.1",
33 | "@sanity/webhook": "^2.0.0",
34 | "@tailwindcss/typography": "^0.5.9",
35 | "@vercel/og": "^0.5.6",
36 | "classnames": "^2.3.2",
37 | "clsx": "^1.2.1",
38 | "date-fns": "^2.30.0",
39 | "mjml": "^4.14.1",
40 | "mjml-browser": "^4.14.1",
41 | "next": "^13.4.4",
42 | "next-sanity": "^4.3.3",
43 | "next-seo": "^6.0.0",
44 | "react": "^18.2.0",
45 | "react-dom": "^18.2.0",
46 | "react-icons": "^4.9.0",
47 | "react-is": "^18.2.0",
48 | "react-player": "^2.12.0",
49 | "sanity": "^3.28.0",
50 | "sanity-plugin-asset-source-unsplash": "^1.1.0",
51 | "sanity-plugin-documents-pane": "^2.1.0",
52 | "sanity-plugin-iframe-pane": "^2.3.1",
53 | "sanity-plugin-media": "^2.0.5",
54 | "sanity-plugin-seo-pane": "^2.0.1",
55 | "seedrandom": "^3.0.5",
56 | "styled-components": "^5.3.11",
57 | "twig": "^1.16.0"
58 | },
59 | "devDependencies": {
60 | "@commitlint/cli": "^17.6.5",
61 | "@commitlint/config-conventional": "^17.6.5",
62 | "@sanity/cli": "^3.20.1",
63 | "@types/mjml": "^4.7.1",
64 | "@types/react": "^18.2.7",
65 | "@types/seedrandom": "^3.0.5",
66 | "@types/styled-components": "^5.1.26",
67 | "@types/twig": "^1.12.9",
68 | "@typescript-eslint/eslint-plugin": "^5.59.8",
69 | "autoprefixer": "^10.4.14",
70 | "eslint": "^8.41.0",
71 | "eslint-config-next": "^13.4.4",
72 | "eslint-config-prettier": "^8.8.0",
73 | "eslint-config-sanity": "^6.0.0",
74 | "eslint-plugin-react": "^7.32.2",
75 | "eslint-plugin-simple-import-sort": "^10.0.0",
76 | "husky": "^8.0.3",
77 | "lint-staged": "^13.2.2",
78 | "postcss": "^8.4.24",
79 | "prettier": "^2.8.8",
80 | "prettier-plugin-packagejson": "^2.4.3",
81 | "prettier-plugin-tailwindcss": "^0.3.0",
82 | "tailwindcss": "^3.3.2",
83 | "typescript": "^5.0.4"
84 | }
85 | }
86 |
--------------------------------------------------------------------------------
/pages/[brand]/articles/[slug].tsx:
--------------------------------------------------------------------------------
1 | import Layout from 'components/Layout'
2 | import {config} from 'lib/config'
3 | import {GetStaticProps} from 'next'
4 | import ErrorPage from 'next/error'
5 | import {useRouter} from 'next/router'
6 | import {PreviewSuspense} from 'next-sanity/preview'
7 | import {lazy, ReactElement} from 'react'
8 | import * as React from 'react'
9 |
10 | import ArticlePage from '../../../components/ArticlePage'
11 | import LayoutLifestyle from '../../../components/LayoutLifestyle'
12 | import Title from '../../../components/Title'
13 | import {articleQuery, articleSlugsQuery} from '../../../lib/queries'
14 | import {getClient, overlayDrafts} from '../../../lib/sanity.server'
15 | import {Article} from '../../../types'
16 |
17 | const PreviewArticlePage = lazy(
18 | () => import('../../../components/PreviewArticlePage')
19 | )
20 |
21 | interface Props {
22 | data: {article: Article; moreArticles: any}
23 | previewData?: {token?: string}
24 | }
25 |
26 | export default function ArticleRoute(props: Props) {
27 | const {data, previewData} = props
28 | const article = data?.article
29 | const router = useRouter()
30 |
31 | const slug = article?.slug
32 | const brand = article?.brand ?? 'tech'
33 |
34 | if (!router.isFallback && !slug) {
35 | return
36 | }
37 |
38 | if (router.isFallback) {
39 | return Loading…
40 | }
41 |
42 | if (slug && previewData?.token) {
43 | return (
44 | }>
45 |
50 |
51 | )
52 | }
53 |
54 | return
55 | }
56 |
57 | export const getStaticProps: GetStaticProps = async ({
58 | params,
59 | preview = false,
60 | previewData = {},
61 | }) => {
62 | const {article, moreArticles} = await getClient(preview).fetch(articleQuery, {
63 | slug: params?.slug,
64 | brand: params?.brand,
65 | })
66 |
67 | return {
68 | props: {
69 | previewData,
70 | data: {
71 | article,
72 | moreArticles: overlayDrafts(moreArticles),
73 | },
74 | },
75 | // If webhooks isn't setup then attempt to re-generate in 1 minute intervals
76 | revalidate: config.revalidateSecret ? undefined : 60,
77 | }
78 | }
79 |
80 | export async function getStaticPaths() {
81 | const paths = await getClient(false).fetch(articleSlugsQuery)
82 | return {
83 | paths: paths.map(({brand, slug}: {brand: string; slug: string}) => ({
84 | params: {slug, brand},
85 | })),
86 | fallback: true,
87 | }
88 | }
89 |
90 | ArticleRoute.getLayout = function getLayout(page: ReactElement) {
91 | const {data, preview} = page?.props
92 | if (data?.article?.brand == config.lifestyleBrand) {
93 | return {page}
94 | }
95 | return {page}
96 | }
97 |
--------------------------------------------------------------------------------
/pages/[brand]/authors/[slug].tsx:
--------------------------------------------------------------------------------
1 | import {config} from 'lib/config'
2 | import {GetStaticProps} from 'next'
3 | import ErrorPage from 'next/error'
4 | import {useRouter} from 'next/router'
5 | import {PreviewSuspense} from 'next-sanity/preview'
6 | import {lazy, ReactElement} from 'react'
7 | import React from 'react'
8 |
9 | import AuthorPage from '../../../components/AuthorPage'
10 | import Layout from '../../../components/Layout'
11 | import LayoutLifestyle from '../../../components/LayoutLifestyle'
12 | import Title from '../../../components/Title'
13 | import {personBySlugQuery, personSlugsQuery} from '../../../lib/queries'
14 | import {getClient, overlayDrafts} from '../../../lib/sanity.server'
15 | import {Author} from '../../../types'
16 |
17 | const PreviewAuthorPage = lazy(
18 | () => import('../../../components/PreviewAuthorPage')
19 | )
20 |
21 | interface Props {
22 | data: Author
23 | previewData: {token?: string}
24 | }
25 |
26 | export default function AuthorRoute(props: Props) {
27 | const {data, previewData} = props
28 | const router = useRouter()
29 |
30 | const slug = data?.slug
31 |
32 | if (!router.isFallback && !slug) {
33 | return
34 | }
35 |
36 | if (router.isFallback) {
37 | return Loading…
38 | }
39 |
40 | if (previewData?.token && slug) {
41 | return (
42 | }>
43 |
44 |
45 | )
46 | }
47 |
48 | return
49 | }
50 |
51 | export const getStaticProps: GetStaticProps = async ({
52 | params,
53 | preview = false,
54 | previewData = {},
55 | }) => {
56 | const person = await getClient(preview).fetch(personBySlugQuery, {
57 | slug: params?.slug,
58 | })
59 | return {
60 | props: {
61 | previewData,
62 | data: {
63 | ...person,
64 | articles: overlayDrafts(person?.articles),
65 | },
66 | },
67 | // If webhooks isn't setup then attempt to re-generate in 1 minute intervals
68 | revalidate: config.revalidateSecret ? undefined : 60,
69 | }
70 | }
71 |
72 | export async function getStaticPaths() {
73 | const paths = await getClient(false).fetch(personSlugsQuery)
74 | return {
75 | paths: paths.map(({brand, slug}: {brand: string; slug: string}) => ({
76 | params: {slug, brand},
77 | })),
78 | fallback: true,
79 | }
80 | }
81 |
82 | AuthorRoute.getLayout = function getLayout(page: ReactElement) {
83 | const {data, preview} = page?.props
84 | if (data?.brand == 'lifestyle') {
85 | return {page}
86 | }
87 | return {page}
88 | }
89 |
--------------------------------------------------------------------------------
/pages/[brand]/home/[[...variant]].tsx:
--------------------------------------------------------------------------------
1 | import Container from 'components/Container'
2 | import Layout from 'components/Layout'
3 | import LayoutLifestyle from 'components/LayoutLifestyle'
4 | import MoreStories from 'components/MoreStories'
5 | import {config} from 'lib/config'
6 | import {indexQuery} from 'lib/queries'
7 | import {getClient, overlayDrafts} from 'lib/sanity.server'
8 | import {GetStaticPaths, GetStaticProps} from 'next'
9 | import {NextSeo} from 'next-seo'
10 | import * as React from 'react'
11 | import {Article, Review} from 'types'
12 |
13 | interface IndexProps {
14 | allArticles: (Article | Review)[]
15 | preview: boolean
16 | brand?: string
17 | }
18 | export default function Index({allArticles, preview, brand}: IndexProps) {
19 | const metadata =
20 | brand === config.lifestyleBrand
21 | ? {
22 | title: 'Latest Lifestyle News, Trends & Tips | STREETREADY',
23 | description:
24 | 'STREETREADY delivers the biggest moments, the hottest trends, and the best tips in entertainment, fashion, beauty, fitness, and food and the ability to shop for it all in one place.',
25 | }
26 | : {
27 | title: 'Latest Tech News',
28 | }
29 |
30 | return (
31 | <>
32 |
33 |
34 |
35 | {/* live preview commented out here for demo day purposes, please do not uncomment!! */}
36 | {/* {preview ? (
37 |
38 |
39 |
40 | ) : ( */}
41 |
42 | {/* )} */}
43 |
44 |
45 | >
46 | )
47 | }
48 |
49 | export const getStaticPaths: GetStaticPaths = () => {
50 | return {
51 | // we return no paths at build time.
52 | // paths will be revalidated once built.
53 | paths: [],
54 | fallback: 'blocking',
55 | }
56 | }
57 |
58 | export const getStaticProps: GetStaticProps = async ({
59 | params,
60 | preview = false,
61 | }) => {
62 | const variant = (params?.variant as string[]) || []
63 |
64 | /* check if the project id has been defined by fetching the vercel envs */
65 | if (config.sanity.projectId) {
66 | // given a url like /a:1/b:2/c:3, split it out into {a:"1", b:"2", c:"3"}
67 | const variantMap: Record = variant
68 | .map((variantParam) => variantParam.split(':'))
69 | .reduce((prev: Record, current) => {
70 | prev[current[0]] = current[1]
71 | return prev
72 | }, {})
73 |
74 | const brand = params?.brand || 'tech'
75 |
76 | const rawArticles = overlayDrafts(
77 | await getClient(preview).fetch(indexQuery, {brand})
78 | ) as Article[]
79 |
80 | const allArticles = rawArticles.map((rawArticle) => {
81 | const {variations, ...article} = rawArticle
82 |
83 | const variantToShow = variantMap[article._id]
84 |
85 | if (variations && variantToShow) {
86 | // variantToShow may be 'fallback', which is fine as we can just spread undefined below
87 | const variantValues = variations.find(
88 | (variation) => variation._key === variantToShow
89 | )
90 |
91 | return {
92 | ...article,
93 | ...variantValues,
94 | }
95 | }
96 |
97 | return article
98 | })
99 |
100 | return {
101 | props: {allArticles, preview, brand},
102 | // If webhooks isn't setup then attempt to re-generate in 1 minute intervals
103 | revalidate: config.revalidateSecret ? undefined : 60,
104 | }
105 | }
106 |
107 | return {
108 | props: {},
109 | revalidate: undefined,
110 | }
111 | }
112 |
113 | Index.getLayout = function getLayout(page: React.ReactElement) {
114 | const {preview, data} = page?.props
115 | if (data?.brand == 'lifestyle') {
116 | return {page}
117 | }
118 | return {page}
119 | }
120 |
--------------------------------------------------------------------------------
/pages/[brand]/index.tsx:
--------------------------------------------------------------------------------
1 | import ArticleIndex from 'components/ArticleIndex'
2 | import {ArticleIndexLifestyle} from 'components/ArticleIndexLifestyle'
3 | import Container from 'components/Container'
4 | import Layout from 'components/Layout'
5 | import LayoutLifestyle from 'components/LayoutLifestyle'
6 | import {config} from 'lib/config'
7 | import {indexQuery} from 'lib/queries'
8 | import {getClient, overlayDrafts} from 'lib/sanity.server'
9 | import {GetStaticPaths, GetStaticProps} from 'next'
10 | // import {PreviewSuspense} from 'next-sanity/preview'
11 | import {NextSeo} from 'next-seo'
12 | import * as React from 'react'
13 | import {Article, Review} from 'types'
14 |
15 | interface IndexProps {
16 | data: {
17 | allArticles: (Article | Review)[]
18 | brand: string
19 | }
20 | previewData?: {token?: string}
21 | }
22 | export default function Index({data, previewData}: IndexProps) {
23 | const metadata =
24 | data?.brand === config.lifestyleBrand
25 | ? {
26 | title: 'Latest Lifestyle News, Trends & Tips | STREETREADY',
27 | description:
28 | 'STREETREADY delivers the biggest moments, the hottest trends, and the best tips in entertainment, fashion, beauty, fitness, and food and the ability to shop for it all in one place.',
29 | }
30 | : {
31 | title: 'Latest Tech News',
32 | }
33 |
34 | return (
35 | <>
36 |
37 |
38 | {data?.brand === config.lifestyleBrand ? (
39 |
43 | ) : (
44 |
48 | )}
49 |
50 | >
51 | )
52 | }
53 |
54 | export const getStaticProps: GetStaticProps = async ({
55 | params,
56 | preview = false,
57 | previewData = {},
58 | }) => {
59 | if (config.sanity.projectId) {
60 | const brand = params?.brand ?? 'tech'
61 | const fetchedArticles = await getClient(preview).fetch(indexQuery, {brand})
62 | const allArticles = overlayDrafts(fetchedArticles) ?? []
63 |
64 | return {
65 | props: {
66 | previewData,
67 | preview,
68 | data: {
69 | allArticles,
70 | brand,
71 | },
72 | // If webhooks isn't setup then attempt to re-generate in 1 minute intervals
73 | },
74 | revalidate: config.revalidateSecret ? undefined : 60,
75 | }
76 | }
77 |
78 | /* when the client isn't set up */
79 | return {
80 | props: {},
81 | revalidate: undefined,
82 | }
83 | }
84 |
85 | Index.getLayout = function getLayout(page: React.ReactElement) {
86 | const {preview, data} = page?.props
87 | if (data?.brand == config.lifestyleBrand) {
88 | return {page}
89 | }
90 | return {page}
91 | }
92 |
93 | export const getStaticPaths: GetStaticPaths = () => {
94 | const brand = config.brand
95 | const lifestyleBrand = config.lifestyleBrand
96 | return {
97 | paths: [{params: {brand: brand}}, {params: {brand: lifestyleBrand}}],
98 | fallback: true,
99 | }
100 | }
101 |
--------------------------------------------------------------------------------
/pages/[brand]/sections/[slug].tsx:
--------------------------------------------------------------------------------
1 | import {config} from 'lib/config'
2 | import {GetStaticProps} from 'next'
3 | import ErrorPage from 'next/error'
4 | import {useRouter} from 'next/router'
5 | import {PreviewSuspense} from 'next-sanity/preview'
6 | import {lazy} from 'react'
7 | import * as React from 'react'
8 |
9 | import SectionPage from '../../../components/SectionPage'
10 | import Title from '../../../components/Title'
11 | import {sectionBySlugQuery, sectionSlugsQuery} from '../../../lib/queries'
12 | import {getClient, overlayDrafts} from '../../../lib/sanity.server'
13 | import {Section} from '../../../types'
14 |
15 | const PreviewSectionPage = lazy(
16 | () => import('../../../components/PreviewSectionPage')
17 | )
18 |
19 | interface Props {
20 | data: Section
21 | preview: any
22 | }
23 |
24 | export default function SectionRoute(props: Props) {
25 | const {data, preview} = props
26 | const router = useRouter()
27 |
28 | const slug = data?.slug
29 | const brand = data?.brand ?? 'tech'
30 |
31 | if (!router.isFallback && !slug) {
32 | return
33 | }
34 |
35 | if (router.isFallback) {
36 | return Loading…
37 | }
38 |
39 | if (preview && slug) {
40 | return (
41 | }>
42 |
43 |
44 | )
45 | }
46 |
47 | return
48 | }
49 |
50 | export const getStaticProps: GetStaticProps = async ({
51 | params,
52 | preview = false,
53 | }) => {
54 | const section = await getClient(preview).fetch(sectionBySlugQuery, {
55 | slug: params?.slug,
56 | brand: params?.brand,
57 | })
58 | return {
59 | props: {
60 | preview,
61 | data: {
62 | ...section,
63 | articles: overlayDrafts(section?.articles),
64 | },
65 | },
66 | // If webhooks isn't setup then attempt to re-generate in 1 minute intervals
67 | revalidate: config.revalidateSecret ? undefined : 60,
68 | }
69 | }
70 |
71 | export async function getStaticPaths() {
72 | const paths = await getClient(false).fetch(sectionSlugsQuery)
73 | return {
74 | paths: paths.map(({brand, slug}: {brand: string; slug: string}) => ({
75 | params: {slug, brand},
76 | })),
77 | fallback: true,
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/pages/_app.tsx:
--------------------------------------------------------------------------------
1 | import '../styles/index.css'
2 |
3 | import type {NextPage} from 'next'
4 | import type {AppProps} from 'next/app'
5 | import {useRouter} from 'next/router'
6 | import {DefaultSeo} from 'next-seo'
7 | import Home from 'pages'
8 | import type {ReactElement, ReactNode} from 'react'
9 | import * as React from 'react'
10 |
11 | import LayoutTech from '../components/Layout'
12 | import LayoutLifestyle from '../components/LayoutLifestyle'
13 | import seoConfig from '../lib/next-seo.config'
14 |
15 | export type NextPageWithLayout = NextPage
& {
16 | getLayout?: (page: ReactElement) => ReactNode
17 | }
18 |
19 | type AppPropsWithLayout = AppProps & {
20 | Component: NextPageWithLayout
21 | }
22 |
23 | function MyApp({Component, pageProps}: AppPropsWithLayout) {
24 | // Don't wrap the index page in a brand-specific layout
25 | const router = useRouter()
26 |
27 | if (router.asPath === '/') {
28 | return
29 | }
30 |
31 | const brand = pageProps?.data?.brand || pageProps?.brand
32 |
33 | const Layout = brand === 'lifestyle' ? LayoutLifestyle : LayoutTech
34 | // Use the layout defined at the page level, if available
35 | const pageWithLayout = (page: ReactElement) => {
36 | if (Component.getLayout) {
37 | return Component.getLayout(page)
38 | }
39 | return {page}
40 | }
41 |
42 | return (
43 | <>
44 |
45 | {pageWithLayout()}
46 | >
47 | )
48 | }
49 |
50 | export default MyApp
51 |
--------------------------------------------------------------------------------
/pages/_document.tsx:
--------------------------------------------------------------------------------
1 | import {Head, Html, Main, NextScript} from 'next/document'
2 | import * as React from 'react'
3 |
4 | export default function Document() {
5 | return (
6 |
7 |
8 |
9 |
10 |
14 |
15 |
16 |
17 |
18 |
19 |
20 | )
21 | }
22 |
--------------------------------------------------------------------------------
/pages/api/dynamic-og.tsx:
--------------------------------------------------------------------------------
1 | import {ImageResponse} from '@vercel/og'
2 | import {NextRequest} from 'next/server'
3 | import * as React from 'react'
4 |
5 | import {getClient} from '../../lib/sanity.server'
6 |
7 | export const config = {
8 | runtime: 'edge',
9 | }
10 |
11 | export default async function handler(req: NextRequest) {
12 | const {searchParams} = req.nextUrl
13 | const username = searchParams.get('username')
14 | const type = searchParams.get('type')
15 | const id = searchParams.get('id')
16 | // const secret = searchParams.get('secret')
17 | if (!id || !type) {
18 | return new ImageResponse(<>{'Visit with "?type=&id="'}>, {
19 | width: 1200,
20 | height: 630,
21 | })
22 | }
23 |
24 | // Validate type
25 | if (
26 | !['article', 'person', 'section', 'podcast', 'newsletter'].includes(type)
27 | ) {
28 | throw new Error('Invalid type')
29 | }
30 |
31 | const doc = await getClient(false).fetch(
32 | `*[_type == $type && _id == $id][0]`,
33 | {id, type}
34 | )
35 |
36 | if (type === 'article') {
37 | return new ImageResponse(
38 | (
39 |
50 |
51 |
52 |
53 |
54 |
66 | {' '}
67 | Reach
68 |
69 |
70 |
71 | {doc?.title || 'Untitled'}
72 |
73 |
74 |
75 |
76 |
77 | ),
78 | {
79 | width: 1200,
80 | height: 630,
81 | }
82 | )
83 | }
84 |
85 | return new ImageResponse(
86 | (
87 |
101 | {/* eslint-disable-next-line @next/next/no-img-element */}
102 |

110 |
github.com/{username}
111 |
112 | ),
113 | {
114 | width: 1200,
115 | height: 630,
116 | }
117 | )
118 | }
119 |
--------------------------------------------------------------------------------
/pages/api/exit-preview.tsx:
--------------------------------------------------------------------------------
1 | import {NextApiRequest, NextApiResponse} from 'next'
2 |
3 | export default function exit(_: NextApiRequest, res: NextApiResponse) {
4 | // Exit the current user from "Preview Mode". This function accepts no args.
5 | res.clearPreviewData()
6 |
7 | // Redirect the user back to the index page.
8 | res.writeHead(307, {Location: '/tech'})
9 | return res.end()
10 | }
11 |
--------------------------------------------------------------------------------
/pages/api/newsletter/preview.tsx:
--------------------------------------------------------------------------------
1 | import path from 'node:path'
2 |
3 | import {toPlainText} from '@portabletext/react'
4 | import {SanityDocumentStub} from '@sanity/client'
5 | import * as fs from 'fs'
6 | import mjml2html from 'mjml'
7 | import {NextApiRequest, NextApiResponse} from 'next'
8 | import {twig} from 'twig'
9 |
10 | import {newslettersByIdQuery} from '../../../lib/queries/newsletter'
11 | import {getClient} from '../../../lib/sanity.server'
12 | import {
13 | blocksToCustomContentBlocks,
14 | customToPlainText,
15 | formatDate,
16 | } from '../../../plugins/newsletter/utils/format'
17 |
18 | function wrapIds(ids: any) {
19 | if (!Array.isArray(ids)) {
20 | return [ids]
21 | }
22 |
23 | return ids
24 | }
25 |
26 | function renderMjml(s: string) {
27 | return mjml2html(s).html
28 | }
29 |
30 | async function renderNewsletter(newsletter: SanityDocumentStub) {
31 | const data = {
32 | subject: newsletter.subject ?? newsletter.title,
33 | intro: toPlainText(newsletter.intro),
34 | publishDate: formatDate(newsletter.date),
35 | contentBlocks: blocksToCustomContentBlocks(newsletter.content),
36 | html: customToPlainText(newsletter.content),
37 | text: customToPlainText(newsletter.content),
38 | }
39 |
40 | const templateFile = await getTemplateFile()
41 | const template = twig({data: templateFile.toString()})
42 |
43 | try {
44 | const htmlOutput = renderMjml(template.render(data))
45 | return {data, htmlOutput}
46 | } catch (e) {
47 | throw e
48 | }
49 | // console.dir(data.contentBlocks)
50 | }
51 |
52 | export default async function preview(
53 | req: NextApiRequest,
54 | res: NextApiResponse
55 | ) {
56 | // if (secret && req.query.secret !== secret) {
57 | // return res.status(401).json({ message: 'Invalid secret' })
58 | // }
59 |
60 | // If no ids is provided return error
61 | if (!req.query.ids) {
62 | return res.status(401).json({message: 'Invalid ids'})
63 | }
64 |
65 | // // Get newsletter by ids
66 | const newsletters = await getClient(true).fetch(newslettersByIdQuery, {
67 | ids: wrapIds(req.query.ids),
68 | })
69 |
70 | // If no newsletter is found return error
71 | if (!newsletters || newsletters?.length === 0) {
72 | return res.status(401).json({message: 'No newsletter documents found'})
73 | }
74 |
75 | const renderedNewsletter = await renderNewsletter(newsletters[0])
76 |
77 | return res.status(200).json({output: renderedNewsletter, newsletters})
78 | }
79 |
80 | function getTemplateFile(): Promise {
81 | return new Promise((resolve) => {
82 | const templateLocation = path.join(
83 | process.cwd(),
84 | 'plugins/newsletter/templates/newsletter.mjml'
85 | )
86 | fs.readFile(templateLocation, (error, data) => {
87 | if (error) {
88 | throw error
89 | }
90 | return resolve(data)
91 | })
92 | })
93 | }
94 |
--------------------------------------------------------------------------------
/pages/api/preview.ts:
--------------------------------------------------------------------------------
1 | import {config} from 'lib/config'
2 | import {NextApiHandler, NextApiResponse, PageConfig} from 'next'
3 | import {createClient} from 'next-sanity'
4 | import {getSecret} from 'plugins/productionUrl/utils'
5 | import {getUrlForDocumentType} from 'utils/routing'
6 |
7 | // res.setPreviewData only exists in the nodejs runtime, setting the config here allows changing the global runtime
8 | // option in next.config.mjs without breaking preview mode
9 | export const runtimeConfig: PageConfig = {runtime: 'nodejs'}
10 |
11 | function redirectToPreview(
12 | res: NextApiResponse,
13 | Location: `/${string}` | `${string}/${string}/${string}`
14 | ): void {
15 | // Enable Preview Mode by setting the cookies
16 | // Redirect to a preview capable route
17 | res.writeHead(307, {Location})
18 | res.end()
19 | }
20 |
21 | const {previewSecretId, projectId, dataset, apiVersion, useCdn, readToken} =
22 | config.sanity
23 | const _client = createClient({projectId, dataset, apiVersion, useCdn})
24 |
25 | const preview: NextApiHandler = async (req, res): Promise => {
26 | const previewData: {token?: string} = {}
27 | const client = _client.withConfig({useCdn: false, token: readToken})
28 |
29 | if (!req.query.secret) {
30 | return res.status(401).json({message: 'Invalid secret'})
31 | }
32 |
33 | // If a secret is present in the URL, verify it and if valid we upgrade to token based preview mode, which works in Safari and Incognito mode
34 | if (req.query.secret) {
35 | if (!readToken) {
36 | throw new Error(
37 | `
38 | A secret is provided but there is no \`SANITY_API_READ_TOKEN\` environment variable setup.
39 | Please ensure \`SANITY_API_READ_TOKEN\` is in your environment,
40 | and available in the \`config.ts\` file in the \`lib\` folder.
41 | `
42 | )
43 | }
44 |
45 | // @ts-ignore
46 | const secret = await getSecret(client, previewSecretId)
47 | if (req.query.secret !== secret) {
48 | return res.status(401).send('Invalid secret')
49 | }
50 |
51 | previewData.token = readToken
52 | res.setPreviewData(previewData)
53 | }
54 |
55 | const slug = req.query.slug
56 | const brand = req.query.brand ?? 'tech'
57 |
58 | // If no slug is provided open preview mode on the frontpage
59 | // add brand logic here
60 | if (!slug) {
61 | return redirectToPreview(res, `/${brand}`)
62 | }
63 |
64 | //get document type
65 | const docType = await client.fetch(`*[slug.current == $slug][0]._type`, {
66 | slug,
67 | })
68 | const pathname = getUrlForDocumentType(
69 | docType,
70 | slug as string,
71 | brand as string
72 | )
73 |
74 | //SEO preview logic: SEO panel should receive page as HTML string.
75 | //we send the "fetch" query param to the preview endpoint to trigger this logic
76 | if (req.query.fetch) {
77 | const proto =
78 | //eslint-disable-next-line no-process-env
79 | process.env.NODE_ENV === 'development' ? `http://` : `https://`
80 | const host = req.headers.host
81 | const absoluteUrl = new URL(`${proto}${host}${pathname}`).toString()
82 |
83 | // Create preview headers from the setPreviewData above
84 | const previewHeader = res.getHeader('Set-Cookie')
85 | const previewHeaderString =
86 | typeof previewHeader === 'string' || typeof previewHeader === 'number'
87 | ? previewHeader.toString()
88 | : previewHeader?.join('; ')
89 | const headers = new Headers()
90 | headers.append('credentials', 'include')
91 | headers.append('Cookie', previewHeaderString ?? '')
92 |
93 | const previewHtml = await fetch(absoluteUrl, {
94 | credentials: `include`,
95 | headers,
96 | })
97 | .then((previewRes) => previewRes.text())
98 | .catch((err) => console.error(err))
99 | const corsOrigin =
100 | //eslint-disable-next-line no-process-env
101 | process.env.NODE_ENV === 'development'
102 | ? 'http://localhost:3333'
103 | : 'https://demo-media-site-nextjs.sanity.studio'
104 | res.setHeader('Access-Control-Allow-Origin', corsOrigin)
105 | res.setHeader('Access-Control-Allow-Credentials', 'true')
106 | return res.send(previewHtml)
107 | }
108 |
109 | // Redirect to the path from the fetched post
110 | // We don't redirect to req.query.slug as that might lead to open redirect vulnerabilities
111 | return redirectToPreview(res, pathname)
112 | }
113 |
114 | export default preview
115 |
--------------------------------------------------------------------------------
/pages/api/revalidate.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * This code is responsible for revalidating the cache when a post or author is updated.
3 | *
4 | * It is set up to receive a validated GROQ-powered Webhook from Sanity.io:
5 | * https://www.sanity.io/docs/webhooks
6 | *
7 | * You can quickly add the webhook configuration to your Sanity project with this URL:
8 |
9 | https://www.sanity.io/manage/webhooks/share?name=On-demand+Revalidation&description=Webhook+configuration+for+a+Next.js+blog+with+an+Incremental+Static+Revalidation+serverless+route+set+up.+Remember+to+update+the+URL+for+your+hosted+site%2C+as+well+as+a+secret+that+you+copy+to+the+environment+variables+where+your+Next.js+site+is+hosted+%28SANITY_REVALIDATE_SECRET%29.&url=https%3A%2F%2FYOUR_NEXTJS_SITE_URL%2Fapi%2Frevalidate&on=create&on=update&on=delete&filter=%5B%22post%22%2C+%22author%22%2C+%22settings%22%5D+in+_type&projection=&httpMethod=POST&apiVersion=v2021-03-25&includeDrafts=&headers=%7B%7D
10 |
11 | * MANUAL SETUP:
12 | * 1. Go to the API section of your Sanity project on sanity.io/manage
13 | * 2. Click "Create webhook"
14 | * 3. Set the URL to https://YOUR_NEXTJS_SITE_URL/api/revalidate
15 | * 4. Trigger on: "Create", "Update", and "Delete"
16 | * 5. Filter: ["post", "author", "settings"] in _type
17 | * 6. Projection: Leave empty
18 | * 7. HTTP method: POST
19 | * 8. API version: v2021-03-25
20 | * 9. Include drafts: No
21 | * 10. HTTP Headers: Leave empty
22 | * 11. Secret: Set to the same value as SANITY_REVALIDATE_SECRET (create a random one if you haven't)
23 | * 12. Save the cofiguration
24 |
25 | */
26 |
27 | import {isValidSignature, SIGNATURE_HEADER_NAME} from '@sanity/webhook'
28 | import {config as globalConfig} from 'lib/config'
29 | import {NextApiRequest, NextApiResponse} from 'next'
30 |
31 | import {getClient} from '../../lib/sanity.server'
32 |
33 | // Next.js will by default parse the body, which can lead to invalid signatures
34 | export const config = {
35 | api: {
36 | bodyParser: false,
37 | },
38 | }
39 |
40 | const AUTHOR_UPDATED_QUERY = /* groq */ `
41 | *[_type == "author" && _id == $id] {
42 | "slug": *[_type == "post" && references(^._id)].slug.current
43 | }["slug"][]`
44 | const POST_UPDATED_QUERY = /* groq */ `*[_type == "post" && _id == $id].slug.current`
45 | const SETTINGS_UPDATED_QUERY = /* groq */ `*[_type == "post"].slug.current`
46 |
47 | const getQueryForType = (type: string) => {
48 | switch (type) {
49 | case 'author':
50 | return AUTHOR_UPDATED_QUERY
51 | case 'post':
52 | return POST_UPDATED_QUERY
53 | case 'settings':
54 | return SETTINGS_UPDATED_QUERY
55 | default:
56 | throw new TypeError(`Unknown type: ${type}`)
57 | }
58 | }
59 |
60 | const log = (msg: string, error?: boolean) =>
61 | // eslint-disable-next-line no-console
62 | console[error ? 'error' : 'log'](`[revalidate] ${msg}`)
63 |
64 | async function readBody(readable: NodeJS.ReadableStream) {
65 | const chunks = []
66 | for await (const chunk of readable) {
67 | chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk)
68 | }
69 | return Buffer.concat(chunks).toString('utf8')
70 | }
71 |
72 | export default async function revalidate(
73 | req: NextApiRequest,
74 | res: NextApiResponse
75 | ) {
76 | let signature = req.headers[SIGNATURE_HEADER_NAME]
77 | if (Array.isArray(signature)) {
78 | signature = signature[0]
79 | } else if (typeof signature !== 'string') {
80 | signature = ''
81 | }
82 | const body = await readBody(req) // Read the body into a string
83 | if (
84 | !isValidSignature(
85 | body,
86 | signature,
87 | globalConfig.revalidateSecret?.trim() || ''
88 | )
89 | ) {
90 | const invalidSignature = 'Invalid signature'
91 | log(invalidSignature, true)
92 | return res.status(401).json({success: false, message: invalidSignature})
93 | }
94 |
95 | const jsonBody = JSON.parse(body)
96 | const {_id: id, _type} = jsonBody
97 | if (typeof id !== 'string' || !id) {
98 | const invalidId = 'Invalid _id'
99 | log(invalidId, true)
100 | return res.status(400).json({message: invalidId})
101 | }
102 |
103 | log(`Querying post slug for _id '${id}', type '${_type}' ..`)
104 | const slug = await getClient(false).fetch(getQueryForType(_type), {id})
105 | const slugs = (Array.isArray(slug) ? slug : [slug]).map(
106 | (_slug) => `/articles/${_slug}`
107 | )
108 | const staleRoutes = ['/', ...slugs]
109 |
110 | try {
111 | await Promise.all(staleRoutes.map((route) => res.revalidate(route)))
112 | const updatedRoutes = `Updated routes: ${staleRoutes.join(', ')}`
113 | log(updatedRoutes)
114 | return res.status(200).json({message: updatedRoutes})
115 | } catch (err: any) {
116 | log(err.message, true)
117 | return res.status(500).json({message: err.message})
118 | }
119 | }
120 |
--------------------------------------------------------------------------------
/pages/index.tsx:
--------------------------------------------------------------------------------
1 | import {BRANDS} from 'lib/constants'
2 | import Link from 'next/link'
3 | import React from 'react'
4 |
5 | export default function Home() {
6 | return (
7 |
8 | Media brands:
9 | {BRANDS.map((brand) => (
10 |
11 | {brand.title}
12 |
13 | {brand.title} Website
14 |
15 |
16 |
17 | {brand.title} Sanity Studio
18 |
19 |
20 |
21 |
22 | ))}
23 |
24 | )
25 | }
26 |
--------------------------------------------------------------------------------
/pages/studio/[[...index]].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 Head from 'next/head'
11 | import {NextStudio} from 'next-sanity/studio'
12 | import {NextStudioHead} from 'next-sanity/studio/head'
13 | import {ReactElement} from 'react'
14 | import * as React from 'react'
15 |
16 | import config from '../../sanity.config'
17 |
18 | export default function StudioPage() {
19 | return (
20 | <>
21 |
22 |
23 |
24 |
25 | >
26 | )
27 | }
28 |
29 | StudioPage.getLayout = function getLayout(page: ReactElement) {
30 | return <>{page}>
31 | }
32 |
--------------------------------------------------------------------------------
/plugins/PreviewPane/index.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * This component is responsible for rendering previews of pages in the studio.
3 | */
4 | import React, {memo} from 'react'
5 | import Iframe, {IframeOptions} from 'sanity-plugin-iframe-pane'
6 | import {BrandSlugDocument} from 'types'
7 |
8 | import {useBuildPreviewUrl} from '../../plugins/productionUrl'
9 |
10 | type Props = {
11 | document: {
12 | displayed: BrandSlugDocument
13 | }
14 | fetch?: boolean
15 | }
16 |
17 | export const PreviewPane = memo(function PreviewPane({document, fetch}: Props) {
18 | const url = useBuildPreviewUrl(document.displayed)
19 |
20 | const options: IframeOptions = {
21 | url,
22 | reload: {
23 | button: true,
24 | // revision: false
25 | },
26 | }
27 |
28 | if (document.displayed._type == 'siteSettings') {
29 | /* @ts-expect-error -- revision: false does not work as expected */
30 | options.reload.revision = true
31 | }
32 |
33 | return (
34 |
40 |
41 |
42 | )
43 | })
44 |
--------------------------------------------------------------------------------
/plugins/defaultConfig/defaultDocumentNode.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import {DefaultDocumentNodeResolver} from 'sanity/structure'
3 | import DocumentsPane from 'sanity-plugin-documents-pane'
4 |
5 | import {PreviewPane} from '../../plugins/PreviewPane'
6 | import {NewsletterPreview} from '../newsletter'
7 |
8 | const defaultDocumentNode: DefaultDocumentNodeResolver = (S, {schemaType}) => {
9 | const previewTypes = ['article', 'person', 'section', 'siteSettings']
10 | const articleReferenceTypes = ['person', 'section']
11 | const views = []
12 |
13 | if (previewTypes.includes(schemaType)) {
14 | views.push(
15 | S.view
16 | .component(({document}) => )
17 | .title('Preview')
18 | )
19 | }
20 |
21 | if (articleReferenceTypes.includes(schemaType)) {
22 | views.push(
23 | S.view
24 | .component(DocumentsPane)
25 | .options({
26 | query: `*[!(_id in path("drafts.**")) && references($id)]`,
27 | params: {id: `_id`},
28 | })
29 | .title('Related Content')
30 | )
31 | }
32 |
33 | if (schemaType === 'newsletter') {
34 | views.push(S.view.component(NewsletterPreview).title('Preview'))
35 | }
36 |
37 | return S.document().views([S.view.form(), ...views])
38 | }
39 |
40 | export default defaultDocumentNode
41 |
--------------------------------------------------------------------------------
/plugins/defaultConfig/index.ts:
--------------------------------------------------------------------------------
1 | export * from './mediaConfigPlugin'
2 | export * from './structure'
3 |
--------------------------------------------------------------------------------
/plugins/defaultConfig/mediaConfigPlugin.ts:
--------------------------------------------------------------------------------
1 | import {definePlugin} from 'sanity'
2 | import {media, mediaAssetSource} from 'sanity-plugin-media'
3 |
4 | const mediaConfigPlugin = definePlugin({
5 | name: 'mediaConfigPlugin',
6 | plugins: [media()],
7 | form: {
8 | // Don't use this plugin when selecting files only (but allow all other enabled asset sources)
9 | file: {
10 | assetSources: (previousAssetSources) => {
11 | return previousAssetSources.filter(
12 | (assetSource) => assetSource !== mediaAssetSource
13 | )
14 | },
15 | },
16 | // Filter out the default image selector
17 | image: {
18 | assetSources: (previousAssetSources) => {
19 | return previousAssetSources.filter(
20 | (assetSource) => assetSource.name !== 'sanity-default'
21 | )
22 | },
23 | },
24 | },
25 | })
26 |
27 | export {mediaConfigPlugin}
28 |
--------------------------------------------------------------------------------
/plugins/defaultConfig/structure/index.ts:
--------------------------------------------------------------------------------
1 | export * from './lifestyleStructure'
2 | export * from './reviewStructure'
3 | export * from './techStructure'
4 |
--------------------------------------------------------------------------------
/plugins/defaultConfig/structure/lifestyleStructure.ts:
--------------------------------------------------------------------------------
1 | import {StructureResolver} from 'sanity/structure'
2 |
3 | import article from '../../../schemas/article'
4 | import person from '../../../schemas/person'
5 | import section from '../../../schemas/section'
6 | import {createSchemaItemForBrand, createSiteSettingsNodeForBrand} from './utils'
7 |
8 | export const lifestyleStructure: StructureResolver = (S) =>
9 | S.list()
10 | .id('lifestyle-root')
11 | .title('Lifestyle content')
12 | .items([
13 | createSiteSettingsNodeForBrand(S, 'lifestyle'),
14 | S.divider(),
15 | createSchemaItemForBrand(S, article, 'lifestyle'),
16 | S.divider(),
17 | createSchemaItemForBrand(S, person, 'lifestyle'),
18 | createSchemaItemForBrand(S, section, 'lifestyle'),
19 | ])
20 |
--------------------------------------------------------------------------------
/plugins/defaultConfig/structure/reviewStructure.ts:
--------------------------------------------------------------------------------
1 | import {StructureResolver} from 'sanity/structure'
2 |
3 | import review from '../../../schemas/review'
4 | import {createSchemaItemForBrand} from './utils'
5 |
6 | export const reviewStructure: StructureResolver = (S) =>
7 | S.list()
8 | .id('review-root')
9 | .title('Review content')
10 | .items([createSchemaItemForBrand(S, review, 'reviews')])
11 |
--------------------------------------------------------------------------------
/plugins/defaultConfig/structure/techStructure.ts:
--------------------------------------------------------------------------------
1 | import {Role} from 'sanity'
2 | import {StructureResolver} from 'sanity/structure'
3 |
4 | import article from '../../../schemas/article'
5 | import newsletter from '../../../schemas/newsletter'
6 | import person from '../../../schemas/person'
7 | import podcast from '../../../schemas/podcast'
8 | import section from '../../../schemas/section'
9 | import {createSchemaItemForBrand, createSiteSettingsNodeForBrand} from './utils'
10 |
11 | export const techStructure: StructureResolver = (S, context) => {
12 | const {currentUser} = context
13 | const roles = currentUser?.roles || []
14 | //don't block out editors from the real life project! :)
15 | const isAdmin = !!roles.find(
16 | (role: Role) => role.name === 'administrator' || role.name === 'editor'
17 | )
18 |
19 | const adminItems = [
20 | createSiteSettingsNodeForBrand(S, 'tech'),
21 | S.divider(),
22 | createSchemaItemForBrand(S, article, 'tech'),
23 | createSchemaItemForBrand(S, newsletter, 'tech'),
24 | createSchemaItemForBrand(S, podcast, 'tech'),
25 | S.divider(),
26 | createSchemaItemForBrand(S, person, 'tech'),
27 | createSchemaItemForBrand(S, section, 'tech'),
28 | ]
29 |
30 | const contributorItems = [
31 | createSchemaItemForBrand(S, article, 'tech'),
32 | S.divider(),
33 | createSchemaItemForBrand(S, person, 'tech'),
34 | createSchemaItemForBrand(S, section, 'tech'),
35 | ]
36 |
37 | return S.list()
38 | .id('tech-root')
39 | .title('Tech content')
40 | .items(isAdmin ? adminItems : contributorItems)
41 | }
42 |
--------------------------------------------------------------------------------
/plugins/defaultConfig/structure/utils.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import {FiSliders} from 'react-icons/fi'
3 | import {DocumentDefinition} from 'sanity'
4 | import {StructureBuilder} from 'sanity/structure'
5 |
6 | import {config} from '../../../lib/config'
7 | import {PreviewPane} from '../../PreviewPane'
8 |
9 | export const createSiteSettingsNodeForBrand = (
10 | S: StructureBuilder,
11 | brandName: string
12 | ) =>
13 | S.listItem()
14 | .title('Settings')
15 | .icon(FiSliders)
16 | .child(
17 | S.document()
18 | .schemaType('siteSettings')
19 | .documentId(`${brandName}-siteSettings`)
20 | .title('Settings')
21 | .views([
22 | S.view.form(),
23 | S.view
24 | .component(({document}) => )
25 | .title('Preview'),
26 | ])
27 | )
28 |
29 | export const createSchemaItemForBrand = (
30 | S: StructureBuilder,
31 | schemaItem: DocumentDefinition,
32 | brandName: string
33 | ) =>
34 | S.listItem()
35 | .title(schemaItem.title || schemaItem.name)
36 | .icon(schemaItem?.icon)
37 | .child(
38 | S.documentTypeList(schemaItem.name)
39 | .title(`${schemaItem.title}`)
40 | .filter(`_type == $schemaType && brand == $brand`)
41 | .apiVersion(config.sanity.apiVersion)
42 | .params({
43 | schemaType: schemaItem.name,
44 | brand: brandName,
45 | })
46 | .initialValueTemplates([
47 | S.initialValueTemplateItem(`${schemaItem.name}-${brandName}`),
48 | ])
49 | )
50 |
--------------------------------------------------------------------------------
/plugins/newsletter/components/EMSStatusWrapper.tsx:
--------------------------------------------------------------------------------
1 | import {Button, Card, Flex, Label, Stack, Text} from '@sanity/ui'
2 | // import {formatRelative, parseISO} from 'date-fns'
3 | import React, {useState} from 'react'
4 | import type {InputProps} from 'sanity'
5 |
6 | const STATUS_PENDING = 'pending'
7 | const STATUS_SYNCED = 'synced'
8 |
9 | // interface SyncStatus {
10 | // syncStatus: 'pending' | 'syncing' | 'synced'
11 | // id?: string
12 | // dateSynced?: string
13 | // }
14 |
15 | // function randomString(length: number) {
16 | // const chars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
17 | // let result = ''
18 | // for (let i = length; i > 0; --i)
19 | // result += chars[Math.floor(Math.random() * chars.length)]
20 | // return result
21 | // }
22 |
23 | // https://github.com/sanity-io/sanity/releases/tag/v3.0.0-dev-preview.22
24 | // https://mailchimp.com/developer/marketing/api/campaigns/add-campaign/
25 | export function EMSStatusWrapper(props: InputProps) {
26 | const [status] = useState(STATUS_PENDING)
27 | // const [_, setStatusDetails] = useState()
28 |
29 | // const handleSync = useCallback(() => {
30 | // setStatusDetails((prev) =>
31 | // prev ? {...prev, syncStatus: 'syncing'} : {syncStatus: 'syncing'}
32 | // )
33 | // setTimeout(() => {
34 | // setStatusDetails({
35 | // syncStatus: 'synced',
36 | // id: randomString(10),
37 | // dateSynced: new Date().toISOString(),
38 | // })
39 | // }, 2500)
40 | // }, [])
41 |
42 | // const syncDate = useMemo(() => {
43 | // const dateFormatted = statusDetails?.dateSynced
44 | // ? formatRelative(parseISO(statusDetails.dateSynced), new Date())
45 | // : ''
46 | // return statusDetails?.dateSynced ? `Synced ${dateFormatted}` : 'Not pushed'
47 | // }, [statusDetails])
48 |
49 | // const { _id } = props.value as SanityDocumentLike
50 | return (
51 |
52 |
59 |
60 |
61 |
62 | No EMS set up for this studio.
63 |
64 |
65 |
77 |
78 |
79 |
80 | {/* Render the default document form */}
81 | {props.renderDefault(props)}
82 |
83 | )
84 | }
85 |
--------------------------------------------------------------------------------
/plugins/newsletter/components/InputWrappers.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import type {InputProps} from 'sanity'
3 |
4 | import {EMSStatusWrapper} from './EMSStatusWrapper'
5 | import {SyncNewArticlesWrapper} from './SyncNewArticles'
6 |
7 | // const isArrayType = (def: ArraySchemaType): def is ArrayDefinition =>
8 | // def.type === 'portableText'
9 |
10 | export function InputWrappers(props: InputProps) {
11 | // console.log(props.schemaType)
12 |
13 | if (
14 | props.schemaType.type?.name === 'document' &&
15 | props.schemaType.name === 'newsletter'
16 | ) {
17 | return
18 | }
19 |
20 | // if (isArrayType(props.schemaType)) {
21 | // }
22 |
23 | if (props.schemaType?.options?.showSyncButton) {
24 | return
25 | }
26 |
27 | return props.renderDefault(props)
28 | }
29 |
--------------------------------------------------------------------------------
/plugins/newsletter/components/NewsletterPreview.tsx:
--------------------------------------------------------------------------------
1 | import {Box, Button, Card, Flex, Spinner, useToast} from '@sanity/ui'
2 | import React, {useCallback, useEffect, useState} from 'react'
3 | import {SanityDocumentLike} from 'sanity'
4 | import styled from 'styled-components'
5 |
6 | export type IframeProps = {
7 | document: {
8 | displayed: SanityDocumentLike
9 | }
10 | }
11 |
12 | const WRAPPER_STYLE = {height: `100%`}
13 | const IFRAME_STYLE = {width: '100%', maxHeight: '100%', height: `100%`}
14 |
15 | const ButtonWrapper = styled(Box)`
16 | position: absolute;
17 | right: 0;
18 | top: 0;
19 | `
20 |
21 | async function fetchEmails(_id: string) {
22 | const res = await fetch(`/api/newsletter/preview?ids=${_id}`)
23 | const result = await res.json()
24 |
25 | return result?.output?.htmlOutput
26 | }
27 |
28 | export function NewsletterPreview(props: IframeProps) {
29 | const toast = useToast()
30 | const {document: sanityDocument} = props
31 | const id = sanityDocument?.displayed?._id
32 | const [emailContent, setEmailContent] = useState(null)
33 | const [isLoading, setIsLoading] = useState(false)
34 | const handleRefresh = useCallback(async () => {
35 | setIsLoading(true)
36 | try {
37 | setEmailContent(await fetchEmails(id))
38 | } catch (e: any) {
39 | console.error(e)
40 | toast.push({
41 | id: 'email-preview-error',
42 | status: 'error',
43 | title: 'Could not fetch email content',
44 | description: e.message,
45 | })
46 | }
47 | setIsLoading(false)
48 | toast.push({
49 | id: 'email-preview-success',
50 | status: 'success',
51 | title: 'Refreshed preview',
52 | })
53 | }, [id, toast])
54 |
55 | useEffect(() => {
56 | handleRefresh()
57 | }, [id, handleRefresh])
58 |
59 | return (
60 |
61 | {emailContent && (
62 |
63 |
68 |
69 | )}
70 |
71 |
72 | {emailContent ? (
73 |
79 | ) : (
80 |
81 |
82 |
83 | )}
84 |
85 |
86 |
87 | )
88 | }
89 |
--------------------------------------------------------------------------------
/plugins/newsletter/components/SyncNewArticles.tsx:
--------------------------------------------------------------------------------
1 | import {PortableTextBlock} from '@portabletext/types'
2 | import {Box, Button, Card, Flex, Stack, Text, useToast} from '@sanity/ui'
3 | import groq from 'groq'
4 | import {nanoid} from 'nanoid'
5 | import React, {useCallback, useEffect, useMemo, useState} from 'react'
6 | import type {InputProps, SanityDocumentLike} from 'sanity'
7 | import {useClient, useFormValue} from 'sanity'
8 |
9 | interface SyncStatus {
10 | syncStatus: 'loading' | 'done'
11 | ids?: string[]
12 | }
13 |
14 | interface ArticleReferencesBlock extends PortableTextBlock {
15 | _type: 'articleReferences'
16 | _key: string
17 | references: {
18 | _ref: string
19 | _key: string
20 | _type: 'articleReference'
21 | }[]
22 | }
23 |
24 | /*
25 | groq`*[_type == 'newsletter']{content[_type == 'articleReferences']}`
26 | groq`*[_type=="newsletter" && references(*[_type=="article"]._id)]{title}`
27 | */
28 |
29 | const findExistingReferenceIds = (
30 | queryResult: SanityDocumentLike[],
31 | typeReferences: string
32 | ) => {
33 | const existingReferenceIds = [
34 | ...new Set(
35 | queryResult
36 | ?.map((newsletter: SanityDocumentLike) => {
37 | const content = (newsletter?.content ||
38 | []) as ArticleReferencesBlock[]
39 | return (
40 | content
41 | ?.filter((block) => block._type === typeReferences)
42 | ?.map(
43 | (block: ArticleReferencesBlock) =>
44 | block.references?.map((reference) => reference._ref) ?? []
45 | ) ?? []
46 | )
47 | .flat()
48 | .filter(Boolean)
49 | })
50 | .flat() ?? []
51 | ),
52 | ]
53 |
54 | return existingReferenceIds
55 | }
56 |
57 | export function SyncNewArticlesWrapper(props: InputProps) {
58 | const toast = useToast()
59 | const [status, setStatus] = useState({syncStatus: 'loading'})
60 | const client = useClient({apiVersion: '2022-03-13'})
61 | const [statusDetails, setStatusDetails] = useState()
62 | const content = useFormValue(['content']) as PortableTextBlock[]
63 | const documentId = useFormValue(['_id']) as string
64 | const TYPE_REFERENCES = 'articleReferences'
65 | const QUERY = groq`*[_type=="newsletter" && references(*[_type=="article"]._id)]{title, content[_type == 'articleReferences']}`
66 | const QUERY_NOT_REFERENCED = groq`*[_type == 'article' && (!defined(brand) || brand == $brand) && !(_id in $referencedArticles) && !(_id in path("drafts.**"))]._id
67 | `
68 | const articleReferenceBlock = content?.find(
69 | (block: any) => block._type === TYPE_REFERENCES
70 | )
71 |
72 | const handleFetch = useCallback(async () => {
73 | setStatus({syncStatus: 'loading', ids: []})
74 | const queryResult = await client.fetch(QUERY)
75 |
76 | const existingReferenceIds = findExistingReferenceIds(
77 | queryResult,
78 | TYPE_REFERENCES
79 | )
80 |
81 | // Then we get any articles not referenced in any newsletter
82 | const articleRefs = (await client.fetch(QUERY_NOT_REFERENCED, {
83 | referencedArticles: existingReferenceIds,
84 | brand: 'tech',
85 | })) as string[]
86 |
87 | setStatus({syncStatus: 'done', ids: articleRefs})
88 | }, [client, QUERY, QUERY_NOT_REFERENCED])
89 |
90 | useEffect(() => {
91 | handleFetch()
92 | }, [handleFetch])
93 |
94 | const isLoading = useMemo(() => status.syncStatus === 'loading', [status])
95 | const pendingArticleCount = useMemo(() => status?.ids?.length || 0, [status])
96 | const hasPendingArticles = useMemo(
97 | () => pendingArticleCount > 0,
98 | [pendingArticleCount]
99 | )
100 |
101 | const handleSync = useCallback(async () => {
102 | // Create new block or update existing
103 | setStatusDetails(
104 | statusDetails
105 | ? {...statusDetails, syncStatus: 'loading'}
106 | : {syncStatus: 'loading'}
107 | )
108 |
109 | try {
110 | await client
111 | .transaction()
112 | .patch(documentId, (patch) => {
113 | const references = [
114 | // @ts-ignore
115 | ...(articleReferenceBlock?.references ?? []),
116 | ...status.ids!.map((id) => ({
117 | _key: nanoid(10),
118 | _ref: id,
119 | _type: 'articleReference',
120 | })),
121 | ]
122 |
123 | if (articleReferenceBlock?._key) {
124 | patch.set({
125 | [`content[_key=="${articleReferenceBlock._key}"].references`]:
126 | references,
127 | })
128 | } else {
129 | patch.insert('after', 'content[-1]', [
130 | {
131 | _key: nanoid(10),
132 | _type: TYPE_REFERENCES,
133 | references,
134 | },
135 | ])
136 | }
137 | return patch
138 | })
139 | .commit()
140 |
141 | toast.push({
142 | status: 'success',
143 | title: 'All new articles added to newsletter',
144 | })
145 |
146 | await handleFetch()
147 | } catch (error: any) {
148 | console.error(error)
149 | toast.push({
150 | duration: 5000,
151 | status: 'error',
152 | title: 'Error',
153 | description: error.message,
154 | })
155 | }
156 | // setTimeout(() => {
157 | // setStatusDetails({ syncStatus: 'synced', dateSynced: new Date().toISOString() })
158 | // }, 2500)
159 | }, [
160 | toast,
161 | handleFetch,
162 | articleReferenceBlock,
163 | client,
164 | documentId,
165 | status.ids,
166 | statusDetails,
167 | ])
168 |
169 | return (
170 |
171 | {/* Render the default document form */}
172 | {props.renderDefault(props)}
173 |
174 |
175 |
182 | {pendingArticleCount ? (
183 |
184 |
185 | {pendingArticleCount} new{' '}
186 | {pendingArticleCount === 1 ? 'article' : 'articles'} to sync
187 |
188 |
189 | ) : null}
190 |
191 |
192 |
193 | )
194 | }
195 |
--------------------------------------------------------------------------------
/plugins/newsletter/components/index.ts:
--------------------------------------------------------------------------------
1 | export * from './InputWrappers'
2 |
--------------------------------------------------------------------------------
/plugins/newsletter/index.ts:
--------------------------------------------------------------------------------
1 | import {definePlugin} from 'sanity'
2 |
3 | import {InputWrappers} from './components'
4 |
5 | declare module 'sanity' {
6 | export interface ArrayOptions {
7 | showSyncButton?: boolean
8 | }
9 | }
10 |
11 | export default definePlugin({
12 | name: 'newsletterPlugin',
13 | document: {},
14 | form: {
15 | components: {
16 | input: InputWrappers,
17 | },
18 | },
19 | })
20 |
21 | export * from './components/NewsletterPreview'
22 |
--------------------------------------------------------------------------------
/plugins/newsletter/templates/newsletter.text.mjml.twig:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | {{ subject ?? 'No subject' }}
6 |
7 |
8 |
9 |
10 |
11 | Media
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 | {{intro}}
25 |
26 |
27 |
28 |
29 |
30 | {{publishDate}}
31 |
32 |
33 |
34 |
35 |
36 |
37 | {% set lastBlockType = null %}
38 | {% for contentBlock in contentBlocks %}
39 | {% case contentBlock.type %}
40 | {% when 'text' %}
41 | {% if lastBlockType and lastBlockType %}
42 |
43 |
44 |
45 | {{value}}
46 |
47 |
48 |
49 | {% endcase %}
50 | {% endfor %}
51 |
52 |
53 |
54 |
--------------------------------------------------------------------------------
/plugins/newsletter/utils/format.ts:
--------------------------------------------------------------------------------
1 | import {toPlainText} from '@portabletext/react'
2 | import {toHTML} from '@portabletext/to-html'
3 | import type {
4 | PortableTextBlock,
5 | PortableTextBlockStyle,
6 | } from '@portabletext/types'
7 | import {format, parseISO} from 'date-fns'
8 | import {Article} from 'types'
9 |
10 | import {urlForImage} from '../../../lib/sanity'
11 |
12 | interface TextBlock {
13 | type: 'paragraph' | 'heading'
14 | value: string
15 | html: string
16 | style: PortableTextBlockStyle
17 | }
18 |
19 | interface ModuleBlock {
20 | type: 'article' | 'image' | 'articles'
21 | [key: string]: unknown
22 | }
23 |
24 | type CustomBlock =
25 | | (Article & {_type: 'article'})
26 | | {_type: 'articleReferences'; references: Article[]}
27 |
28 | function isCustomBlock(
29 | block: PortableTextBlock | CustomBlock
30 | ): block is CustomBlock {
31 | return block._type !== 'block'
32 | }
33 |
34 | function renderTextBlock(block: PortableTextBlock): TextBlock {
35 | if (block.style !== 'normal') {
36 | return {
37 | type: 'heading',
38 | value: toPlainText(block),
39 | html: toHTML(block),
40 | style: block.style as PortableTextBlockStyle,
41 | }
42 | }
43 |
44 | return {
45 | type: 'paragraph',
46 | value: toPlainText(block),
47 | html: toHTML(block),
48 | style: block.style,
49 | }
50 | }
51 |
52 | export function blocksToCustomContentBlocks(
53 | blocks: Array = []
54 | ): (ModuleBlock | TextBlock)[] {
55 | return blocks
56 | .map((block) => {
57 | if (isCustomBlock(block)) {
58 | return renderCustomBlock(block)
59 | }
60 | return renderTextBlock(block)
61 | })
62 | .filter(Boolean) as (ModuleBlock | TextBlock)[]
63 | }
64 |
65 | export function customToPlainText(blocks: PortableTextBlock[] = []): string {
66 | return blocks
67 | .map((block) => {
68 | if (isCustomBlock(block)) {
69 | return renderCustomBlock(block)
70 | }
71 |
72 | return toPlainText(block)
73 | })
74 | .filter(Boolean)
75 | .join('\n\n')
76 | }
77 |
78 | export function renderCustomBlock(block: CustomBlock): ModuleBlock | null {
79 | switch (block._type) {
80 | case 'article':
81 | return {
82 | type: 'article',
83 | title: block.title,
84 | description: block?.intro ? toPlainText(block.intro) : null,
85 | slug: block.slug,
86 | imageUrl: block.mainImage
87 | ? urlForImage(block.mainImage?.image).height(600).width(600).url()
88 | : null,
89 | imageAlt: block?.mainImage?.alt,
90 | url: `https://demo-media-site-nextjs.sanity.build/articles/${block.slug}`,
91 | }
92 | case 'articleReferences':
93 | return {
94 | type: 'articles',
95 | articles: block?.references.filter(Boolean).map((article) => ({
96 | title: article?.title,
97 | description: article?.intro ? toPlainText(article.intro) : null,
98 | slug: article.slug,
99 | imageUrl: article.mainImage
100 | ? urlForImage(article.mainImage?.image).width(340).height(480).url()
101 | : null,
102 | imageAlt: article?.mainImage?.alt,
103 | url: `https://demo-media-site-nextjs.sanity.build/articles/${article.slug}`,
104 | })),
105 | }
106 | default:
107 | return null
108 | }
109 | }
110 |
111 | export function formatDate(dateValue: string): string {
112 | try {
113 | return format(parseISO(dateValue), 'LLLL d, yyyy')
114 | } catch (err) {
115 | return dateValue
116 | }
117 | }
118 |
119 | type Email = {
120 | subject: string
121 | content: PortableTextBlock[]
122 | }
123 |
124 | export function emailToHTML(email: Email): string {
125 | return `
126 | ${email.subject}
127 | ${toPlainText(email.content)}
128 | `
129 | }
130 |
--------------------------------------------------------------------------------
/plugins/productionUrl/index.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * This plugin sets up the "Open preview (CTRL + ALT + O)" in the dropdown menu that hosts
3 | * other actions like "Review changes" and "Inspect"
4 | */
5 |
6 | import {definePlugin} from 'sanity'
7 |
8 | import {getSecret, useBuildPreviewUrl} from './utils'
9 | import {buildPreviewUrl} from './utils/buildPreviewUrl'
10 |
11 | export const productionUrl = definePlugin<{
12 | previewSecretId: `${string}.${string}`
13 | types: string[]
14 | apiVersion?: string
15 | }>(({previewSecretId, types: _types, apiVersion = '2022-11-17'}) => {
16 | if (!previewSecretId) {
17 | throw new TypeError('`previewSecretId` is required')
18 | }
19 | if (!previewSecretId.includes('.')) {
20 | throw new TypeError(
21 | '`previewSecretId` must contain a `.` to ensure it can only be queried by authenticated users'
22 | )
23 | }
24 | if (!_types || _types.length === 0) {
25 | throw new TypeError('`types` is required')
26 | }
27 | const types = new Set(_types)
28 | return {
29 | name: 'productionUrl',
30 | document: {
31 | productionUrl: async (prev, {document, getClient}) => {
32 | const client = getClient({apiVersion})
33 | const secret = await getSecret(client, previewSecretId, true)
34 | if (secret && types.has(document._type)) {
35 | return buildPreviewUrl({document, secret})
36 | }
37 | return prev
38 | },
39 | },
40 | }
41 | })
42 |
43 | export {buildPreviewUrl, getSecret, useBuildPreviewUrl}
44 |
--------------------------------------------------------------------------------
/plugins/productionUrl/utils/buildPreviewUrl.ts:
--------------------------------------------------------------------------------
1 | import {SanityDocumentLike} from 'sanity'
2 | import {BrandSlugDocument} from 'types'
3 | //helper function used by the hook version of this for the preview pane
4 | //and for the resolve production url function for the production url plugin
5 | //and anywhere else we need a URL.
6 |
7 | interface BuildPreviewUrlOptions {
8 | document: BrandSlugDocument | SanityDocumentLike
9 | secret?: string | null
10 | fetch?: boolean
11 | }
12 |
13 | export const buildPreviewUrl = ({
14 | document,
15 | secret,
16 | }: BuildPreviewUrlOptions): string => {
17 | let previewLoc = location.origin
18 | //for running the studio independently -- can clean up later
19 | if (previewLoc.includes('localhost:3333')) {
20 | previewLoc = 'http://localhost:3000'
21 | } else if (previewLoc.includes('sanity.studio')) {
22 | previewLoc = 'https://demo-media-site-nextjs.sanity.build/'
23 | }
24 | const url = new URL('/api/preview', previewLoc)
25 | if (secret) {
26 | url.searchParams.set('secret', secret)
27 | }
28 | const slug = (document as BrandSlugDocument).slug?.current
29 | if (slug) {
30 | url.searchParams.set('slug', slug)
31 | }
32 |
33 | const brand = document.brand as string
34 |
35 | if (brand) {
36 | url.searchParams.set('brand', brand)
37 | }
38 |
39 | return url.toString()
40 | }
41 |
--------------------------------------------------------------------------------
/plugins/productionUrl/utils/getSecret.ts:
--------------------------------------------------------------------------------
1 | import type {SanityClient} from 'sanity'
2 |
3 | // updated within the hour, if it's older it'll create a new secret or return null
4 | const query = (ttl: number) =>
5 | /* groq */ `*[_id == $id && dateTime(_updatedAt) > dateTime(now()) - ${ttl}][0].secret`
6 |
7 | const tag = 'preview.secret'
8 |
9 | export async function getSecret(
10 | client: SanityClient,
11 | id: `${string}.${string}`,
12 | createIfNotExists?: true | (() => string)
13 | ): Promise {
14 | const secret = await client.fetch(
15 | // Use a TTL of 1 hour when reading the secret, while using a 30min expiry if `createIfNotExists` is defined to avoid a race condition where
16 | // a preview pane could get a Secret and use it just as it expires. Twice the TTL gives a wide margin to ensure that when the secret is read
17 | // it's recent enough to be valid no matter if it's used in an iframe URL, or a "Open Preview" URL.
18 | query(createIfNotExists ? 60 * 30 : 60 * 60),
19 | {id}
20 | )
21 | if (!secret && createIfNotExists) {
22 | const newSecret =
23 | createIfNotExists === true
24 | ? Math.random().toString(36).slice(2)
25 | : createIfNotExists()
26 | try {
27 | const patch = client.patch(id).set({secret: newSecret})
28 | await client
29 | .transaction()
30 | .createIfNotExists({_id: id, _type: id})
31 | .patch(patch)
32 | .commit({tag})
33 | return newSecret
34 | } catch (err) {
35 | console.error(
36 | 'Failed to create a new preview secret. Ensure the `client` has a `token` specified that has `write` permissions.',
37 | err
38 | )
39 | }
40 | }
41 |
42 | return secret
43 | }
44 |
--------------------------------------------------------------------------------
/plugins/productionUrl/utils/index.ts:
--------------------------------------------------------------------------------
1 | export {getSecret} from './getSecret'
2 | export {useBuildPreviewUrl} from './useBuildPreviewUrl'
3 |
--------------------------------------------------------------------------------
/plugins/productionUrl/utils/useBuildPreviewUrl.ts:
--------------------------------------------------------------------------------
1 | import {useClient} from 'sanity'
2 | import {suspend} from 'suspend-react'
3 | import {BrandSlugDocument} from 'types'
4 |
5 | import {config} from '../../../lib/config'
6 | import {buildPreviewUrl} from './buildPreviewUrl'
7 | import {getSecret} from './getSecret'
8 |
9 | // Used as a cache key that doesn't risk collision or getting affected by other components that might be using `suspend-react`
10 | const fetchSecret = Symbol('preview.secret')
11 |
12 | export const useBuildPreviewUrl = (document: BrandSlugDocument): string => {
13 | const {apiVersion, previewSecretId} = config.sanity
14 |
15 | const client = useClient({apiVersion})
16 |
17 | const secret = suspend(
18 | // @ts-ignore
19 | () => getSecret(client, previewSecretId, true),
20 | [getSecret, previewSecretId, fetchSecret],
21 | // The secret fetch has a TTL of 1 minute, just to check if it's necessary to recreate the secret which has a TTL of 60 minutes
22 | {lifespan: 60000}
23 | )
24 |
25 | return buildPreviewUrl({document, secret})
26 | }
27 |
--------------------------------------------------------------------------------
/plugins/variations/index.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | defineArrayMember,
3 | defineField,
4 | definePlugin,
5 | DocumentDefinition,
6 | FieldDefinition,
7 | SchemaTypeDefinition,
8 | } from 'sanity'
9 |
10 | // enables autocompletion and validation of document options
11 | declare module 'sanity' {
12 | export interface DocumentOptions {
13 | enableVariations?: string[]
14 | }
15 | }
16 |
17 | /**
18 | * Check if we are looking at a document schema type (and not an object or primitive)
19 | */
20 | const isDocument = (def: SchemaTypeDefinition): def is DocumentDefinition =>
21 | def.type === 'document'
22 |
23 | /**
24 | * Return a list of allowed variable fields.
25 | */
26 | function filterVariableFields(
27 | fields: FieldDefinition[],
28 | variableFieldNames: string[]
29 | ) {
30 | return fields.filter((field) => variableFieldNames.includes(field.name))
31 | }
32 |
33 | /**
34 | * Given a document schema, add the variations field.
35 | */
36 | function augmentSchema(type: DocumentDefinition): DocumentDefinition {
37 | const variableFieldNames = type?.options?.enableVariations
38 |
39 | return {
40 | ...type,
41 | fields: [
42 | ...type.fields,
43 | defineField({
44 | type: 'array' as const,
45 | name: 'variations',
46 | of: [
47 | defineArrayMember({
48 | type: 'object',
49 | name: 'variation',
50 | fields: [
51 | defineField({
52 | type: 'string' as const,
53 | name: 'variationId',
54 | title: 'Variation ID',
55 | validation: (rule) => rule.required(),
56 | }),
57 | ...filterVariableFields(type.fields, variableFieldNames ?? []),
58 | ],
59 | preview: {
60 | select: {
61 | title: variableFieldNames?.[0] ?? '',
62 | subtitle: 'variationId',
63 | },
64 | },
65 | }),
66 | ],
67 | }),
68 | ],
69 | }
70 | }
71 |
72 | /* eslint-disable-next-line no-warning-comments */
73 | /**
74 | * Given all the types registered in the schema, add the variation field to those that are opted in
75 | * TODO: also define and hoist the variation object shape for each enabled type, to support GraphQL
76 | * TODO: consider making the variation opt-in and configuration at the plugin configuration object instead of the schema type's options object
77 | */
78 | function buildTypes(types: SchemaTypeDefinition[]) {
79 | return types.map((def) => {
80 | if (isDocument(def) && def.options?.enableVariations) {
81 | return augmentSchema(def)
82 | }
83 | return def
84 | })
85 | }
86 |
87 | /**
88 | * Sanity Studio plugin to add a variations field to document types where variations are enabled
89 | */
90 | export default definePlugin(() => {
91 | return {
92 | schema: {
93 | types: buildTypes,
94 | },
95 | }
96 | })
97 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | // If you want to use other PostCSS plugins, see the following:
2 | // https://tailwindcss.com/docs/using-with-preprocessors
3 | module.exports = {
4 | plugins: {
5 | tailwindcss: {},
6 | autoprefixer: {},
7 | },
8 | }
9 |
--------------------------------------------------------------------------------
/public/favicon/android-chrome-192x192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sanity-io/demo-media-site-nextjs/52f0b7ec86df5ab694361ebfdcfc63e305115926/public/favicon/android-chrome-192x192.png
--------------------------------------------------------------------------------
/public/favicon/android-chrome-512x512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sanity-io/demo-media-site-nextjs/52f0b7ec86df5ab694361ebfdcfc63e305115926/public/favicon/android-chrome-512x512.png
--------------------------------------------------------------------------------
/public/favicon/apple-touch-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sanity-io/demo-media-site-nextjs/52f0b7ec86df5ab694361ebfdcfc63e305115926/public/favicon/apple-touch-icon.png
--------------------------------------------------------------------------------
/public/favicon/browserconfig.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | #000000
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/public/favicon/favicon-16x16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sanity-io/demo-media-site-nextjs/52f0b7ec86df5ab694361ebfdcfc63e305115926/public/favicon/favicon-16x16.png
--------------------------------------------------------------------------------
/public/favicon/favicon-32x32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sanity-io/demo-media-site-nextjs/52f0b7ec86df5ab694361ebfdcfc63e305115926/public/favicon/favicon-32x32.png
--------------------------------------------------------------------------------
/public/favicon/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sanity-io/demo-media-site-nextjs/52f0b7ec86df5ab694361ebfdcfc63e305115926/public/favicon/favicon.ico
--------------------------------------------------------------------------------
/public/favicon/mstile-150x150.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sanity-io/demo-media-site-nextjs/52f0b7ec86df5ab694361ebfdcfc63e305115926/public/favicon/mstile-150x150.png
--------------------------------------------------------------------------------
/public/favicon/safari-pinned-tab.svg:
--------------------------------------------------------------------------------
1 |
2 |
4 |
34 |
--------------------------------------------------------------------------------
/public/favicon/site.webmanifest:
--------------------------------------------------------------------------------
1 | {
2 | "name": "Next.js",
3 | "short_name": "Next.js",
4 | "icons": [
5 | {
6 | "src": "/favicons/android-chrome-192x192.png",
7 | "sizes": "192x192",
8 | "type": "image/png"
9 | },
10 | {
11 | "src": "/favicons/android-chrome-512x512.png",
12 | "sizes": "512x512",
13 | "type": "image/png"
14 | }
15 | ],
16 | "theme_color": "#000000",
17 | "background_color": "#000000",
18 | "display": "standalone"
19 | }
20 |
--------------------------------------------------------------------------------
/renovate.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json",
3 | "extends": [
4 | "github>sanity-io/renovate-presets//ecosystem/auto",
5 | "github>sanity-io/renovate-presets//ecosystem/studio-v3"
6 | ]
7 | }
8 |
--------------------------------------------------------------------------------
/sanity.cli.ts:
--------------------------------------------------------------------------------
1 | import {loadEnvConfig} from '@next/env'
2 | import {UserViteConfig} from '@sanity/cli'
3 | import {config} from 'lib/config'
4 | import {defineCliConfig} from 'sanity/cli'
5 | import {nodePolyfills} from 'vite-plugin-node-polyfills'
6 |
7 | const dev = config.env !== 'production'
8 | loadEnvConfig(__dirname, dev, {info: () => null, error: console.error})
9 |
10 | const projectId = config.sanity.projectId
11 | const dataset = config.sanity.dataset
12 |
13 | export default defineCliConfig({
14 | api: {projectId, dataset},
15 | vite: (prev: UserViteConfig) => ({
16 | ...prev,
17 | build: {
18 | ...prev.build,
19 | target: 'esnext',
20 | },
21 | plugins: [...prev.plugins, nodePolyfills({util: true})],
22 | define: {
23 | ...prev.define,
24 | 'process.env': {},
25 | },
26 | }),
27 | })
28 |
--------------------------------------------------------------------------------
/sanity.config.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * This config is used to set up Sanity Studio that's mounted on the `/pages/studio/[[...index]].tsx` route
3 | */
4 |
5 | import {createClient} from '@sanity/client'
6 | import {scheduledPublishing} from '@sanity/scheduled-publishing'
7 | import {visionTool} from '@sanity/vision'
8 | import {theme} from 'https://themer.sanity.build/api/hues?preset=tw-cyan&primary=b595f9'
9 | import {defineConfig, definePlugin, WorkspaceOptions} from 'sanity'
10 | import {structureTool} from 'sanity/structure'
11 | import {unsplashImageAsset} from 'sanity-plugin-asset-source-unsplash'
12 |
13 | import {config, reviewConfig} from './lib/config'
14 | import {
15 | LifestyleLogo,
16 | LifestyleWorkspaceLogo,
17 | ReviewsLogo,
18 | ReviewsWorkspaceLogo,
19 | TechLogo,
20 | TechWorkspaceLogo,
21 | } from './logo'
22 | import {mediaConfigPlugin} from './plugins/defaultConfig'
23 | import defaultDocumentNode from './plugins/defaultConfig/defaultDocumentNode'
24 | import {
25 | lifestyleStructure,
26 | reviewStructure,
27 | techStructure,
28 | } from './plugins/defaultConfig/structure'
29 | import newsletterPlugin from './plugins/newsletter'
30 | import {productionUrl} from './plugins/productionUrl'
31 | import variations from './plugins/variations'
32 | import {schemaTemplates, schemaTypes} from './schemas'
33 |
34 | //https://github.com/sanity-io/next-sanity#the-usebackgroundcolorsfromtheme-usebasepath-useconfigwithbasepath-and-usetextfontfamilyfromtheme-hooks-are-removed
35 | //as useBasePath is removed, we need to manually set the base path for each studio
36 | const basePaths = {
37 | tech: '/studio/tech',
38 | lifestyle: '/studio/lifestyle',
39 | reviews: '/studio/reviews',
40 | }
41 |
42 | const defaultConfig = (type: string) => {
43 | const plugins = [
44 | visionTool({
45 | defaultApiVersion: '2022-11-11',
46 | }),
47 | productionUrl({
48 | apiVersion: config.sanity.apiVersion,
49 | previewSecretId: config.sanity.previewSecretId,
50 | types: ['article', 'person', 'section'],
51 | }),
52 | ]
53 |
54 | const minimumUserPlugins = [
55 | mediaConfigPlugin(),
56 | unsplashImageAsset(),
57 | variations(),
58 | ]
59 | minimumUserPlugins.forEach((plugin) => plugins.push(plugin))
60 |
61 | if (type === 'tech') {
62 | const techPlugins = [scheduledPublishing(), newsletterPlugin()]
63 | techPlugins.forEach((plugin) => plugins.push(plugin))
64 | }
65 |
66 | return definePlugin({
67 | name: 'default-config',
68 | schema: {
69 | types: schemaTypes,
70 | templates: (prev) => schemaTemplates(prev, type),
71 | },
72 | document: {
73 | unstable_comments: {
74 | enabled: ({documentType}) => {
75 | const allowedCommentTypes = ['siteSettings', 'article', 'newsletter']
76 | if (allowedCommentTypes.includes(documentType)) {
77 | return true
78 | }
79 | return false
80 | },
81 | },
82 | },
83 | plugins,
84 | })()
85 | }
86 |
87 | // create Sanity client from config
88 | const client = createClient({
89 | projectId: config.sanity.projectId,
90 | dataset: config.sanity.dataset,
91 | apiVersion: config.sanity.apiVersion,
92 | useCdn: false,
93 | })
94 |
95 | let studioConfig: WorkspaceOptions[] = [
96 | {
97 | basePath: basePaths.tech,
98 | name: 'tech',
99 | projectId: config.sanity.projectId,
100 | dataset: config.sanity.dataset,
101 | title: config.sanity.projectTitle || 'Technology',
102 | plugins: [
103 | structureTool({
104 | structure: techStructure,
105 | defaultDocumentNode,
106 | }),
107 | defaultConfig('tech'),
108 | ],
109 | icon: TechWorkspaceLogo,
110 | studio: {
111 | components: {
112 | logo: TechLogo,
113 | },
114 | },
115 | },
116 | ]
117 |
118 | //unfortunately, this doesn't work in Safari.
119 | //Safari users will be limited to the initial config
120 | const currentUser = await client.request({
121 | uri: '/users/me',
122 | withCredentials: true,
123 | })
124 |
125 | if (currentUser?.role === 'administrator' || currentUser?.role === 'write') {
126 | studioConfig = [
127 | ...studioConfig,
128 | {
129 | name: 'lifestyle',
130 | basePath: basePaths.lifestyle,
131 | projectId: config.sanity.projectId,
132 | dataset: config.sanity.dataset,
133 | title: config.sanity.projectTitle || 'Lifestyle',
134 | theme,
135 | plugins: [
136 | structureTool({
137 | structure: lifestyleStructure,
138 | defaultDocumentNode,
139 | }),
140 | defaultConfig('lifestyle'),
141 | ],
142 | icon: LifestyleWorkspaceLogo,
143 | studio: {
144 | components: {
145 | logo: LifestyleLogo,
146 | },
147 | },
148 | },
149 | {
150 | name: 'reviews',
151 | basePath: basePaths.reviews,
152 | projectId: reviewConfig.sanity.projectId,
153 | dataset: reviewConfig.sanity.dataset || 'reviews',
154 | title: reviewConfig.sanity.projectTitle || 'Reviews',
155 | theme,
156 | plugins: [
157 | structureTool({structure: reviewStructure}),
158 | defaultConfig('reviews'),
159 | ],
160 | icon: ReviewsWorkspaceLogo,
161 | studio: {
162 | components: {
163 | logo: ReviewsLogo,
164 | },
165 | },
166 | },
167 | ]
168 | }
169 |
170 | export default defineConfig(studioConfig)
171 |
--------------------------------------------------------------------------------
/schemas/article.ts:
--------------------------------------------------------------------------------
1 | import {FiFeather} from 'react-icons/fi'
2 | import {defineArrayMember, defineField, defineType} from 'sanity'
3 |
4 | import {referenceBrandFilter} from './utils'
5 |
6 | export default defineType({
7 | name: 'article',
8 | title: 'Article',
9 | icon: FiFeather,
10 | type: 'document',
11 | fields: [
12 | defineField({
13 | name: 'title',
14 | title: 'Title',
15 | type: 'string',
16 | }),
17 | defineField({
18 | name: 'slug',
19 | title: 'Slug',
20 | type: 'slug',
21 | options: {source: 'title'},
22 | validation: (Rule) =>
23 | Rule.required().error(
24 | `A slug is required to generate a page on the website`
25 | ),
26 | }),
27 | defineField({
28 | type: 'mainImage',
29 | name: 'mainImage',
30 | }),
31 | defineField({
32 | name: 'people',
33 | type: 'array',
34 | description: 'List of people involved with the production of the article',
35 | of: [defineArrayMember({type: 'contentRole'})],
36 | }),
37 | defineField({
38 | name: 'sections',
39 | type: 'array',
40 | of: [
41 | {
42 | type: 'reference',
43 | to: [{type: 'section'}],
44 | options: {
45 | filter: referenceBrandFilter,
46 | },
47 | },
48 | ],
49 | }),
50 | defineField({
51 | name: 'intro',
52 | title: 'Summary',
53 | description:
54 | 'Used by certain presentations to describe what the article is about',
55 | type: 'minimalPortableText',
56 | }),
57 | defineField({
58 | name: 'content',
59 | title: 'Content',
60 | type: 'portableText',
61 | }),
62 | defineField({type: 'seo', name: 'seo', title: 'SEO'}),
63 | defineField({type: 'brand', name: 'brand', hidden: true}),
64 | ],
65 | options: {
66 | enableVariations: ['title', 'mainImage'],
67 | },
68 | preview: {
69 | select: {
70 | title: 'title',
71 | author: 'people.0.person.name',
72 | media: 'mainImage.image',
73 | },
74 | prepare({title, author, media}) {
75 | return {
76 | title,
77 | subtitle: author,
78 | media,
79 | }
80 | },
81 | },
82 | })
83 |
--------------------------------------------------------------------------------
/schemas/index.ts:
--------------------------------------------------------------------------------
1 | //document types
2 | import {startCase} from 'lodash'
3 | import {SchemaTypeDefinition, Template} from 'sanity'
4 |
5 | import article from './article'
6 | import newsletter from './newsletter'
7 | import articleReference from './objects/articleReference'
8 | import articleReferences from './objects/articleReferences'
9 | import brandSchemaType from './objects/brand'
10 | import contentRole from './objects/contentRole'
11 | import mainImage from './objects/mainImage'
12 | import minimalPortableText from './objects/minimalPortableText'
13 | import podcastEpisode from './objects/podcastEpisode'
14 | import podcastReference from './objects/podcastReference'
15 | import portableText from './objects/portableText'
16 | import reviewReference from './objects/reviewReference'
17 | import seo from './objects/seo'
18 | import video from './objects/video'
19 | import person from './person'
20 | import podcast from './podcast'
21 | import review from './review'
22 | import section from './section'
23 | import siteSettings from './siteSettings'
24 |
25 | export const schemaTypes = (
26 | prev: SchemaTypeDefinition[]
27 | ): SchemaTypeDefinition[] =>
28 | [
29 | ...prev,
30 | // Objects
31 | articleReference,
32 | articleReferences,
33 | podcastReference,
34 | contentRole,
35 | mainImage,
36 | minimalPortableText,
37 | portableText,
38 | seo,
39 | podcastEpisode,
40 | reviewReference,
41 | video,
42 | brandSchemaType,
43 |
44 | // Document types
45 | article,
46 | review,
47 | newsletter,
48 | person,
49 | podcast,
50 | section,
51 | siteSettings,
52 | ] as SchemaTypeDefinition[]
53 |
54 | export const schemaTemplates = (
55 | prev: Template[],
56 | brand: string
57 | ): Template[] => {
58 | const brandTypes = [
59 | 'article',
60 | 'review',
61 | 'newsletter',
62 | 'person',
63 | 'podcast',
64 | 'section',
65 | ]
66 | const brandTemplates = brandTypes.map((schemaType) => ({
67 | id: `${schemaType}-${brand}`,
68 | title: startCase(`${brand} ${schemaType}`),
69 | type: 'initialValueTemplateItem',
70 | schemaType,
71 | value: {brand},
72 | }))
73 | //remove the original, non-brand templates
74 | const filteredTemplates = prev.filter(
75 | (template) => !brandTypes.includes(template.schemaType)
76 | )
77 | return [...filteredTemplates, ...brandTemplates]
78 | }
79 |
--------------------------------------------------------------------------------
/schemas/newsletter.ts:
--------------------------------------------------------------------------------
1 | import {FiMail} from 'react-icons/fi'
2 | import {defineField, defineType} from 'sanity'
3 |
4 | import {getVariablePortableText} from './utils'
5 |
6 | export default defineType({
7 | name: 'newsletter',
8 | title: 'Newsletter',
9 | icon: FiMail,
10 | type: 'document',
11 | fields: [
12 | defineField({
13 | name: 'title',
14 | title: 'Title',
15 | type: 'string',
16 | validation: (Rule) => Rule.required(),
17 | }),
18 | defineField({
19 | name: 'subject',
20 | title: 'Subject',
21 | type: 'string',
22 | validation: (Rule) => Rule.required(),
23 | }),
24 | defineField({
25 | name: 'intro',
26 | title: 'Summary',
27 | description:
28 | 'Used by certain presentations to describe what the article is about',
29 | type: 'minimalPortableText',
30 | }),
31 | defineField({
32 | ...getVariablePortableText('newsletter', 'content'),
33 | options: {
34 | showSyncButton: true,
35 | },
36 | }),
37 | defineField({
38 | name: 'hasCustomTextContent',
39 | title: 'Override Text Content Body',
40 | description:
41 | 'By default the text version is generated from the HTML version. Enable this to set the text content manually.',
42 | type: 'boolean',
43 | }),
44 | defineField({
45 | name: 'textContent',
46 | title: 'Text Content',
47 | type: 'minimalPortableText',
48 | hidden: ({parent}) => !parent?.hasCustomTextContent,
49 | }),
50 | defineField({type: 'brand', name: 'brand'}),
51 | ],
52 | preview: {
53 | select: {
54 | title: 'title',
55 | subject: 'subject',
56 | },
57 | prepare({title, subject}) {
58 | return {
59 | title,
60 | subtitle: subject,
61 | }
62 | },
63 | },
64 | })
65 |
--------------------------------------------------------------------------------
/schemas/objects/articleReference.ts:
--------------------------------------------------------------------------------
1 | import {defineType} from 'sanity'
2 | import {referenceBrandFilter} from 'schemas/utils'
3 |
4 | export default defineType({
5 | type: 'reference',
6 | name: 'articleReference',
7 | title: 'Article Reference',
8 | to: [{type: 'article'}],
9 | options: {
10 | filter: referenceBrandFilter,
11 | },
12 | })
13 |
--------------------------------------------------------------------------------
/schemas/objects/articleReferences.ts:
--------------------------------------------------------------------------------
1 | import {SanityReference} from '@sanity/image-url/lib/types/types'
2 | import {defineArrayMember, defineField, defineType} from 'sanity'
3 |
4 | export default defineType({
5 | type: 'object',
6 | name: 'articleReferences',
7 | title: 'Article Reference',
8 | preview: {
9 | select: {
10 | references: 'references',
11 | },
12 | prepare({references}) {
13 | const count = references?.filter(
14 | (ref: SanityReference) => ref._ref
15 | ).length
16 | return {
17 | title: count > 0 ? `${count} references` : 'No references',
18 | }
19 | },
20 | },
21 | fields: [
22 | defineField({
23 | type: 'array',
24 | name: 'references',
25 | title: 'References',
26 | of: [defineArrayMember({type: 'articleReference'})],
27 | }),
28 | ],
29 | })
30 |
--------------------------------------------------------------------------------
/schemas/objects/brand.ts:
--------------------------------------------------------------------------------
1 | import {defineField} from 'sanity'
2 |
3 | import {BRANDS} from '../../lib/constants'
4 |
5 | export default defineField({
6 | name: 'brand',
7 | title: 'Brands',
8 | description: 'Used to colocate documents to only those in the same "Brand"',
9 | type: 'string',
10 | hidden: true,
11 | validation: (Rule) => Rule.required(),
12 | options: {
13 | list: BRANDS.map((brand) => ({
14 | value: brand.name,
15 | title: brand.title,
16 | })),
17 | },
18 | })
19 |
--------------------------------------------------------------------------------
/schemas/objects/contentRole.ts:
--------------------------------------------------------------------------------
1 | import {defineField, defineType} from 'sanity'
2 |
3 | import {referenceBrandFilter} from '../utils'
4 |
5 | export default defineType({
6 | name: 'contentRole',
7 | title: 'Content role',
8 | type: 'object',
9 | fields: [
10 | defineField({
11 | name: 'role',
12 | type: 'string',
13 | options: {
14 | list: [
15 | {value: 'author', title: 'Author'},
16 | {value: 'contributor', title: 'Contributor'},
17 | {value: 'photographer', title: 'Photographer'},
18 | {value: 'copyEditor', title: 'Copy editor'},
19 | ],
20 | },
21 | }),
22 | defineField({
23 | name: 'person',
24 | type: 'reference',
25 | to: [{type: 'person'}],
26 | options: {
27 | filter: referenceBrandFilter,
28 | },
29 | }),
30 | ],
31 | preview: {
32 | select: {
33 | title: 'person.name',
34 | media: 'person.image',
35 | subtitle: 'role',
36 | },
37 | },
38 | })
39 |
--------------------------------------------------------------------------------
/schemas/objects/mainImage.ts:
--------------------------------------------------------------------------------
1 | import {FiImage} from 'react-icons/fi'
2 | import {defineField, defineType} from 'sanity'
3 |
4 | export default defineType({
5 | name: 'mainImage',
6 | title: 'Image',
7 | type: 'object',
8 | icon: FiImage,
9 | fieldsets: [
10 | {
11 | name: 'text',
12 | title: 'Text',
13 | options: {
14 | collapsible: true,
15 | collapsed: false,
16 | },
17 | },
18 | ],
19 | fields: [
20 | defineField({
21 | name: 'image',
22 | type: 'image',
23 | options: {
24 | hotspot: true,
25 | metadata: ['exif', 'location', 'lqip', 'palette'],
26 | },
27 | }),
28 | defineField({
29 | name: 'alt',
30 | type: 'string',
31 | validation: (Rule) =>
32 | Rule.custom((value, context) => {
33 | const {parent} = context as any
34 |
35 | // Alt text only required if an image is set in the parent
36 | if (!parent?.image) {
37 | return true
38 | }
39 |
40 | return value
41 | ? true
42 | : 'Alternative text is helpful for accessibility and SEO'
43 | }),
44 | hidden: ({parent}) => !parent?.image,
45 | //there's currently a bug where "parent" is the whole document if we have a fieldset
46 | //making this hidden param problematic
47 | // fieldset: 'text',
48 | }),
49 | defineField({
50 | name: 'caption',
51 | type: 'string',
52 | hidden: ({parent}) => !parent?.image,
53 | // fieldset: 'text',
54 | }),
55 | ],
56 | preview: {
57 | select: {
58 | media: 'image',
59 | alt: 'alt',
60 | caption: 'caption',
61 | },
62 | prepare({media, alt, caption}) {
63 | return {
64 | title: alt || caption || 'No alt text or caption',
65 | media,
66 | }
67 | },
68 | },
69 | })
70 |
--------------------------------------------------------------------------------
/schemas/objects/minimalPortableText.ts:
--------------------------------------------------------------------------------
1 | import {defineArrayMember, defineType} from 'sanity'
2 |
3 | export default defineType({
4 | name: 'minimalPortableText',
5 | type: 'array',
6 | title: 'Minimal Portable Text',
7 | of: [
8 | defineArrayMember({
9 | type: 'block',
10 | of: [],
11 | styles: [{title: 'Normal', value: 'normal'}],
12 | lists: [],
13 | marks: {
14 | decorators: [
15 | {title: 'Strong', value: 'strong'},
16 | {title: 'Emphasis', value: 'em'},
17 | ],
18 | annotations: [],
19 | },
20 | }),
21 | ],
22 | })
23 |
--------------------------------------------------------------------------------
/schemas/objects/podcastEpisode.tsx:
--------------------------------------------------------------------------------
1 | import {Box, Stack, Text} from '@sanity/ui'
2 | import * as React from 'react'
3 | import {FiVideo} from 'react-icons/fi'
4 | import ReactPlayer from 'react-player'
5 | import {
6 | defineField,
7 | defineType,
8 | InputProps,
9 | PreviewProps,
10 | UrlRule,
11 | } from 'sanity'
12 | import styled from 'styled-components'
13 |
14 | interface Audio {
15 | url: string
16 | }
17 |
18 | const AudioWrapper = styled(Box)`
19 | position: relative;
20 | `
21 |
22 | export default defineType({
23 | name: 'podcastEpisode',
24 | type: 'object',
25 | icon: FiVideo,
26 | title: 'Podcast Audio',
27 | fields: [
28 | defineField({
29 | name: 'url',
30 | type: 'url',
31 | title: 'Audio URL',
32 | description: `Accepts: SoundCloud and Mixcloud`,
33 | validation: (rule: UrlRule) => rule.required(),
34 | }),
35 | ],
36 | components: {
37 | input: (props: InputProps) => {
38 | const audio = props.value as Audio
39 | const hasAudio = audio && audio.url
40 | return (
41 |
42 | {props.renderDefault(props)}
43 |
44 | {hasAudio ? (
45 |
51 | ) : (
52 | Audio missing URL
53 | )}
54 |
55 |
56 | )
57 | },
58 | //@ts-expect-error until fixed in core Sanity
59 | preview: (props: PreviewProps & {url: string}) => {
60 | const url = props.url
61 | if (url) {
62 | return (
63 |
64 |
70 |
71 | )
72 | }
73 | return (
74 |
75 | Audio missing URL
76 |
77 | )
78 | },
79 | },
80 | preview: {
81 | select: {
82 | url: 'url',
83 | },
84 | },
85 | })
86 |
--------------------------------------------------------------------------------
/schemas/objects/podcastReference.ts:
--------------------------------------------------------------------------------
1 | import {defineType} from 'sanity'
2 |
3 | export default defineType({
4 | type: 'reference',
5 | name: 'podcastReference',
6 | title: 'Podcast Reference',
7 | to: [{type: 'podcast'}],
8 | })
9 |
--------------------------------------------------------------------------------
/schemas/objects/portableText.ts:
--------------------------------------------------------------------------------
1 | import {FiFeather} from 'react-icons/fi'
2 | import {defineArrayMember, defineType} from 'sanity'
3 |
4 | export const rawPortableTextObj = {
5 | name: 'portableText',
6 | type: 'array',
7 | title: 'Content',
8 | of: [
9 | defineArrayMember({
10 | type: 'block',
11 | title: 'Block',
12 | // Styles let you set what your user can mark up blocks with. These
13 | // corrensponds with HTML tags, but you can set any title or value
14 | // you want and decide how you want to deal with it where you want to
15 | // use your content.
16 | styles: [
17 | {title: 'Normal', value: 'normal'},
18 | {title: 'H2', value: 'h2'},
19 | {title: 'H3', value: 'h3'},
20 | {title: 'Quote', value: 'blockquote'},
21 | ],
22 | lists: [
23 | {title: 'Bullet', value: 'bullet'},
24 | {title: 'Number', value: 'number'},
25 | ],
26 | // Marks let you mark up inline text in the block editor.
27 | marks: {
28 | // Decorators usually describe a single property – e.g. a typographic
29 | // preference or highlighting by editors.
30 | decorators: [
31 | {title: 'Strong', value: 'strong'},
32 | {title: 'Emphasis', value: 'em'},
33 | ],
34 | // Annotations can be any object structure – e.g. a link or a footnote.
35 | annotations: [
36 | {
37 | name: 'articleLink',
38 | icon: FiFeather,
39 | type: 'object',
40 | fields: [{type: 'articleReference', name: 'article'}],
41 | },
42 | {
43 | name: 'link',
44 | type: 'object',
45 | title: 'URL',
46 | fields: [
47 | {
48 | title: 'URL',
49 | name: 'href',
50 | type: 'url',
51 | },
52 | ],
53 | },
54 | ],
55 | },
56 | }),
57 | defineArrayMember({type: 'articleReference', title: 'Article'}),
58 | defineArrayMember({type: 'podcastReference', title: 'Podcast'}),
59 | defineArrayMember({type: 'mainImage'}),
60 | defineArrayMember({type: 'video'}),
61 | ],
62 | }
63 |
64 | //by default, we export this as a top-level object type
65 | //but we can also use the object for contexts where we want more/different blocks
66 | export default defineType(rawPortableTextObj)
67 |
--------------------------------------------------------------------------------
/schemas/objects/reviewReference.ts:
--------------------------------------------------------------------------------
1 | import {FiStar} from 'react-icons/fi'
2 | import {defineArrayMember, defineField, defineType} from 'sanity'
3 |
4 | import {config, reviewConfig} from '../../lib/config'
5 |
6 | export default defineType({
7 | name: 'reviewReference',
8 | type: 'object',
9 | title: 'Review',
10 | icon: FiStar,
11 | fields: [
12 | defineField({
13 | name: 'review',
14 | type: 'crossDatasetReference',
15 | dataset: reviewConfig.sanity.dataset || 'reviews',
16 | //required by crossDatasetReference type but not actually required?
17 | projectId: config.sanity.projectId,
18 | to: [
19 | {
20 | type: 'review',
21 | preview: {
22 | select: {
23 | title: 'title',
24 | media: 'mainImage.image',
25 | },
26 | },
27 | },
28 | ],
29 | }),
30 | defineField({
31 | name: 'titleOverride',
32 | type: 'string',
33 | description: 'Use a custom title for this review.',
34 | // validation: (Rule) => Rule.required(),
35 | }),
36 | defineField({
37 | name: 'sections',
38 | type: 'array',
39 | of: [
40 | defineArrayMember({
41 | type: 'reference',
42 | to: [{type: 'section'}],
43 | }),
44 | ],
45 | }),
46 | ],
47 | preview: {
48 | select: {
49 | title: 'titleOverride',
50 | reviewTitle: 'review.title',
51 | //broken for some reason
52 | // media: 'review.mainImage.image'
53 | },
54 | prepare({title, reviewTitle}) {
55 | return {
56 | title: title || reviewTitle,
57 | media: FiStar,
58 | }
59 | },
60 | },
61 | })
62 |
--------------------------------------------------------------------------------
/schemas/objects/seo.ts:
--------------------------------------------------------------------------------
1 | import {defineField, defineType} from 'sanity'
2 |
3 | export default defineType({
4 | name: 'seo',
5 | title: 'SEO',
6 | type: 'object',
7 | options: {collapsible: true, collapsed: true},
8 | fields: [
9 | defineField({
10 | name: 'title',
11 | title: 'SEO Title',
12 | type: 'string',
13 | description: 'Overrides the default title',
14 | }),
15 | defineField({
16 | name: 'description',
17 | title: 'SEO Description',
18 | type: 'string',
19 | description: 'Overrides the summary or description',
20 | }),
21 | defineField({
22 | name: 'image',
23 | title: 'SEO Image',
24 | type: 'image',
25 | }),
26 | defineField({
27 | name: 'keywords',
28 | title: 'Keywords',
29 | type: 'string',
30 | }),
31 | defineField({
32 | name: 'synonyms',
33 | title: 'Synonyms',
34 | type: 'string',
35 | }),
36 | ],
37 | })
38 |
--------------------------------------------------------------------------------
/schemas/objects/video.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import {FiVideo} from 'react-icons/fi'
3 | import ReactPlayer from 'react-player'
4 | import {defineType, PreviewProps} from 'sanity'
5 |
6 | export default defineType({
7 | name: 'video',
8 | type: 'object',
9 | icon: FiVideo,
10 | title: 'Video',
11 | fields: [
12 | {
13 | name: 'url',
14 | type: 'url',
15 | title: 'Media URL',
16 | description: `Accepts: YouTube, Facebook, Twitch, SoundCloud, Streamable, Vimeo, Wistia, Mixcloud, DailyMotion and Kaltura`,
17 | validation: (Rule) => Rule.required(),
18 | },
19 | ],
20 | components: {
21 | //@ts-expect-error until fixed in core Sanity
22 | preview: (props: PreviewProps & {url: string}) => {
23 | const url = props.url
24 | if (url) {
25 | return (
26 |
27 |
33 |
34 | )
35 | }
36 | return Video missing URL
37 | },
38 | },
39 | preview: {
40 | select: {
41 | url: 'url',
42 | },
43 | },
44 | })
45 |
--------------------------------------------------------------------------------
/schemas/person.ts:
--------------------------------------------------------------------------------
1 | import {FiUser} from 'react-icons/fi'
2 | import {defineField, defineType} from 'sanity'
3 |
4 | export default defineType({
5 | name: 'person',
6 | title: 'People',
7 | icon: FiUser,
8 | type: 'document',
9 | fields: [
10 | defineField({
11 | name: 'name',
12 | type: 'string',
13 | }),
14 | defineField({
15 | name: 'slug',
16 | type: 'slug',
17 | options: {
18 | source: 'name',
19 | },
20 | }),
21 | defineField({
22 | name: 'image',
23 | type: 'image',
24 | options: {
25 | hotspot: true,
26 | },
27 | }),
28 | defineField({
29 | name: 'bio',
30 | type: 'minimalPortableText',
31 | }),
32 | defineField({
33 | name: 'isStaff',
34 | type: 'boolean',
35 | }),
36 | defineField({type: 'brand', name: 'brand'}),
37 | defineField({type: 'seo', name: 'seo', title: 'SEO'}),
38 | ],
39 | preview: {
40 | select: {
41 | title: 'name',
42 | isStaff: 'isStaff',
43 | media: 'image',
44 | },
45 | prepare({title, isStaff, media}) {
46 | return {
47 | title: `${title}`,
48 | subtitle: isStaff ? 'Staff' : 'Contributor',
49 | media,
50 | }
51 | },
52 | },
53 | })
54 |
--------------------------------------------------------------------------------
/schemas/podcast.ts:
--------------------------------------------------------------------------------
1 | import {FiHeadphones} from 'react-icons/fi'
2 | import {defineField, defineType} from 'sanity'
3 |
4 | export default defineType({
5 | name: 'podcast',
6 | title: 'Podcast',
7 | icon: FiHeadphones,
8 | type: 'document',
9 | fields: [
10 | defineField({
11 | name: 'title',
12 | title: 'Title',
13 | type: 'string',
14 | validation: (Rule) => Rule.required(),
15 | }),
16 | defineField({
17 | name: 'podcastEpisode',
18 | type: 'podcastEpisode',
19 | }),
20 | defineField({
21 | name: 'intro',
22 | title: 'Summary',
23 | description:
24 | 'Used by certain presentations to describe what the podcast episode is about',
25 | type: 'minimalPortableText',
26 | validation: (Rule) => Rule.required(),
27 | }),
28 | defineField({type: 'brand', name: 'brand'}),
29 | ],
30 | preview: {
31 | select: {
32 | title: 'title',
33 | },
34 | prepare({title}) {
35 | return {
36 | title,
37 | }
38 | },
39 | },
40 | })
41 |
--------------------------------------------------------------------------------
/schemas/review.ts:
--------------------------------------------------------------------------------
1 | import {FiStar} from 'react-icons/fi'
2 | import {defineField, defineType} from 'sanity'
3 |
4 | export default defineType({
5 | name: 'review',
6 | title: 'Review',
7 | icon: FiStar,
8 | type: 'document',
9 | // readOnly: true,
10 | fields: [
11 | defineField({
12 | name: 'title',
13 | title: 'Title',
14 | type: 'string',
15 | validation: (Rule) => Rule.required(),
16 | }),
17 | defineField({
18 | type: 'mainImage',
19 | name: 'mainImage',
20 | }),
21 | defineField({
22 | name: 'slug',
23 | title: 'Slug',
24 | type: 'slug',
25 | options: {source: 'title'},
26 | validation: (Rule) => Rule.required(),
27 | }),
28 | defineField({
29 | name: 'intro',
30 | title: 'Summary',
31 | description:
32 | 'Used by certain presentations to describe what the review is about',
33 | type: 'minimalPortableText',
34 | validation: (Rule) => Rule.required(),
35 | }),
36 | defineField({
37 | name: 'content',
38 | title: 'Content',
39 | type: 'portableText',
40 | }),
41 | defineField({type: 'boolean', name: 'soldOut'}),
42 | defineField({type: 'seo', name: 'seo', title: 'SEO'}),
43 | defineField({type: 'brand', name: 'brand'}),
44 | ],
45 | preview: {
46 | select: {
47 | title: 'title',
48 | media: 'mainImage.image',
49 | },
50 | prepare({title, media}) {
51 | return {
52 | title,
53 | media,
54 | }
55 | },
56 | },
57 | })
58 |
--------------------------------------------------------------------------------
/schemas/section.ts:
--------------------------------------------------------------------------------
1 | import {FiHash} from 'react-icons/fi'
2 | import {defineField, defineType} from 'sanity'
3 |
4 | export default defineType({
5 | name: 'section',
6 | title: 'Section',
7 | icon: FiHash,
8 | type: 'document',
9 | fields: [
10 | defineField({
11 | name: 'name',
12 | title: 'Name',
13 | type: 'string',
14 | }),
15 | defineField({
16 | name: 'slug',
17 | title: 'Slug',
18 | type: 'slug',
19 | options: {source: 'name'},
20 | validation: (Rule) => Rule.required(),
21 | }),
22 | defineField({type: 'brand', name: 'brand'}),
23 | defineField({type: 'seo', name: 'seo', title: 'SEO'}),
24 | ],
25 | preview: {
26 | select: {
27 | title: 'name',
28 | },
29 | prepare({title}) {
30 | return {
31 | title,
32 | media: FiHash,
33 | }
34 | },
35 | },
36 | })
37 |
--------------------------------------------------------------------------------
/schemas/siteSettings.ts:
--------------------------------------------------------------------------------
1 | import {defineArrayMember, defineField, defineType} from 'sanity'
2 |
3 | export default defineType({
4 | name: 'siteSettings',
5 | title: 'Site Settings',
6 | type: 'document',
7 | fields: [
8 | defineField({
9 | name: 'brand',
10 | type: 'brand',
11 | hidden: true,
12 | }),
13 | defineField({
14 | name: 'featured',
15 | type: 'array',
16 | of: [
17 | defineArrayMember({
18 | type: 'articleReference',
19 | }),
20 | defineArrayMember({
21 | type: 'reviewReference',
22 | }),
23 | ],
24 | }),
25 | ],
26 | preview: {
27 | prepare: () => ({title: 'Site Settings'}),
28 | },
29 | })
30 |
--------------------------------------------------------------------------------
/schemas/utils/getVariablePortableText.ts:
--------------------------------------------------------------------------------
1 | import {defineArrayMember, FieldDefinition} from 'sanity'
2 |
3 | import {rawPortableTextObj} from '../objects/portableText'
4 |
5 | //in certain contexts we may want to add blocks. this function
6 | //allows us to use the already-defined "default" Portable Text object
7 | //and make changes
8 | export const getVariablePortableText = (
9 | documentType: string,
10 | fieldName: string
11 | ): FieldDefinition => {
12 | const blocksToAdd = []
13 | if (documentType === 'newsletter') {
14 | blocksToAdd.push(
15 | defineArrayMember({
16 | type: 'articleReferences',
17 | title: 'Articles',
18 | })
19 | )
20 | }
21 | return {
22 | ...rawPortableTextObj,
23 | //@ts-ignore
24 | of: [...rawPortableTextObj.of, ...blocksToAdd],
25 | name: fieldName,
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/schemas/utils/index.ts:
--------------------------------------------------------------------------------
1 | export {getVariablePortableText} from './getVariablePortableText'
2 | export {referenceBrandFilter} from './referenceBrandHelpers'
3 |
--------------------------------------------------------------------------------
/schemas/utils/referenceBrandHelpers.ts:
--------------------------------------------------------------------------------
1 | import {ReferenceFilterResolver} from 'sanity'
2 |
3 | export const referenceBrandFilter: ReferenceFilterResolver = ({document}) => {
4 | return {
5 | filter: 'brand == $brand',
6 | params: {brand: document.brand},
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/styles/index.css:
--------------------------------------------------------------------------------
1 | /* purgecss start ignore */
2 | @tailwind base;
3 | @tailwind components;
4 | /* purgecss end ignore */
5 | @tailwind utilities;
6 |
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 |
3 | const {theme} = require('@sanity/demo/tailwind')
4 |
5 | module.exports = {
6 | content: [
7 | './pages/**/*.{js,ts,jsx,tsx}',
8 | './components/**/*.{js,ts,jsx,tsx}',
9 | ],
10 | theme,
11 | plugins: [require('@tailwindcss/typography')],
12 | }
13 |
--------------------------------------------------------------------------------
/themer.d.ts:
--------------------------------------------------------------------------------
1 | declare module 'https://themer.sanity.build/api/hues?*' {
2 | interface Hue
3 | extends Omit {
4 | midPoint: 50 | 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900 | 950
5 | }
6 |
7 | interface Hues {
8 | default: Hue
9 | transparent: Hue
10 | primary: Hue
11 | positive: Hue
12 | caution: Hue
13 | critical: Hue
14 | }
15 |
16 | export const hues: Hues
17 | type Theme = import('sanity').StudioTheme
18 |
19 | export function createTheme(_hues: Hues): Theme
20 |
21 | export const theme: Theme
22 | }
23 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ESNext",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "forceConsistentCasingInFileNames": true,
9 | "noEmit": true,
10 | "incremental": true,
11 | "esModuleInterop": true,
12 | "module": "esnext",
13 | "moduleResolution": "node",
14 | "resolveJsonModule": true,
15 | "isolatedModules": true,
16 | "jsx": "preserve",
17 | "baseUrl": "."
18 | },
19 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
20 | "exclude": ["node_modules"]
21 | }
22 |
--------------------------------------------------------------------------------
/types/index.tsx:
--------------------------------------------------------------------------------
1 | import {SanityDocument, SanityImageAssetDocument} from '@sanity/client'
2 | import {Image, PortableTextBlock, Slug} from 'sanity'
3 | import {CrossDatasetReferenceValue} from 'sanity'
4 |
5 | export type BrandSpecificProps = {
6 | brand?: 'tech' | 'lifestyle'
7 | }
8 |
9 | export interface MainImage {
10 | image: {
11 | asset: {
12 | _ref: string
13 | }
14 | }
15 | alt: string
16 | caption: string
17 | }
18 |
19 | export type SeoProps = {
20 | seo?: {
21 | title?: string
22 | description?: string
23 | keywords?: string[]
24 | synonyms?: string[]
25 | image?: Image
26 | }
27 | }
28 |
29 | export type Author = {
30 | name: string
31 | _type: 'person'
32 | slug?: string
33 | image: Image
34 | bio: PortableTextBlock[]
35 | role: string
36 | articles?: Article[]
37 | } & BrandSpecificProps &
38 | SeoProps
39 |
40 | export type Section = {
41 | _id?: string
42 | _type: 'section'
43 | name: string
44 | slug?: string
45 | articles?: Article[]
46 | brand?: 'tech' | 'lifestyle'
47 | } & BrandSpecificProps &
48 | SeoProps
49 |
50 | export type Article = {
51 | _id: string
52 | _type: 'article'
53 | title: string
54 | mainImage?: MainImage
55 | date?: string
56 | intro?: PortableTextBlock[]
57 | people?: Author[]
58 | sections?: Section[]
59 | slug?: string
60 | variations?: {
61 | _key: string
62 | title: string
63 | mainImage: MainImage
64 | }[]
65 | content?: PortableTextBlock[]
66 | isHighlighted?: boolean
67 | } & BrandSpecificProps &
68 | SeoProps
69 |
70 | export type Review = Pick<
71 | Article,
72 | | 'title'
73 | | 'mainImage'
74 | | 'intro'
75 | | 'isHighlighted'
76 | | 'slug'
77 | | 'content'
78 | | 'sections'
79 | > & {_type: 'review'; soldOut?: boolean}
80 |
81 | export type ArticlePreviewProps = Pick<
82 | Article,
83 | | 'title'
84 | | 'mainImage'
85 | | 'date'
86 | | 'intro'
87 | | 'people'
88 | | 'sections'
89 | | 'isHighlighted'
90 | | 'slug'
91 | > & {sectionType?: 'featured' | 'normal'}
92 |
93 | export const isArticle = (article: any): article is Article =>
94 | article._type === 'article'
95 |
96 | export type CrossDatasetSource = {
97 | _type: 'image'
98 | asset: CrossDatasetReferenceValue & SanityImageAssetDocument
99 | }
100 |
101 | export type BrandSlugDocument = {
102 | slug?: Slug
103 | } & BrandSpecificProps &
104 | SanityDocument
105 |
106 | export type SEODocumentType = Article | Author | Section
107 |
108 | export const isAuthor = (doc: any): doc is Author => doc._type === 'person'
109 |
--------------------------------------------------------------------------------
/utils/brand.ts:
--------------------------------------------------------------------------------
1 | import {config} from 'lib/config'
2 |
3 | export const BRAND_LIFESTYLE_NAME = 'lifestyle'
4 |
5 | export const getBrandName = (): string => config.brand
6 |
7 | export function isLifestyle(): boolean {
8 | return getBrandName() === BRAND_LIFESTYLE_NAME
9 | }
10 |
--------------------------------------------------------------------------------
/utils/logError.ts:
--------------------------------------------------------------------------------
1 | export const logError = (
2 | message: string,
3 | options: Record
4 | ): void => {
5 | console.error(message, {
6 | // eg `someUnknownType`
7 | type: options.type,
8 |
9 | // 'block' | 'mark' | 'blockStyle' | 'listStyle' | 'listItemStyle'
10 | nodeType: options.nodeType,
11 | })
12 | }
13 |
--------------------------------------------------------------------------------
/utils/routing.ts:
--------------------------------------------------------------------------------
1 | export const getUrlForDocumentType = (
2 | type: string,
3 | slug?: string,
4 | brand?: string
5 | ): `/${string}` | `${string}/${string}/${string}` => {
6 | if (!slug) {
7 | return `/${brand ?? 'tech'}`
8 | }
9 |
10 | switch (type) {
11 | case 'article':
12 | return `/${brand ?? 'tech'}/articles/${slug}`
13 | case 'section':
14 | return `/${brand ?? 'tech'}/sections/${slug}`
15 | case 'person':
16 | return `/${brand ?? 'tech'}/authors/${slug}`
17 | default:
18 | return `/${brand ?? 'tech'}`
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/utils/useArticleOrPreview.tsx:
--------------------------------------------------------------------------------
1 | import ArticlePreview from 'components/ArticlePreview'
2 | import {PreviewArticlePreview} from 'components/PreviewArticlePreview'
3 | import {PreviewReviewPreview} from 'components/PreviewReviewPreview'
4 | import {PreviewSuspense} from 'next-sanity/preview'
5 | import React, {useMemo} from 'react'
6 |
7 | import {Article, Review} from '../types'
8 |
9 | export const useArticleOrPreview = (
10 | articles?: (Article | Review)[],
11 | brandName?: 'tech' | 'lifestyle',
12 | token?: string,
13 | sectionType?: 'featured' | 'normal'
14 | ) => {
15 | const componentProps = useMemo(() => {
16 | if (!articles) return []
17 | const articleProps = articles.map((article) => ({
18 | ...article,
19 | sectionType,
20 | isHighlighted: Boolean(article?.isHighlighted),
21 | brandName: brandName,
22 | }))
23 | if (token) {
24 | return articleProps.map((props) => (
25 | }
27 | key={props.slug}
28 | >
29 | {props._type === 'review' ? (
30 |
40 | ) : (
41 |
48 | )}
49 |
50 | ))
51 | }
52 | return articleProps.map((props) => (
53 |
54 | ))
55 | }, [articles, token, sectionType, brandName])
56 | return componentProps
57 | }
58 |
--------------------------------------------------------------------------------
/utils/useRandom.ts:
--------------------------------------------------------------------------------
1 | import {useId, useRef} from 'react'
2 | import seedrandom from 'seedrandom'
3 |
4 | export const useRandom = (values: (string | number)[]): string | number => {
5 | const seed = useId()
6 | const random = useRef(values[Math.floor(seedrandom(seed)() * values.length)])
7 |
8 | return random.current
9 | }
10 |
11 | export function randomValue(values: (string | number)[]): string | number {
12 | return values[Math.floor(Math.random() * values.length)]
13 | }
14 |
--------------------------------------------------------------------------------
/utils/useSplitLifestyleArticles.ts:
--------------------------------------------------------------------------------
1 | import {useMemo} from 'react'
2 |
3 | import {Article, Review} from '../types'
4 |
5 | export const useSplitLifestyleArticles = (
6 | articles: (Article | Review)[],
7 | limit = 5
8 | ): {topArticles: (Article | Review)[]; restArticles: (Article | Review)[]} => {
9 | const topArticles = useMemo(() => {
10 | const sliced = articles?.slice(0, limit)
11 | const lastIndex = sliced?.length - 1
12 | return sliced?.map((article, index) => {
13 | // Highlight last article in this segment
14 | if (index === lastIndex) {
15 | return {...article, isHighlighted: true}
16 | }
17 | return {
18 | ...article,
19 | isHighlighted: false,
20 | }
21 | })
22 | }, [articles, limit])
23 |
24 | const restArticles = useMemo(() => articles?.slice(limit), [articles, limit])
25 |
26 | return {
27 | topArticles,
28 | restArticles,
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/vite.d.ts:
--------------------------------------------------------------------------------
1 | declare module 'vite-plugin-node-polyfills'
2 |
--------------------------------------------------------------------------------
/yalc.lock:
--------------------------------------------------------------------------------
1 | {
2 | "version": "v1",
3 | "packages": {
4 | "sanity-plugin-seo-pane": {
5 | "signature": "c8c763ad8023b3e3c4fa151ec3128ea0",
6 | "replaced": "^2.0.0"
7 | }
8 | }
9 | }
--------------------------------------------------------------------------------