├── .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 |
39 |
{children}
40 |
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 |
184 | 185 |
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 | {name} 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 | {alt} 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 |
148 | 153 |
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 | {alt 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 | {firstPerson.name} 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 |
36 | 37 |
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 | {mainImage?.alt 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 |