├── .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 |
--------------------------------------------------------------------------------
/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 |
45 |
46 | )}
47 |
54 | {date}
55 |
56 |
57 | {post.icon && (
58 |
59 | {post.icon.type === 'emoji' ? (
60 | post.icon.emoji
61 | ) : (
62 |
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 |
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 |
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 |
253 |
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 |
--------------------------------------------------------------------------------