├── .github └── pull_request_template.md ├── .gitignore ├── README.md ├── docker-compose.yml ├── studio ├── .gitignore ├── .npmignore ├── Dockerfile ├── README.md ├── config │ ├── .checksums │ ├── @sanity │ │ ├── data-aspects.json │ │ ├── default-layout.json │ │ ├── default-login.json │ │ ├── form-builder.json │ │ └── vision.json │ └── leaflet-input.json ├── package.json ├── plugins │ ├── .gitkeep │ └── structured-content-logo │ │ ├── Logo.module.css │ │ ├── Logo.tsx │ │ └── sanity.json ├── prettier.config.js ├── sanity.json ├── schemas │ ├── arrays │ │ ├── blockContent.ts │ │ └── simpleBlockContent.ts │ ├── documents │ │ ├── article.ts │ │ ├── benefit.ts │ │ ├── event.ts │ │ ├── navigation.ts │ │ ├── page.ts │ │ ├── person.ts │ │ ├── program.ts │ │ ├── route.ts │ │ ├── session.ts │ │ ├── sharedSections.ts │ │ ├── spec.ts │ │ ├── sponsor.ts │ │ ├── sponsorship.ts │ │ ├── ticket.ts │ │ └── venue.ts │ ├── objects │ │ ├── figure.ts │ │ ├── index.ts │ │ ├── link.ts │ │ ├── navigationItem.ts │ │ ├── questionAndAnswer.ts │ │ ├── richText.ts │ │ ├── seo.ts │ │ └── simpleCallToAction.ts │ ├── schema.ts │ └── sections │ │ ├── articleSection.ts │ │ ├── formSection.ts │ │ ├── graphSection.ts │ │ ├── index.ts │ │ ├── programsSection.ts │ │ ├── questionAndAnswerCollectionSection.ts │ │ ├── sessionsSection.ts │ │ ├── speakersSection.ts │ │ ├── sponsorsSection.ts │ │ ├── sponsorshipsSection.ts │ │ ├── textAndImageSection.ts │ │ ├── ticketsSection.ts │ │ └── venuesSection.ts ├── src │ ├── SlotPreview.tsx │ ├── deskStructure.ts │ ├── migrations │ │ └── new-link-structure.ts │ ├── resolveProductionUrl.ts │ ├── setMissingField.ts │ ├── spec.tsx │ └── urlResolver.ts ├── static │ ├── .gitkeep │ └── favicon.ico ├── tsconfig.json ├── vercel.json └── yarn.lock ├── web ├── .eslintrc.json ├── .prettierignore ├── Dockerfile ├── LICENSE ├── README.md ├── __tests__ │ └── util │ │ └── array.ts ├── components │ ├── Accordion │ │ ├── Accordion.module.css │ │ ├── Accordion.tsx │ │ └── index.ts │ ├── ButtonLink │ │ ├── ButtonLink.module.css │ │ ├── ButtonLink.tsx │ │ └── index.ts │ ├── Card │ │ ├── Card.module.css │ │ ├── Card.tsx │ │ └── index.ts │ ├── ConferenceHeader │ │ ├── ConferenceHeader.module.css │ │ ├── ConferenceHeader.tsx │ │ └── index.ts │ ├── ConferenceUpdatesForm │ │ ├── ConferenceUpdatesForm.module.css │ │ ├── ConferenceUpdatesForm.tsx │ │ └── index.ts │ ├── CookieConsent │ │ ├── CookieConsent.module.css │ │ ├── CookieConsent.tsx │ │ └── index.ts │ ├── FeatureCheckmark │ │ ├── FeatureCheckmark.module.css │ │ ├── FeatureCheckmark.tsx │ │ └── index.ts │ ├── FeatureSection │ │ ├── FeatureSection.module.css │ │ ├── FeatureSection.tsx │ │ └── index.ts │ ├── Footer │ │ ├── Footer.module.css │ │ ├── Footer.tsx │ │ └── index.ts │ ├── GridWrapper │ │ ├── GridWrapper.module.css │ │ ├── GridWrapper.tsx │ │ └── index.ts │ ├── Heading │ │ ├── Heading.module.css │ │ ├── Heading.tsx │ │ └── index.ts │ ├── Hero │ │ ├── Hero.module.css │ │ ├── Hero.tsx │ │ └── index.ts │ ├── HighlightedSpeakerBlock │ │ ├── HighlightedSpeakerBlock.module.css │ │ ├── HighlightedSpeakerBlock.tsx │ │ └── index.ts │ ├── MetaTags │ │ ├── MetaTags.tsx │ │ └── index.ts │ ├── Nav │ │ ├── MenuItem │ │ │ ├── MenuItem.tsx │ │ │ └── index.ts │ │ ├── Nav.module.css │ │ ├── Nav.tsx │ │ └── index.ts │ ├── NavBlock │ │ ├── FakeItem │ │ │ ├── FakeItem.tsx │ │ │ └── index.ts │ │ ├── Item │ │ │ ├── Item.tsx │ │ │ └── index.ts │ │ ├── NavBlock.module.css │ │ ├── NavBlock.tsx │ │ └── index.ts │ ├── SectionBlock │ │ ├── SectionBlock.module.css │ │ ├── SectionBlock.tsx │ │ └── index.ts │ ├── SessionCard │ │ ├── SessionCard.module.css │ │ ├── SessionCard.tsx │ │ └── index.ts │ ├── SessionSpeakers │ │ ├── SessionSpeakers.module.css │ │ ├── SessionSpeakers.tsx │ │ └── index.ts │ ├── Sessions │ │ ├── Sessions.tsx │ │ └── index.ts │ ├── Shape │ │ ├── Shape.module.css │ │ ├── Shape.tsx │ │ └── index.ts │ ├── SimpleCallToAction │ │ ├── SimpleCallToAction.tsx │ │ └── index.ts │ ├── Sponsor │ │ ├── Sponsor.module.css │ │ ├── Sponsor.tsx │ │ └── index.ts │ ├── Tag │ │ ├── Tag.module.css │ │ ├── Tag.tsx │ │ └── index.ts │ ├── TextBlock │ │ ├── Figure │ │ │ ├── Figure.module.css │ │ │ ├── Figure.tsx │ │ │ └── index.ts │ │ ├── Person │ │ │ ├── Person.tsx │ │ │ └── index.ts │ │ ├── Programs │ │ │ ├── Programs.module.css │ │ │ ├── Programs.tsx │ │ │ └── index.ts │ │ ├── QuestionAndAnswerCollection │ │ │ ├── QuestionAndAnswerCollection.module.css │ │ │ ├── QuestionAndAnswerCollection.tsx │ │ │ └── index.ts │ │ ├── RichText │ │ │ ├── RichText.module.css │ │ │ ├── RichText.tsx │ │ │ └── index.ts │ │ ├── SharedSections │ │ │ ├── SharedSections.tsx │ │ │ └── index.ts │ │ ├── SimpleCallToAction │ │ │ ├── SimpleCallToAction.tsx │ │ │ └── index.ts │ │ ├── Speakers │ │ │ ├── Speakers.module.css │ │ │ ├── Speakers.tsx │ │ │ └── index.ts │ │ ├── SponsorsSection │ │ │ ├── SponsorsSection.module.css │ │ │ ├── SponsorsSection.tsx │ │ │ └── index.ts │ │ ├── Sponsorships │ │ │ ├── BenefitRow │ │ │ │ ├── BenefitRow.tsx │ │ │ │ └── index.ts │ │ │ ├── Sponsorships.module.css │ │ │ ├── Sponsorships.tsx │ │ │ └── index.ts │ │ ├── TextAndImage │ │ │ ├── TextAndImage.module.css │ │ │ ├── TextAndImage.tsx │ │ │ └── index.ts │ │ ├── TextBlock │ │ │ ├── TextBlock.tsx │ │ │ └── index.ts │ │ ├── Tickets │ │ │ ├── Tickets.module.css │ │ │ ├── Tickets.tsx │ │ │ └── index.ts │ │ ├── VenuesSection │ │ │ ├── VenuesSection.module.css │ │ │ ├── VenuesSection.tsx │ │ │ └── index.ts │ │ └── index.ts │ ├── VenueNav │ │ ├── VenueNav.module.css │ │ ├── VenueNav.tsx │ │ └── index.ts │ └── YouTubeBlock │ │ ├── YouTubeBlock.tsx │ │ └── index.ts ├── fonts │ ├── national-2-narrow-bold.fnt │ └── national-2-narrow-bold.png ├── hooks │ ├── shapes.module.css │ ├── useAnimationProperties.ts │ ├── useIntersection.ts │ └── useRandomShape.ts ├── images │ ├── checkmark.svg │ ├── close.svg │ ├── cross.svg │ ├── favicon-128x128.png │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── favicon-64x64.png │ ├── instagram_logo_white.svg │ ├── linkedin_logo_black.svg │ ├── linkedin_logo_white.svg │ ├── logo.svg │ ├── menu.svg │ ├── minus.svg │ ├── navblock_c.svg │ ├── navblock_halfoval.svg │ ├── navblock_o.svg │ ├── navblock_ovals.svg │ ├── navblock_plus.svg │ ├── newsletter-shapes.svg │ ├── og_image_background.png │ ├── plus.svg │ ├── sanity_logo_black.svg │ ├── sanity_logo_white.svg │ ├── twitter_logo_black.svg │ └── twitter_logo_white.svg ├── jest.config.js ├── lib │ ├── config.js │ ├── sanity.server.js │ └── sanity.ts ├── next-env.d.ts ├── next.config.js ├── package.json ├── pages │ ├── [[...slug]].tsx │ ├── _app.tsx │ ├── _document.tsx │ ├── api │ │ ├── exit-preview.ts │ │ ├── og-image │ │ │ └── [slug].ts │ │ └── preview.ts │ ├── app.module.css │ ├── articles │ │ ├── [slug].tsx │ │ └── article.module.css │ ├── program │ │ ├── [slug].tsx │ │ └── program.module.css │ └── speakers │ │ ├── [slug].tsx │ │ └── speakers.module.css ├── postcss.config.js ├── prettier.config.js ├── public │ ├── favicon.ico │ └── static │ │ └── fonts │ │ ├── inter-var-latin.woff2 │ │ └── national-2-narrow-bold.woff2 ├── styles │ ├── globals.css │ ├── media.css │ └── variables.css ├── tsconfig.json ├── types │ ├── Article.ts │ ├── EntitySectionSelection.ts │ ├── Figure.ts │ ├── Hero.ts │ ├── Link.ts │ ├── Person.ts │ ├── PrimaryNavItem.ts │ ├── Program.ts │ ├── RichTextSection.ts │ ├── Section.ts │ ├── Session.ts │ ├── Shape.ts │ ├── SimpleCallToAction.ts │ ├── Slug.ts │ ├── Sponsor.ts │ ├── Sponsorship.ts │ ├── Ticket.ts │ └── Venue.ts ├── util │ ├── animation.ts │ ├── array.ts │ ├── constants.js │ ├── date.ts │ ├── entity.ts │ ├── entityPaths.ts │ ├── number.ts │ ├── pages.ts │ ├── queries.ts │ └── session.ts └── yarn.lock └── yarn.lock /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | # [PR title] 2 | 3 | ## Intent 4 | 5 | A short summary of what this PR aims to achieve. 6 | 7 | ## Description 8 | 9 | In-depth information on how this PR works, technical details, assumptions, 10 | caveats and such. 11 | 12 | ## Testing this PR 13 | 14 | Step-by-step instructions which reviewers can use to verify that this PR 15 | achieves its aim. Specify any prerequisites and assumptions. Be explicit. 16 | 17 | 1. Start the application locally with `yarn develop` 18 | 2. Open [localhost:3000](http://localhost:3000) in your browser, and navigate to 19 | ... 20 | 3. Verify that ... 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Optional stylelint cache 58 | .stylelintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # dotenv environment variable files 76 | .env 77 | .env.development.local 78 | .env.test.local 79 | .env.production.local 80 | .env.local 81 | 82 | # parcel-bundler cache (https://parceljs.org/) 83 | .cache 84 | .parcel-cache 85 | 86 | # Next.js build output 87 | .next 88 | out 89 | 90 | # Nuxt.js build / generate output 91 | .nuxt 92 | dist 93 | 94 | # Gatsby files 95 | .cache/ 96 | # Comment in the public line in if your project uses Gatsby and not Next.js 97 | # https://nextjs.org/blog/next-9-1#public-directory-support 98 | # public 99 | 100 | # vuepress build output 101 | .vuepress/dist 102 | 103 | # vuepress v2.x temp and cache directory 104 | .temp 105 | .cache 106 | 107 | # Docusaurus cache and generated files 108 | .docusaurus 109 | 110 | # Serverless directories 111 | .serverless/ 112 | 113 | # FuseBox cache 114 | .fusebox/ 115 | 116 | # DynamoDB Local files 117 | .dynamodb/ 118 | 119 | # TernJS port file 120 | .tern-port 121 | 122 | # Stores VSCode versions used for testing VSCode extensions 123 | .vscode-test 124 | 125 | # yarn v2 126 | .yarn/cache 127 | .yarn/unplugged 128 | .yarn/build-state.yml 129 | .yarn/install-state.gz 130 | .pnp.* 131 | .DS_Store 132 | 133 | .vercel 134 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Structured Content 2022 2 | 3 | ## Development 4 | 5 | For manual setup instructions, see the individual web/ and studio/ READMEs. 6 | 7 | Alternatively, you can use Docker and docker-compose. (The setup is tested with 8 | Docker version 20.10.12 and docker-compose version 1.25.3.) 9 | 10 | Starting both studio and web: 11 | 12 | ``` 13 | docker-compose up 14 | ``` 15 | 16 | The Studio is available on http://localhost:3333 while the app is available on 17 | http://localhost:3000. 18 | 19 | If you only want to start one of the services, you can use `docker-compose up 20 | studio` or `docker-compose up web` respectively. 21 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.7' 2 | services: 3 | studio: 4 | build: 5 | dockerfile: ./studio/Dockerfile 6 | context: . 7 | ports: 8 | - 3333:3333 9 | volumes: 10 | - ./studio:/app 11 | 12 | web: 13 | build: 14 | dockerfile: ./web/Dockerfile 15 | context: . 16 | ports: 17 | - 3000:3000 18 | volumes: 19 | - ./web:/app 20 | -------------------------------------------------------------------------------- /studio/.gitignore: -------------------------------------------------------------------------------- 1 | .vercel 2 | .env* 3 | -------------------------------------------------------------------------------- /studio/.npmignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | /logs 3 | *.log 4 | 5 | # Coverage directory used by tools like istanbul 6 | /coverage 7 | 8 | # Dependency directories 9 | node_modules 10 | 11 | # Compiled sanity studio 12 | /dist 13 | -------------------------------------------------------------------------------- /studio/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:14 2 | 3 | RUN mkdir /app 4 | ADD . /app 5 | 6 | WORKDIR /app 7 | RUN yarn 8 | 9 | EXPOSE 3333 10 | 11 | CMD ["yarn", "sanity", "start", "--host=0.0.0.0"] 12 | -------------------------------------------------------------------------------- /studio/README.md: -------------------------------------------------------------------------------- 1 | # Sanity Clean Content Studio 2 | 3 | Congratulations, you have now installed the Sanity Content Studio, an open source real-time content editing environment connected to the Sanity backend. 4 | 5 | Now you can do the following things: 6 | 7 | - [Read “getting started” in the docs](https://www.sanity.io/docs/introduction/getting-started?utm_source=readme) 8 | - [Join the community Slack](https://slack.sanity.io/?utm_source=readme) 9 | - [Extend and build plugins](https://www.sanity.io/docs/content-studio/extending?utm_source=readme) 10 | 11 | ## Enabling Live Preview 12 | 13 | [Live Preview][0] is a great way to test your content in real-time. Your local page can reflect your Studio drafts. 14 | 15 | You can enable Live Preview by adding a _.env.development_ file to the _/studio_ directory with a `SANITY_STUDIO_PREVIEW_SECRET` variable, 16 | and an _.env_ file to the _/web_ directory. Fetch the value for this key [in the "Environment Variables" section of the Vercel project][1]. 17 | Once added, restart the project locally. After you've logged in to Sanity Studio, open a Route document, click the top-right meatball menu, 18 | and select "Open preview". That page will load draft content client-side after the initial render. 19 | 20 | ## Environment variables 21 | 22 | To override defaults, add a _.env.development_ file to this _/studio_ directory. The file should not be committed to your repository. The 23 | following variables are supported: 24 | 25 | - SANITY_STUDIO_PREVIEW_SECRET: The secret used to enable Live Preview. (See above.) 26 | - SANITY_STUDIO_API_DATASET: The name of the dataset to use for the Sanity API. Defaults to `staging` locally and for Live Previews (due to 27 | being set in the [Vercel project settings][1]), and `production` in production. 28 | - Read more about supported Studio environment variables [here][2]. 29 | 30 | [0]: https://www.sanity.io/guides/nextjs-live-preview 31 | [1]: https://vercel.com/sanity-io/structured-content-2022-studio/settings/environment-variables 32 | [2]: https://www.sanity.io/docs/studio-environment-variables 33 | -------------------------------------------------------------------------------- /studio/config/.checksums: -------------------------------------------------------------------------------- 1 | { 2 | "#": "Used by Sanity to keep track of configuration file checksums, do not delete or modify!", 3 | "@sanity/default-layout": "bb034f391ba508a6ca8cd971967cbedeb131c4d19b17b28a0895f32db5d568ea", 4 | "@sanity/default-login": "6fb6d3800aa71346e1b84d95bbcaa287879456f2922372bb0294e30b968cd37f", 5 | "@sanity/form-builder": "b38478227ba5e22c91981da4b53436df22e48ff25238a55a973ed620be5068aa", 6 | "@sanity/data-aspects": "d199e2c199b3e26cd28b68dc84d7fc01c9186bf5089580f2e2446994d36b3cb6", 7 | "sanity-plugin-leaflet-input": "719f6bf672040fa5af3485f155f7da339033b564cc0978f1249c274ddd26a483", 8 | "leaflet-input": "719f6bf672040fa5af3485f155f7da339033b564cc0978f1249c274ddd26a483", 9 | "@sanity/vision": "da5b6ed712703ecd04bf4df560570c668aa95252c6bc1c41d6df1bda9b8b8f60" 10 | } 11 | -------------------------------------------------------------------------------- /studio/config/@sanity/data-aspects.json: -------------------------------------------------------------------------------- 1 | { 2 | "listOptions": {} 3 | } 4 | -------------------------------------------------------------------------------- /studio/config/@sanity/default-layout.json: -------------------------------------------------------------------------------- 1 | { 2 | "toolSwitcher": { 3 | "order": [], 4 | "hidden": [] 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /studio/config/@sanity/default-login.json: -------------------------------------------------------------------------------- 1 | { 2 | "providers": { 3 | "mode": "append", 4 | "redirectOnSingle": false, 5 | "entries": [] 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /studio/config/@sanity/form-builder.json: -------------------------------------------------------------------------------- 1 | { 2 | "images": { 3 | "directUploads": true 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /studio/config/@sanity/vision.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaultApiVersion": "2021-10-21" 3 | } 4 | -------------------------------------------------------------------------------- /studio/config/leaflet-input.json: -------------------------------------------------------------------------------- 1 | { 2 | "tileLayer": { 3 | "attribution": "© OpenStreetMap contributors", 4 | "url": "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" 5 | }, 6 | "defaultLocation": { 7 | "lat": 37.779048, 8 | "lng": -122.415214 9 | }, 10 | "defaultZoom": 13 11 | } 12 | -------------------------------------------------------------------------------- /studio/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "structured-content-conf-studio", 3 | "private": true, 4 | "version": "1.0.0", 5 | "description": "", 6 | "main": "package.json", 7 | "author": "Knut Melvær ", 8 | "license": "UNLICENSED", 9 | "scripts": { 10 | "dev": "sanity start", 11 | "start": "sanity start", 12 | "build": "sanity build" 13 | }, 14 | "keywords": [ 15 | "sanity" 16 | ], 17 | "dependencies": { 18 | "@portabletext/react": "^1.0.3", 19 | "@sanity/base": "^2.28.2", 20 | "@sanity/core": "^2.28.1", 21 | "@sanity/default-layout": "^2.28.2", 22 | "@sanity/default-login": "^2.28.2", 23 | "@sanity/desk-tool": "^2.28.2", 24 | "@sanity/hierarchical-document-list": "^1.0.0", 25 | "@sanity/production-preview": "^2.15.0", 26 | "@sanity/vision": "^2.28.2", 27 | "date-fns": "^2.28.0", 28 | "prop-types": "^15.7", 29 | "react": "^17.0", 30 | "react-dom": "^17.0", 31 | "sanity-plugin-asset-source-unsplash": "^0.1.5", 32 | "sanity-plugin-documents-pane": "^1.0.5", 33 | "sanity-plugin-iframe-pane": "^1.0.15", 34 | "sanity-plugin-leaflet-input": "^1.0.1", 35 | "sanity-plugin-media": "^1.4.4", 36 | "sanity-plugin-social-preview": "^0.1.5", 37 | "styled-components": "^5.2.0", 38 | "timezones-list": "^3.0.1" 39 | }, 40 | "devDependencies": { 41 | "@sanity/cli": "^2.28.0", 42 | "typescript": "^4.5.5" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /studio/plugins/.gitkeep: -------------------------------------------------------------------------------- 1 | User-specific packages can be placed here 2 | -------------------------------------------------------------------------------- /studio/plugins/structured-content-logo/Logo.module.css: -------------------------------------------------------------------------------- 1 | @keyframes spin { 2 | 0% { 3 | transform: rotateY(0deg); 4 | } 5 | 50% { 6 | transform: rotateY(160deg); 7 | } 8 | 100% { 9 | transform: rotateY(360deg); 10 | } 11 | } 12 | .logo { 13 | display: block; 14 | animation: spin 20s infinite ease-in-out; 15 | font-weight: bold; 16 | } 17 | -------------------------------------------------------------------------------- /studio/plugins/structured-content-logo/sanity.json: -------------------------------------------------------------------------------- 1 | { 2 | "parts": [ 3 | { 4 | "implements": "part:@sanity/base/brand-logo", 5 | "path": "./Logo.tsx" 6 | } 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /studio/prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | semi: true, 3 | singleQuote: false, 4 | trailingComma: 'es5', 5 | bracketSpacing: true, 6 | }; 7 | -------------------------------------------------------------------------------- /studio/sanity.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "project": { 4 | "name": "structured-content-conf" 5 | }, 6 | "api": { 7 | "projectId": "33zsuc7i", 8 | "dataset": "production" 9 | }, 10 | "plugins": [ 11 | "@sanity/base", 12 | "@sanity/default-layout", 13 | "@sanity/default-login", 14 | "@sanity/desk-tool", 15 | "asset-source-unsplash", 16 | "leaflet-input", 17 | "media", 18 | "iframe-pane", 19 | "documents-pane", 20 | "@sanity/hierarchical-document-list", 21 | "@sanity/production-preview", 22 | "@sanity/vision", 23 | "social-preview", 24 | "structured-content-logo" 25 | ], 26 | "parts": [ 27 | { 28 | "name": "part:@sanity/base/schema", 29 | "path": "./schemas/schema" 30 | }, 31 | { 32 | "name": "part:@sanity/desk-tool/structure", 33 | "path": "./src/deskStructure.ts" 34 | }, 35 | { 36 | "implements": "part:@sanity/production-preview/resolve-production-url", 37 | "path": "./src/resolveProductionUrl.ts" 38 | } 39 | ], 40 | "env": { 41 | "development": { 42 | "api": { 43 | "dataset": "staging" 44 | } 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /studio/schemas/arrays/blockContent.ts: -------------------------------------------------------------------------------- 1 | import { InlineElementIcon } from "@sanity/icons"; 2 | import { DocumentsIcon } from "@sanity/icons"; 3 | 4 | export default { 5 | name: "blockContent", 6 | title: "Block Content", 7 | type: "array", 8 | of: [ 9 | { 10 | type: "block", 11 | of: [ 12 | { 13 | type: "reference", 14 | title: "Embed inline", 15 | /* icon: InlineElementIcon, */ 16 | to: [ 17 | { type: "person" }, 18 | { type: "session" }, 19 | { type: "venue" }, 20 | { type: "sponsorship" }, 21 | { type: "sponsor" }, 22 | { type: "ticket" }, 23 | ], 24 | }, 25 | ], 26 | marks: { 27 | decorators: [ 28 | { title: "Strong", value: "strong" }, 29 | { title: "Emphasis", value: "em" }, 30 | { title: "Code", value: "code" }, 31 | ], 32 | annotations: [ 33 | { 34 | name: "link", 35 | type: "link", 36 | title: "Link to internal or external content", 37 | }, 38 | ], 39 | }, 40 | }, 41 | { 42 | type: "simpleCallToAction", 43 | }, 44 | ], 45 | }; 46 | -------------------------------------------------------------------------------- /studio/schemas/arrays/simpleBlockContent.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | name: "simpleBlockContent", 3 | title: "Simple Block Content", 4 | type: "array", 5 | of: [ 6 | { 7 | type: "block", 8 | marks: { 9 | decorators: [ 10 | { title: "Strong", value: "strong" }, 11 | { title: "Emphasis", value: "em" }, 12 | { title: "Code", value: "code" }, 13 | ], 14 | annotations: [ 15 | { 16 | name: "link", 17 | type: "link", 18 | }, 19 | ], 20 | }, 21 | }, 22 | { 23 | type: "simpleCallToAction", 24 | }, 25 | ], 26 | }; 27 | -------------------------------------------------------------------------------- /studio/schemas/documents/benefit.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | name: "benefit", 3 | title: "Sponsporship benefit", 4 | type: "document", 5 | fields: [ 6 | { 7 | name: "name", 8 | title: "Name", 9 | type: "string", 10 | }, 11 | ], 12 | }; 13 | -------------------------------------------------------------------------------- /studio/schemas/documents/navigation.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | name: "navigation", 3 | title: "Navigation", 4 | type: "document", 5 | fields: [ 6 | { 7 | name: "internalName", 8 | title: "Internal name", 9 | type: "string", 10 | }, 11 | { 12 | name: "slug", 13 | type: "slug", 14 | title: "Navigation slug", 15 | description: "Used to identify this navigation", 16 | validation: (Rule) => Rule.required(), 17 | }, 18 | { 19 | name: "items", 20 | type: "array", 21 | title: "Navigation items", 22 | of: [ 23 | { 24 | type: "navigation.item", 25 | }, 26 | ], 27 | }, 28 | ], 29 | }; 30 | -------------------------------------------------------------------------------- /studio/schemas/documents/page.ts: -------------------------------------------------------------------------------- 1 | import { DocumentIcon, SyncIcon } from "@sanity/icons"; 2 | import * as sections from "../sections"; 3 | export default { 4 | name: "page", 5 | title: "Pages", 6 | type: "document", 7 | icon: DocumentIcon, 8 | groups: [ 9 | { 10 | title: "Hero", 11 | name: "hero", 12 | }, 13 | { 14 | title: "Sections", 15 | name: "sections", 16 | }, 17 | ], 18 | preview: { 19 | select: { 20 | title: "internalName", 21 | }, 22 | }, 23 | fields: [ 24 | { 25 | name: "internalName", 26 | title: "Interal name", 27 | type: "string", 28 | description: "For internal use.", 29 | }, 30 | { 31 | name: "hero", 32 | title: "Hero", 33 | type: "object", 34 | group: "hero", 35 | fields: [ 36 | { 37 | name: "heading", 38 | title: "Hero heading", 39 | type: "string", 40 | description: "This will be the editorial headline of the page.", 41 | }, 42 | { 43 | name: "summary", 44 | title: "Hero summary", 45 | type: "text", 46 | validation: (Rule) => 47 | Rule.max(315).warning("Keep it short and sweet!"), 48 | }, 49 | { 50 | name: "callToAction", 51 | title: "Hero call to action", 52 | type: "simpleCallToAction", 53 | }, 54 | ], 55 | }, 56 | { 57 | name: "sections", 58 | group: "sections", 59 | type: "array", 60 | title: "Sections", 61 | of: [ 62 | { 63 | type: "reference", 64 | title: "Shared section", 65 | icon: SyncIcon, 66 | to: [{ type: "sharedSections" }], 67 | }, 68 | { type: "figure" }, 69 | { type: "article" }, 70 | ...Object.keys(sections).map((type) => ({ type })), 71 | ], 72 | }, 73 | ], 74 | }; 75 | -------------------------------------------------------------------------------- /studio/schemas/documents/person.ts: -------------------------------------------------------------------------------- 1 | import { UsersIcon } from "@sanity/icons"; 2 | 3 | export default { 4 | name: "person", 5 | title: "People", 6 | type: "document", 7 | icon: UsersIcon, 8 | preview: { 9 | select: { 10 | title: "name", 11 | subtitle: "social.twitter", 12 | media: "photo", 13 | }, 14 | }, 15 | fields: [ 16 | { 17 | name: "name", 18 | title: "Name", 19 | type: "string", 20 | }, 21 | { 22 | name: "prononciation", 23 | title: "Prononciation", 24 | type: "string", 25 | description: "Let's figure out how to use this field", 26 | }, 27 | { 28 | name: "slug", 29 | title: "Slug", 30 | type: "slug", 31 | validation: (Rule) => Rule.required(), 32 | options: { 33 | source: "name", 34 | maxLength: 96, 35 | }, 36 | }, 37 | { 38 | name: "title", 39 | title: "Profesional title", 40 | type: "string", 41 | }, 42 | { 43 | name: "company", 44 | title: "Company", 45 | type: "string", 46 | description: "Company or affiliation", 47 | }, 48 | { 49 | name: "pronouns", 50 | type: "string", 51 | title: "Pronouns", 52 | }, 53 | { 54 | name: "photo", 55 | type: "figure", 56 | }, 57 | { 58 | name: "social", 59 | type: "object", 60 | title: "Social media", 61 | fields: [ 62 | { 63 | name: "twitter", 64 | type: "string", 65 | title: "Twitter", 66 | validation: (Rule) => 67 | Rule.regex(/^@[a-zA-Z0-9_]+$/).error('Include "@" symbol'), 68 | description: "Only the handle is required. (e.g. @sanity_io)", 69 | }, 70 | { 71 | name: "linkedin", 72 | type: "url", 73 | title: "LinkedIn", 74 | description: "Full URL", 75 | }, 76 | ], 77 | }, 78 | { 79 | name: "bio", 80 | type: "array", 81 | title: "Bio", 82 | of: [ 83 | { 84 | type: "block", 85 | }, 86 | ], 87 | }, 88 | ], 89 | }; 90 | -------------------------------------------------------------------------------- /studio/schemas/documents/route.ts: -------------------------------------------------------------------------------- 1 | import { LinkIcon } from "@sanity/icons"; 2 | 3 | export default { 4 | name: "route", 5 | type: "document", 6 | title: "Routes", 7 | icon: LinkIcon, 8 | preview: { 9 | select: { 10 | title: "seo.title", 11 | subtitle: "internalName", 12 | }, 13 | }, 14 | groups: [ 15 | { 16 | name: "seo", 17 | title: "SEO", 18 | }, 19 | ], 20 | fields: [ 21 | { 22 | name: "internalName", 23 | type: "string", 24 | title: "Internal name", 25 | description: "Used to identify a route ", 26 | }, 27 | { 28 | name: "slug", 29 | type: "slug", 30 | title: "Slug", 31 | validation: (Rule) => Rule.required(), 32 | options: { 33 | source: "seo.title", 34 | }, 35 | }, 36 | { 37 | name: "page", 38 | type: "reference", 39 | title: "Page", 40 | to: [{ type: "page" }, { type: "article" }], 41 | }, 42 | { 43 | name: "seo", 44 | type: "seo", 45 | title: "Metadata", 46 | description: "For Search Engine Optimization", 47 | group: "seo", 48 | }, 49 | ], 50 | }; 51 | -------------------------------------------------------------------------------- /studio/schemas/documents/sharedSections.ts: -------------------------------------------------------------------------------- 1 | import { BlockElementIcon } from "@sanity/icons"; 2 | 3 | import * as sections from "../sections"; 4 | 5 | export default { 6 | name: "sharedSections", 7 | title: "Shared Sections", 8 | type: "document", 9 | icon: BlockElementIcon, 10 | preview: { 11 | select: { 12 | title: "internalName", 13 | sections: "sections", 14 | }, 15 | prepare({ title, sections }) { 16 | return { 17 | title, 18 | subtitle: sections.length 19 | ? sections.map(({ _type }) => _type).join(", ") 20 | : undefined, 21 | }; 22 | }, 23 | }, 24 | fields: [ 25 | { 26 | name: "internalName", 27 | title: "Interal name", 28 | type: "string", 29 | description: "For internal use.", 30 | }, 31 | { 32 | name: "sections", 33 | type: "array", 34 | title: "Sections", 35 | of: [ 36 | { type: "figure" }, 37 | ...Object.keys(sections).map((type) => ({ type })), 38 | ], 39 | }, 40 | ], 41 | }; 42 | -------------------------------------------------------------------------------- /studio/schemas/documents/spec.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | name: "spec", 3 | type: "document", 4 | title: "Content Specification Sheet", 5 | fields: [ 6 | { 7 | name: "references", 8 | type: "object", 9 | fields: [ 10 | { 11 | name: "type", 12 | type: "string", 13 | options: { 14 | list: [ 15 | { value: "session", title: "Session" }, 16 | { value: "person", title: "Person" }, 17 | { value: "article", title: "Article" }, 18 | { value: "sponsor", title: "Sponsor" }, 19 | { value: "sponsorship", title: "Sponsorship" }, 20 | { value: "ticket", title: "Ticket" }, 21 | { value: "venue", title: "Venue" }, 22 | ], 23 | }, 24 | }, 25 | { 26 | name: "route", 27 | type: "reference", 28 | to: [{ type: "route" }], 29 | }, 30 | ], 31 | }, 32 | { 33 | name: "audience", 34 | type: "text", 35 | }, 36 | { 37 | name: "topTask", 38 | type: "array", 39 | of: [{ type: "string" }], 40 | }, 41 | { 42 | name: "businessGoal", 43 | type: "text", 44 | }, 45 | { 46 | name: "fields", 47 | type: "array", 48 | of: [ 49 | { 50 | type: "object", 51 | preview: { 52 | select: { 53 | type: "type", 54 | field: "field", 55 | validation: "validation", 56 | sample: "sample", 57 | }, 58 | prepare({ type = "", field = "", validation = "", sample = "" }) { 59 | return { 60 | title: `${type} - ${field} - ${validation}`, 61 | subtitle: sample, 62 | }; 63 | }, 64 | }, 65 | fields: [ 66 | { 67 | name: "type", 68 | type: "string", 69 | }, 70 | { 71 | name: "field", 72 | type: "string", 73 | }, 74 | { 75 | name: "validation", 76 | type: "string", 77 | }, 78 | { 79 | name: "sample", 80 | type: "text", 81 | }, 82 | ], 83 | }, 84 | ], 85 | }, 86 | ], 87 | }; 88 | -------------------------------------------------------------------------------- /studio/schemas/documents/sponsor.ts: -------------------------------------------------------------------------------- 1 | import { HeartFilledIcon } from "@sanity/icons"; 2 | 3 | export default { 4 | name: "sponsor", 5 | title: "Sponsors", 6 | type: "document", 7 | icon: HeartFilledIcon, 8 | fields: [ 9 | { 10 | name: "title", 11 | title: "Title", 12 | type: "string", 13 | }, 14 | { 15 | name: "url", 16 | type: "url", 17 | title: "URL", 18 | description: "Main URL of the sponsor", 19 | }, 20 | { 21 | name: "callToActionURL", 22 | type: "url", 23 | title: "Call to Action URL", 24 | description: 25 | "The sponsor's target URL for where they want to direct people to.", 26 | validation: (Rule) => 27 | Rule.regex(/utm_[a-z]+=\w+/).warning( 28 | "Remember to ask this sponsor if they want to include UTM tags." 29 | ), 30 | }, 31 | { 32 | name: "image", 33 | title: "Image", 34 | type: "figure", 35 | description: "Preferably SVG, and the monochrome version of the logo", 36 | }, 37 | ], 38 | }; 39 | -------------------------------------------------------------------------------- /studio/schemas/objects/figure.ts: -------------------------------------------------------------------------------- 1 | import { ImageIcon } from "@sanity/icons"; 2 | export default { 3 | name: "figure", 4 | title: "Figure", 5 | type: "image", 6 | icon: ImageIcon, 7 | description: "An image with alternative text and an optional caption", 8 | options: { 9 | hotspot: true, 10 | }, 11 | fields: [ 12 | { 13 | name: "alt", 14 | title: "Alternative text", 15 | type: "string", 16 | description: "Describe the image as you would to someone over the phone.", 17 | }, 18 | { 19 | name: "caption", 20 | type: "string", 21 | }, 22 | ], 23 | }; 24 | -------------------------------------------------------------------------------- /studio/schemas/objects/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Entering the object types here will automatically add them to schema.ts 3 | * becaus it maps over all the exports in this file 4 | */ 5 | 6 | export { default as figure } from "./figure"; 7 | export { default as richText } from "./richText"; 8 | export { default as seo } from "./seo"; 9 | export { default as simpleCallToAction } from "./simpleCallToAction"; 10 | export { default as link } from "./link"; 11 | -------------------------------------------------------------------------------- /studio/schemas/objects/link.ts: -------------------------------------------------------------------------------- 1 | const warning = "You need to delete this or the other link value."; 2 | export default { 3 | name: "link", 4 | type: "object", 5 | title: "Link", 6 | modal: { 7 | type: "popover", 8 | width: "medium", 9 | }, 10 | fields: [ 11 | { 12 | name: "internal", 13 | type: "reference", 14 | title: "Internal link", 15 | description: "Use this to link to something on this site.", 16 | hidden: ({ parent }) => !!parent?.external && !parent.internal, 17 | validation: (Rule) => 18 | Rule.custom((value, context) => { 19 | return value && context.parent?.external ? warning : true; 20 | }), 21 | to: [ 22 | { 23 | type: "route", 24 | }, 25 | { 26 | type: "article", 27 | }, 28 | ], 29 | }, 30 | { 31 | name: "external", 32 | type: "url", 33 | hidden: ({ parent }) => !!parent?.internal && !parent.external, 34 | validation: (Rule) => [ 35 | // Comment this out until bug is fixed 36 | /* Rule.custom((value, context) => { 37 | if (value === undefined) { 38 | return true; 39 | } 40 | return value && context.parent?.internal ? warning : true; 41 | }).warning(), */ 42 | Rule.uri({ scheme: ["https", "mailto", "tel"] }).error( 43 | "Valid URL schemes are `https`, `mailto` and `tel`" 44 | ), 45 | ], 46 | title: "External", 47 | description: 48 | "Use this for links to external domains, email, or telephone numbers.", 49 | }, 50 | { 51 | name: "blank", 52 | type: "boolean", 53 | title: "Open in new tab or window", 54 | validation: (Rule) => 55 | Rule.custom((value, context) => { 56 | return value && context.parent?.internal ? "" : true; 57 | }).warning( 58 | "Are you sure you want to open this internal link in a new tab or window?" 59 | ), 60 | description: `Only use this if the user needs to keep the current page persistent (if there is a form etc.). When true, this will lessen the user experience on mobile devices.`, 61 | }, 62 | ], 63 | }; 64 | -------------------------------------------------------------------------------- /studio/schemas/objects/navigationItem.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | name: "navigation.item", 3 | type: "object", 4 | title: "Navigation item", 5 | fields: [ 6 | { 7 | title: "Navigation label", 8 | name: "label", 9 | type: "string", 10 | description: "Overrides the route's title", 11 | }, 12 | { 13 | title: "Navigation target", 14 | name: "target", 15 | type: "link", 16 | }, 17 | ], 18 | }; 19 | -------------------------------------------------------------------------------- /studio/schemas/objects/questionAndAnswer.ts: -------------------------------------------------------------------------------- 1 | import { HelpCircleIcon } from "@sanity/icons"; 2 | export default { 3 | name: "questionAndAnswer", 4 | type: "object", 5 | title: "Question and Answer", 6 | icon: HelpCircleIcon, 7 | preview: { 8 | select: { 9 | title: "question", 10 | subtitle: "answer", 11 | }, 12 | }, 13 | fields: [ 14 | { 15 | name: "question", 16 | type: "string", 17 | title: "Question", 18 | }, 19 | { 20 | name: "answer", 21 | type: "simpleBlockContent", 22 | title: "Answer", 23 | }, 24 | ], 25 | }; 26 | -------------------------------------------------------------------------------- /studio/schemas/objects/richText.ts: -------------------------------------------------------------------------------- 1 | import { EditIcon } from "@sanity/icons"; 2 | import { toPlainText } from "@portabletext/react"; 3 | export default { 4 | name: "richText", 5 | type: "object", 6 | title: "Rich text", 7 | icon: EditIcon, 8 | preview: { 9 | select: { 10 | heading: "heading", 11 | subheading: "subheading", 12 | content: "content", 13 | }, 14 | prepare({ heading, subheading, content = [] }) { 15 | return { 16 | title: heading || subheading || toPlainText(content), 17 | subtitle: "Rich text", 18 | }; 19 | }, 20 | }, 21 | fields: [ 22 | { 23 | name: "heading", 24 | type: "string", 25 | title: "Heading", 26 | }, 27 | { 28 | name: "subheading", 29 | type: "string", 30 | title: "Subheading", 31 | }, 32 | { 33 | name: "content", 34 | type: "simpleBlockContent", 35 | }, 36 | ], 37 | }; 38 | -------------------------------------------------------------------------------- /studio/schemas/objects/seo.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | name: "seo", 3 | type: "object", 4 | fields: [ 5 | ...[ 6 | { name: "title", type: "string", title: "Title" }, 7 | { name: "description", type: "string", title: "Description" }, 8 | ].map((field) => ({ ...field, validation: (Rule) => Rule.required() })), 9 | { 10 | name: "image", 11 | type: "figure", 12 | title: "Image", 13 | description: "Override the automatically generated SEO image", 14 | }, 15 | { 16 | name: "noIndex", 17 | type: "boolean", 18 | title: "Hide this route from search engines", 19 | description: "This route will not be indexed by search engines", 20 | }, 21 | ], 22 | }; 23 | -------------------------------------------------------------------------------- /studio/schemas/objects/simpleCallToAction.ts: -------------------------------------------------------------------------------- 1 | import { ArrowRightIcon } from "@sanity/icons"; 2 | 3 | export default { 4 | name: "simpleCallToAction", 5 | type: "object", 6 | title: "Simple Call To Action", 7 | icon: ArrowRightIcon, 8 | fields: [ 9 | { 10 | name: "text", 11 | type: "string", 12 | title: "Text", 13 | }, 14 | { 15 | name: "link", 16 | type: "link", 17 | title: "Link", 18 | }, 19 | ], 20 | }; 21 | -------------------------------------------------------------------------------- /studio/schemas/schema.ts: -------------------------------------------------------------------------------- 1 | import createSchema from "part:@sanity/base/schema-creator"; 2 | 3 | import schemaTypes from "all:part:@sanity/base/schema-type"; 4 | import article from "./documents/article"; 5 | import event from "./documents/event"; 6 | import page from "./documents/page"; 7 | import person from "./documents/person"; 8 | import route from "./documents/route"; 9 | import session from "./documents/session"; 10 | import sharedSections from "./documents/sharedSections"; 11 | import sponsor from "./documents/sponsor"; 12 | import sponsorship from "./documents/sponsorship"; 13 | import ticket from "./documents/ticket"; 14 | import venue from "./documents/venue"; 15 | 16 | // Objects 17 | 18 | import { figure, richText, seo, simpleCallToAction, link } from "./objects"; 19 | import blockContent from "./arrays/blockContent"; 20 | import questionAndAnswer from "./objects/questionAndAnswer"; 21 | import simpleBlockContent from "./arrays/simpleBlockContent"; 22 | 23 | import * as sections from "./sections"; 24 | 25 | import spec from "./documents/spec"; 26 | import program from "./documents/program"; 27 | import navigation from "./documents/navigation"; 28 | import navigationItem from "./objects/navigationItem"; 29 | import benefit from "./documents/benefit"; 30 | 31 | export default createSchema({ 32 | name: "default", 33 | types: schemaTypes.concat([ 34 | ...Object.values(sections), 35 | figure, 36 | benefit, 37 | questionAndAnswer, 38 | simpleCallToAction, 39 | richText, 40 | seo, 41 | event, 42 | link, 43 | session, 44 | person, 45 | navigation, 46 | navigationItem, 47 | article, 48 | page, 49 | route, 50 | sponsor, 51 | venue, 52 | sponsorship, 53 | ticket, 54 | sharedSections, 55 | simpleBlockContent, 56 | blockContent, 57 | spec, 58 | program, 59 | ]), 60 | }); 61 | -------------------------------------------------------------------------------- /studio/schemas/sections/articleSection.ts: -------------------------------------------------------------------------------- 1 | import { toPlainText } from "@portabletext/react"; 2 | export default { 3 | name: "articleSection", 4 | title: "Article", 5 | type: "object", 6 | preview: { 7 | select: { 8 | heading: "heading", 9 | subheading: "subheading", 10 | content: "content", 11 | }, 12 | prepare({ heading, subheading, content = [] }) { 13 | return { 14 | title: heading || subheading || toPlainText(content), 15 | subtitle: "Rich text", 16 | }; 17 | }, 18 | }, 19 | description: 20 | "An article-like section with heading, subheading and rich-text content", 21 | fields: [ 22 | { 23 | name: "heading", 24 | type: "string", 25 | title: "Heading", 26 | }, 27 | { 28 | name: "subheading", 29 | type: "string", 30 | title: "Subheading", 31 | }, 32 | { 33 | name: "content", 34 | type: "simpleBlockContent", 35 | title: "Content", 36 | }, 37 | ], 38 | }; 39 | -------------------------------------------------------------------------------- /studio/schemas/sections/formSection.ts: -------------------------------------------------------------------------------- 1 | import { ClipboardIcon } from "@sanity/icons"; 2 | 3 | export default { 4 | name: "formSection", 5 | title: "Form section", 6 | type: "object", 7 | icon: ClipboardIcon, 8 | fields: [ 9 | { 10 | name: "type", 11 | type: "string", 12 | title: "Form type", 13 | description: "", 14 | options: { 15 | list: [ 16 | { title: "Contact form", value: "contact" }, 17 | { title: "Registration form", value: "registration" }, 18 | { title: "Newsletter form", value: "newsletter" }, 19 | ], 20 | }, 21 | }, 22 | { 23 | name: "id", 24 | type: "string", 25 | title: "ID", 26 | description: 27 | "ID for connecting the form with an external service (Mailchimp, Hopin, etc.)", 28 | }, 29 | { 30 | name: "buttonText", 31 | type: "string", 32 | title: "Button text", 33 | description: "Text for the submit button", 34 | }, 35 | { 36 | name: "target", 37 | type: "url", 38 | title: "Target", 39 | description: 40 | 'The target of the form. Must beging with "mailto:" or "https://"', 41 | validation: (Rule) => Rule.uri({ scheme: ["mailto", "https"] }), 42 | }, 43 | { 44 | name: "redirect", 45 | type: "reference", 46 | title: "Redirect", 47 | description: "The page to redirect to after the form has been submitted", 48 | to: [{ type: "route" }, { type: "article" }], 49 | }, 50 | ], 51 | }; 52 | -------------------------------------------------------------------------------- /studio/schemas/sections/graphSection.ts: -------------------------------------------------------------------------------- 1 | import { BarChartIcon } from "@sanity/icons"; 2 | 3 | export default { 4 | name: "graphSection", 5 | title: "Graph section", 6 | type: "object", 7 | icon: BarChartIcon, 8 | fields: [ 9 | { 10 | name: "type", 11 | type: "string", 12 | title: "Section type", 13 | description: "", 14 | options: { 15 | list: [ 16 | { title: "Table view", value: "table" }, 17 | { title: "Block graph", value: "block" }, 18 | ], 19 | }, 20 | }, 21 | { 22 | name: "data", 23 | type: "array", 24 | title: "Data", 25 | of: [ 26 | { 27 | name: "entry", 28 | type: "object", 29 | title: "Entry", 30 | preview: { 31 | select: { 32 | label: "label", 33 | value: "value", 34 | unit: "unit", 35 | }, 36 | prepare({ label, value, unit }) { 37 | return { 38 | title: `${label}: ${[value, unit].filter(Boolean).join("")}`, 39 | }; 40 | }, 41 | }, 42 | fields: [ 43 | { 44 | name: "label", 45 | type: "string", 46 | title: "Label", 47 | }, 48 | { 49 | name: "value", 50 | type: "string", 51 | title: "Value", 52 | description: "Data will be stored as a string.", 53 | }, 54 | { 55 | name: "unit", 56 | type: "string", 57 | title: "unit", 58 | description: "%, $, miles, etc.", 59 | }, 60 | ], 61 | }, 62 | ], 63 | }, 64 | ], 65 | }; 66 | -------------------------------------------------------------------------------- /studio/schemas/sections/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Entering the section types here will automatically add them to schema.ts 3 | * because it maps over all the exports in this file 4 | * 5 | * The sections will also be added to `sharedSections.ts` as object types in its sections array 6 | * 7 | * This means that the `default as NAME` need to be the same as the `name: "NAME"` 8 | */ 9 | 10 | export { default as articleSection } from "./articleSection"; 11 | export { default as textAndImageSection } from "./textAndImageSection"; 12 | export { default as questionAndAnswerCollectionSection } from "./questionAndAnswerCollectionSection"; 13 | export { default as speakersSection } from "./speakersSection"; 14 | export { default as sessionsSection } from "./sessionsSection"; 15 | export { default as venuesSection } from "./venuesSection"; 16 | export { default as sponsorsSection } from "./sponsorsSection"; 17 | export { default as sponsorshipsSection } from "./sponsorshipsSection"; 18 | export { default as ticketsSection } from "./ticketsSection"; 19 | export { default as formSection } from "./formSection"; 20 | export { default as graphSection } from "./graphSection"; 21 | export { default as programsSection } from "./programsSection"; 22 | -------------------------------------------------------------------------------- /studio/schemas/sections/programsSection.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | name: "programsSection", 3 | type: "object", 4 | title: "Programs section", 5 | fields: [ 6 | { 7 | name: "heading", 8 | type: "string", 9 | title: "Section heading", 10 | }, 11 | { 12 | name: "callToAction", 13 | type: "simpleCallToAction", 14 | title: "Call to action", 15 | }, 16 | { 17 | name: "type", 18 | type: "string", 19 | title: "Section type", 20 | description: "", 21 | options: { 22 | list: [ 23 | { title: "All programs", value: "all" }, 24 | { title: "Highlighted programs", value: "highlighted" }, 25 | { title: "No programs", value: "none" }, 26 | ], 27 | }, 28 | }, 29 | { 30 | name: "programs", 31 | type: "array", 32 | title: "programs", 33 | hidden: ({ parent }) => parent?.type !== "highlighted", 34 | of: [ 35 | { 36 | type: "reference", 37 | to: [{ type: "program" }], 38 | options: { 39 | // Just include programs that's part of an event, and that hasn't been selected already 40 | filter: ({ parent }) => ({ 41 | filter: "!(_id in $current)", 42 | params: { 43 | current: parent?.map(({ _ref }) => _ref), 44 | }, 45 | }), 46 | }, 47 | }, 48 | ], 49 | }, 50 | ], 51 | }; 52 | -------------------------------------------------------------------------------- /studio/schemas/sections/questionAndAnswerCollectionSection.ts: -------------------------------------------------------------------------------- 1 | import { HelpCircleIcon } from "@sanity/icons"; 2 | 3 | export default { 4 | name: "questionAndAnswerCollectionSection", 5 | title: "Question and Answer Collection", 6 | type: "object", 7 | icon: HelpCircleIcon, 8 | preview: { 9 | select: { 10 | title: "title", 11 | questions: "questions", 12 | }, 13 | prepare({ title, questions }) { 14 | return { 15 | title, 16 | subtitle: questions.length 17 | ? `${questions.length} questions` 18 | : undefined, 19 | }; 20 | }, 21 | }, 22 | fields: [ 23 | { 24 | name: "title", 25 | type: "string", 26 | title: "Title", 27 | description: "Optional title for the collection", 28 | }, 29 | { 30 | name: "questions", 31 | type: "array", 32 | title: "Questions", 33 | description: 34 | "We should put a disclaimer here about what to use this section for", 35 | of: [ 36 | { 37 | type: "questionAndAnswer", 38 | }, 39 | ], 40 | }, 41 | ], 42 | }; 43 | -------------------------------------------------------------------------------- /studio/schemas/sections/sessionsSection.ts: -------------------------------------------------------------------------------- 1 | const typeOptions = [ 2 | { title: "All sessions", value: "all" }, 3 | { title: "Highlighted sessions", value: "highlighted" }, 4 | { title: "No sessions", value: "none" }, 5 | ]; 6 | 7 | export default { 8 | name: "sessionsSection", 9 | type: "object", 10 | title: "Sessions section", 11 | preview: { 12 | select: { 13 | type: "type", 14 | }, 15 | prepare({ type }) { 16 | return { 17 | title: typeOptions.find(({ value }) => value === type)?.title, 18 | subtitle: "Sessions section", 19 | }; 20 | }, 21 | }, 22 | fields: [ 23 | { 24 | name: "heading", 25 | type: "string", 26 | title: "Ticket table heading", 27 | }, 28 | { 29 | name: "callToAction", 30 | type: "simpleCallToAction", 31 | title: "Call to action", 32 | }, 33 | { 34 | name: "type", 35 | type: "string", 36 | title: "Section type", 37 | description: "", 38 | options: { 39 | list: typeOptions, 40 | }, 41 | }, 42 | { 43 | name: "sessions", 44 | type: "array", 45 | title: "Sessions list", 46 | hidden: ({ parent }) => parent?.type !== "highlighted", 47 | of: [ 48 | { 49 | type: "reference", 50 | to: [{ type: "session" }], 51 | options: { 52 | // Just include people that's part of a session, and that hasn't been selected already 53 | filter: ({ parent }) => ({ 54 | filter: "events != null && !(_id in $current)", 55 | params: { 56 | current: parent?.map(({ _ref }) => _ref), 57 | }, 58 | }), 59 | }, 60 | }, 61 | ], 62 | }, 63 | ], 64 | }; 65 | -------------------------------------------------------------------------------- /studio/schemas/sections/speakersSection.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | name: "speakersSection", 3 | type: "object", 4 | title: "Speakers section", 5 | fields: [ 6 | { 7 | name: "heading", 8 | type: "string", 9 | title: "Ticket table heading", 10 | }, 11 | { 12 | name: "callToAction", 13 | type: "simpleCallToAction", 14 | title: "Call to action", 15 | }, 16 | { 17 | name: "type", 18 | type: "string", 19 | title: "Section type", 20 | description: "", 21 | options: { 22 | list: [ 23 | { title: "All speakers", value: "all" }, 24 | { title: "Highlighted speakers", value: "highlighted" }, 25 | { title: "No speakers", value: "none" }, 26 | ], 27 | }, 28 | }, 29 | { 30 | name: "speakers", 31 | type: "array", 32 | title: "Speakers", 33 | hidden: ({ parent }) => parent?.type !== "highlighted", 34 | of: [ 35 | { 36 | type: "reference", 37 | to: [{ type: "person" }], 38 | options: { 39 | // Just include people that's part of a session, and that hasn't been selected already 40 | filter: ({ parent }) => ({ 41 | filter: "!(_id in $current)", 42 | params: { 43 | current: parent?.map(({ _ref }) => _ref), 44 | }, 45 | }), 46 | }, 47 | }, 48 | ], 49 | }, 50 | ], 51 | }; 52 | -------------------------------------------------------------------------------- /studio/schemas/sections/sponsorsSection.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | name: "sponsorsSection", 3 | type: "object", 4 | title: "Sponsors section", 5 | fields: [ 6 | { 7 | name: "heading", 8 | type: "string", 9 | title: "Ticket table heading", 10 | }, 11 | { 12 | name: "callToAction", 13 | type: "simpleCallToAction", 14 | title: "Call to action", 15 | }, 16 | { 17 | name: "type", 18 | type: "string", 19 | title: "Section type", 20 | description: "", 21 | options: { 22 | list: [ 23 | { title: "All sponsors", value: "all" }, 24 | { title: "Highlighted sponsors", value: "highlighted" }, 25 | { title: "No sponsors", value: "none" }, 26 | ], 27 | }, 28 | }, 29 | { 30 | name: "sponsors", 31 | type: "array", 32 | title: "sponsor", 33 | hidden: ({ parent }) => parent?.type !== "highlighted", 34 | of: [ 35 | { 36 | type: "reference", 37 | to: [{ type: "sponsor" }], 38 | options: { 39 | // Just include people that's part of a session, and that hasn't been selected already 40 | filter: ({ parent }) => ({ 41 | filter: "!(_id in $current)", 42 | params: { 43 | current: parent?.map(({ _ref }) => _ref), 44 | }, 45 | }), 46 | }, 47 | }, 48 | ], 49 | }, 50 | ], 51 | preview: { 52 | select: { 53 | title: "heading", 54 | subtitle: "type", 55 | }, 56 | }, 57 | }; 58 | -------------------------------------------------------------------------------- /studio/schemas/sections/sponsorshipsSection.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | name: "sponsorshipsSection", 3 | type: "object", 4 | title: "Sponsorships section", 5 | fields: [ 6 | { 7 | name: "heading", 8 | type: "string", 9 | title: "Ticket table heading", 10 | }, 11 | { 12 | name: "callToAction", 13 | type: "simpleCallToAction", 14 | title: "Call to action", 15 | }, 16 | { 17 | name: "type", 18 | type: "string", 19 | title: "Section type", 20 | description: "", 21 | options: { 22 | list: [ 23 | { title: "All sponsorships", value: "all" }, 24 | { title: "Highlighted sponsorships", value: "highlighted" }, 25 | { title: "No sponsorships", value: "none" }, 26 | ], 27 | }, 28 | }, 29 | { 30 | name: "sponsorships", 31 | type: "array", 32 | title: "Sponsorhips", 33 | hidden: ({ parent }) => parent?.type !== "highlighted", 34 | of: [ 35 | { 36 | type: "reference", 37 | to: [{ type: "sponsorship" }], 38 | options: { 39 | // Just include people that's part of a session, and that hasn't been selected already 40 | filter: ({ parent }) => ({ 41 | filter: 42 | '_id in *[_type == "event"].sponsorships[]._ref && !(_id in $current)', 43 | params: { 44 | current: parent?.map(({ _ref }) => _ref), 45 | }, 46 | }), 47 | }, 48 | }, 49 | ], 50 | }, 51 | ], 52 | }; 53 | -------------------------------------------------------------------------------- /studio/schemas/sections/textAndImageSection.ts: -------------------------------------------------------------------------------- 1 | import { toPlainText } from "@portabletext/react"; 2 | export default { 3 | name: "textAndImageSection", 4 | title: "Text and Image", 5 | type: "object", 6 | preview: { 7 | select: { 8 | title: "title", 9 | subtitle: "subtitle", 10 | media: "image", 11 | text: "text", 12 | }, 13 | prepare({ title, subtitle, text = [], media }) { 14 | return { 15 | title: title || subtitle || toPlainText(text), 16 | subtitle: "Text and image", 17 | media, 18 | }; 19 | }, 20 | }, 21 | fields: [ 22 | { 23 | name: "title", 24 | type: "string", 25 | }, 26 | { 27 | name: "tagline", 28 | type: "string", 29 | }, 30 | { 31 | name: "text", 32 | type: "simpleBlockContent", 33 | }, 34 | { 35 | name: "image", 36 | type: "figure", 37 | }, 38 | ], 39 | }; 40 | -------------------------------------------------------------------------------- /studio/schemas/sections/ticketsSection.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | name: "ticketsSection", 3 | type: "object", 4 | title: "Tickets section", 5 | fields: [ 6 | { 7 | name: "heading", 8 | type: "string", 9 | title: "Ticket table heading", 10 | }, 11 | { 12 | name: "callToAction", 13 | type: "simpleCallToAction", 14 | title: "Call to action", 15 | }, 16 | { 17 | name: "type", 18 | type: "string", 19 | title: "Section type", 20 | description: "", 21 | options: { 22 | list: [ 23 | { title: "All tickets", value: "all" }, 24 | { title: "Highlighted tickets", value: "highlighted" }, 25 | { title: "No tickets", value: "none" }, 26 | ], 27 | }, 28 | }, 29 | { 30 | name: "tickets", 31 | type: "array", 32 | title: "Tickets", 33 | hidden: ({ parent }) => parent?.type !== "highlighted", 34 | of: [ 35 | { 36 | type: "reference", 37 | to: [{ type: "ticket" }], 38 | options: { 39 | // Just include people that's part of a session, and that hasn't been selected already 40 | filter: ({ parent }) => ({ 41 | filter: 42 | '_id in *[_type == "event"].tickets[]._ref && !(_id in $current)', 43 | params: { 44 | current: parent?.map(({ _ref }) => _ref), 45 | }, 46 | }), 47 | }, 48 | }, 49 | ], 50 | }, 51 | ], 52 | }; 53 | -------------------------------------------------------------------------------- /studio/schemas/sections/venuesSection.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | name: "venuesSection", 3 | type: "object", 4 | title: "Venues section", 5 | fields: [ 6 | { 7 | name: "heading", 8 | type: "string", 9 | title: "Ticket table heading", 10 | }, 11 | { 12 | name: "lead", 13 | type: "simpleBlockContent", 14 | title: "Lead", 15 | }, 16 | { 17 | name: "callToAction", 18 | type: "simpleCallToAction", 19 | title: "Call to action", 20 | }, 21 | { 22 | name: "type", 23 | type: "string", 24 | title: "Section type", 25 | description: "", 26 | options: { 27 | list: [ 28 | { title: "All venues", value: "all" }, 29 | { title: "Highlighted venues", value: "highlighted" }, 30 | { title: "No venues", value: "none" }, 31 | ], 32 | }, 33 | }, 34 | { 35 | name: "venues", 36 | type: "array", 37 | title: "Venues", 38 | description: 39 | "Venues have to be listed in an event document to show up here", 40 | hidden: ({ parent }) => parent?.type !== "highlighted", 41 | of: [ 42 | { 43 | type: "reference", 44 | to: [{ type: "venue" }], 45 | options: { 46 | // Just include venues that's part of an event, and that hasn't been selected already 47 | filter: ({ parent }) => ({ 48 | filter: 49 | '_id in *[_type == "event"].venues[]._ref && !(_id in $current)', 50 | params: { 51 | current: parent?.map(({ _ref }) => _ref), 52 | }, 53 | }), 54 | }, 55 | }, 56 | ], 57 | }, 58 | ], 59 | }; 60 | -------------------------------------------------------------------------------- /studio/src/SlotPreview.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { withDocument } from "part:@sanity/form-builder"; 3 | 4 | function SlotPreview(props) { 5 | return
{JSON.stringify(props, null, 2)}
; 6 | } 7 | 8 | export default withDocument(SlotPreview); 9 | -------------------------------------------------------------------------------- /studio/src/resolveProductionUrl.ts: -------------------------------------------------------------------------------- 1 | import { getEnvironment, rootURLs } from "./urlResolver"; 2 | 3 | export default function resolveProductionUrl(doc) { 4 | if (!["route", "page"].includes(doc._type)) { 5 | return null; 6 | } 7 | 8 | const previewUrl = new URL(rootURLs[getEnvironment()].web); 9 | previewUrl.pathname = `/api/preview`; 10 | previewUrl.searchParams.append( 11 | `secret`, 12 | process.env.SANITY_STUDIO_PREVIEW_SECRET 13 | ); 14 | previewUrl.searchParams.append(`slug`, doc?.slug?.current ?? `/`); 15 | 16 | return previewUrl.toString(); 17 | } 18 | -------------------------------------------------------------------------------- /studio/src/setMissingField.ts: -------------------------------------------------------------------------------- 1 | import sanityClient from "part:@sanity/base/client"; 2 | 3 | const versionedClient = sanityClient.withConfig({ 4 | apiVersion: "2021-03-15", 5 | }); 6 | 7 | /** 8 | * 9 | * Sets the field to the provided value, only if it is missing for the provided type. 10 | * 11 | * ## Usage 12 | * Simply put this in a schema-file: then it will run during studio start. 13 | * It is idempotent, so rerunning it should be safe (when the path has value, nothing is done). 14 | * 15 | * `setMissingField('my-type', 'path.to.field', 'value-to-set').catch(console.error);` 16 | * 17 | * ## Caveates 18 | * For this to work, parent object must already exist for nested objects. 19 | * 20 | * @param type document type name 21 | * @param path jsonpath, eg: some.nested.field 22 | * @param value the value to setIfMissing for the field 23 | */ 24 | export async function setMissingField( 25 | type: string, 26 | path: string, 27 | value: unknown 28 | ) { 29 | const transaction = versionedClient.transaction(); 30 | const ids: string[] = await versionedClient.fetch( 31 | `* [_type=="${type}" && !defined(${path})]._id` 32 | ); 33 | 34 | console.log(`patching path ${path} for ids`, ids); 35 | 36 | ids.forEach((id) => { 37 | const patch = versionedClient.patch(id).setIfMissing({ 38 | [path]: value, 39 | }); 40 | transaction.patch(patch); 41 | }); 42 | return transaction.commit(); 43 | } 44 | -------------------------------------------------------------------------------- /studio/src/spec.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import S from "@sanity/desk-tool/structure-builder"; 3 | import { IntentLink } from "part:@sanity/base/router"; 4 | import client from "part:@sanity/base/client"; 5 | import { Card, Container, Flex, Stack, Text } from "@sanity/ui"; 6 | 7 | export const SpecPreview = (props) => { 8 | const { document } = props; 9 | const { published } = document; 10 | if (!published) { 11 | return ( 12 |
13 |

No content found.

14 |
15 | ); 16 | } 17 | const [doc, setDoc] = React.useState({}); 18 | React.useEffect(() => { 19 | const subscriber = client.observable 20 | .fetch(`*[_type == "spec" && references.route._ref == $route][0]`, { 21 | route: published._id, 22 | }) 23 | .subscribe((doc) => setDoc(doc)); 24 | return () => subscriber.unsubscribe(); 25 | }, [props]); 26 | 27 | const { audience, topTask, businessGoal, fields } = doc; 28 | return ( 29 | 30 | 31 | 32 | 33 | Primary audience: {audience} 34 | 35 | 36 | Top tasks 37 | 38 | {topTask && ( 39 | 40 | {topTask.map((task) => ( 41 | {task} 42 | ))} 43 | 44 | )} 45 | 46 | Business goal: {businessGoal} 47 | 48 | 49 | Fields 50 | {fields && 51 | fields.map((field) => ( 52 | 53 | 54 | 55 | 56 | {field?.type} 57 | 58 | 59 | {field?.field} 60 | 61 | 62 | {field?.validation} 63 | 64 | 65 | 66 | 67 | {field?.sample} 68 | 69 | 70 | 71 | 72 | ))} 73 | 74 |
{JSON.stringify(doc, null, 2)}
75 |
76 |
77 |
78 | ); 79 | }; 80 | 81 | export default S.view.component(SpecPreview).title("Content Spec Sheet"); 82 | -------------------------------------------------------------------------------- /studio/src/urlResolver.ts: -------------------------------------------------------------------------------- 1 | export const rootURLs = { 2 | production: { 3 | studio: "https://admin.structuredcontent.live", 4 | web: "https://structuredcontent.live", 5 | }, 6 | staging: { 7 | studio: "https://stagingadmin.structuredcontent.live/", 8 | web: "https://structured-content-2022.sanity.build/", 9 | }, 10 | development: { 11 | studio: "http://localhost:3333", 12 | web: "http://localhost:3000", 13 | }, 14 | }; 15 | 16 | export const paths = { 17 | venue: "venues", 18 | person: "speakers", 19 | session: "sessions", 20 | sponsor: "sponsors", 21 | sponsorship: "sponsorships", 22 | ticket: "tickets", 23 | article: "articles", 24 | event: "", 25 | }; 26 | 27 | export const getEnvironment = () => { 28 | const location = window.location.href; 29 | if (location.includes("localhost")) { 30 | return "development"; 31 | } 32 | if (location.includes("build")) { 33 | return "staging"; 34 | } 35 | if (location.includes("stagingadmin")) { 36 | return "staging"; 37 | } 38 | if (location.includes("live")) { 39 | return "production"; 40 | } 41 | }; 42 | 43 | /** 44 | * 45 | * @param root The root of the URL, e.g. rootURLs.production.web 46 | * @param type The documnt type, e.g. "venue" 47 | * @returns The URL based on the document type and environment 48 | */ 49 | export const getPath = (root: string, type: string): string => { 50 | return `${root}${paths[type] ? `/${paths[type]}` : ""}`; 51 | }; 52 | 53 | export const getPreviewUrl = (type: string, slug: string = ""): string => { 54 | const environment = getEnvironment(); 55 | const root = rootURLs[environment].web; 56 | return getPath(root, type) + "/" + slug; 57 | }; 58 | -------------------------------------------------------------------------------- /studio/static/.gitkeep: -------------------------------------------------------------------------------- 1 | Files placed here will be served by the Sanity server under the `/static`-prefix 2 | -------------------------------------------------------------------------------- /studio/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sanity-io/structured-content-2022/80468e8524c70be3ad3d54c5afcb4d4bfa167f1d/studio/static/favicon.ico -------------------------------------------------------------------------------- /studio/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // Note: This config is only used to help editors like VS Code understand/resolve 3 | // parts, the actual transpilation is done by babel. Any compiler configuration in 4 | // here will be ignored. 5 | "include": ["./node_modules/@sanity/base/types/**/*.ts", "./**/*.ts", "./**/*.tsx"] 6 | } 7 | -------------------------------------------------------------------------------- /studio/vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 2, 3 | "routes": [ 4 | { "handle": "filesystem" }, 5 | { "src": "/.*", "dest": "/index.html" } 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /web/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /web/.prettierignore: -------------------------------------------------------------------------------- 1 | .next 2 | -------------------------------------------------------------------------------- /web/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:14 2 | 3 | RUN mkdir /app 4 | ADD . /app 5 | 6 | WORKDIR /app 7 | RUN yarn 8 | 9 | EXPOSE 3000 10 | 11 | CMD ["yarn", "dev"] 12 | -------------------------------------------------------------------------------- /web/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Sanity 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 | -------------------------------------------------------------------------------- /web/README.md: -------------------------------------------------------------------------------- 1 | The website of [Structured Content 2022][sc2022] conference. 2 | 3 | ## Getting Started 4 | 5 | Install dependencies: 6 | 7 | ```bash 8 | npm i 9 | # or 10 | yarn 11 | ``` 12 | 13 | Run the development server: 14 | 15 | ```bash 16 | npm run dev 17 | # or 18 | yarn dev 19 | ``` 20 | 21 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 22 | 23 | ## Environment variables 24 | 25 | To override defaults, add a _.env_ file to this _/web_ directory. The file should not be committed to your repository. The 26 | following variables are supported: 27 | 28 | - SANITY_STUDIO_API_DATASET: The name of the dataset to use for the Sanity API. If not specified, `staging` will be used 29 | locally and for Live Previews, and `production` in production. 30 | 31 | ## Learn More 32 | 33 | To learn more about Next.js, take a look at the following resources: 34 | 35 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 36 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 37 | 38 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! 39 | 40 | ## Deploy on Vercel 41 | 42 | Will auto-deploy to production from the `main` branch. Pull Requests will also be deployed on their own staging URLs. 43 | 44 | [sc2022]: https://www.structuredcontent.live 45 | [env-vars]: https://vercel.com/sanity-io/structured-content-2022-web/settings/environment-variables 46 | -------------------------------------------------------------------------------- /web/__tests__/util/array.ts: -------------------------------------------------------------------------------- 1 | import { partition } from '../../util/array'; 2 | 3 | describe('partition', () => { 4 | it('should partition an array into two arrays', () => { 5 | const array = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; 6 | const { even, odd } = partition(array, (x) => 7 | x % 2 === 0 ? 'even' : 'odd' 8 | ); 9 | expect(even).toEqual([2, 4, 6, 8, 10]); 10 | expect(odd).toEqual([1, 3, 5, 7, 9]); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /web/components/Accordion/Accordion.module.css: -------------------------------------------------------------------------------- 1 | .accordion { 2 | display: block; 3 | width: 100%; 4 | 5 | background-color: var(--color-ui-gray-100); 6 | background-image: url('../../images/plus.svg'); 7 | background-repeat: no-repeat; 8 | background-position: right 32px center; 9 | border-radius: 12px; 10 | cursor: pointer; 11 | padding: 32px 88px 32px 32px; 12 | margin: 8px 0; 13 | 14 | text-align: left; 15 | border: none; 16 | } 17 | 18 | .sectionHeading { 19 | font: var(--font-display-xxs); 20 | margin: 0; 21 | } 22 | 23 | .accordion:hover { 24 | background-color: var(--color-ui-gray-300); 25 | } 26 | 27 | .accordion.active { 28 | background-image: url('../../images/minus.svg'); 29 | background-color: var(--color-brand-yellow-light); 30 | margin-bottom: 0; 31 | border-bottom-left-radius: 0; 32 | border-bottom-right-radius: 0; 33 | } 34 | 35 | .panel { 36 | background-color: var(--color-brand-yellow-light); 37 | padding: 1px 32px 32px; /* 1px is just to contain any top margin */ 38 | border-radius: 0 0 12px 12px; 39 | display: none; 40 | } 41 | 42 | .panel.open { 43 | display: block; 44 | } 45 | 46 | @media (--medium-up) { 47 | .sectionHeading { 48 | font: var(--font-display-xs); 49 | } 50 | 51 | .sectionHeading, 52 | .panel { 53 | margin-left: -52px; 54 | margin-right: -52px; 55 | } 56 | 57 | .accordion, 58 | .panel { 59 | padding-left: 52px; 60 | padding-right: 52px; 61 | padding-bottom: 48px; 62 | } 63 | 64 | .accordion { 65 | padding-top: 48px; 66 | padding-right: 108px; 67 | background-position: right 52px center; 68 | } 69 | } 70 | 71 | @media (--large-up) { 72 | .sectionHeading, 73 | .panel { 74 | margin-left: -64px; 75 | margin-right: -64px; 76 | } 77 | 78 | .accordion, 79 | .panel { 80 | padding-left: 64px; 81 | padding-right: 64px; 82 | } 83 | 84 | .accordion { 85 | padding-right: 120px; 86 | background-position: right 64px center; 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /web/components/Accordion/Accordion.tsx: -------------------------------------------------------------------------------- 1 | import { useState, ReactNode } from 'react'; 2 | import clsx from 'clsx'; 3 | import styles from './Accordion.module.css'; 4 | 5 | interface AccordionProps { 6 | baseId: string; 7 | items: { 8 | title: string; 9 | content: ReactNode | ReactNode[]; 10 | }[]; 11 | } 12 | 13 | interface AccordionSectionProps { 14 | title: string; 15 | content: ReactNode | ReactNode[]; 16 | baseId: string; 17 | } 18 | 19 | const AccordionSection = ({ 20 | title, 21 | content, 22 | baseId, 23 | }: AccordionSectionProps) => { 24 | const [open, setOpen] = useState(false); 25 | const panelId = `${baseId}-panel`; 26 | 27 | return ( 28 |
29 |

