├── .editorconfig ├── .gitignore ├── README.md ├── lerna.json ├── package-lock.json ├── package.json ├── studio ├── .eslintrc.js ├── .gitignore ├── .prettierrc ├── README.md ├── config │ ├── .checksums │ └── @sanity │ │ ├── data-aspects.json │ │ ├── default-layout.json │ │ ├── default-login.json │ │ └── form-builder.json ├── package-lock.json ├── package.json ├── plugins │ └── dashboard-widget-structure-menu │ │ ├── sanity.json │ │ └── src │ │ ├── components │ │ ├── StructureMenuWidget.css │ │ ├── StructureMenuWidget.js │ │ └── index.js │ │ ├── lib │ │ └── structure.js │ │ ├── props.js │ │ └── widget.js ├── sanity.json ├── schemas │ ├── documents │ │ ├── author.js │ │ ├── category.js │ │ ├── post.js │ │ └── talk.js │ ├── objects │ │ ├── authorReference.js │ │ ├── bioPortableText.js │ │ ├── bodyPortableText.js │ │ ├── codePen.js │ │ ├── codeSandbox.js │ │ ├── excerptPortableText.js │ │ ├── mainImage.js │ │ ├── twitter.js │ │ ├── unfurledUrl.js │ │ └── youtube.js │ └── schema.js ├── src │ ├── dashboardConfig.js │ ├── previews │ │ ├── IframePreview.js │ │ └── IframePreview.module.css │ ├── structure │ │ └── deskStructure.js │ ├── studioHintsConfig.js │ └── styles │ │ └── variables.css └── static │ └── favicon.ico └── web ├── .eslintrc ├── .gitignore ├── README.md ├── app ├── entry.client.tsx ├── entry.server.tsx ├── features │ ├── analytics │ │ └── analytics.tsx │ ├── chakra-setup │ │ ├── createEmotionCache.ts │ │ └── styleContext.ts │ ├── design-system │ │ └── TextLink.tsx │ ├── error-boundary │ │ └── RootErrorBoundary.tsx │ ├── light-switch │ │ └── LightSwitch.tsx │ ├── portable-text │ │ ├── PortableText.tsx │ │ └── blocks │ │ │ ├── BlockBlock.tsx │ │ │ ├── CodeBlock.tsx │ │ │ ├── CodePenBlock.tsx │ │ │ ├── CodeSandboxBlock.tsx │ │ │ ├── ImageBlock.tsx │ │ │ ├── TwitterBlock.tsx │ │ │ ├── UnfurledUrlBlock.tsx │ │ │ └── YouTubeBlock.tsx │ ├── searchable-grid │ │ ├── SearchPanel.tsx │ │ └── SearchableGrid.tsx │ ├── site-footer │ │ └── SiteFooter.tsx │ ├── site-header │ │ └── SiteHeader.tsx │ └── video-viewer │ │ └── VideoViewer.tsx ├── root.tsx ├── routes │ ├── __main-layout.tsx │ ├── __main-layout │ │ ├── blog │ │ │ ├── $slug.tsx │ │ │ └── index.tsx │ │ ├── projects │ │ │ └── index.tsx │ │ └── talks │ │ │ ├── $slug.tsx │ │ │ └── index.tsx │ └── index.tsx └── utils │ └── sanity │ ├── client.ts │ ├── config.ts │ └── image.ts ├── package-lock.json ├── package.json ├── public ├── android-chrome-192x192.png ├── android-chrome-512x512.png ├── apple-touch-icon.png ├── favicon-16x16.png ├── favicon-32x32.png ├── favicon.ico ├── og-image.jpg ├── robots.txt ├── selfie-dark.webp ├── selfie.webp └── site.webmanifest ├── remix.config.js ├── remix.env.d.ts ├── server.js ├── tsconfig.json └── vercel.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Mac files 2 | .DS_Store 3 | 4 | # Dependency directories 5 | /node_modules 6 | 7 | # lerna files 8 | /lerna-debug.log 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # selbekk.io 2 | 3 | My web site, based on Remix and Sanity. 4 | 5 | ## Development 6 | 7 | Clone and install dependencies with npm, and start a local dev server with `npm run dev`. 8 | 9 | The repo is split in two - a `studio` folder and a `web` folder. The `studio` folder contains all schema and plugin code for our CMS - Sanity. The `web` folder contains the Remix site that generates the web site. 10 | 11 | ## Deployment 12 | 13 | Both the web site and CMS is deployed to Netlify whenever we merge to master. 14 | 15 | ## Questions? Suggestions? 16 | 17 | If you have questions, please let me know in the issues. If you find a bug or a typo, feel free to submit a pull request! 18 | -------------------------------------------------------------------------------- /lerna.json: -------------------------------------------------------------------------------- 1 | { 2 | "packages": [ 3 | "web", 4 | "studio" 5 | ], 6 | "version": "1.0.0" 7 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "name": "selbekk-io", 4 | "version": "1.0.9", 5 | "scripts": { 6 | "build": "lerna run build --parallel", 7 | "dev": "lerna run dev --parallel", 8 | "format": "lerna run format", 9 | "build-studio": "lerna bootstrap && cd studio && npm run build", 10 | "build-web": "lerna bootstrap && (cd studio && SANITY_AUTH_TOKEN=$SANITY_DEPLOY_STUDIO_TOKEN npm run graphql-deploy) && (cd web && npm run build)", 11 | "graphql-deploy": "lerna run graphql-deploy", 12 | "lint": "lerna run lint", 13 | "postinstall": "lerna bootstrap", 14 | "test": "echo \"Error: no test specified\" && exit 1" 15 | }, 16 | "devDependencies": { 17 | "@sanity/cli": "^1.148.4", 18 | "lerna": "^3.13.1" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /studio/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['react-app'], 3 | }; 4 | -------------------------------------------------------------------------------- /studio/.gitignore: -------------------------------------------------------------------------------- 1 | /dist 2 | /node_modules 3 | 4 | yarn.lock -------------------------------------------------------------------------------- /studio/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 80, 3 | "semi": true, 4 | "arrowParens": "always", 5 | "singleQuote": true, 6 | "tabWidth": 2, 7 | "trailingComma": "all" 8 | } 9 | -------------------------------------------------------------------------------- /studio/README.md: -------------------------------------------------------------------------------- 1 | # selbekk.io studio 2 | 3 | This is the studio part of the selbekk.io site - this is where the schemas and stuff is specified 4 | 5 | You can start it locally with `npm run dev`, and have it pop up at `localhost:3333`. 6 | -------------------------------------------------------------------------------- /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/data-aspects": "d199e2c199b3e26cd28b68dc84d7fc01c9186bf5089580f2e2446994d36b3cb6", 6 | "@sanity/form-builder": "b38478227ba5e22c91981da4b53436df22e48ff25238a55a973ed620be5068aa" 7 | } 8 | -------------------------------------------------------------------------------- /studio/config/@sanity/data-aspects.json: -------------------------------------------------------------------------------- 1 | { 2 | "listOptions": {} 3 | } -------------------------------------------------------------------------------- /studio/config/@sanity/default-layout.json: -------------------------------------------------------------------------------- 1 | { 2 | "toolSwitcher": { 3 | "order": [], 4 | "hidden": [] 5 | } 6 | } -------------------------------------------------------------------------------- /studio/config/@sanity/default-login.json: -------------------------------------------------------------------------------- 1 | { 2 | "providers": { 3 | "mode": "append", 4 | "redirectOnSingle": false, 5 | "entries": [] 6 | } 7 | } -------------------------------------------------------------------------------- /studio/config/@sanity/form-builder.json: -------------------------------------------------------------------------------- 1 | { 2 | "images": { 3 | "directUploads": true 4 | } 5 | } -------------------------------------------------------------------------------- /studio/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "name": "studio", 4 | "version": "1.0.6", 5 | "main": "package.json", 6 | "author": "Kristofer Giltvedt Selbekk ", 7 | "scripts": { 8 | "dev": "sanity start", 9 | "format": "prettier --write \"**/*.js\" \"!node_modules/**\"", 10 | "build": "sanity build", 11 | "graphql-deploy": "sanity graphql deploy --playground", 12 | "lint": "eslint .", 13 | "test": "sanity check" 14 | }, 15 | "dependencies": { 16 | "@opengraphninja/react": "^0.1.6", 17 | "@sanity/base": "^2.23.2", 18 | "@sanity/cli": "^2.23.2", 19 | "@sanity/code-input": "^2.23.2", 20 | "@sanity/components": "^2.14.0", 21 | "@sanity/core": "^2.23.2", 22 | "@sanity/dashboard": "^2.23.2", 23 | "@sanity/default-layout": "^2.23.2", 24 | "@sanity/default-login": "^2.23.2", 25 | "@sanity/desk-tool": "^2.23.2", 26 | "date-fns": "^1.30.1", 27 | "get-youtube-id": "^1.0.1", 28 | "prop-types": "^15.7.2", 29 | "react": "^17.0.2", 30 | "react-dom": "^17.0.2", 31 | "react-icons": "^3.9.0", 32 | "react-twitter-embed": "^3.0.3", 33 | "react-youtube": "^7.9.0", 34 | "reading-time": "^1.3.0", 35 | "sanity-plugin-dashboard-widget-document-list": "^0.0.9", 36 | "sanity-plugin-dashboard-widget-netlify": "^1.0.1", 37 | "styled-components": "^5.2.0" 38 | }, 39 | "devDependencies": { 40 | "@typescript-eslint/eslint-plugin": "^2.21.0", 41 | "@typescript-eslint/parser": "^2.21.0", 42 | "babel-eslint": "^10.1.0", 43 | "eslint": "^6.8.0", 44 | "eslint-config-react-app": "^5.2.0", 45 | "eslint-plugin-flowtype": "^4.6.0", 46 | "eslint-plugin-import": "^2.20.1", 47 | "eslint-plugin-jsx-a11y": "^6.2.3", 48 | "eslint-plugin-react": "^7.18.3", 49 | "eslint-plugin-react-hooks": "^2.4.0", 50 | "prettier": "^1.19.1" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /studio/plugins/dashboard-widget-structure-menu/sanity.json: -------------------------------------------------------------------------------- 1 | { 2 | "paths": { 3 | "source": "./src", 4 | "compiled": "./lib" 5 | }, 6 | "parts": [ 7 | ] 8 | } -------------------------------------------------------------------------------- /studio/plugins/dashboard-widget-structure-menu/src/components/StructureMenuWidget.css: -------------------------------------------------------------------------------- 1 | @import 'part:@sanity/base/theme/variables-style'; 2 | 3 | .root { 4 | composes: container from 'part:@sanity/dashboard/widget-styles'; 5 | } 6 | 7 | .header { 8 | composes: header from 'part:@sanity/dashboard/widget-styles'; 9 | } 10 | 11 | .title { 12 | composes: title from 'part:@sanity/dashboard/widget-styles'; 13 | } 14 | 15 | .content { 16 | display: grid; 17 | padding: var(--small-padding); 18 | grid-gap: var(--small-padding); 19 | grid-template-columns: 1fr 1fr; 20 | overflow-x: auto; 21 | border-top: 1px solid var(--hairline-color); 22 | 23 | @media (--screen-medium) { 24 | grid-template-columns: 1fr 1fr 1fr 1fr; 25 | } 26 | } 27 | 28 | .link { 29 | composes: item from 'part:@sanity/base/theme/layout/selectable-style'; 30 | display: block; 31 | border-radius: 2px; 32 | padding: var(--small-padding); 33 | text-decoration: none; 34 | text-align: center; 35 | } 36 | 37 | .iconWrapper { 38 | font-size: 2em; 39 | } 40 | -------------------------------------------------------------------------------- /studio/plugins/dashboard-widget-structure-menu/src/components/StructureMenuWidget.js: -------------------------------------------------------------------------------- 1 | import { Link } from 'part:@sanity/base/router'; 2 | import FolderIcon from 'part:@sanity/base/folder-icon'; 3 | import FileIcon from 'part:@sanity/base/file-icon'; 4 | import React from 'react'; 5 | import styles from './StructureMenuWidget.css'; 6 | 7 | function getIconComponent(item) { 8 | if (item.icon) return item.icon; 9 | if (!item.schemaType) return FileIcon; 10 | return item.schemaType.icon || FolderIcon; 11 | } 12 | 13 | function StructureMenuWidget(props) { 14 | return ( 15 |
16 |
17 |

