├── .eslintrc.json ├── public ├── favicon.ico └── vercel.svg ├── .vscode └── settings.json ├── env.d.ts ├── next.config.js ├── pages ├── api │ └── hello.ts ├── _document.tsx ├── _app.tsx ├── index.tsx └── [id].tsx ├── .gitignore ├── tsconfig.json ├── styles ├── globals.css ├── index.module.css └── post.module.css ├── package.json ├── README.md ├── lib └── notion.ts └── components ├── PostCard.tsx ├── layout └── Layout.tsx └── TableOfContents.tsx /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lapsangsouchy/notion-blog/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": ["Greycliff", "notionhq", "Puppetier", "tabler"] 3 | } 4 | -------------------------------------------------------------------------------- /env.d.ts: -------------------------------------------------------------------------------- 1 | namespace NodeJS { 2 | interface ProcessEnv { 3 | NOTION_ACCESS_TOKEN: string; 4 | NOTION_BLOG_DATABASE_ID: string; 5 | } 6 | } 7 | // 8 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | reactStrictMode: true, 4 | swcMinify: true, 5 | images: { 6 | domains: [ 7 | 's3.us-west-2.amazonaws.com', 8 | 'images.unsplash.com', 9 | 'www.notion.so', 10 | ], 11 | }, 12 | }; 13 | 14 | module.exports = nextConfig; 15 | -------------------------------------------------------------------------------- /pages/api/hello.ts: -------------------------------------------------------------------------------- 1 | // Next.js API route support: https://nextjs.org/docs/api-routes/introduction 2 | import type { NextApiRequest, NextApiResponse } from 'next' 3 | 4 | type Data = { 5 | name: string 6 | } 7 | 8 | export default function handler( 9 | req: NextApiRequest, 10 | res: NextApiResponse 11 | ) { 12 | res.status(200).json({ name: 'John Doe' }) 13 | } 14 | -------------------------------------------------------------------------------- /pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import { createGetInitialProps } from '@mantine/next'; 2 | import Document, { Head, Html, Main, NextScript } from 'next/document'; 3 | 4 | const getInitialProps = createGetInitialProps(); 5 | 6 | export default class _Document extends Document { 7 | static getInitialProps = getInitialProps; 8 | 9 | render() { 10 | return ( 11 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | ); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | .pnpm-debug.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | 38 | .vscode -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true 17 | }, 18 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "env.d.ts"], 19 | "exclude": ["node_modules"] 20 | } 21 | // 22 | -------------------------------------------------------------------------------- /styles/globals.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | padding: 0; 4 | margin: 0; 5 | font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, 6 | Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif; 7 | } 8 | 9 | body { 10 | background-color: white; 11 | color: black; 12 | } 13 | 14 | @media (prefers-color-scheme: dark) { 15 | body { 16 | background-color: rgb(24, 24, 31); 17 | color: rgb(238, 238, 238); 18 | } 19 | } 20 | 21 | a { 22 | color: #2563eb; 23 | text-decoration: none; 24 | } 25 | 26 | * { 27 | box-sizing: border-box; 28 | } 29 | 30 | h1 { 31 | font-weight: 800; 32 | } 33 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "notion-blog-draft2", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@emotion/react": "^11.10.5", 13 | "@emotion/server": "^11.10.0", 14 | "@mantine/core": "^5.8.0", 15 | "@mantine/hooks": "^5.8.0", 16 | "@mantine/next": "^5.8.0", 17 | "@mantine/prism": "^5.8.0", 18 | "@notionhq/client": "^2.2.2", 19 | "@tabler/icons": "^1.112.0", 20 | "@types/node": "18.11.9", 21 | "@types/react": "18.0.25", 22 | "@types/react-dom": "18.0.9", 23 | "@vercel/analytics": "^0.1.5", 24 | "cookies-next": "^2.1.1", 25 | "eslint": "8.27.0", 26 | "eslint-config-next": "13.0.3", 27 | "next": "13.0.3", 28 | "notion-to-md": "^2.5.5", 29 | "react": "18.2.0", 30 | "react-dom": "18.2.0", 31 | "react-markdown": "^8.0.3", 32 | "react-player": "^2.11.0", 33 | "typescript": "4.9.3" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Social Asides With Notion and Next.js 2 | 3 | This is my ongoing project to fully build a blog or website with [Next.js](https://nextjs.org/) and [Notion API](https://developers.notion.com/) as a JAMStack application. Styled with [Mantine](https://mantine.dev/) and CSS Modules 4 | 5 | ## Demo 6 | 7 | https://social-notion-blog.vercel.app/ 8 | 9 | If you'd like more of an explanation of how everything works, check the ["Explaining All This"](https://social-notion-blog.vercel.app/521021f4-fef2-45d2-86a5-4ad8d8ee2e9d) page 10 | 11 | For a list of what's to come I've also included a [@TODO Post](https://social-notion-blog.vercel.app/d24d1388-fe2a-4196-9aa3-32545fc917bd) that I'll be updating as well 12 | 13 | ## Run Locally 14 | 15 | Clone the project 16 | 17 | ```bash 18 | git clone https://github.com/lapsangsouchy/notion-blog.git 19 | ``` 20 | 21 | Install dependencies 22 | 23 | ```bash 24 | npm install 25 | ``` 26 | 27 | ### Environment Variables 28 | 29 | Then you'll want to create an `.env.local` in the root directory and add your `NOTION_ACCESS_TOKEN` and `NOTION_BLOG_DATABASE_ID` variables. 30 | 31 | [Here's the official Notion API Documentation for getting these variables as well as setting up a basic database](https://developers.notion.com/docs/create-a-notion-integration) 32 | 33 | Once you've created your database, connected the integration, and added your environment variables, start the server 34 | 35 | ```bash 36 | npm run dev 37 | ``` 38 | 39 | ## Feedback 40 | 41 | I'm trying to implement as many features as possible from scratch for learning purposes. 42 | 43 | If you want a primo Notion API Renderer, I can't stress how amazing [react-notion-x](https://github.com/NotionX/react-notion-x) is. 44 | 45 | If you have any feedback, please reach out to to me at aleesmithnyc@gmail.com or feel free to contribute! 46 | -------------------------------------------------------------------------------- /styles/index.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | padding: 0 20px; 3 | max-width: 700px; 4 | margin: 0 auto; 5 | } 6 | 7 | .logos { 8 | display: flex; 9 | align-items: center; 10 | padding: 40px 0 20px; 11 | } 12 | 13 | .header { 14 | margin-bottom: 50px; 15 | } 16 | 17 | .header p { 18 | opacity: 0.7; 19 | line-height: 1.5; 20 | } 21 | 22 | .plus { 23 | font-size: 20px; 24 | margin: 0 20px; 25 | } 26 | 27 | .heading { 28 | margin-bottom: 20px; 29 | padding-bottom: 20px; 30 | border-bottom: 1px solid #dedede; 31 | text-transform: uppercase; 32 | font-size: 15px; 33 | opacity: 0.6; 34 | letter-spacing: 0.5px; 35 | display: flex; 36 | } 37 | 38 | @media (prefers-color-scheme: dark) { 39 | .heading { 40 | border-color: #343539; 41 | } 42 | } 43 | 44 | .posts { 45 | list-style: none; 46 | margin: 0; 47 | padding: 0; 48 | } 49 | 50 | .post { 51 | margin-bottom: 50px; 52 | display: flex; 53 | flex-direction: column; 54 | /* border: #dedede solid 0.5px; */ 55 | } 56 | 57 | .postHead { 58 | font-size: 1.4rem; 59 | } 60 | 61 | .postHead a { 62 | color: inherit; 63 | } 64 | 65 | .postHead p { 66 | font-size: 15px; 67 | opacity: 0.65; 68 | padding-bottom: 0; 69 | margin-bottom: 0; 70 | } 71 | 72 | .coverImg { 73 | flex-shrink: 0; 74 | } 75 | 76 | .coverImg img { 77 | height: 250px; 78 | width: 100%; 79 | object-fit: cover; 80 | } 81 | 82 | .postTitle { 83 | display: flex; 84 | align-items: center; 85 | 86 | padding-top: 0px; 87 | line-height: 0; 88 | } 89 | 90 | .postDescription { 91 | margin-top: 0; 92 | margin-bottom: 12px; 93 | /* opacity: 0.65; */ 94 | } 95 | 96 | .tag { 97 | opacity: 1; 98 | color: 'black'; 99 | margin: 2px; 100 | padding: 5px; 101 | border-radius: 5px; 102 | font-size: 15px; 103 | } 104 | -------------------------------------------------------------------------------- /pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { 3 | MantineProvider, 4 | ColorSchemeProvider, 5 | ColorScheme, 6 | } from '@mantine/core'; 7 | import NextApp, { AppProps, AppContext } from 'next/app'; 8 | import Layout from '../components/layout/Layout'; 9 | import { getCookie, setCookie } from 'cookies-next'; 10 | import { Analytics } from '@vercel/analytics/react'; 11 | 12 | export default function App(props: AppProps & { colorScheme: ColorScheme }) { 13 | const { Component, pageProps } = props; 14 | const [colorScheme, setColorScheme] = useState( 15 | props.colorScheme 16 | ); 17 | 18 | const toggleColorScheme = (value?: ColorScheme) => { 19 | const nextColorScheme = 20 | value || (colorScheme === 'dark' ? 'light' : 'dark'); 21 | setColorScheme(nextColorScheme); 22 | setCookie('mantine-color-scheme', nextColorScheme, { 23 | maxAge: 60 * 60 * 24 * 30, 24 | sameSite: 'none', 25 | secure: true, 26 | }); 27 | }; 28 | 29 | return ( 30 | 34 | ({ 41 | a: { 42 | color: '#2563eb', 43 | textDecoration: 'none', 44 | }, 45 | }), 46 | }} 47 | > 48 | 49 | 50 | 51 | 52 | 53 | ); 54 | } 55 | 56 | App.getInitialProps = async (appContext: AppContext) => { 57 | const appProps = await NextApp.getInitialProps(appContext); 58 | // get color scheme from cookie 59 | return { 60 | ...appProps, 61 | colorScheme: getCookie('mantine-color-scheme', appContext.ctx) || 'dark', 62 | }; 63 | }; 64 | -------------------------------------------------------------------------------- /styles/post.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | padding: 0 20px; 3 | max-width: 700px; 4 | margin: 0 auto; 5 | line-height: 1.5; 6 | } 7 | 8 | .container h1 { 9 | font-size: 32px; 10 | } 11 | 12 | .container img { 13 | width: 100%; 14 | height: auto; 15 | } 16 | 17 | .container figure { 18 | margin-left: 0; 19 | margin-right: 0; 20 | } 21 | 22 | .container figcaption { 23 | opacity: 0.6; 24 | } 25 | 26 | .name { 27 | font-size: 36px; 28 | } 29 | 30 | .back { 31 | display: inline-block; 32 | margin-bottom: 20px; 33 | } 34 | 35 | .bold { 36 | font-weight: bold; 37 | } 38 | 39 | .code { 40 | font-family: monospace; 41 | background-color: rgb(242, 242, 242); 42 | padding: 2px 4px; 43 | border-radius: 2px; 44 | } 45 | 46 | .italic { 47 | font-style: italic; 48 | } 49 | 50 | .pre { 51 | background-color: rgb(242, 242, 242); 52 | padding: 2px 4px; 53 | margin: 20px 0; 54 | line-height: 2.3; 55 | border-radius: 12px; 56 | overflow: auto; 57 | } 58 | 59 | .code_block { 60 | padding: 20px; 61 | font-family: monospace; 62 | display: flex; 63 | flex-wrap: wrap; 64 | } 65 | 66 | .file { 67 | padding: 2px 4px; 68 | text-decoration: none; 69 | } 70 | 71 | .file a { 72 | color: inherit; 73 | } 74 | 75 | .file:hover { 76 | background: rgba(55, 53, 47, 0.08); 77 | cursor: pointer; 78 | border-radius: 2px; 79 | } 80 | 81 | .italic { 82 | font-style: italic; 83 | } 84 | 85 | .strikethrough { 86 | text-decoration: line-through; 87 | } 88 | 89 | .underline { 90 | text-decoration: underline; 91 | } 92 | 93 | .bookmark { 94 | display: block; 95 | margin-bottom: 10px; 96 | } 97 | 98 | @media (prefers-color-scheme: dark) { 99 | .code { 100 | background-color: rgb(15, 8, 28); 101 | } 102 | .file:hover { 103 | background: rgba(255, 255, 255, 0.1); 104 | cursor: pointer; 105 | border-radius: 2px; 106 | } 107 | .pre { 108 | background-color: rgb(15, 8, 28); 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /lib/notion.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Client, 3 | isNotionClientError, 4 | LogLevel, 5 | APIErrorCode, 6 | ClientErrorCode, 7 | } from '@notionhq/client'; 8 | // import { NotionToMarkdown } from 'notion-to-md'; 9 | 10 | const notion = new Client({ 11 | auth: process.env.NOTION_ACCESS_TOKEN, 12 | // logLevel: LogLevel.DEBUG, 13 | }); 14 | 15 | export const getMeta = async (databaseId: string) => { 16 | try { 17 | const response = await notion.databases.retrieve({ 18 | database_id: databaseId, 19 | }); 20 | // console.log(response); 21 | return response; 22 | } catch (error: unknown) { 23 | if (isNotionClientError(error)) { 24 | switch (error.code) { 25 | case ClientErrorCode.RequestTimeout: 26 | console.log(error); 27 | break; 28 | case APIErrorCode.ObjectNotFound: 29 | console.log(error); 30 | break; 31 | case APIErrorCode.Unauthorized: 32 | console.log(error); 33 | break; 34 | } 35 | } 36 | } 37 | }; 38 | 39 | export const getDatabase = async (databaseId: string) => { 40 | try { 41 | const response = await notion.databases.query({ 42 | database_id: databaseId, 43 | filter: { 44 | property: 'Published', 45 | checkbox: { 46 | equals: true, 47 | }, 48 | }, 49 | sorts: [ 50 | { 51 | property: 'Created', 52 | direction: 'descending', 53 | }, 54 | ], 55 | }); 56 | // console.log(response); 57 | return response.results; 58 | } catch (error: unknown) { 59 | if (isNotionClientError(error)) { 60 | switch (error.code) { 61 | case ClientErrorCode.RequestTimeout: 62 | console.log(error); 63 | break; 64 | case APIErrorCode.ObjectNotFound: 65 | console.log(error); 66 | break; 67 | case APIErrorCode.Unauthorized: 68 | console.log(error); 69 | break; 70 | } 71 | } 72 | } 73 | }; 74 | 75 | export const getPage = async (pageId: string) => { 76 | const response = await notion.pages.retrieve({ page_id: pageId }); 77 | // console.log(response.properties); 78 | return response; 79 | }; 80 | 81 | export const getBlocks = async (blockId: string) => { 82 | const blocks = []; 83 | let cursor: string | undefined; 84 | while (true) { 85 | const { 86 | results, 87 | next_cursor, 88 | }: { results: any; next_cursor: string | null } = 89 | await notion.blocks.children.list({ 90 | start_cursor: cursor, 91 | block_id: blockId, 92 | }); 93 | blocks.push(...results); 94 | if (!next_cursor) { 95 | break; 96 | } 97 | cursor = next_cursor; 98 | } 99 | return blocks; 100 | }; 101 | 102 | // // Create ReadMe from Selected Notion Page 103 | // const n2m = new NotionToMarkdown({ notionClient: notion }); 104 | 105 | // (async () => { 106 | // const mdblocks = await n2m.pageToMarkdown('521021f4-fef2-45d2-86a5-4ad8d8ee2e9d'); 107 | // const mdString = n2m.toMarkdownString(mdblocks); 108 | 109 | // // write to file 110 | // fs.writeFile('') 111 | // }) 112 | -------------------------------------------------------------------------------- /components/PostCard.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @next/next/no-img-element */ 2 | import Link from 'next/link'; 3 | import styles from '../styles/index.module.css'; 4 | import { createStyles, Card, Image, Text, AspectRatio } from '@mantine/core'; 5 | 6 | const useStyles = createStyles((theme) => ({ 7 | card: { 8 | transition: 'transform 150ms ease, box-shadow 150ms ease', 9 | 10 | '&:hover': { 11 | transform: 'scale(1.01)', 12 | boxShadow: theme.shadows.md, 13 | }, 14 | }, 15 | 16 | title: { 17 | fontFamily: `Greycliff CF, ${theme.fontFamily}`, 18 | fontWeight: 600, 19 | }, 20 | })); 21 | 22 | const PostCard = ({ post }: { post: any }) => { 23 | const { classes } = useStyles(); 24 | 25 | const date = new Date(post.created_time).toLocaleString('en-US', { 26 | month: 'short', 27 | day: '2-digit', 28 | year: 'numeric', 29 | }); 30 | 31 | return ( 32 | 33 |
34 | 35 | {post.cover && ( 36 | 37 | {post.properties.Name.title} 45 | 46 | )} 47 | 54 | {date} 55 | 56 |
57 | {post.icon && ( 58 | 59 | {post.icon.type === 'emoji' ? ( 60 | post.icon.emoji 61 | ) : ( 62 | icon 67 | )} 68 | 69 | )} 70 | 71 | {post.properties.Name.title[0]['plain_text']} 72 | 73 |
74 | 75 |
76 |
77 |

{post.properties.Description.rich_text[0]['plain_text']}

78 |

79 | {post.properties.Tags['multi_select'].map((tag: any) => ( 80 | 89 | #{tag.name}{' '} 90 | 91 | ))} 92 |

93 |
94 | 95 | Read post → 96 |
97 | ); 98 | }; 99 | 100 | export default PostCard; 101 | -------------------------------------------------------------------------------- /pages/index.tsx: -------------------------------------------------------------------------------- 1 | import Head from 'next/head'; 2 | import styles from '../styles/index.module.css'; 3 | import { getDatabase, getMeta } from '../lib/notion'; 4 | import PostCard from '../components/PostCard'; 5 | import { 6 | SimpleGrid, 7 | Container, 8 | Title, 9 | Text, 10 | Overlay, 11 | createStyles, 12 | } from '@mantine/core'; 13 | 14 | export const databaseId: string = process.env.NOTION_BLOG_DATABASE_ID; 15 | 16 | const useStyles = createStyles((theme) => ({ 17 | wrapper: { 18 | position: 'relative', 19 | paddingTop: 180, 20 | paddingBottom: 130, 21 | 22 | backgroundSize: 'cover', 23 | backgroundPosition: 'center', 24 | 25 | '@media (max-width: 520px)': { 26 | paddingTop: 80, 27 | paddingBottom: 50, 28 | }, 29 | }, 30 | 31 | inner: { 32 | position: 'relative', 33 | zIndex: 1, 34 | }, 35 | 36 | title: { 37 | fontWeight: 800, 38 | fontSize: 40, 39 | letterSpacing: -1, 40 | paddingLeft: theme.spacing.md, 41 | paddingRight: theme.spacing.md, 42 | color: theme.white, 43 | marginBottom: theme.spacing.xs, 44 | textAlign: 'center', 45 | fontFamily: `Greycliff CF, ${theme.fontFamily}`, 46 | 47 | '@media (max-width: 520px)': { 48 | fontSize: 28, 49 | textAlign: 'left', 50 | }, 51 | }, 52 | 53 | highlight: { 54 | color: theme.colors[theme.primaryColor][4], 55 | }, 56 | 57 | description: { 58 | color: theme.colors.gray[0], 59 | textAlign: 'center', 60 | 61 | '@media (max-width: 520px)': { 62 | fontSize: theme.fontSizes.md, 63 | textAlign: 'left', 64 | }, 65 | }, 66 | })); 67 | 68 | export default function Home({ posts, meta }: { posts: any; meta: any }) { 69 | const { classes } = useStyles(); 70 | return ( 71 |
72 | 73 | Social Asides 74 | 75 | 76 | 77 |
81 | 82 |
83 | {meta.title[0]['plain_text']} 84 | 85 | 86 | {meta.description[0]['plain_text']} 87 | 88 | 89 |
90 |
91 |
92 |
93 |

All Posts

94 |
95 | 96 | 97 | 98 | {posts.map((post: any) => ( 99 | 100 | ))} 101 | 102 | 103 |
104 |
105 | ); 106 | } 107 | 108 | export const getStaticProps = async () => { 109 | const database = await getDatabase(databaseId); 110 | const dbMeta = await getMeta(databaseId); 111 | return { 112 | props: { 113 | posts: database, 114 | meta: dbMeta, 115 | }, 116 | revalidate: 1, 117 | }; 118 | }; 119 | -------------------------------------------------------------------------------- /components/layout/Layout.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { 3 | createStyles, 4 | Header, 5 | Group, 6 | ActionIcon, 7 | Container, 8 | Burger, 9 | useMantineColorScheme, 10 | } from '@mantine/core'; 11 | import { useDisclosure } from '@mantine/hooks'; 12 | import { IconBrandLinkedin, IconBrandGithub } from '@tabler/icons'; 13 | import { IconSun, IconMoonStars } from '@tabler/icons'; 14 | import Link from 'next/link'; 15 | 16 | const useStyles = createStyles((theme) => ({ 17 | inner: { 18 | display: 'flex', 19 | justifyContent: 'space-between', 20 | alignItems: 'center', 21 | height: 56, 22 | 23 | [theme.fn.smallerThan('sm')]: { 24 | justifyContent: 'flex-start', 25 | }, 26 | }, 27 | 28 | links: { 29 | width: 260, 30 | 31 | [theme.fn.smallerThan('sm')]: { 32 | display: 'none', 33 | }, 34 | }, 35 | 36 | social: { 37 | width: 260, 38 | 39 | [theme.fn.smallerThan('sm')]: { 40 | width: 'auto', 41 | marginLeft: 'auto', 42 | }, 43 | }, 44 | 45 | burger: { 46 | marginRight: theme.spacing.md, 47 | 48 | [theme.fn.largerThan('sm')]: { 49 | display: 'none', 50 | }, 51 | }, 52 | 53 | link: { 54 | display: 'block', 55 | lineHeight: 1, 56 | padding: '8px 12px', 57 | borderRadius: theme.radius.sm, 58 | textDecoration: 'none', 59 | color: 60 | theme.colorScheme === 'dark' 61 | ? theme.colors.dark[0] 62 | : theme.colors.gray[7], 63 | fontSize: theme.fontSizes.sm, 64 | fontWeight: 500, 65 | 66 | '&:hover': { 67 | backgroundColor: 68 | theme.colorScheme === 'dark' 69 | ? theme.colors.dark[6] 70 | : theme.colors.gray[0], 71 | }, 72 | }, 73 | 74 | linkActive: { 75 | '&, &:hover': { 76 | backgroundColor: theme.fn.variant({ 77 | variant: 'light', 78 | color: theme.primaryColor, 79 | }).background, 80 | color: theme.fn.variant({ variant: 'light', color: theme.primaryColor }) 81 | .color, 82 | }, 83 | }, 84 | })); 85 | 86 | export default function Layout() { 87 | const [opened, { toggle }] = useDisclosure(false); 88 | 89 | const { colorScheme, toggleColorScheme } = useMantineColorScheme(); 90 | const dark = colorScheme === 'dark'; 91 | const { classes, cx } = useStyles(); 92 | 93 | return ( 94 |
95 | 96 | 102 | 103 | 110 | 111 | 112 | 119 | 120 | 121 | 122 | 123 | 124 |

Social Asides

125 | 126 | 127 | 128 | toggleColorScheme()} 132 | title='Toggle color scheme' 133 | > 134 | {dark ? : } 135 | 136 | 137 |
138 |
139 | ); 140 | } 141 | -------------------------------------------------------------------------------- /components/TableOfContents.tsx: -------------------------------------------------------------------------------- 1 | import { Text, Box, Group, createStyles } from '@mantine/core'; 2 | import { useState, useEffect, useRef } from 'react'; 3 | import { IconListSearch } from '@tabler/icons'; 4 | 5 | // Styles 6 | const useStyles = createStyles((theme) => ({ 7 | toc: { 8 | position: 'fixed', 9 | right: '3em', 10 | top: '5em', 11 | padding: '1em', 12 | width: '14em', 13 | zIndex: 1, 14 | 15 | [`@media (max-width: 1175px)`]: { 16 | display: 'none', 17 | }, 18 | }, 19 | 20 | link: { 21 | ...theme.fn.focusStyles(), 22 | display: 'block', 23 | textDecoration: 'none', 24 | color: theme.colorScheme === 'dark' ? theme.colors.dark[0] : theme.black, 25 | lineHeight: 1.2, 26 | fontSize: theme.fontSizes.sm, 27 | padding: theme.spacing.xs, 28 | borderTopRightRadius: theme.radius.sm, 29 | borderBottomRightRadius: theme.radius.sm, 30 | borderLeft: `1px solid ${ 31 | theme.colorScheme === 'dark' ? theme.colors.dark[4] : theme.colors.gray[3] 32 | }`, 33 | 34 | '&:hover': { 35 | backgroundColor: 36 | theme.colorScheme === 'dark' 37 | ? theme.colors.dark[6] 38 | : theme.colors.gray[0], 39 | }, 40 | }, 41 | 42 | linkActive: { 43 | fontWeight: 500, 44 | borderLeftColor: 45 | theme.colors[theme.primaryColor][theme.colorScheme === 'dark' ? 6 : 7], 46 | color: 47 | theme.colors[theme.primaryColor][theme.colorScheme === 'dark' ? 2 : 7], 48 | 49 | '&, &:hover': { 50 | backgroundColor: 51 | theme.colorScheme === 'dark' 52 | ? theme.fn.rgba(theme.colors[theme.primaryColor][9], 0.25) 53 | : theme.colors[theme.primaryColor][0], 54 | }, 55 | }, 56 | })); 57 | 58 | // IntersectObserver Hook 59 | const useIntersectionObserver = (setActive: any) => { 60 | const headingElementsRef = useRef({}); 61 | useEffect(() => { 62 | const callback = (headings: any) => { 63 | headingElementsRef.current = headings.reduce( 64 | (map: any, headingElement: any) => { 65 | map[headingElement.target.id] = headingElement; 66 | return map; 67 | }, 68 | headingElementsRef.current 69 | ); 70 | // console.log(headingElementsRef); 71 | 72 | const visibleHeadings: any[] = []; 73 | Object.keys(headingElementsRef.current).forEach((key) => { 74 | const headingElement = headingElementsRef.current[key]; 75 | // console.log(headingElement); 76 | if (headingElement.isIntersecting) visibleHeadings.push(headingElement); 77 | }); 78 | 79 | const getIndexFromId = (id: string) => 80 | headingElements.findIndex((heading: any) => heading.id === id); 81 | 82 | // console.log(visibleHeadings); 83 | 84 | if (visibleHeadings.length === 1) { 85 | // console.log(visibleHeadings[0].target.id); 86 | setActive(`#${visibleHeadings[0].target.id}`); 87 | } else if (visibleHeadings.length > 1) { 88 | const sortedVisibleHeadings = visibleHeadings.sort( 89 | (a, b) => getIndexFromId(a.target.id) - getIndexFromId(b.target.id) 90 | ); 91 | // console.log(sortedVisibleHeadings[0].target.id); 92 | setActive(`#${sortedVisibleHeadings[0].target.id}`); 93 | } 94 | }; 95 | const observer = new IntersectionObserver(callback, { 96 | rootMargin: '0px 0px -40% 0px', 97 | }); 98 | 99 | const section = document?.querySelector('section') as HTMLElement; 100 | const headingElements = Array.from(section.querySelectorAll('h1, h2, h3')); 101 | // console.log(headingElements); 102 | headingElements.forEach((element) => observer.observe(element)); 103 | 104 | return () => observer.disconnect(); 105 | }, [setActive]); 106 | }; 107 | 108 | export default function TableOfContents({ 109 | links, 110 | }: { 111 | links: { label: string; link: string; order: number }[]; 112 | }): JSX.Element { 113 | const { classes, cx } = useStyles(); 114 | const [active, setActive] = useState(); 115 | useIntersectionObserver(setActive); 116 | 117 | const items = links.map((item: any) => ( 118 | 119 | component='a' 120 | href={item.link} 121 | onClick={(e) => { 122 | e.preventDefault(); 123 | document.querySelector(item.link)?.scrollIntoView({ 124 | behavior: 'smooth', 125 | }); 126 | setActive(item.link); 127 | }} 128 | key={item.label} 129 | className={cx(classes.link, { 130 | [classes.linkActive]: active === item.link, 131 | })} 132 | sx={(theme) => ({ paddingLeft: item.order * theme.spacing.md })} 133 | > 134 | {item.label} 135 | 136 | )); 137 | 138 | return ( 139 |
140 | 141 | 142 | Table of contents 143 | 144 | {items} 145 |
146 | ); 147 | } 148 | -------------------------------------------------------------------------------- /pages/[id].tsx: -------------------------------------------------------------------------------- 1 | import { Fragment } from 'react'; 2 | import Head from 'next/head'; 3 | import { GetStaticProps, GetStaticPaths, InferGetStaticPropsType } from 'next'; 4 | import { ParsedUrlQuery } from 'querystring'; 5 | import { getDatabase, getPage, getBlocks } from '../lib/notion'; 6 | import Link from 'next/link'; 7 | import { databaseId } from './index'; 8 | import { 9 | Container, 10 | Title, 11 | Text, 12 | Overlay, 13 | createStyles, 14 | Code, 15 | Box, 16 | Group, 17 | } from '@mantine/core'; 18 | import TableOfContents from '../components/TableOfContents'; 19 | import { Prism } from '@mantine/prism'; 20 | import styles from '../styles/post.module.css'; 21 | // Prevents Video Player from creating mismatch UI hydration glitch 22 | import dynamic from 'next/dynamic'; 23 | const ReactPlayer = dynamic(() => import('react-player'), { ssr: false }); 24 | 25 | // Styles 26 | 27 | const useStyles = createStyles((theme) => ({ 28 | wrapper: { 29 | position: 'relative', 30 | paddingTop: 180, 31 | paddingBottom: 130, 32 | 33 | backgroundSize: 'cover', 34 | backgroundPosition: 'center', 35 | 36 | '@media (max-width: 520px)': { 37 | paddingTop: 80, 38 | paddingBottom: 50, 39 | }, 40 | }, 41 | 42 | inner: { 43 | position: 'relative', 44 | zIndex: 1, 45 | }, 46 | 47 | title: { 48 | fontWeight: 800, 49 | fontSize: 40, 50 | letterSpacing: -1, 51 | paddingLeft: theme.spacing.md, 52 | paddingRight: theme.spacing.md, 53 | color: theme.white, 54 | marginBottom: theme.spacing.xs, 55 | textAlign: 'center', 56 | fontFamily: `Greycliff CF, ${theme.fontFamily}`, 57 | 58 | '@media (max-width: 520px)': { 59 | fontSize: 28, 60 | textAlign: 'left', 61 | }, 62 | }, 63 | 64 | highlight: { 65 | color: theme.colors[theme.primaryColor][4], 66 | }, 67 | 68 | description: { 69 | color: theme.colors.gray[0], 70 | textAlign: 'center', 71 | 72 | '@media (max-width: 520px)': { 73 | fontSize: theme.fontSizes.md, 74 | textAlign: 'left', 75 | }, 76 | }, 77 | 78 | code: { 79 | fontFamily: 'monospace', 80 | backgroundColor: theme.colorScheme === 'dark' ? 'lightgrey' : 'darkgrey', 81 | padding: '2px 4px', 82 | borderRadius: '2px', 83 | color: theme.colorScheme === 'dark' ? theme.black : theme.white, 84 | }, 85 | })); 86 | 87 | export const TextBlock = ({ text }: { text: any }) => { 88 | const { classes } = useStyles(); 89 | 90 | if (!text) { 91 | return null; 92 | } 93 | return text.map((value: any) => { 94 | const { 95 | annotations: { bold, code, color, italic, strikethrough, underline }, 96 | text, 97 | } = value; 98 | return ( 99 | 110 | {text.link ? {text.content} : text.content} 111 | 112 | ); 113 | }); 114 | }; 115 | 116 | const renderNestedList = (block: any) => { 117 | const { type } = block; 118 | const value = block[type]; 119 | if (!value) return null; 120 | 121 | const isNumberedList = value.children[0].type === 'numbered_list_item'; 122 | 123 | if (isNumberedList) { 124 | return
    {value.children.map((block: any) => renderBlock(block))}