30 | 38 |

39 |
40 | {content} 41 |
42 |
43 | ); 44 | }; 45 | 46 | export const Accordion = ({ baseId, items }: AccordionProps) => ( 47 | <> 48 | {items.map(({ title, content }, index) => ( 49 | 55 | ))} 56 | 57 | ); 58 | -------------------------------------------------------------------------------- /web/components/Accordion/index.ts: -------------------------------------------------------------------------------- 1 | export { Accordion as default } from './Accordion'; 2 | -------------------------------------------------------------------------------- /web/components/ButtonLink/ButtonLink.module.css: -------------------------------------------------------------------------------- 1 | .button { 2 | color: var(--color-brand-black); 3 | text-decoration: none; 4 | font: var(--font-ui-sm-medium); 5 | letter-spacing: var(--letter-spacing-ui-sm); 6 | background: var(--color-brand-orange); 7 | padding: 9px 16px; 8 | border-radius: 6px; 9 | display: inline-block; 10 | } 11 | 12 | .button:focus { 13 | outline: none; 14 | box-shadow: 0 0 0 2px var(--color-brand-black); 15 | } 16 | 17 | .button:hover { 18 | background: var(--color-brand-yellow); 19 | } 20 | 21 | .button:active { 22 | background: var(--color-brand-yellow-light); 23 | } 24 | 25 | .button::after { 26 | content: ' ->'; 27 | letter-spacing: normal; /* Chromium disables ligature otherwise */ 28 | } 29 | 30 | @media (--large-up) { 31 | .button { 32 | font: var(--font-body-base-medium); 33 | letter-spacing: var(--letter-spacing-body-base); 34 | padding: 12px 20px; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /web/components/ButtonLink/ButtonLink.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link'; 2 | import styles from './ButtonLink.module.css'; 3 | 4 | interface ButtonLinkProps { 5 | text: string; 6 | url: string; 7 | openInNewTab?: boolean; 8 | } 9 | 10 | export const ButtonLink = ({ text, url, openInNewTab }: ButtonLinkProps) => 11 | openInNewTab ? ( 12 | 13 | {text} 14 | 15 | ) : ( 16 | 17 | {text} 18 | 19 | ); 20 | -------------------------------------------------------------------------------- /web/components/ButtonLink/index.ts: -------------------------------------------------------------------------------- 1 | export { ButtonLink as default } from './ButtonLink'; 2 | -------------------------------------------------------------------------------- /web/components/Card/Card.module.css: -------------------------------------------------------------------------------- 1 | .card { 2 | border-radius: 4px; 3 | padding: 10px 14px; 4 | border: 1px solid var(--color-brand-black); 5 | margin: 2px; 6 | display: inline-flex; 7 | align-items: center; 8 | vertical-align: top; 9 | font: var(--font-ui-xs); 10 | letter-spacing: var(--letter-spacing-ui-xs); 11 | } 12 | 13 | .figure { 14 | margin: 0 14px 0 0; 15 | height: 28px; 16 | width: 28px; 17 | border-radius: 2px; 18 | } 19 | 20 | .link { 21 | text-decoration: none; 22 | display: inline-block; /* nicer focus outline */ 23 | } 24 | 25 | .link:hover .card { 26 | background-color: var(--color-brand-yellow); 27 | } 28 | -------------------------------------------------------------------------------- /web/components/Card/Card.tsx: -------------------------------------------------------------------------------- 1 | import type { HTMLProps, ReactNode } from 'react'; 2 | import Link from 'next/link'; 3 | import { imageUrlFor } from '../../lib/sanity'; 4 | import type { Figure } from '../../types/Figure'; 5 | import styles from './Card.module.css'; 6 | 7 | interface CardProps { 8 | children: ReactNode | ReactNode[]; 9 | figure?: Figure; 10 | linkProps?: HTMLProps; 11 | } 12 | 13 | export const Card = ({ children, figure, linkProps }: CardProps) => { 14 | const src = 15 | figure && 16 | imageUrlFor(figure).size(112, 112).fit('min').saturation(-100).url(); 17 | const Card = ( 18 |
19 | {src /* eslint-disable-next-line @next/next/no-img-element */ ? ( 20 | {figure.alt 27 | ) : null} 28 | {children} 29 |
30 | ); 31 | 32 | if (!linkProps) { 33 | return Card; 34 | } 35 | 36 | const { className, href, ...otherLinkProps } = linkProps; 37 | return href ? ( 38 | 39 | 40 | {Card} 41 | 42 | 43 | ) : ( 44 | Card 45 | ); 46 | }; 47 | -------------------------------------------------------------------------------- /web/components/Card/index.ts: -------------------------------------------------------------------------------- 1 | export { Card as default } from './Card'; 2 | -------------------------------------------------------------------------------- /web/components/ConferenceHeader/ConferenceHeader.module.css: -------------------------------------------------------------------------------- 1 | .root { 2 | margin: 24px 0 80px; 3 | } 4 | 5 | .logo { 6 | width: 100%; 7 | height: auto; 8 | } 9 | 10 | .summary { 11 | margin-top: 24px; 12 | font: var(--font-body-base-medium); 13 | letter-spacing: var(--letter-spacing-body-base); 14 | display: grid; 15 | row-gap: 24px; 16 | } 17 | 18 | .dates, 19 | .hostedBy, 20 | .description { 21 | margin: 0; 22 | } 23 | 24 | @media (--medium-up) { 25 | .root { 26 | margin: 32px 0 96px; 27 | } 28 | 29 | .summary { 30 | margin-top: 32px; 31 | column-gap: var(--grid-medium-gutter); 32 | grid-template-columns: repeat(2, minmax(0, 1fr)); 33 | } 34 | } 35 | 36 | @media (--large-up) { 37 | .root { 38 | margin: 40px 0 128px; 39 | } 40 | 41 | .summary { 42 | margin-top: 40px; 43 | column-gap: var(--grid-gutter); 44 | font: var(--font-body-xl-medium); 45 | letter-spacing: var(--letter-spacing-body-xl); 46 | } 47 | 48 | .datesAndHostedByWrapper { 49 | font: var(--font-ui-xl-medium); 50 | letter-spacing: var(--letter-spacing-ui-xl); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /web/components/ConferenceHeader/ConferenceHeader.tsx: -------------------------------------------------------------------------------- 1 | import logo from '../../images/logo.svg'; 2 | import styles from './ConferenceHeader.module.css'; 3 | 4 | interface ConferenceHeaderProps { 5 | name?: string; 6 | description?: string; 7 | } 8 | 9 | export const ConferenceHeader = ({ 10 | name, 11 | description, 12 | }: ConferenceHeaderProps) => { 13 | return ( 14 |
15 |

16 | {/* eslint-disable-next-line @next/next/no-img-element */} 17 | {name 24 |

25 |
26 |
27 |

US: May 24–25, 2022

28 |

UK & Europe: May 25–26, 2022

29 |

Hosted by Sanity

30 |
31 |

{description}

32 |
33 |
34 | ); 35 | }; 36 | -------------------------------------------------------------------------------- /web/components/ConferenceHeader/index.ts: -------------------------------------------------------------------------------- 1 | export { ConferenceHeader as default } from './ConferenceHeader'; 2 | -------------------------------------------------------------------------------- /web/components/ConferenceUpdatesForm/ConferenceUpdatesForm.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | text-align: center; 3 | padding: 48px 24px; 4 | background: var(--color-brand-yellow-light); 5 | margin: 80px 0; 6 | border-radius: 12px; 7 | } 8 | 9 | @media (prefers-reduced-motion: no-preference) { 10 | .container.isIntersecting .image, 11 | .container.isIntersecting .heading, 12 | .container.isIntersecting .form, 13 | .container.isIntersecting .emailParagraph { 14 | transform-origin: bottom; 15 | animation: var(--item-enter-animation); 16 | animation-delay: var(--delay); 17 | } 18 | } 19 | 20 | .image { 21 | display: none; 22 | } 23 | 24 | .heading { 25 | font: var(--font-display-sm); 26 | margin: 0 0 32px; 27 | } 28 | 29 | .formContents { 30 | position: relative; 31 | } 32 | 33 | .emailInput { 34 | border: none; 35 | /* Design sketch has left padding 24px but then the placeholder won't fit */ 36 | padding: 19.5px 56px 19.5px 10px; 37 | width: 100%; 38 | border-radius: 8px; 39 | } 40 | 41 | .submitButton { 42 | position: absolute; 43 | top: 8px; 44 | right: 8px; 45 | bottom: 8px; 46 | border: none; 47 | background: var(--color-brand-orange); 48 | border-radius: 4px; 49 | min-width: 48px; 50 | cursor: pointer; 51 | } 52 | 53 | .submitButton:hover { 54 | background: var(--color-brand-yellow); 55 | } 56 | 57 | .submitButton:active { 58 | background: var(--color-brand-yellow-light); 59 | } 60 | 61 | .emailParagraph { 62 | margin: 24px 0 0; 63 | font: var(--font-ui-sm-medium); 64 | letter-spacing: var(--letter-spacing-ui-sm); 65 | } 66 | 67 | @media (--small-up) { 68 | .emailInput { 69 | padding-left: 24px; 70 | } 71 | } 72 | 73 | @media (--medium-up) { 74 | .heading { 75 | font: var(--font-display-base); 76 | } 77 | 78 | .container { 79 | padding: 64px 48px; 80 | margin: 96px 0; 81 | } 82 | 83 | .formContents { 84 | /* Simplification due to time constraints. Sketch has it cover exactly 4 85 | * columns, but on desktop it doesn't match the grid columns anyway, so it 86 | * seems somewhat arbitrary. 87 | */ 88 | max-width: 19em; 89 | margin: 0 auto; 90 | } 91 | } 92 | 93 | @media (--large-up) { 94 | .heading { 95 | font: var(--font-display-lg); 96 | } 97 | 98 | .container { 99 | text-align: left; 100 | padding: 116px 0; 101 | margin: 128px 0; 102 | display: grid; 103 | grid-template-columns: repeat(12, minmax(0, 1fr)); 104 | align-items: center; 105 | } 106 | 107 | .image { 108 | display: block; 109 | grid-column: 2 / span 4; 110 | } 111 | 112 | .mainContents { 113 | grid-column: 7 / span 4; 114 | } 115 | 116 | .formContents { 117 | max-width: 100%; 118 | margin: 0; 119 | } 120 | } 121 | 122 | @keyframes item-enter { 123 | from { 124 | opacity: 0; 125 | transform: translateY(var(--distance)) rotate(var(--rotation)); 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /web/components/ConferenceUpdatesForm/index.ts: -------------------------------------------------------------------------------- 1 | export { ConferenceUpdatesForm as default } from './ConferenceUpdatesForm'; 2 | -------------------------------------------------------------------------------- /web/components/CookieConsent/CookieConsent.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | position: fixed; 3 | bottom: 16px; 4 | left: 0; 5 | right: 0; 6 | max-width: var(--grid-width); 7 | padding: 0 var(--grid-half-gutter); 8 | margin: 0 auto; 9 | font: var(--font-body-small-medium); 10 | letter-spacing: var(--letter-spacing-body-small); 11 | } 12 | 13 | .consentContainer { 14 | padding: 24px; 15 | display: flex; 16 | flex-direction: column; 17 | width: 100%; 18 | background-color: var(--color-brand-yellow-light); 19 | border-radius: 8px; 20 | box-shadow: 0 6px 0 rgba(0, 0, 0, 0.1); 21 | } 22 | 23 | .link { 24 | white-space: nowrap; 25 | } 26 | 27 | .link::after { 28 | content: ' ->'; 29 | letter-spacing: normal; /* to enable -> ligature in Chrome */ 30 | } 31 | 32 | .buttonWrapper { 33 | display: flex; 34 | margin-top: 1rem; 35 | } 36 | 37 | .declineButton { 38 | padding: 0.5rem 1rem; 39 | border: 1px solid black; 40 | border-radius: 6px; 41 | flex: 1 1 auto; 42 | cursor: pointer; 43 | background: transparent; 44 | color: inherit; 45 | font-size: 100%; 46 | } 47 | 48 | .declineButton:hover { 49 | background-color: var(--color-brand-yellow); 50 | } 51 | 52 | .consentButton { 53 | color: rgb(62, 30, 37); 54 | padding: 0.5rem 1rem; 55 | background-color: var(--color-brand-orange); 56 | border: none; 57 | border-radius: 6px; 58 | cursor: pointer; 59 | margin-left: 1rem; 60 | flex: 1 1 0; 61 | } 62 | 63 | .consentButton:hover { 64 | background-color: var(--color-brand-yellow); 65 | } 66 | 67 | .consentButton:active { 68 | background-color: var(--color-brand-yellow-light); 69 | } 70 | 71 | @media (--medium-up) { 72 | .container { 73 | padding: 0 var(--grid-medium-gutter); 74 | } 75 | 76 | .consentContainer { 77 | justify-content: space-between; 78 | align-items: center; 79 | flex-direction: row; 80 | } 81 | 82 | .consentContent { 83 | max-width: var(--max-body-text-width); 84 | margin-right: 24px; 85 | } 86 | 87 | .buttonWrapper { 88 | margin-top: 0; 89 | flex: none; 90 | } 91 | 92 | .consentButton { 93 | padding-left: 1.5rem; 94 | padding-right: 1.5rem; 95 | flex: 1 1 auto; 96 | } 97 | } 98 | 99 | @media (--large-up) { 100 | .container { 101 | padding: 0 var(--grid-gutter); 102 | border-radius: 12px; 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /web/components/CookieConsent/CookieConsent.tsx: -------------------------------------------------------------------------------- 1 | import ReactCookieConsent from 'react-cookie-consent'; 2 | import styles from './CookieConsent.module.css'; 3 | 4 | export const CookieConsent = () => ( 5 |
6 | 17 | We use cookies to see how you use our website and to show you related ads 18 | later.{' '} 19 | 25 | Learn more 26 | 27 | 28 |
29 | ); 30 | -------------------------------------------------------------------------------- /web/components/CookieConsent/index.ts: -------------------------------------------------------------------------------- 1 | export { CookieConsent as default } from './CookieConsent'; 2 | -------------------------------------------------------------------------------- /web/components/FeatureCheckmark/FeatureCheckmark.module.css: -------------------------------------------------------------------------------- 1 | .icon { 2 | vertical-align: top; 3 | } 4 | -------------------------------------------------------------------------------- /web/components/FeatureCheckmark/FeatureCheckmark.tsx: -------------------------------------------------------------------------------- 1 | import checkmarkIcon from '../../images/checkmark.svg'; 2 | import crossIcon from '../../images/cross.svg'; 3 | import styles from './FeatureCheckmark.module.css'; 4 | 5 | interface FeatureCheckmarkProps { 6 | included?: boolean; 7 | hideAltText?: boolean; 8 | } 9 | 10 | /* eslint-disable @next/next/no-img-element */ 11 | export const FeatureCheckmark = ({ 12 | included, 13 | hideAltText, 14 | }: FeatureCheckmarkProps) => 15 | included ? ( 16 | {hideAltText 23 | ) : ( 24 | {hideAltText 31 | ); 32 | /* eslint-enable @next/next/no-img-element */ 33 | -------------------------------------------------------------------------------- /web/components/FeatureCheckmark/index.ts: -------------------------------------------------------------------------------- 1 | export { FeatureCheckmark as default } from './FeatureCheckmark'; 2 | -------------------------------------------------------------------------------- /web/components/FeatureSection/FeatureSection.module.css: -------------------------------------------------------------------------------- 1 | .header { 2 | background-color: var(--color-brand-yellow); 3 | border-radius: 8px 8px 0 0; 4 | padding: 24px; 5 | } 6 | 7 | .features { 8 | list-style-type: none; 9 | padding: 24px; 10 | border: 1px solid var(--color-brand-yellow); 11 | border-bottom-left-radius: 8px; 12 | border-bottom-right-radius: 8px; 13 | margin-bottom: 8px; 14 | } 15 | 16 | .feature { 17 | margin-left: 36px; 18 | text-indent: -36px; 19 | } 20 | 21 | .feature:not(:last-child) { 22 | padding-bottom: 16px; 23 | } 24 | 25 | .featureDescription { 26 | padding-left: 12px; 27 | } 28 | -------------------------------------------------------------------------------- /web/components/FeatureSection/FeatureSection.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react'; 2 | import FeatureCheckmark from '../FeatureCheckmark'; 3 | import styles from './FeatureSection.module.css'; 4 | 5 | interface FeatureSection { 6 | features?: string[]; 7 | children: ReactNode | ReactNode[]; 8 | } 9 | 10 | export const FeatureSection = ({ children, features }: FeatureSection) => ( 11 |
12 |
{children}
13 |
    14 | {features?.map((feature) => ( 15 |
  • 16 | 17 | {feature} 18 |
  • 19 | ))} 20 |
21 |
22 | ); 23 | -------------------------------------------------------------------------------- /web/components/FeatureSection/index.ts: -------------------------------------------------------------------------------- 1 | export { FeatureSection as default } from './FeatureSection'; 2 | -------------------------------------------------------------------------------- /web/components/Footer/Footer.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | background: var(--color-brand-black); 3 | color: var(--color-brand-white); 4 | text-align: center; 5 | padding: var(--spacing-medium) 0 var(--spacing-large); 6 | font: var(--font-ui-sm-medium); 7 | letter-spacing: var(--letter-spacing-ui-sm); 8 | } 9 | 10 | .logoContainer { 11 | margin-bottom: 20px; 12 | } 13 | 14 | .logoLink { 15 | display: inline-block; /* nicer focus outline */ 16 | } 17 | 18 | .logoLink:focus-visible, 19 | .mailLink:focus-visible, 20 | .socialLink:focus-visible, 21 | .linksItemLink:focus-visible { 22 | outline-color: var(--color-brand-white); 23 | } 24 | 25 | .mailLink { 26 | text-decoration: none; 27 | color: var(--color-ui-gray-600); 28 | } 29 | 30 | .mailLink:hover { 31 | text-decoration: underline; 32 | } 33 | 34 | .social { 35 | margin: 38px 0; 36 | padding: 0; 37 | text-align: center; 38 | } 39 | 40 | .socialItem { 41 | padding: 0 12px; 42 | } 43 | 44 | /* Including the link itself here to make it contain the image, for a better 45 | * focus outline 46 | */ 47 | .socialItem, 48 | .socialLink { 49 | display: inline-block; 50 | line-height: 1; 51 | } 52 | 53 | .separator { 54 | border-top: 1px solid; 55 | border-color: var( 56 | --color-ui-gray-900 57 | ); /* Chrome doesn't set border color here if using shorthand property */ 58 | width: 100%; 59 | margin: 0; 60 | padding: 0; 61 | box-sizing: border-box; 62 | } 63 | 64 | .links { 65 | list-style: none; 66 | margin: 20px -12px 0; 67 | padding: 0; 68 | text-align: center; 69 | } 70 | 71 | .linksItem { 72 | display: inline-block; 73 | padding: 12px 12px 0; 74 | } 75 | 76 | .linksItemLink { 77 | color: var(--color-ui-gray-600); 78 | text-decoration: none; 79 | } 80 | 81 | .linksItemLink:hover { 82 | text-decoration: underline; 83 | } 84 | 85 | @media (--medium-up) { 86 | .container { 87 | padding-bottom: 32px; 88 | } 89 | 90 | .social { 91 | margin-bottom: 70px; 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /web/components/Footer/index.ts: -------------------------------------------------------------------------------- 1 | export { Footer as default } from './Footer'; 2 | -------------------------------------------------------------------------------- /web/components/GridWrapper/GridWrapper.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | max-width: var(--grid-width); 3 | padding: 0 var(--grid-half-gutter); 4 | margin: 0 auto; 5 | } 6 | 7 | @media (--medium-up) { 8 | .container { 9 | padding: 0 var(--grid-medium-gutter); 10 | } 11 | } 12 | 13 | @media (--large-up) { 14 | .container { 15 | padding: 0 var(--grid-gutter); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /web/components/GridWrapper/GridWrapper.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react'; 2 | import styles from './GridWrapper.module.css'; 3 | 4 | interface GridWrapperProps { 5 | children: ReactNode; 6 | } 7 | 8 | export const GridWrapper = ({ children }: GridWrapperProps) => ( 9 |
{children}
10 | ); 11 | -------------------------------------------------------------------------------- /web/components/GridWrapper/index.ts: -------------------------------------------------------------------------------- 1 | export { GridWrapper as default } from './GridWrapper'; 2 | -------------------------------------------------------------------------------- /web/components/Heading/Heading.module.css: -------------------------------------------------------------------------------- 1 | .heading { 2 | margin: 0; 3 | } 4 | 5 | .h2 { 6 | font: var(--font-display-xs); 7 | margin-bottom: 18px; 8 | } 9 | 10 | .h3 { 11 | /* Heading hierarchy in sketches seems a bit unclear, this is currently the 12 | * same as the h2... 13 | */ 14 | font: var(--font-display-xs); 15 | } 16 | 17 | @media (--medium-up) { 18 | .h2 { 19 | font: var(--font-display-base); 20 | margin-bottom: 30px; 21 | } 22 | 23 | .h3 { 24 | font: var(--font-display-sm); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /web/components/Heading/Heading.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx'; 2 | import { HTMLAttributes } from 'react'; 3 | import styles from './Heading.module.css'; 4 | 5 | interface HeadingProps extends HTMLAttributes { 6 | type?: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'; 7 | } 8 | 9 | const headingsMap = { 10 | h1: (props: HTMLAttributes) =>

, 11 | h2: (props: HTMLAttributes) =>

, 12 | h3: (props: HTMLAttributes) =>

, 13 | h4: (props: HTMLAttributes) =>

, 14 | h5: (props: HTMLAttributes) =>
, 15 | h6: (props: HTMLAttributes) =>
, 16 | }; 17 | 18 | export const Heading = ({ className, type = 'h1', ...props }: HeadingProps) => { 19 | const Heading = headingsMap[type]; 20 | return ( 21 | 25 | ); 26 | }; 27 | -------------------------------------------------------------------------------- /web/components/Heading/index.ts: -------------------------------------------------------------------------------- 1 | export { Heading as default } from './Heading'; 2 | -------------------------------------------------------------------------------- /web/components/Hero/Hero.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | background: var(--color-brand-yellow-light); 3 | text-align: center; 4 | padding: 40px 0 80px; 5 | } 6 | 7 | @media (prefers-reduced-motion: no-preference) { 8 | .container.isIntersecting .heading, 9 | .container.isIntersecting .summary, 10 | .container.isIntersecting .cta { 11 | transform-origin: bottom; 12 | animation: var(--item-enter-animation); 13 | animation-delay: var(--delay); 14 | } 15 | } 16 | 17 | .summary { 18 | font: var(--font-body-small-medium); 19 | letter-spacing: var(--letter-spacing-body-small); 20 | margin: 32px auto 0 auto; 21 | max-width: var(--max-body-text-width); 22 | } 23 | 24 | .cta { 25 | margin-top: 32px; 26 | } 27 | 28 | @media (--medium-up) { 29 | .container { 30 | padding: 96px 0; 31 | } 32 | 33 | .summary { 34 | font: var(--font-body-base-medium); 35 | letter-spacing: var(--letter-spacing-body-base); 36 | margin-top: 40px; 37 | } 38 | 39 | .cta { 40 | margin-top: 40px; 41 | } 42 | } 43 | 44 | @media (--large-up) { 45 | .container { 46 | padding: 128px 0; 47 | } 48 | 49 | .headingContainer { 50 | display: grid; 51 | grid-template-columns: repeat(12, minmax(0, 1fr)); 52 | column-gap: var(--grid-gutter); 53 | } 54 | 55 | .heading, 56 | .cta { 57 | grid-column: 3 / span 8; 58 | } 59 | 60 | .summary { 61 | grid-column: 4 / span 6; 62 | max-width: none; 63 | } 64 | } 65 | 66 | @keyframes item-enter { 67 | from { 68 | opacity: 0; 69 | transform: translateY(var(--distance)) rotate(var(--rotation)); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /web/components/Hero/Hero.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx'; 2 | import type { ReactNode } from 'react'; 3 | import { useRef } from 'react'; 4 | import { Hero as HeroProps } from '../../types/Hero'; 5 | import useIntersection from '../../hooks/useIntersection'; 6 | import { useAnimationProperties } from '../../hooks/useAnimationProperties'; 7 | import GridWrapper from '../GridWrapper'; 8 | import SimpleCallToAction from '../SimpleCallToAction'; 9 | import styles from './Hero.module.css'; 10 | 11 | export const Hero = ({ 12 | heading, 13 | summary, 14 | callToAction, 15 | children, 16 | }: HeroProps & { children?: ReactNode }) => { 17 | const wrapperRef = useRef(null); 18 | const isIntersecting = useIntersection(wrapperRef, '-80px 0px'); 19 | const headingAnimation = useAnimationProperties(); 20 | const summaryAnimation = useAnimationProperties(); 21 | const ctaAnimation = useAnimationProperties(); 22 | 23 | return ( 24 |
31 | 32 |
33 |

34 | {heading} 35 |

36 | {summary && ( 37 |

38 | {summary} 39 |

40 | )} 41 | {callToAction && ( 42 |
43 | 44 |
45 | )} 46 |
47 | {children} 48 |
49 |
50 | ); 51 | }; 52 | -------------------------------------------------------------------------------- /web/components/Hero/index.ts: -------------------------------------------------------------------------------- 1 | export { Hero as default } from './Hero'; 2 | -------------------------------------------------------------------------------- /web/components/HighlightedSpeakerBlock/HighlightedSpeakerBlock.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | overflow: hidden; 3 | } 4 | 5 | .column { 6 | display: none; 7 | } 8 | 9 | .imageColumn { 10 | display: grid; 11 | grid-template-columns: repeat(3, 178px); 12 | gap: 12px; 13 | justify-content: center; 14 | } 15 | 16 | .image { 17 | width: 178px; 18 | height: 255px; 19 | border-radius: 16px; 20 | } 21 | 22 | @media (--medium-up) { 23 | .image { 24 | width: 260px; 25 | height: 372px; 26 | } 27 | 28 | .imageColumn { 29 | grid-template-columns: repeat(3, 260px); 30 | } 31 | } 32 | 33 | @media (--large-up) { 34 | .container { 35 | display: grid; 36 | grid-template-columns: repeat(2, 336px); 37 | gap: 21px; 38 | justify-content: end; 39 | } 40 | 41 | .column, 42 | .imageColumn { 43 | display: flex; 44 | flex-direction: column; 45 | width: 336px; 46 | gap: 21px; 47 | } 48 | 49 | .column { 50 | margin-top: -205px; 51 | } 52 | 53 | .imageColumn { 54 | margin-top: -168px; 55 | } 56 | 57 | .image { 58 | width: 336px; 59 | height: 480px; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /web/components/HighlightedSpeakerBlock/HighlightedSpeakerBlock.tsx: -------------------------------------------------------------------------------- 1 | import type { Figure } from '../../types/Figure'; 2 | import { imageUrlFor } from '../../lib/sanity'; 3 | import Shape from '../../components/Shape'; 4 | import styles from './HighlightedSpeakerBlock.module.css'; 5 | 6 | interface HighlightedSpeakerBlockProps { 7 | photo?: Figure; 8 | } 9 | 10 | export const HighlightedSpeakerBlock = ({ 11 | photo, 12 | }: HighlightedSpeakerBlockProps) => { 13 | const photoSrc = 14 | photo && imageUrlFor(photo).size(336, 480).saturation(-100).url(); 15 | return ( 16 |
17 | 22 |
23 | 24 | {photoSrc ? ( 25 | /* eslint-disable-next-line @next/next/no-img-element */ 26 | {photo.alt 33 | ) : ( 34 | 35 | )} 36 | 37 |
38 |
39 | ); 40 | }; 41 | -------------------------------------------------------------------------------- /web/components/HighlightedSpeakerBlock/index.ts: -------------------------------------------------------------------------------- 1 | export { HighlightedSpeakerBlock as default } from './HighlightedSpeakerBlock'; 2 | -------------------------------------------------------------------------------- /web/components/MetaTags/MetaTags.tsx: -------------------------------------------------------------------------------- 1 | import { NextSeo } from 'next-seo'; 2 | import urlJoin from 'proper-url-join'; 3 | import { imageUrlFor } from '../../lib/sanity'; 4 | import { Figure } from '../../types/Figure'; 5 | import { productionUrl } from '../../util/constants'; 6 | 7 | interface MetaTagsProps { 8 | title: string; 9 | description: string; 10 | image?: Figure; 11 | fallbackImage: { url: string; alt: string }; 12 | currentPath: string; 13 | noIndex?: boolean; 14 | rewrittenArticleSlugs?: string[]; 15 | } 16 | 17 | export const MetaTags = ({ 18 | title, 19 | description, 20 | image, 21 | fallbackImage, 22 | currentPath, 23 | noIndex, 24 | rewrittenArticleSlugs, 25 | }: MetaTagsProps) => { 26 | const isRewrittenPath = 27 | Array.isArray(rewrittenArticleSlugs) && 28 | rewrittenArticleSlugs.includes( 29 | urlJoin(currentPath, { leadingSlash: false }) 30 | ); 31 | const canonicalPath = isRewrittenPath 32 | ? urlJoin('article', currentPath) 33 | : currentPath; 34 | const imageUrl = image && imageUrlFor(image).size(1260, 630).url(); 35 | return ( 36 | 55 | ); 56 | }; 57 | -------------------------------------------------------------------------------- /web/components/MetaTags/index.ts: -------------------------------------------------------------------------------- 1 | export { MetaTags as default } from './MetaTags'; 2 | -------------------------------------------------------------------------------- /web/components/Nav/MenuItem/MenuItem.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link'; 2 | import clsx from 'clsx'; 3 | import type { HTMLProps } from 'react'; 4 | import styles from '../Nav.module.css'; 5 | 6 | interface MenuItemProps extends HTMLProps { 7 | currentPath: string; 8 | closeMenu: () => void; 9 | } 10 | 11 | export const MenuItem = ({ 12 | href, 13 | className, 14 | currentPath, 15 | closeMenu, 16 | ...rest 17 | }: MenuItemProps) => 18 | href ? ( 19 |
  • 20 | 21 | 26 | 27 |
  • 28 | ) : null; 29 | -------------------------------------------------------------------------------- /web/components/Nav/MenuItem/index.ts: -------------------------------------------------------------------------------- 1 | export { MenuItem as default } from './MenuItem'; 2 | -------------------------------------------------------------------------------- /web/components/Nav/index.ts: -------------------------------------------------------------------------------- 1 | export { Nav as default } from './Nav'; 2 | -------------------------------------------------------------------------------- /web/components/NavBlock/FakeItem/FakeItem.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx'; 2 | import { useAnimationProperties } from '../../../hooks/useAnimationProperties'; 3 | import { useRandomShape } from '../../../hooks/useRandomShape'; 4 | import styles from '../NavBlock.module.css'; 5 | 6 | interface FakeItemProps { 7 | divider?: boolean; 8 | mobile?: boolean; 9 | tablet?: boolean; 10 | desktop?: boolean; 11 | } 12 | 13 | const RANDOM_SHAPE_PERCENT_CHANCE = 0.33; 14 | 15 | export const FakeItem = ({ 16 | divider, 17 | mobile, 18 | tablet, 19 | desktop, 20 | }: FakeItemProps) => { 21 | const randomShapeClass = useRandomShape(RANDOM_SHAPE_PERCENT_CHANCE); 22 | return ( 23 |
  • 14 | 15 | 16 | 17 |
  • 18 | ); 19 | }; 20 | -------------------------------------------------------------------------------- /web/components/NavBlock/Item/index.ts: -------------------------------------------------------------------------------- 1 | export { Item as default } from './Item'; 2 | -------------------------------------------------------------------------------- /web/components/NavBlock/NavBlock.module.css: -------------------------------------------------------------------------------- 1 | .nav { 2 | margin: 80px 0; 3 | } 4 | 5 | @media (prefers-reduced-motion: no-preference) { 6 | .nav.isIntersecting .item, 7 | .nav.isIntersecting .fakeItem { 8 | transform-origin: bottom; 9 | animation: var(--item-enter-animation); 10 | animation-delay: var(--delay); 11 | } 12 | } 13 | 14 | .list { 15 | display: flex; 16 | flex-wrap: wrap; 17 | list-style: none; 18 | margin: 0 -8px 0 0; 19 | padding: 0; 20 | align-items: stretch; 21 | } 22 | 23 | .item, 24 | .fakeItem { 25 | margin: 0 8px 8px 0; 26 | } 27 | 28 | .link { 29 | display: block; 30 | font: var(--font-display-sm); 31 | background: var(--color-brand-orange); 32 | border-radius: 8px; 33 | padding: 22.5px 32px; 34 | color: var(--color-brand-black); 35 | text-decoration: none; 36 | } 37 | 38 | .link:focus { 39 | outline: none; 40 | box-shadow: 0 0 0 2px var(--color-brand-black); 41 | } 42 | 43 | .link:hover { 44 | background: var(--color-brand-yellow); 45 | } 46 | 47 | .link:active { 48 | background: var(--color-brand-yellow-light); 49 | } 50 | 51 | .fakeItem { 52 | flex: auto; 53 | border-radius: 8px; 54 | display: none; 55 | } 56 | 57 | .fakeItem.noShape { 58 | background-color: var(--color-brand-orange); 59 | } 60 | 61 | .fakeItem.mobile { 62 | display: block; 63 | } 64 | 65 | .divider { 66 | flex: 0 0 100%; 67 | height: 0; 68 | display: none; 69 | } 70 | 71 | .divider.mobile { 72 | display: block; 73 | } 74 | 75 | @media (--medium-up) { 76 | .nav { 77 | margin: 96px 0 128px; 78 | } 79 | 80 | .fakeItem.mobile, 81 | .divider.mobile { 82 | display: none; 83 | } 84 | 85 | .fakeItem.tablet, 86 | .divider.tablet { 87 | display: block; 88 | } 89 | } 90 | 91 | @media (--large-up) { 92 | .nav { 93 | margin: 128px 0 192px; 94 | } 95 | 96 | .link { 97 | font: var(--font-display-base); 98 | border-radius: 12px; 99 | padding: 28.5px 39px; 100 | } 101 | 102 | .fakeItem { 103 | border-radius: 12px; 104 | } 105 | 106 | .fakeItem.tablet, 107 | .divider.tablet { 108 | display: none; 109 | } 110 | 111 | .fakeItem.desktop, 112 | .divider.desktop { 113 | display: block; 114 | } 115 | } 116 | 117 | @keyframes item-enter { 118 | from { 119 | opacity: 0; 120 | transform: translateY(var(--distance)) rotate(var(--rotation)); 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /web/components/NavBlock/NavBlock.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx'; 2 | import { useRef } from 'react'; 3 | import useIntersection from '../../hooks/useIntersection'; 4 | import FakeItem from './FakeItem'; 5 | import Item from './Item'; 6 | import styles from './NavBlock.module.css'; 7 | 8 | interface NavBlockProps { 9 | ticketsUrl?: string; 10 | } 11 | 12 | export const NavBlock = ({ ticketsUrl }: NavBlockProps) => { 13 | const wrapperRef = useRef(null); 14 | const isIntersecting = useIntersection(wrapperRef); 15 | return ( 16 | 63 | ); 64 | }; 65 | -------------------------------------------------------------------------------- /web/components/NavBlock/index.ts: -------------------------------------------------------------------------------- 1 | export { NavBlock as default } from './NavBlock'; 2 | -------------------------------------------------------------------------------- /web/components/SectionBlock/SectionBlock.module.css: -------------------------------------------------------------------------------- 1 | .sectionBlock { 2 | background: var(--color-brand-yellow-light); 3 | padding: 1rem; 4 | border-radius: 12px; 5 | margin: 1rem 1rem var(--spacing-large); 6 | } 7 | 8 | .sectionBlock--gray { 9 | background: var(--color-ui-gray-100); 10 | } 11 | 12 | .sectionBlock--noBackground { 13 | background: none; 14 | } 15 | -------------------------------------------------------------------------------- /web/components/SectionBlock/SectionBlock.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx'; 2 | import { forwardRef, HTMLAttributes } from 'react'; 3 | import styles from './SectionBlock.module.css'; 4 | 5 | interface SectionBlockProps extends HTMLAttributes { 6 | noBackground?: boolean; 7 | gray?: boolean; 8 | } 9 | 10 | export const SectionBlock = forwardRef( 11 | ({ className, noBackground, gray, ...props }: SectionBlockProps, ref) => ( 12 |
    22 | ) 23 | ); 24 | 25 | SectionBlock.displayName = 'SectionBlock'; 26 | -------------------------------------------------------------------------------- /web/components/SectionBlock/index.ts: -------------------------------------------------------------------------------- 1 | export { SectionBlock as default } from './SectionBlock'; 2 | -------------------------------------------------------------------------------- /web/components/SessionCard/SessionCard.module.css: -------------------------------------------------------------------------------- 1 | .link { 2 | text-decoration: none; 3 | } 4 | 5 | .card { 6 | border-radius: 8px; 7 | padding: 24px; 8 | border: 1px solid var(--color-brand-black); 9 | margin: 8px 0; 10 | font: var(--font-ui-xs-medium); 11 | letter-spacing: var(--letter-spacing-ui-xs); 12 | } 13 | 14 | .link:hover .card { 15 | background: var(--color-brand-yellow); 16 | } 17 | 18 | .link:active .card { 19 | background: var(--color-brand-yellow-light); 20 | } 21 | 22 | .title { 23 | display: block; 24 | font: var(--font-display-xxs); 25 | margin: 0 0 8px; 26 | } 27 | -------------------------------------------------------------------------------- /web/components/SessionCard/SessionCard.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link'; 2 | import type { Session } from '../../types/Session'; 3 | import { 4 | defaultTimezone, 5 | formatDateWithDay, 6 | formatTimeDuration, 7 | formatTimeRange, 8 | getNonLocationTimezone, 9 | } from '../../util/date'; 10 | import { getEntityPath } from '../../util/entityPaths'; 11 | import styles from './SessionCard.module.css'; 12 | 13 | export interface SessionCardProps 14 | extends Pick { 15 | sessionStart: Date | null; 16 | timezone?: string; 17 | } 18 | 19 | export const SessionCard = ({ 20 | _type, 21 | title, 22 | sessionStart, 23 | duration, 24 | timezone = defaultTimezone, 25 | slug, 26 | }: SessionCardProps) => { 27 | const Content = ( 28 |
    29 | {title} 30 | {sessionStart && ( 31 |
    32 | 35 |
    36 | )} 37 | {sessionStart && duration && ( 38 |
    39 | 43 |
    44 | )} 45 |
    46 | ); 47 | 48 | return slug?.current ? ( 49 | 50 |
    {Content} 51 | 52 | ) : ( 53 | Content 54 | ); 55 | }; 56 | -------------------------------------------------------------------------------- /web/components/SessionCard/index.ts: -------------------------------------------------------------------------------- 1 | export { SessionCard as default } from './SessionCard'; 2 | export type { SessionCardProps } from './SessionCard'; 3 | -------------------------------------------------------------------------------- /web/components/SessionSpeakers/SessionSpeakers.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | overflow: hidden; 3 | } 4 | 5 | .column1 { 6 | display: grid; 7 | grid-template-columns: repeat(3, 148px); 8 | gap: 10px; 9 | justify-content: center; 10 | } 11 | 12 | .container.hasTwoSpeakers .column1 { 13 | grid-template-columns: repeat(4, 148px); 14 | } 15 | 16 | .column2 { 17 | display: none; 18 | } 19 | 20 | .speaker { 21 | margin: 0; 22 | } 23 | 24 | .speaker.desktopOnly { 25 | display: none; 26 | } 27 | 28 | .shape, 29 | .image { 30 | width: 148px; 31 | height: 226px; 32 | } 33 | 34 | .image { 35 | border-radius: 16px; 36 | vertical-align: top; 37 | } 38 | 39 | .caption { 40 | font: 400 12px/14px 'Inter', sans-serif; 41 | letter-spacing: normal; 42 | margin: 16px 8px 0; 43 | text-align: center; 44 | hyphens: auto; 45 | overflow-wrap: break-word; 46 | } 47 | 48 | .speakerName { 49 | font-weight: 600; 50 | display: block; 51 | margin-bottom: 2px; 52 | } 53 | 54 | @media (--medium-up) { 55 | .column1 { 56 | grid-template-columns: repeat(3, 244px); 57 | gap: 16px; 58 | } 59 | 60 | .container.hasTwoSpeakers .column1 { 61 | grid-template-columns: repeat(4, 244px); 62 | } 63 | 64 | .speaker { 65 | position: relative; 66 | } 67 | 68 | .shape, 69 | .image { 70 | width: 244px; 71 | height: 372px; 72 | } 73 | 74 | .caption { 75 | margin: 0; 76 | position: absolute; 77 | left: 24px; 78 | right: 24px; 79 | bottom: 24px; 80 | background: var(--color-brand-yellow); 81 | padding: 12px 16px; 82 | max-height: 25%; 83 | overflow: auto; 84 | border-radius: 8px; 85 | } 86 | 87 | .speakerName { 88 | font: 600 16px/20px 'Inter', sans-serif; 89 | letter-spacing: -0.011em; 90 | } 91 | } 92 | 93 | @media (--session-large-up) { 94 | .container { 95 | display: grid; 96 | grid-template-columns: repeat(2, 256px); 97 | gap: 21px; 98 | } 99 | 100 | .column1, 101 | .column2 { 102 | display: flex; 103 | flex-direction: column; 104 | width: 256px; 105 | gap: 16px; 106 | justify-content: flex-start; 107 | } 108 | 109 | .column1 { 110 | margin-top: -198px; 111 | } 112 | 113 | .column2 { 114 | margin-top: -102px; 115 | } 116 | 117 | .speaker.desktopOnly { 118 | display: block; 119 | } 120 | 121 | .speaker.nonDesktop { 122 | display: none; 123 | } 124 | 125 | .shape { 126 | width: 256px; 127 | height: 256px; 128 | } 129 | 130 | .image { 131 | width: 256px; 132 | height: 390px; 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /web/components/SessionSpeakers/SessionSpeakers.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx'; 2 | import Link from 'next/link'; 3 | import type { Person } from '../../types/Person'; 4 | import { getEntityPath } from '../../util/entityPaths'; 5 | import { imageUrlFor } from '../../lib/sanity'; 6 | import { useRandomShape } from '../../hooks/useRandomShape'; 7 | import styles from './SessionSpeakers.module.css'; 8 | 9 | interface SessionSpeakersProps { 10 | speaker1?: Person; 11 | speaker2?: Person; 12 | } 13 | 14 | const Shape = () => ( 15 |