Edit your content

18 |
19 | 20 |
21 | {props.structure.items 22 | .filter((item) => item.type !== 'divider') 23 | .map((item) => { 24 | const Icon = getIconComponent(item); 25 | return ( 26 |
27 | 28 |
29 | 30 |
31 |
{item.title}
32 | 33 |
34 | ); 35 | })} 36 |
37 |
38 | ); 39 | } 40 | 41 | export default StructureMenuWidget; 42 | -------------------------------------------------------------------------------- /studio/plugins/dashboard-widget-structure-menu/src/components/index.js: -------------------------------------------------------------------------------- 1 | export { default as StructureMenuWidget } from './StructureMenuWidget'; 2 | -------------------------------------------------------------------------------- /studio/plugins/dashboard-widget-structure-menu/src/lib/structure.js: -------------------------------------------------------------------------------- 1 | /* global __DEV__ */ 2 | 3 | import { 4 | defer, 5 | from as observableFrom, 6 | of as observableOf, 7 | throwError, 8 | } from 'rxjs'; 9 | import { mergeMap } from 'rxjs/operators'; 10 | import StructureBuilder from '@sanity/desk-tool/structure-builder'; 11 | 12 | let prevStructureError = null; 13 | if (__DEV__) { 14 | if (module.hot && module.hot.data) { 15 | prevStructureError = module.hot.data.prevError; 16 | } 17 | } 18 | 19 | export function isSubscribable(thing) { 20 | return ( 21 | thing && 22 | (typeof thing.then === 'function' || typeof thing.subscribe === 'function') 23 | ); 24 | } 25 | 26 | export function isStructure(structure) { 27 | return ( 28 | structure && 29 | (typeof structure === 'function' || 30 | typeof structure.serialize !== 'function' || 31 | typeof structure.then !== 'function' || 32 | typeof structure.subscribe !== 'function' || 33 | typeof structure.type !== 'string') 34 | ); 35 | } 36 | 37 | export function serializeStructure(item, context, resolverArgs = []) { 38 | // Lazy 39 | if (typeof item === 'function') { 40 | return serializeStructure(item(...resolverArgs), context, resolverArgs); 41 | } 42 | 43 | // Promise/observable returning a function, builder or plain JSON structure 44 | if (isSubscribable(item)) { 45 | return observableFrom(item).pipe( 46 | mergeMap((val) => serializeStructure(val, context, resolverArgs)), 47 | ); 48 | } 49 | 50 | // Builder? 51 | if (item && typeof item.serialize === 'function') { 52 | return serializeStructure(item.serialize(context)); 53 | } 54 | 55 | // Plain value? 56 | return observableOf(item); 57 | } 58 | 59 | export function getDefaultStructure() { 60 | const items = StructureBuilder.documentTypeListItems(); 61 | return StructureBuilder.list() 62 | .id('__root__') 63 | .title('Content') 64 | .showIcons(items.some((item) => item.getSchemaType().icon)) 65 | .items(items); 66 | } 67 | 68 | // We are lazy-requiring/resolving the structure inside of a function in order to catch errors 69 | // on the root-level of the module. Any loading errors will be caught and emitted as errors 70 | // eslint-disable-next-line complexity 71 | export function loadStructure() { 72 | let structure; 73 | try { 74 | const mod = 75 | require('part:@sanity/desk-tool/structure?') || getDefaultStructure(); 76 | structure = mod && mod.__esModule ? mod.default : mod; 77 | 78 | // On invalid modules, when HMR kicks in, we sometimes get an empty object back when the 79 | // source has changed without fixing the problem. In this case, keep showing the error 80 | if ( 81 | __DEV__ && 82 | prevStructureError && 83 | structure && 84 | structure.constructor.name === 'Object' && 85 | Object.keys(structure).length === 0 86 | ) { 87 | return throwError(prevStructureError); 88 | } 89 | 90 | prevStructureError = null; 91 | } catch (err) { 92 | prevStructureError = err; 93 | return throwError(err); 94 | } 95 | 96 | if (!isStructure(structure)) { 97 | return throwError( 98 | new Error( 99 | `Structure needs to export a function, an observable, a promise or a stucture builder, got ${typeof structure}`, 100 | ), 101 | ); 102 | } 103 | 104 | // Defer to catch immediately thrown errors on serialization 105 | return defer(() => serializeStructure(structure)); 106 | } 107 | -------------------------------------------------------------------------------- /studio/plugins/dashboard-widget-structure-menu/src/props.js: -------------------------------------------------------------------------------- 1 | import { combineLatest } from 'rxjs'; 2 | import { map } from 'rxjs/operators'; 3 | import { loadStructure } from './lib/structure'; 4 | 5 | export function toPropsStream(props$) { 6 | const structure$ = loadStructure(); 7 | 8 | return combineLatest(props$, structure$).pipe( 9 | map(([props, structure]) => ({ ...props, structure })), 10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /studio/plugins/dashboard-widget-structure-menu/src/widget.js: -------------------------------------------------------------------------------- 1 | import { withPropsStream } from 'react-props-stream'; 2 | import { withRouterHOC } from 'part:@sanity/base/router'; 3 | import { StructureMenuWidget } from './components'; 4 | import { toPropsStream } from './props'; 5 | 6 | export default { 7 | name: 'structure-menu', 8 | component: withRouterHOC(withPropsStream(toPropsStream, StructureMenuWidget)), 9 | layout: { width: 'full' }, 10 | }; 11 | -------------------------------------------------------------------------------- /studio/sanity.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "project": { 4 | "name": "selbekk.io" 5 | }, 6 | "api": { 7 | "projectId": "topo6k9s", 8 | "dataset": "production" 9 | }, 10 | "plugins": [ 11 | "@sanity/base", 12 | "@sanity/components", 13 | "@sanity/default-layout", 14 | "@sanity/default-login", 15 | "@sanity/desk-tool", 16 | "@sanity/code-input" 17 | ], 18 | "parts": [ 19 | { 20 | "name": "part:@sanity/base/schema", 21 | "path": "./schemas/schema.js" 22 | } 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /studio/schemas/documents/author.js: -------------------------------------------------------------------------------- 1 | export default { 2 | name: 'author', 3 | type: 'document', 4 | title: 'Author', 5 | fields: [ 6 | { 7 | name: 'name', 8 | type: 'string', 9 | title: 'Name', 10 | }, 11 | { 12 | name: 'slug', 13 | type: 'slug', 14 | title: 'Slug', 15 | description: 16 | 'Some frontends will require a slug to be set to be able to show the person', 17 | options: { 18 | source: 'name', 19 | maxLength: 96, 20 | }, 21 | }, 22 | { 23 | name: 'image', 24 | type: 'mainImage', 25 | title: 'Image', 26 | }, 27 | { 28 | name: 'bio', 29 | type: 'bioPortableText', 30 | title: 'Biography', 31 | }, 32 | ], 33 | preview: { 34 | select: { 35 | title: 'name', 36 | subtitle: 'slug.current', 37 | media: 'image', 38 | }, 39 | }, 40 | }; 41 | -------------------------------------------------------------------------------- /studio/schemas/documents/category.js: -------------------------------------------------------------------------------- 1 | export default { 2 | name: 'category', 3 | type: 'document', 4 | title: 'Category', 5 | fields: [ 6 | { 7 | name: 'title', 8 | type: 'string', 9 | title: 'Title', 10 | }, 11 | { 12 | name: 'description', 13 | type: 'text', 14 | title: 'Description', 15 | }, 16 | ], 17 | }; 18 | -------------------------------------------------------------------------------- /studio/schemas/documents/post.js: -------------------------------------------------------------------------------- 1 | import { format } from 'date-fns'; 2 | 3 | export default { 4 | name: 'post', 5 | type: 'document', 6 | title: 'Blog Post', 7 | fields: [ 8 | { 9 | name: 'title', 10 | type: 'string', 11 | title: 'Title', 12 | description: 'Titles should be catchy, descriptive, and not too long', 13 | }, 14 | { 15 | name: 'slug', 16 | type: 'slug', 17 | title: 'Slug', 18 | description: 19 | 'Some frontends will require a slug to be set to be able to show the post', 20 | options: { 21 | source: 'title', 22 | maxLength: 96, 23 | }, 24 | }, 25 | { 26 | name: 'series', 27 | type: 'string', 28 | title: 'Series Name', 29 | description: 30 | 'If part of a series, name it here. The name must be a complete match', 31 | }, 32 | { 33 | name: 'canonicalUrl', 34 | type: 'url', 35 | title: 'Canonical URL', 36 | description: 'The URL of where the article was originally posted', 37 | }, 38 | { 39 | name: 'publishedAt', 40 | type: 'datetime', 41 | title: 'Published at', 42 | description: 'This can be used to schedule post for publishing', 43 | }, 44 | { 45 | name: 'mainImage', 46 | type: 'mainImage', 47 | title: 'Main image', 48 | }, 49 | { 50 | name: 'excerpt', 51 | type: 'excerptPortableText', 52 | title: 'Excerpt', 53 | description: 54 | 'This ends up on summary pages, on Google, when people share your post in social media.', 55 | }, 56 | { 57 | name: 'authors', 58 | title: 'Authors', 59 | type: 'array', 60 | of: [ 61 | { 62 | type: 'authorReference', 63 | }, 64 | ], 65 | }, 66 | { 67 | name: 'categories', 68 | type: 'array', 69 | title: 'Categories', 70 | of: [ 71 | { 72 | type: 'reference', 73 | to: { 74 | type: 'category', 75 | }, 76 | }, 77 | ], 78 | }, 79 | { 80 | name: 'body', 81 | type: 'bodyPortableText', 82 | title: 'Body', 83 | }, 84 | ], 85 | orderings: [ 86 | { 87 | name: 'publishingDateAsc', 88 | title: 'Publishing date new–>old', 89 | by: [ 90 | { 91 | field: 'publishedAt', 92 | direction: 'asc', 93 | }, 94 | { 95 | field: 'title', 96 | direction: 'asc', 97 | }, 98 | ], 99 | }, 100 | { 101 | name: 'publishingDateDesc', 102 | title: 'Publishing date old->new', 103 | by: [ 104 | { 105 | field: 'publishedAt', 106 | direction: 'desc', 107 | }, 108 | { 109 | field: 'title', 110 | direction: 'asc', 111 | }, 112 | ], 113 | }, 114 | ], 115 | preview: { 116 | select: { 117 | title: 'title', 118 | publishedAt: 'publishedAt', 119 | slug: 'slug', 120 | media: 'mainImage', 121 | }, 122 | prepare({ title = 'No title', publishedAt, slug = {}, media }) { 123 | const dateSegment = format(publishedAt, 'YYYY/MM'); 124 | const path = `/${dateSegment}/${slug.current}/`; 125 | return { 126 | title, 127 | media, 128 | subtitle: publishedAt ? path : 'Missing publishing date', 129 | }; 130 | }, 131 | }, 132 | }; 133 | -------------------------------------------------------------------------------- /studio/schemas/documents/talk.js: -------------------------------------------------------------------------------- 1 | import { FaMicrophoneAlt } from 'react-icons/fa'; 2 | export default { 3 | name: 'talk', 4 | type: 'document', 5 | title: 'Talk', 6 | icon: FaMicrophoneAlt, 7 | fields: [ 8 | { 9 | name: 'title', 10 | type: 'string', 11 | title: 'Title', 12 | }, 13 | { 14 | name: 'slug', 15 | type: 'slug', 16 | title: 'Slug', 17 | description: 'Create a special slug, or generate one', 18 | options: { 19 | source: 'title', 20 | maxLength: 96, 21 | }, 22 | }, 23 | { 24 | name: 'mainImage', 25 | type: 'mainImage', 26 | title: 'Main image', 27 | }, 28 | { 29 | name: 'when', 30 | type: 'datetime', 31 | title: 'When was the talk recorded?', 32 | }, 33 | { 34 | name: 'where', 35 | type: 'string', 36 | title: 'Where was the talk recorded?', 37 | }, 38 | { 39 | name: 'videoUrl', 40 | type: 'url', 41 | title: 'Video URL', 42 | }, 43 | { 44 | name: 'excerpt', 45 | type: 'bioPortableText', 46 | title: 'Short summary', 47 | }, 48 | { 49 | name: 'description', 50 | type: 'bioPortableText', 51 | title: 'Talk description', 52 | }, 53 | { 54 | name: 'categories', 55 | type: 'array', 56 | title: 'Categories', 57 | of: [ 58 | { 59 | type: 'reference', 60 | to: { 61 | type: 'category', 62 | }, 63 | }, 64 | ], 65 | }, 66 | ], 67 | preview: { 68 | select: { 69 | title: 'title', 70 | subtitle: 'where', 71 | }, 72 | }, 73 | }; 74 | -------------------------------------------------------------------------------- /studio/schemas/objects/authorReference.js: -------------------------------------------------------------------------------- 1 | export default { 2 | name: 'authorReference', 3 | type: 'object', 4 | title: 'Author reference', 5 | fields: [ 6 | { 7 | name: 'author', 8 | type: 'reference', 9 | to: [ 10 | { 11 | type: 'author', 12 | }, 13 | ], 14 | }, 15 | ], 16 | preview: { 17 | select: { 18 | title: 'author.name', 19 | media: 'author.image.asset', 20 | }, 21 | }, 22 | }; 23 | -------------------------------------------------------------------------------- /studio/schemas/objects/bioPortableText.js: -------------------------------------------------------------------------------- 1 | export default { 2 | name: 'bioPortableText', 3 | type: 'array', 4 | title: 'Excerpt', 5 | of: [ 6 | { 7 | type: 'block', 8 | title: 'Block', 9 | styles: [{ title: 'Normal', value: 'normal' }], 10 | lists: [], 11 | marks: { 12 | decorators: [ 13 | { title: 'Strong', value: 'strong' }, 14 | { title: 'Emphasis', value: 'em' }, 15 | { title: 'Code', value: 'code' }, 16 | ], 17 | }, 18 | }, 19 | ], 20 | }; 21 | -------------------------------------------------------------------------------- /studio/schemas/objects/bodyPortableText.js: -------------------------------------------------------------------------------- 1 | import { 2 | FiCodepen, 3 | FiCodesandbox, 4 | FiLink2, 5 | FiTwitter, 6 | FiYoutube, 7 | } from 'react-icons/fi'; 8 | export default { 9 | name: 'bodyPortableText', 10 | type: 'array', 11 | title: 'Post body', 12 | of: [ 13 | { 14 | type: 'block', 15 | title: 'Block', 16 | // Styles let you set what your user can mark up blocks with. These 17 | // corrensponds with HTML tags, but you can set any title or value 18 | // you want and decide how you want to deal with it where you want to 19 | // use your content. 20 | styles: [ 21 | { title: 'Normal', value: 'normal' }, 22 | { title: 'H1', value: 'h1' }, 23 | { title: 'H2', value: 'h2' }, 24 | { title: 'H3', value: 'h3' }, 25 | { title: 'H4', value: 'h4' }, 26 | { title: 'Quote', value: 'blockquote' }, 27 | ], 28 | lists: [ 29 | { title: 'Bullet', value: 'bullet' }, 30 | { title: 'Number', value: 'number' }, 31 | ], 32 | // Marks let you mark up inline text in the block editor. 33 | marks: { 34 | // Decorators usually describe a single property – e.g. a typographic 35 | // preference or highlighting by editors. 36 | decorators: [ 37 | { title: 'Strong', value: 'strong' }, 38 | { title: 'Emphasis', value: 'em' }, 39 | { title: 'Code', value: 'code' }, 40 | { title: 'Strike', value: 'strike-through' }, 41 | ], 42 | // Annotations can be any object structure – e.g. a link or a footnote. 43 | annotations: [ 44 | { 45 | name: 'link', 46 | type: 'object', 47 | title: 'URL', 48 | fields: [ 49 | { 50 | title: 'URL', 51 | name: 'href', 52 | type: 'url', 53 | }, 54 | ], 55 | }, 56 | ], 57 | }, 58 | of: [{ type: 'authorReference' }], 59 | }, 60 | { 61 | type: 'mainImage', 62 | options: { hotspot: true }, 63 | }, 64 | // Code blocks 65 | { 66 | type: 'code', 67 | options: { 68 | languageAlternatives: [ 69 | { title: 'CSS', value: 'css' }, 70 | { title: 'HTML', value: 'html' }, 71 | { title: 'JSON', value: 'json' }, 72 | { title: 'JSX', value: 'jsx' }, 73 | { title: 'Markdown', value: 'markdown' }, 74 | { title: 'Plain text', value: 'text' }, 75 | { title: 'Elm', value: 'elm' }, 76 | ], 77 | }, 78 | }, 79 | // Code Sandbox block 80 | { type: 'codeSandbox', icon: FiCodesandbox }, 81 | // CodePen block 82 | { type: 'codePen', icon: FiCodepen }, 83 | // Youtube embeds 84 | { type: 'youtube', icon: FiYoutube }, 85 | // Twitter embeds 86 | { type: 'twitter', icon: FiTwitter }, 87 | // Unfurled URL 88 | { type: 'unfurledUrl', icon: FiLink2 }, 89 | ], 90 | }; 91 | -------------------------------------------------------------------------------- /studio/schemas/objects/codePen.js: -------------------------------------------------------------------------------- 1 | export default { 2 | type: 'object', 3 | name: 'codePen', 4 | title: 'CodePen embed', 5 | fields: [{ name: 'url', type: 'url', description: 'The CodePen url' }], 6 | }; 7 | -------------------------------------------------------------------------------- /studio/schemas/objects/codeSandbox.js: -------------------------------------------------------------------------------- 1 | export default { 2 | type: 'object', 3 | name: 'codeSandbox', 4 | title: 'Code Sandbox embed', 5 | fields: [{ name: 'url', type: 'url', description: 'The Code Sandbox url' }], 6 | }; 7 | -------------------------------------------------------------------------------- /studio/schemas/objects/excerptPortableText.js: -------------------------------------------------------------------------------- 1 | export default { 2 | name: 'excerptPortableText', 3 | type: 'array', 4 | title: 'Excerpt', 5 | of: [ 6 | { 7 | title: 'Block', 8 | type: 'block', 9 | styles: [{ title: 'Normal', value: 'normal' }], 10 | lists: [], 11 | marks: { 12 | decorators: [ 13 | { title: 'Strong', value: 'strong' }, 14 | { title: 'Emphasis', value: 'em' }, 15 | { title: 'Code', value: 'code' }, 16 | ], 17 | annotations: [], 18 | }, 19 | }, 20 | ], 21 | }; 22 | -------------------------------------------------------------------------------- /studio/schemas/objects/mainImage.js: -------------------------------------------------------------------------------- 1 | export default { 2 | name: 'mainImage', 3 | type: 'image', 4 | title: 'Image', 5 | options: { 6 | hotspot: true, 7 | }, 8 | fields: [ 9 | { 10 | name: 'caption', 11 | type: 'string', 12 | title: 'Caption', 13 | options: { 14 | isHighlighted: true, 15 | }, 16 | }, 17 | { 18 | name: 'alt', 19 | type: 'string', 20 | title: 'Alternative text', 21 | description: 'Important for SEO and accessiblity.', 22 | validation: (Rule) => 23 | Rule.error('You have to fill out the alternative text.').required(), 24 | options: { 25 | isHighlighted: true, 26 | }, 27 | }, 28 | ], 29 | preview: { 30 | select: { 31 | imageUrl: 'asset.url', 32 | title: 'caption', 33 | }, 34 | }, 35 | }; 36 | -------------------------------------------------------------------------------- /studio/schemas/objects/twitter.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { TwitterTweetEmbed } from 'react-twitter-embed'; 3 | 4 | const getTweetId = (url) => { 5 | if (!url) { 6 | return ''; 7 | } 8 | const parsedUrl = new URL(url); 9 | return parsedUrl.pathname.split('/').pop(); 10 | }; 11 | 12 | const Preview = ({ value }) => { 13 | const id = getTweetId(value ? value.url : null); 14 | if (!id) { 15 | return null; 16 | } 17 | return ( 18 |
19 | 20 |
21 | ); 22 | }; 23 | 24 | export default { 25 | name: 'twitter', 26 | type: 'object', 27 | title: 'Twitter Embed', 28 | fields: [ 29 | { 30 | name: 'url', 31 | type: 'url', 32 | title: 'Tweet URL', 33 | }, 34 | ], 35 | preview: { 36 | select: { 37 | url: 'url', 38 | }, 39 | component: Preview, 40 | }, 41 | }; 42 | -------------------------------------------------------------------------------- /studio/schemas/objects/unfurledUrl.js: -------------------------------------------------------------------------------- 1 | import { PreviewLink } from '@opengraphninja/react'; 2 | import '@opengraphninja/react/styles.css?raw'; 3 | import React from 'react'; 4 | 5 | export default { 6 | type: 'object', 7 | name: 'unfurledUrl', 8 | title: 'Unfurled URL', 9 | fields: [ 10 | { 11 | name: 'url', 12 | type: 'url', 13 | description: 'The URL to unfurl', 14 | validation: (Rule) => Rule.required(), 15 | }, 16 | ], 17 | preview: { 18 | select: { 19 | href: 'url', 20 | }, 21 | component: (props) => , 22 | }, 23 | }; 24 | -------------------------------------------------------------------------------- /studio/schemas/objects/youtube.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import getYouTubeId from 'get-youtube-id'; 3 | import YouTube from 'react-youtube'; 4 | 5 | const Preview = ({ value }) => { 6 | const { url } = value; 7 | const id = getYouTubeId(url); // shivvers.. 8 | return ; 9 | }; 10 | 11 | export default { 12 | name: 'youtube', 13 | type: 'object', 14 | title: 'YouTube Embed', 15 | fields: [ 16 | { 17 | name: 'url', 18 | type: 'url', 19 | title: 'YouTube video URL', 20 | }, 21 | ], 22 | preview: { 23 | select: { 24 | url: 'url', 25 | }, 26 | component: Preview, 27 | }, 28 | }; 29 | -------------------------------------------------------------------------------- /studio/schemas/schema.js: -------------------------------------------------------------------------------- 1 | // First, we must import the schema creator 2 | // Then import schema types from any plugins that might expose them 3 | import schemaTypes from 'all:part:@sanity/base/schema-type'; 4 | import createSchema from 'part:@sanity/base/schema-creator'; 5 | // document schemas 6 | import author from './documents/author'; 7 | import category from './documents/category'; 8 | import post from './documents/post'; 9 | import talk from './documents/talk'; 10 | import authorReference from './objects/authorReference'; 11 | import bioPortableText from './objects/bioPortableText'; 12 | // Object types 13 | import bodyPortableText from './objects/bodyPortableText'; 14 | import codePen from './objects/codePen'; 15 | import codeSandbox from './objects/codeSandbox'; 16 | import excerptPortableText from './objects/excerptPortableText'; 17 | import mainImage from './objects/mainImage'; 18 | import twitter from './objects/twitter'; 19 | import unfurledUrl from './objects/unfurledUrl'; 20 | import youtube from './objects/youtube'; 21 | 22 | // Then we give our schema to the builder and provide the result to Sanity 23 | export default createSchema({ 24 | // We name our schema 25 | name: 'blog', 26 | // Then proceed to concatenate our our document type 27 | // to the ones provided by any plugins that are installed 28 | types: schemaTypes.concat([ 29 | // The following are document types which will appear 30 | // in the studio. 31 | post, 32 | category, 33 | author, 34 | talk, 35 | mainImage, 36 | authorReference, 37 | bodyPortableText, 38 | bioPortableText, 39 | excerptPortableText, 40 | codeSandbox, 41 | codePen, 42 | youtube, 43 | twitter, 44 | unfurledUrl, 45 | ]), 46 | }); 47 | -------------------------------------------------------------------------------- /studio/src/dashboardConfig.js: -------------------------------------------------------------------------------- 1 | export default { 2 | widgets: [ 3 | { name: 'structure-menu' }, 4 | { name: 'project-users', layout: { height: 'auto' } }, 5 | { 6 | name: 'document-list', 7 | options: { 8 | title: 'Recent blog posts', 9 | order: '_createdAt desc', 10 | types: ['post'], 11 | }, 12 | layout: { width: 'medium' }, 13 | }, 14 | ], 15 | }; 16 | -------------------------------------------------------------------------------- /studio/src/previews/IframePreview.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/no-multi-comp, react/no-did-mount-set-state */ 2 | import React from 'react'; 3 | import PropTypes from 'prop-types'; 4 | import { format } from 'date-fns'; 5 | import styles from './IframePreview.module.css'; 6 | 7 | /** 8 | * Explore more examples of previews: 9 | * https://www.sanity.io/blog/evolve-authoring-experiences-with-views-and-split-panes 10 | */ 11 | 12 | const assemblePostUrl = ({ displayed, options }) => { 13 | const { slug, publishedAt } = displayed; 14 | const { previewURL } = options; 15 | if (!slug || !previewURL) { 16 | console.warn('Missing slug or previewURL', { slug, previewURL }); 17 | return ''; 18 | } 19 | const dateSegment = format(publishedAt, 'YYYY/MM'); 20 | const path = `/${dateSegment}/${slug.current}/`; 21 | return `${previewURL}/blog${path}`; 22 | }; 23 | 24 | const IframePreview = (props) => { 25 | const { options } = props; 26 | const { displayed } = props.document; 27 | 28 | if (!displayed) { 29 | return ( 30 |
31 |

There is no document to preview

32 |
33 | ); 34 | } 35 | 36 | const url = assemblePostUrl({ displayed, options }); 37 | 38 | if (!url) { 39 | return ( 40 |
41 |

Hmm. Having problems constructing the web front-end URL.

42 |
43 | ); 44 | } 45 | 46 | return ( 47 |
48 |
49 |