; 125 | } 126 | return
    {value.children.map((block: any) => renderBlock(block))}
; 127 | }; 128 | 129 | const renderBlock = (block: any) => { 130 | const { type, id } = block; 131 | const value = block[type]; 132 | 133 | let blockColor; 134 | const b4Underscore = new RegExp('^[^_]+'); 135 | if (!value.color || type === 'divider' || value.color === 'default') { 136 | blockColor = 'none'; 137 | } else { 138 | blockColor = value.color.match(b4Underscore)[0]; 139 | } 140 | 141 | // console.log(blockColor); 142 | 143 | switch (type) { 144 | case 'paragraph': 145 | return ( 146 |

147 | 148 |

149 | ); 150 | case 'heading_1': 151 | return ( 152 |

153 | 154 |

155 | ); 156 | case 'heading_2': 157 | return ( 158 |

159 | 160 |

161 | ); 162 | case 'heading_3': 163 | return ( 164 |

165 | 166 |

167 | ); 168 | case 'bulleted_list_item': 169 | case 'numbered_list_item': 170 | return ( 171 |
  • 172 | 173 | {!!value.children && renderNestedList(block)} 174 |
  • 175 | ); 176 | case 'to_do': 177 | return ( 178 |
    179 | 183 |
    184 | ); 185 | case 'toggle': 186 | return ( 187 |
    188 | 189 | 190 | 191 | {value.children?.map((block: any) => ( 192 | {renderBlock(block)} 193 | ))} 194 |
    195 | ); 196 | case 'child_page': 197 | return

    {value.title}

    ; 198 | case 'image': 199 | const src = 200 | value.type === 'external' ? value.external.url : value.file.url; 201 | const caption = value.caption ? value.caption[0]?.plain_text : ''; 202 | return ( 203 |
    204 | {/* eslint-disable-next-line @next/next/no-img-element */} 205 | {caption} 206 | {caption &&
    {caption}
    } 207 |
    208 | ); 209 | case 'video': 210 | const video_src = 211 | value.type === 'external' ? value.external.url : value.file.url; 212 | const video_caption = value.caption ? value.caption[0]?.plain_text : ''; 213 | if (value.type === 'external') { 214 | return ( 215 |
    216 |
    224 | 231 |
    232 | {video_caption && {video_caption}} 233 |
    234 | ); 235 | } else { 236 | return ( 237 |
    238 |
    246 |
    254 | {video_caption && {video_caption}} 255 |
    256 | ); 257 | } 258 | 259 | case 'divider': 260 | return
    ; 261 | case 'quote': 262 | return ( 263 |
    264 | {value.rich_text[0].plain_text} 265 |
    266 | ); 267 | case 'code': 268 | const codeCaption = value.caption ? value.caption[0]?.plain_text : ''; 269 | // console.log(value); 270 | if (value.language === 'plain text') { 271 | return ( 272 |
    273 |
    "raw"
    274 | 275 | {value.rich_text[0].plain_text} 276 | 277 | {codeCaption &&
    {codeCaption}
    } 278 |
    279 | ); 280 | } else { 281 | // console.log(value.rich_text); 282 | return ( 283 |
    284 |
    {value.language}
    285 | 286 | {value.rich_text.length > 0 287 | ? value.rich_text[0][value.rich_text[0].type].content 288 | : '// no code entered here'} 289 | 290 | {codeCaption &&
    {codeCaption}
    } 291 |
    292 | ); 293 | } 294 | case 'file': 295 | const src_file = 296 | value.type === 'external' ? value.external.url : value.file.url; 297 | const splitSourceArray = src_file.split('/'); 298 | const lastElementInArray = splitSourceArray[splitSourceArray.length - 1]; 299 | const caption_file = value.caption ? value.caption[0]?.plain_text : ''; 300 | return ( 301 |
    302 |
    303 | 📎{' '} 304 | 305 | {lastElementInArray.split('?')[0]} 306 | 307 |
    308 | {caption_file &&
    {caption_file}
    } 309 |
    310 | ); 311 | case 'bookmark': 312 | // console.log(block); 313 | // @TODO add in Puppetier function to create social card instead of just url 314 | const href = value.url; 315 | return ( 316 | 322 | {href} 323 | 324 | ); 325 | default: 326 | // console.log(block); 327 | return `❌ Unsupported block (${ 328 | type === 'unsupported' ? 'unsupported by Notion API' : type 329 | })`; 330 | } 331 | }; 332 | 333 | export default function Post({ 334 | page, 335 | blocks, 336 | links, 337 | }: InferGetStaticPropsType) { 338 | const { classes } = useStyles(); 339 | if (!page || !blocks) { 340 | return
    ; 341 | } 342 | 343 | return ( 344 |
    345 | 346 | {page.properties.Name.title[0].plain_text} 347 | 348 | 349 | 350 |
    351 |
    361 | 362 |
    363 | 364 | {page.properties.Name.title[0]['plain_text']} 365 | 366 | 367 | 368 | {page.properties.Description.rich_text[0]['plain_text']} 369 | 370 | 371 |
    372 |
    373 |
    374 | 375 | {blocks.map((block: any) => ( 376 | {renderBlock(block)} 377 | ))} 378 | 379 | ← Go home 380 | 381 |
    382 |
    383 |
    384 | ); 385 | } 386 | 387 | interface IParams extends ParsedUrlQuery { 388 | id: string; 389 | } 390 | 391 | export const getStaticPaths: GetStaticPaths = async () => { 392 | const database = await getDatabase(databaseId); 393 | const paths = database?.map((page) => ({ params: { id: page.id } }))!; 394 | return { 395 | paths, 396 | fallback: true, 397 | }; 398 | }; 399 | 400 | export const getStaticProps: GetStaticProps = async (context) => { 401 | const { id } = context.params as IParams; 402 | const page = await getPage(id); 403 | const blocks = await getBlocks(id); 404 | 405 | // Retrieve block children for nested blocks (one level deep), for example toggle blocks 406 | // https://developers.notion.com/docs/working-with-page-content#reading-nested-blocks 407 | const childBlocks = await Promise.all( 408 | blocks 409 | .filter((block) => block.has_children) 410 | .map(async (block) => { 411 | return { 412 | id: block.id, 413 | children: await getBlocks(block.id), 414 | }; 415 | }) 416 | ); 417 | const blocksWithChildren = blocks.map((block) => { 418 | // Add child blocks if the block should contain children but none exists 419 | if (block.has_children && !block[block.type].children) { 420 | block[block.type]['children'] = childBlocks.find( 421 | (x) => x.id === block.id 422 | )?.children; 423 | } 424 | return block; 425 | }); 426 | 427 | // interface tocHeadingProps { 428 | // links: { label: string; link: string; order: number }[]; 429 | // } 430 | 431 | const getLinks = (blocksWithChildren: any[]) => { 432 | const links: { label: string; link: string; order: number }[] = []; 433 | 434 | blocksWithChildren.map((block: any) => { 435 | const { type, id } = block; 436 | const value = block[type]; 437 | let linkObj; 438 | if ( 439 | type === 'heading_1' || 440 | type === 'heading_2' || 441 | type === 'heading_3' 442 | ) { 443 | linkObj = { 444 | label: value.rich_text[0].text.content, 445 | link: `#id${id}`, 446 | order: Number(type.substr(-1)), 447 | }; 448 | links.push(linkObj); 449 | } 450 | }); 451 | // console.log(tocHeadings); 452 | return links; 453 | }; 454 | 455 | const tocLinks = getLinks(blocksWithChildren); 456 | 457 | return { 458 | props: { 459 | page, 460 | blocks: blocksWithChildren, 461 | links: tocLinks, 462 | }, 463 | revalidate: 1, 464 | }; 465 | }; 466 | --------------------------------------------------------------------------------