├── .editorconfig ├── .eslintrc.json ├── .github ├── dependabot.yml ├── issue_template.md ├── pull_request_template.md └── workflows │ ├── build.yml │ └── combine-prs.yml ├── .gitignore ├── .prettierignore ├── .prettierrc ├── .vscode ├── launch.json └── settings.json ├── components ├── AutoFocus.tsx ├── BreadCrumbs.tsx ├── Button.tsx ├── Card.jsx ├── Collapse.jsx ├── DayView.tsx ├── Fade.tsx ├── GeoLocate.tsx ├── Icon.tsx ├── Link.jsx ├── Loading.tsx ├── LoadingIcon.tsx ├── Modal.tsx ├── Rotate.tsx ├── Select.tsx ├── Switch.tsx ├── TagItemMini.tsx ├── ToggleThemeButton.tsx └── Toolbar.tsx ├── data ├── Vacancy-2022fa.json ├── defaultCoverImage.js └── location_list.json ├── layouts ├── ErrorPage.tsx ├── Layout.tsx ├── NotionPage.tsx ├── Page404.tsx ├── PageHTMLHead.tsx ├── components │ ├── BlogPostCard.jsx │ ├── Footer.tsx │ ├── Giscus.jsx │ ├── Header.tsx │ ├── InfoCard.tsx │ ├── NavBar.tsx │ ├── ReadingTime.tsx │ ├── SocialButton.tsx │ └── TableOfContent.tsx ├── index.ts └── styles.module.css ├── lib ├── config │ ├── get-config-value.ts │ └── index.ts ├── dayjs.ts ├── db.ts ├── get-canonical-page-id.ts ├── get-icon.ts ├── get-site-map.ts ├── get-social-image-url.ts ├── map-image-url.ts ├── map-page-url.ts ├── notion.ts ├── preview-images.ts ├── resolve-notion-page.ts ├── types.ts └── use-dark-mode.ts ├── license ├── next-env.d.ts ├── next-sitemap.config.js ├── next.config.js ├── package.json ├── pages ├── 404.tsx ├── [pageId].tsx ├── _app.tsx ├── _document.tsx ├── _error.tsx ├── api │ ├── revalidate.ts │ └── sunset.ts ├── feed.tsx ├── index.tsx └── projects │ ├── brightspace.tsx │ ├── index.tsx │ ├── nyu-academic-calendar.tsx │ └── nyu-space.tsx ├── postcss.config.js ├── public ├── 404.png ├── avatar-circle.webp ├── avatar.jpg ├── avatar.webp ├── dino.svg ├── error.png ├── favicon.ico ├── favicon.png ├── favicon.svg ├── favicons │ ├── android-chrome-96x96.png │ ├── apple-touch-icon.png │ ├── favicon-16x16.png │ ├── favicon-24x24.webp │ ├── favicon-32x32.png │ ├── mstile-150x150.png │ ├── safari-pinned-tab.svg │ └── site.webmanifest ├── images │ ├── alesia-kazantceva-VWcPlbHglYc-unsplash.jpg │ ├── alfons-morales-YLSwjSy7stw-unsplash.jpg │ ├── bobst-classroom.jpg │ ├── calendar.png │ ├── chair.png │ ├── city.webp │ ├── edwin-andrade-4V1dC_eoCwg-unsplash.jpg │ ├── maarten-van-den-heuvel-8EzNkvLQosk-unsplash.jpg │ ├── metasearch.jpg │ ├── nyu.jpg │ ├── space.png │ ├── space.webp │ ├── sunset.png │ └── tools.webp ├── loading-100x100.gif ├── loading-cat-transparent-120x120.gif ├── loading-cat-transparent.gif └── previews │ ├── lighthouse.webp │ ├── preview-dark.webp │ └── preview-light.webp ├── readme.md ├── site.config.tsx ├── stylelint.config.js ├── styles ├── global.css ├── notion.css ├── prism-theme.css ├── react-notion-x.css └── variables.css ├── tailwind.config.js ├── tsconfig.json ├── utils ├── link.ts ├── useClickOutside.tsx ├── useGlobal.tsx ├── useMediaQuery.tsx └── useModal.tsx └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | charset = utf-8 3 | indent_style = space 4 | indent_size = 2 5 | end_of_line = lf 6 | insert_final_newline = true 7 | trim_trailing_whitespace = true 8 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["next/core-web-vitals", "prettier"], 3 | "rules": { 4 | "import/no-anonymous-default-export": "off" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: 'npm' # See documentation for possible values 9 | directory: '/' # Location of package manifests 10 | schedule: 11 | interval: 'weekly' 12 | -------------------------------------------------------------------------------- /.github/issue_template.md: -------------------------------------------------------------------------------- 1 | #### Description 2 | 3 | 6 | 7 | #### Notion Test Page ID 8 | 9 | 16 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | #### Description 2 | 3 | 6 | 7 | #### Notion Test Page ID 8 | 9 | 16 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | Build: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v2 10 | 11 | - uses: actions/setup-node@v2 12 | with: 13 | node-version: 16 14 | cache: yarn 15 | 16 | - run: yarn install --frozen-lockfile 17 | - name: build 18 | # TODO Enable those lines below if you use a Redis cache, you'll also need to configure GitHub Repository Secrets 19 | # env: 20 | # REDIS_HOST: ${{ secrets.REDIS_HOST }} 21 | # REDIS_PASSWORD: ${{ secrets.REDIS_PASSWORD }} 22 | run: yarn build 23 | -------------------------------------------------------------------------------- /.github/workflows/combine-prs.yml: -------------------------------------------------------------------------------- 1 | name: 'Combine PRs' 2 | 3 | # Controls when the action will run - in this case triggered manually 4 | on: 5 | workflow_dispatch: 6 | inputs: 7 | branchPrefix: 8 | description: 'Branch prefix to find combinable PRs based on' 9 | required: true 10 | default: 'dependabot' 11 | mustBeGreen: 12 | description: 'Only combine PRs that are green (status is success)' 13 | required: true 14 | default: true 15 | combineBranchName: 16 | description: 'Name of the branch to combine PRs into' 17 | required: true 18 | default: 'combine-prs-branch' 19 | ignoreLabel: 20 | description: 'Exclude PRs with this label' 21 | required: true 22 | default: 'nocombine' 23 | 24 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 25 | jobs: 26 | # This workflow contains a single job called "combine-prs" 27 | combine-prs: 28 | # The type of runner that the job will run on 29 | runs-on: ubuntu-latest 30 | 31 | # Steps represent a sequence of tasks that will be executed as part of the job 32 | steps: 33 | - uses: actions/github-script@v6 34 | id: create-combined-pr 35 | name: Create Combined PR 36 | with: 37 | github-token: ${{secrets.GITHUB_TOKEN}} 38 | script: | 39 | const pulls = await github.paginate('GET /repos/:owner/:repo/pulls', { 40 | owner: context.repo.owner, 41 | repo: context.repo.repo 42 | }); 43 | let branchesAndPRStrings = []; 44 | let baseBranch = null; 45 | let baseBranchSHA = null; 46 | for (const pull of pulls) { 47 | const branch = pull['head']['ref']; 48 | console.log('Pull for branch: ' + branch); 49 | if (branch.startsWith('${{ github.event.inputs.branchPrefix }}')) { 50 | console.log('Branch matched prefix: ' + branch); 51 | let statusOK = true; 52 | if(${{ github.event.inputs.mustBeGreen }}) { 53 | console.log('Checking green status: ' + branch); 54 | const stateQuery = `query($owner: String!, $repo: String!, $pull_number: Int!) { 55 | repository(owner: $owner, name: $repo) { 56 | pullRequest(number:$pull_number) { 57 | commits(last: 1) { 58 | nodes { 59 | commit { 60 | statusCheckRollup { 61 | state 62 | } 63 | } 64 | } 65 | } 66 | } 67 | } 68 | }` 69 | const vars = { 70 | owner: context.repo.owner, 71 | repo: context.repo.repo, 72 | pull_number: pull['number'] 73 | }; 74 | const result = await github.graphql(stateQuery, vars); 75 | const [{ commit }] = result.repository.pullRequest.commits.nodes; 76 | const state = commit.statusCheckRollup.state 77 | console.log('Validating status: ' + state); 78 | if(state != 'SUCCESS') { 79 | console.log('Discarding ' + branch + ' with status ' + state); 80 | statusOK = false; 81 | } 82 | } 83 | console.log('Checking labels: ' + branch); 84 | const labels = pull['labels']; 85 | for(const label of labels) { 86 | const labelName = label['name']; 87 | console.log('Checking label: ' + labelName); 88 | if(labelName == '${{ github.event.inputs.ignoreLabel }}') { 89 | console.log('Discarding ' + branch + ' with label ' + labelName); 90 | statusOK = false; 91 | } 92 | } 93 | if (statusOK) { 94 | console.log('Adding branch to array: ' + branch); 95 | const prString = '#' + pull['number'] + ' ' + pull['title']; 96 | branchesAndPRStrings.push({ branch, prString }); 97 | baseBranch = pull['base']['ref']; 98 | baseBranchSHA = pull['base']['sha']; 99 | } 100 | } 101 | } 102 | if (branchesAndPRStrings.length == 0) { 103 | core.setFailed('No PRs/branches matched criteria'); 104 | return; 105 | } 106 | try { 107 | await github.rest.git.createRef({ 108 | owner: context.repo.owner, 109 | repo: context.repo.repo, 110 | ref: 'refs/heads/' + '${{ github.event.inputs.combineBranchName }}', 111 | sha: baseBranchSHA 112 | }); 113 | } catch (error) { 114 | console.log(error); 115 | core.setFailed('Failed to create combined branch - maybe a branch by that name already exists?'); 116 | return; 117 | } 118 | 119 | let combinedPRs = []; 120 | let mergeFailedPRs = []; 121 | for(const { branch, prString } of branchesAndPRStrings) { 122 | try { 123 | await github.rest.repos.merge({ 124 | owner: context.repo.owner, 125 | repo: context.repo.repo, 126 | base: '${{ github.event.inputs.combineBranchName }}', 127 | head: branch, 128 | }); 129 | console.log('Merged branch ' + branch); 130 | combinedPRs.push(prString); 131 | } catch (error) { 132 | console.log('Failed to merge branch ' + branch); 133 | mergeFailedPRs.push(prString); 134 | } 135 | } 136 | 137 | console.log('Creating combined PR'); 138 | const combinedPRsString = combinedPRs.join('\n'); 139 | let body = '✅ This PR was created by the Combine PRs action by combining the following PRs:\n' + combinedPRsString; 140 | if(mergeFailedPRs.length > 0) { 141 | const mergeFailedPRsString = mergeFailedPRs.join('\n'); 142 | body += '\n\n⚠️ The following PRs were left out due to merge conflicts:\n' + mergeFailedPRsString 143 | } 144 | await github.rest.pulls.create({ 145 | owner: context.repo.owner, 146 | repo: context.repo.repo, 147 | title: 'Combined PR', 148 | head: '${{ github.event.inputs.combineBranchName }}', 149 | base: baseBranch, 150 | body: body 151 | }); 152 | -------------------------------------------------------------------------------- /.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 | # ide 23 | .idea 24 | 25 | # debug 26 | npm-debug.log* 27 | yarn-debug.log* 28 | yarn-error.log* 29 | 30 | # local env files 31 | .env 32 | .env.local 33 | .env.build 34 | .env.development.local 35 | .env.test.local 36 | .env.production.local 37 | 38 | # vercel 39 | .vercel 40 | 41 | # temp 42 | tmp* 43 | logs 44 | *.log 45 | cache.json 46 | 47 | # misc 48 | robots.txt 49 | sitemap.xml 50 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .snapshots/ 2 | build/ 3 | dist/ 4 | node_modules/ 5 | .next/ 6 | .vercel/ 7 | 8 | .demo/ 9 | .renderer/ 10 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "jsxSingleQuote": true, 4 | "semi": false, 5 | "useTabs": false, 6 | "tabWidth": 2, 7 | "bracketSpacing": true, 8 | "bracketSameLine": false, 9 | "arrowParens": "always", 10 | "trailingComma": "es5" 11 | } 12 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "type": "node", 6 | "request": "launch", 7 | "name": "next build", 8 | "runtimeExecutable": "${workspaceFolder}/node_modules/.bin/next", 9 | "runtimeArgs": ["build"], 10 | "cwd": "${workspaceFolder}", 11 | "smartStep": true, 12 | "console": "integratedTerminal", 13 | "skipFiles": ["/**"], 14 | "env": { 15 | "NODE_OPTIONS": "--inspect" 16 | } 17 | }, 18 | { 19 | "type": "node", 20 | "request": "launch", 21 | "name": "next dev", 22 | "runtimeExecutable": "${workspaceFolder}/node_modules/.bin/next", 23 | "runtimeArgs": ["dev"], 24 | "cwd": "${workspaceFolder}", 25 | "smartStep": true, 26 | "console": "integratedTerminal", 27 | "skipFiles": ["/**"], 28 | "env": { 29 | "NODE_OPTIONS": "--inspect" 30 | } 31 | }, 32 | { 33 | "type": "node", 34 | "request": "attach", 35 | "name": "Next.js App", 36 | "skipFiles": ["/**"], 37 | "port": 9229 38 | } 39 | ] 40 | } 41 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "./node_modules/typescript/lib" 3 | // "files.exclude": { 4 | // "**/logs": true, 5 | // "**/*.log": true, 6 | // "**/npm-debug.log*": true, 7 | // "**/yarn-debug.log*": true, 8 | // "**/yarn-error.log*": true, 9 | // "**/pids": true, 10 | // "**/*.pid": true, 11 | // "**/*.seed": true, 12 | // "**/*.pid.lock": true, 13 | // "**/.dummy": true, 14 | // "**/lib-cov": true, 15 | // "**/coverage": true, 16 | // "**/.nyc_output": true, 17 | // "**/.grunt": true, 18 | // "**/.snapshots/": true, 19 | // "**/bower_components": true, 20 | // "**/.lock-wscript": true, 21 | // "build/Release": true, 22 | // // "**/node_modules/": true, 23 | // "**/jspm_packages/": true, 24 | // "**/typings/": true, 25 | // "**/.npm": true, 26 | // "**/.eslintcache": true, 27 | // "**/.node_repl_history": true, 28 | // "**/*.tgz": true, 29 | // "**/.yarn-integrity": true, 30 | // // "**/.next/": true, 31 | // "**/dist/": true, 32 | // "**/build/": true, 33 | // "**/.now/": true, 34 | // "**/.vercel/": true, 35 | // "**/.google.json": true 36 | // } 37 | } 38 | -------------------------------------------------------------------------------- /components/AutoFocus.tsx: -------------------------------------------------------------------------------- 1 | import { useRef, useEffect, FunctionComponent, HTMLAttributes } from 'react' 2 | 3 | const AutoFocus: FunctionComponent< 4 | { 5 | as: keyof JSX.IntrinsicElements 6 | } & HTMLAttributes 7 | > = ({ as: Wrapper = 'div', children, ...rest }) => { 8 | const ref = useRef() 9 | useEffect(() => { 10 | if (ref.current) { 11 | ref.current?.scrollIntoView() 12 | ref.current?.focus() 13 | } 14 | }, []) 15 | 16 | return ( 17 | // @ts-ignore 18 | 19 | {children} 20 | 21 | ) 22 | } 23 | 24 | export default AutoFocus 25 | -------------------------------------------------------------------------------- /components/BreadCrumbs.tsx: -------------------------------------------------------------------------------- 1 | import Button from '@/components/Button' 2 | import Icon from '@/components/Icon' 3 | import React from 'react' 4 | 5 | function BreadCrumbs({ breadcrumbs, className = '' }) { 6 | const breadCrumbs = React.useMemo(() => { 7 | const b = breadcrumbs.slice(0, -1).map(({ icon, url, title }, index) => ( 8 |
  • 9 | 20 |
  • 21 | )) 22 | const curr = breadcrumbs[breadcrumbs.length - 1] 23 | if (curr) 24 | b.push( 25 |
  • 31 | {curr.icon ? ( 32 | 33 | ) : null} 34 | 35 | {curr.title} 36 | 37 |
  • 38 | ) 39 | return b 40 | }, [breadcrumbs]) 41 | return breadCrumbs 42 | } 43 | 44 | export default BreadCrumbs 45 | -------------------------------------------------------------------------------- /components/Button.tsx: -------------------------------------------------------------------------------- 1 | import cx from 'clsx' 2 | import React from 'react' 3 | import { linkProps } from '@/utils/link' 4 | 5 | const sizeMap = (isIcon) => ({ 6 | small: cx('text-sm', isIcon ? 'p-2' : 'px-3 p-2'), 7 | medium: cx('text-base', isIcon ? 'p-2.5' : 'px-5 py-2.5'), 8 | large: cx('text-lg', isIcon ? 'p-3.5' : 'px-6 py-3.5'), 9 | }) 10 | 11 | const bgColor = { 12 | primary: cx( 13 | 'acrylic', 14 | 'bg-primary-500', // background 15 | 'hover:bg-primary-550 dark:hover:bg-primary-450 ', // hover 16 | 'hover:shadow-primary-500/50', // shadow 17 | 'active:bg-primary-600 dark:active:bg-primary-600' // active 18 | ), 19 | transparent: cx( 20 | 'hover:bg-gray-500/10', // hover 21 | 'active:bg-gray-500/20' // active 22 | ), 23 | default: cx( 24 | 'acrylic', 25 | 'bg-white/70 dark:bg-gray-700/80', // background 26 | 'hover:bg-gray-white dark:hover:bg-gray-600/80', // hover 27 | 'active:bg-gray-100 dark:active:bg-gray-900/80', // active 28 | 'hover:shadow-gray-500/30 dark:hover:shadow-gray-500/30' // shadow 29 | ), 30 | } 31 | const textColor = { 32 | primary: 'text-white', 33 | transparent: 'text-current', 34 | default: 'text-gray-600 dark:text-gray-100', 35 | } 36 | 37 | const justifyMap = { 38 | start: 'justify-start', 39 | center: 'justify-center', 40 | end: 'justify-end', 41 | } 42 | 43 | const gapMap = { 44 | 0: 'gap-0', 45 | 1: 'gap-1', 46 | 2: 'gap-2', 47 | 3: 'gap-3', 48 | 4: 'gap-4', 49 | 8: 'gap-8', 50 | } 51 | 52 | const Button = React.forwardRef< 53 | HTMLButtonElement | HTMLAnchorElement, 54 | { 55 | className?: string 56 | size?: 'small' | 'medium' | 'large' 57 | color?: 'primary' | 'transparent' | 'default' 58 | rounded?: boolean 59 | icon?: React.ReactNode 60 | leftIcon?: React.ReactNode 61 | rightIcon?: React.ReactNode 62 | href?: string 63 | justify?: 'start' | 'center' | 'end' 64 | gap?: 0 | 1 | 2 | 3 | 4 | 8 65 | position?: 'absolute' | 'relative' | 'static' | 'sticky' | 'fixed' 66 | } & ( 67 | | React.ButtonHTMLAttributes 68 | | React.AnchorHTMLAttributes 69 | ) 70 | >( 71 | ( 72 | { 73 | children, 74 | className = '', 75 | size = 'medium', 76 | color = 'default', 77 | icon = null, 78 | leftIcon = null, 79 | rightIcon = null, 80 | href = null, 81 | rounded = false, 82 | justify = 'center', 83 | gap = 2, 84 | position = 'relative', 85 | ...props 86 | }, 87 | ref 88 | ) => { 89 | const isIcon = icon !== null 90 | 91 | const classNames = cx( 92 | 'flex items-center', 93 | 'group transition-all duration-200 ease-in-out', 94 | 'focus-visible:outline-1 outline-transparent focus-visible:outline-black focus-visible:dark:outline-white', 95 | color === 'transparent' || 'shadow-md active:shadow-none', 96 | bgColor[color], 97 | textColor[color], 98 | justifyMap[justify], 99 | gapMap[gap], 100 | { rounded: rounded }, 101 | sizeMap(isIcon)[size], 102 | position, 103 | className 104 | ) 105 | 106 | const content = ( 107 | <> 108 | {icon} 109 | {leftIcon} 110 | {children} 111 | {rightIcon} 112 | 113 | ) 114 | 115 | if (href) 116 | return ( 117 | )} 122 | ref={ref as React.ForwardedRef} 123 | > 124 | {content} 125 | 126 | ) 127 | 128 | return ( 129 | 136 | ) 137 | } 138 | ) 139 | Button.displayName = 'IconButton' 140 | 141 | export default Button 142 | -------------------------------------------------------------------------------- /components/Card.jsx: -------------------------------------------------------------------------------- 1 | import Image from 'next/legacy/image' 2 | import Link from './Link' 3 | 4 | export const ProjectCard = ({ title, description, coverImage, href }) => ( 5 | 10 | {title} 18 |
    19 |

    20 | {title} 21 |

    22 |

    23 | {description} 24 |

    25 |
    26 | 27 | ) 28 | -------------------------------------------------------------------------------- /components/Collapse.jsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef } from 'react' 2 | 3 | const Collapse = (props) => { 4 | const { id, className } = props 5 | const collapseRef = useRef(null) 6 | const collapseSection = (element) => { 7 | const sectionHeight = element.scrollHeight 8 | const currentHeight = element.style.height 9 | if (currentHeight === '0px') { 10 | return 11 | } 12 | requestAnimationFrame(function () { 13 | element.style.height = sectionHeight + 'px' 14 | requestAnimationFrame(function () { 15 | element.style.height = 0 + 'px' 16 | }) 17 | }) 18 | } 19 | const expandSection = (element) => { 20 | const sectionHeight = element.scrollHeight 21 | element.style.height = sectionHeight + 'px' 22 | const clearTime = setTimeout(() => { 23 | element.style.height = 'auto' 24 | }, 400) 25 | clearTimeout(clearTime) 26 | } 27 | useEffect(() => { 28 | const element = collapseRef.current 29 | if (props.isOpen) { 30 | expandSection(element) 31 | } else { 32 | collapseSection(element) 33 | } 34 | }, [props.isOpen]) 35 | return ( 36 |
    42 | {props.children} 43 |
    44 | ) 45 | } 46 | Collapse.defaultProps = { isOpen: false } 47 | 48 | export default Collapse 49 | -------------------------------------------------------------------------------- /components/DayView.tsx: -------------------------------------------------------------------------------- 1 | import cx from 'clsx' 2 | import { 3 | createContext, 4 | forwardRef, 5 | useContext, 6 | useEffect, 7 | useRef, 8 | useState, 9 | } from 'react' 10 | // const getColorFromTitle = (title: string) => {} 11 | 12 | const WeekDayViewContext = createContext(null) 13 | const BG_COLORS = [ 14 | 'bg-red-400 dark:bg-red-600', 15 | 'bg-orange-400 dark:bg-orange-600', 16 | 'bg-yellow-400 dark:bg-yellow-600', 17 | 'bg-lime-400 dark:bg-lime-600', 18 | 'bg-green-400 dark:bg-green-600', 19 | 'bg-teal-400 dark:bg-teal-600', 20 | 'bg-cyan-400 dark:bg-cyan-600', 21 | 'bg-blue-400 dark:bg-blue-600', 22 | 'bg-indigo-400 dark:bg-indigo-600', 23 | 'bg-purple-400 dark:bg-purple-600', 24 | 'bg-fuchsia-400 dark:bg-fuchsia-600', 25 | 'bg-rose-400 dark:bg-rose-600', 26 | ] 27 | 28 | const getBgColor = (title) => 29 | BG_COLORS[Math.abs(hash(title)) % BG_COLORS.length] 30 | 31 | function useWeekDayViewContext() { 32 | const context = useContext(WeekDayViewContext) 33 | if (context === null) { 34 | const err = new Error(`Missing a valid parent component.`) 35 | if (Error.captureStackTrace) Error.captureStackTrace(err) 36 | throw err 37 | } 38 | return context 39 | } 40 | 41 | const EventBlock = ({ title = '', start, end, className = '' }) => { 42 | const { 43 | blockWidth, 44 | blockHeight, 45 | startHour: dayStartHour, 46 | } = useWeekDayViewContext() 47 | const [startH, startM] = start.split(':') 48 | const [endH, endM] = end.split(':') 49 | const startHour = parseInt(startH) + parseInt(startM) / 60 50 | const endHour = parseInt(endH) + parseInt(endM) / 60 51 | const height = (endHour - startHour) * 2 * blockHeight - 4 52 | const top = (startHour - dayStartHour) * 2 * blockHeight 53 | return ( 54 |
    66 |

    {title}

    67 |
    68 | {start} - {end} 69 |
    70 |
    71 | ) 72 | } 73 | function hash(str, seed = 0x811c9dc5) { 74 | /*jshint bitwise:false */ 75 | let hval = seed 76 | 77 | for (let i = 0, l = str.length; i < l; i++) { 78 | hval ^= str.charCodeAt(i) 79 | hval += (hval << 1) + (hval << 4) + (hval << 7) + (hval << 8) + (hval << 24) 80 | } 81 | return hval >>> 0 82 | } 83 | 84 | const NowIndicator = forwardRef((props, ref) => { 85 | const { scheduleContainerWidth, blockHeight, startHour, endHour } = 86 | useWeekDayViewContext() 87 | const now = new Date() 88 | const nowHour = now.getHours() + now.getMinutes() / 60 89 | const hour = Math.min(endHour + 0.15, Math.max(startHour - 0.15, nowHour)) 90 | const top = (hour - startHour) * 2 * blockHeight + blockHeight 91 | return ( 92 | 104 | {/* {nowHour} */} 105 | 106 | ) 107 | }) 108 | NowIndicator.displayName = 'NowIndicator' 109 | 110 | export const DaySchedule = ({ title, events }) => { 111 | const { borderColor, blockWidthTW, blockHeightTW, times } = 112 | useWeekDayViewContext() 113 | 114 | return ( 115 |
    122 |

    129 | {title} 130 |

    131 | {times.map((t, idx) => ( 132 |
    143 | ))} 144 |
    145 |
    146 |
    147 | {events.map(([start, end]) => ( 148 | 154 | ))} 155 |
    156 |
    157 |
    158 | ) 159 | } 160 | 161 | export function DayViewContainer({ 162 | startHour, 163 | endHour, 164 | blockHeight = 48, 165 | blockWidth = 192 / 2, 166 | minDuration = 30, 167 | children, 168 | className = '', 169 | }) { 170 | const times = [] 171 | // for (let hour = startHour; hour <= endHour; hour++) { 172 | // for (let minute = 0; minute < 60; minute += minDuration) { 173 | // times.push(`${hour}:${minute.toString().padStart(2, '0')}`) 174 | // } 175 | // } 176 | for ( 177 | let minute = startHour * 60; 178 | minute <= endHour * 60; 179 | minute += minDuration 180 | ) { 181 | times.push( 182 | `${Math.floor(minute / 60)}:${(minute % 60).toString().padStart(2, '0')}` 183 | ) 184 | } 185 | const borderColor = 'border-black/15 dark:border-gray-600' 186 | const blockHeightTW = `h-${blockHeight / 4}` || 'h-12' 187 | const blockWidthTW = `w-${blockWidth / 4}` || 'w-24' 188 | const scheduleContainerRef = useRef(null) 189 | const [scheduleContainerWidth, setScheduleContainerWidth] = useState(0) 190 | // const scheduleContainerWidth = scheduleContainerRef.current?.offsetWidth ?? 0 191 | const scheduleContainerHeight = 192 | scheduleContainerRef.current?.offsetHeight ?? 0 193 | const nowIndicatorRef = useRef(null) 194 | useEffect(() => { 195 | setScheduleContainerWidth(scheduleContainerRef.current?.offsetWidth ?? 0) 196 | }, [scheduleContainerRef.current?.offsetWidth, children]) 197 | 198 | return ( 199 |
    205 | {/* Time Scale */} 206 |
    207 |
    208 |
    209 | {/*
    */} 210 | {times.map((t) => ( 211 |
    218 | {t} 219 |
    220 | ))} 221 |
    222 | {/* Marks */} 223 |
    224 |
    225 | {times.map((t, idx) => ( 226 |
    232 | {/* {t} */} 233 |
    234 | ))} 235 |
    236 |
    237 | {/* Schedule */} 238 | 253 |
    258 | {children} 259 | 260 |
    261 |
    262 |
    263 | ) 264 | } 265 | 266 | export default { 267 | DayViewContainer, 268 | DaySchedule, 269 | } 270 | -------------------------------------------------------------------------------- /components/Fade.tsx: -------------------------------------------------------------------------------- 1 | import { Transition } from '@headlessui/react' 2 | 3 | export default function Fade({ show = false, children }) { 4 | return ( 5 | 14 | {children} 15 | 16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /components/GeoLocate.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react' 2 | import { useGeolocated } from 'react-geolocated' 3 | 4 | function GeoLocate({ setGeoLocated, setIsLoading }) { 5 | const { coords, isGeolocationAvailable, isGeolocationEnabled } = 6 | useGeolocated({ 7 | positionOptions: { 8 | enableHighAccuracy: false, 9 | }, 10 | userDecisionTimeout: 5000, 11 | }) 12 | useEffect(() => { 13 | setGeoLocated({ coords, isGeolocationAvailable, isGeolocationEnabled }) 14 | setIsLoading(!coords) 15 | }, [ 16 | coords, 17 | isGeolocationAvailable, 18 | isGeolocationEnabled, 19 | setGeoLocated, 20 | setIsLoading, 21 | ]) 22 | 23 | return null 24 | } 25 | 26 | export default GeoLocate 27 | -------------------------------------------------------------------------------- /components/Icon.tsx: -------------------------------------------------------------------------------- 1 | import { useDarkMode } from '@/lib/use-dark-mode' 2 | import { isUrl } from '@/utils/link' 3 | import clsx from 'clsx' 4 | import Image from 'next/legacy/image' 5 | 6 | const Icon = ({ icon, size = 20, sizeCls = 'w-5 h-5', dark = undefined }) => { 7 | const { isDarkMode } = useDarkMode() 8 | 9 | if (icon.startsWith('/icons')) 10 | return ( 11 | {icon} 22 | ) 23 | if (isUrl(icon)) { 24 | return ( 25 | {icon} 32 | ) 33 | } 34 | return ( 35 | 41 | ) 42 | } 43 | 44 | export default Icon 45 | -------------------------------------------------------------------------------- /components/Link.jsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable jsx-a11y/anchor-has-content */ 2 | import Link from 'next/link' 3 | 4 | const CustomLink = ({ href, ...rest }) => { 5 | const isInternalLink = href && href.startsWith('/') 6 | const isAnchorLink = href && href.startsWith('#') 7 | 8 | if (isInternalLink || isAnchorLink) { 9 | return 10 | } 11 | 12 | return 13 | } 14 | 15 | export default CustomLink 16 | -------------------------------------------------------------------------------- /components/Loading.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react' 2 | import LoadingIcon from '@/public/loading-cat-transparent-120x120.gif' 3 | import Image from 'next/legacy/image' 4 | import Fade from './Fade' 5 | import cx from 'clsx' 6 | 7 | function Loading({ isLoading = true, fullscreen = false }) { 8 | useEffect(() => { 9 | if (fullscreen) document.body.style.overflow = 'hidden' 10 | return () => { 11 | document.body.style.overflow = 'auto' 12 | } 13 | }, [fullscreen]) 14 | 15 | return ( 16 | 17 |
    29 | Loading 30 | Loading... 31 |
    32 |
    33 | ) 34 | } 35 | 36 | export default Loading 37 | -------------------------------------------------------------------------------- /components/LoadingIcon.tsx: -------------------------------------------------------------------------------- 1 | import cx from 'clsx' 2 | 3 | const LoadingIcon = ({ className = '' }) => ( 4 | 10 | 18 | 23 | 24 | ) 25 | 26 | export default LoadingIcon 27 | -------------------------------------------------------------------------------- /components/Modal.tsx: -------------------------------------------------------------------------------- 1 | import Button from '@/components/Button' 2 | import { Dialog, Transition } from '@headlessui/react' 3 | import { Fragment } from 'react' 4 | import { RiCloseFill } from 'react-icons/ri' 5 | 6 | export interface ModalProps { 7 | isOpen: boolean 8 | title: React.ReactNode 9 | icon?: React.ReactNode 10 | content: React.ReactNode 11 | onClose?: () => void 12 | titleProps?: React.PropsWithChildren 13 | closeAble?: boolean 14 | } 15 | 16 | export default function MyModal({ 17 | isOpen, 18 | title, 19 | content, 20 | titleProps, 21 | onClose = () => {}, 22 | icon = null, 23 | closeAble = true, 24 | }: ModalProps) { 25 | return ( 26 | 27 | 28 | 37 |
    { 40 | // if (closeAble) onClose() 41 | // }} 42 | /> 43 | 44 | 45 |
    46 |
    47 | 56 | 57 | 62 | {icon} 63 | 64 | {title} 65 | 66 | 67 |
    80 |
    81 |
    82 |
    83 | ) 84 | } 85 | -------------------------------------------------------------------------------- /components/Rotate.tsx: -------------------------------------------------------------------------------- 1 | import { cloneElement } from 'react' 2 | import cx from 'clsx' 3 | 4 | export default function Rotate({ children, show, className = '', ...props }) { 5 | if (children.length !== 2) return null 6 | return ( 7 | 17 | {cloneElement(children[0], { 18 | className: cx('nc-int-icon-a', children[0].props.className), 19 | })} 20 | {cloneElement(children[1], { 21 | className: cx('nc-int-icon-b', children[1].props.className), 22 | })} 23 | 24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /components/Select.tsx: -------------------------------------------------------------------------------- 1 | import cx from 'clsx' 2 | 3 | const Select = ({ 4 | value, 5 | onChange, 6 | name, 7 | label, 8 | className = '', 9 | placeholder = '', 10 | options, 11 | disabled = false, 12 | ...props 13 | }) => ( 14 |
    22 | 30 | 53 |
    54 | ) 55 | 56 | export default Select 57 | -------------------------------------------------------------------------------- /components/Switch.tsx: -------------------------------------------------------------------------------- 1 | import { Switch as HeadlessSwitch } from '@headlessui/react' 2 | import { forwardRef } from 'react' 3 | import cx from 'clsx' 4 | import LoadingIcon from './LoadingIcon' 5 | 6 | function Switch( 7 | { 8 | checked, 9 | onChange, 10 | className = '', 11 | children, 12 | icon = null, 13 | title = '', 14 | loading = false, 15 | disabled = false, 16 | ...props 17 | }, 18 | ref 19 | ) { 20 | return ( 21 | 32 | 33 | {children || title} 34 | 35 | 49 | {title} 50 | 61 | 62 | 63 | ) 64 | } 65 | Switch.displayName = 'Switch' 66 | 67 | export default forwardRef(Switch) 68 | -------------------------------------------------------------------------------- /components/TagItemMini.tsx: -------------------------------------------------------------------------------- 1 | export type Tag = { 2 | name: string 3 | color: string 4 | } 5 | 6 | const TagItemMini = ({ 7 | tag, 8 | selected = false, 9 | }: { 10 | tag: Tag 11 | selected?: boolean 12 | }) => { 13 | return ( 14 | 18 |
    19 | {selected && } 20 | {tag.name} 21 |
    22 |
    23 | ) 24 | } 25 | 26 | export default TagItemMini 27 | -------------------------------------------------------------------------------- /components/ToggleThemeButton.tsx: -------------------------------------------------------------------------------- 1 | import { useDarkMode } from 'lib/use-dark-mode' 2 | import { RiMoonFill, RiSunFill } from 'react-icons/ri' 3 | import Button from './Button' 4 | import Rotate from './Rotate' 5 | 6 | const ToggleThemeButton = ({ className = '', ...props }) => { 7 | const { isDarkMode, toggleDarkMode } = useDarkMode() 8 | 9 | return ( 10 | 47 | ) 48 | } 49 | 50 | export default function Toolbar({ 51 | hasToc = false, 52 | hasComment = false, 53 | showNav, 54 | }) { 55 | const [hasMounted, setHasMounted] = useState(false) 56 | const { isMobileTocVisible, setIsMobileTocVisible } = useGlobal() 57 | 58 | useEffect(() => { 59 | setHasMounted(true) 60 | }, []) 61 | 62 | if (!hasMounted) return null 63 | 64 | return ( 65 |
    72 | 73 | {hasToc && ( 74 | 75 | )} 76 | {hasComment && ( 77 | 84 | )} 85 | 90 |
    91 | ) 92 | } 93 | -------------------------------------------------------------------------------- /data/defaultCoverImage.js: -------------------------------------------------------------------------------- 1 | export default { 2 | originalWidth: 2048, 3 | originalHeight: 1024, 4 | width: 16, 5 | height: 8, 6 | type: 'webp', 7 | dataURIBase64: 8 | 'data:image/webp;base64,UklGRngAAABXRUJQVlA4WAoAAAAQAAAADwAACAAAQUxQSBYAAAARD9D/iAgYSNsm23//rq8hov/pOXUOVlA4IDwAAADwAQCdASoQAAkABUB8JbACdADp/g5dyYAA/toUq617XwI0yf0KnyIQWI4r6KANzdajEQxjhlvt/pmqgAA=', 9 | src: '/images/space.png', 10 | } 11 | -------------------------------------------------------------------------------- /data/location_list.json: -------------------------------------------------------------------------------- 1 | { 2 | "Manhattan": [ 3 | "Cantor Film Center", 4 | "Global Center For Academic & Spiritual Life, 238 Thompson Street", 5 | "7 E 12Th St", 6 | "Silver Center", 7 | "25 W 4Th St", 8 | "Tisch Hall", 9 | "Bobst Library", 10 | "12 Waverly Pl", 11 | "Meyer Hall", 12 | "Academic Resource Center, 18 Washington Place", 13 | "60 5Th Ave", 14 | "194 Mercer St", 15 | "19 University Pl", 16 | "Casa Italiana", 17 | "19 W 4Th St", 18 | "45 West 4Th", 19 | "20 Cooper Square", 20 | "Kimmel University Center", 21 | "King Juan Carlos Center", 22 | "24 W 12Th St", 23 | "Ireland House", 24 | "East Building", 25 | "Rubin Hall", 26 | "726 Broadway", 27 | "Deutsches Haus", 28 | "10 Washington Pl", 29 | "1 Park Ave", 30 | "58 W 10Th St", 31 | "Warren Weaver Hall", 32 | "708 Broadway", 33 | "Kevorkian Center", 34 | "721 Broadway", 35 | "133 Macdougal St", 36 | "Brown Building", 37 | "Waverly Building", 38 | "244 Greene St", 39 | "1 Washington Pl", 40 | "25 Waverly Pl", 41 | "Henry Kaufman Management Education Center", 42 | "101 Astor Place", 43 | "Barney Building", 44 | "Barney", 45 | "Education Building", 46 | "Kimball Hall", 47 | "665 Broadway", 48 | "Pless Building", 49 | "Pless Annex", 50 | "3Rd Ave North Dormitory", 51 | "411 Lafayette St", 52 | "433 1St Ave", 53 | "NYU Langone Orthopedic Hospita", 54 | "443 1St Ave", 55 | "NYU Langone Tisch Hospital", 56 | "NYU Kimmel Pavilion", 57 | "NYU Langone Hospital", 58 | "Presbyterian Cornell", 59 | "Stephen D Hassenfeld Children", 60 | "NYU Langone Fink", 61 | "Sloan Kettering Chemo Op", 62 | "NYU Langone Radiation Center", 63 | "NYU Langone Orthopedic C4C/Ic/", 64 | "Vns Stanley Isaacs", 65 | "NYU Langone Infusion Center 38", 66 | "NYU Langone Infusion Center 34", 67 | "NYU Langone Rad Energy Buildi", 68 | "Presbyterian Morgan Stanley", 69 | "Institute Of French Studies", 70 | "14 E78 St", 71 | "Institute For The Study Of The Ancient World", 72 | "24 E 8Th St", 73 | "14 Univ Pl", 74 | "Vanderbilt Hall", 75 | "J Furman Hall", 76 | "The Institute Of Fine Arts", 77 | "Laguardia Co", 78 | "Kevo Library", 79 | "295 Lafayette St", 80 | "240 Greene St", 81 | "Pless Hall", 82 | "196 Mercer Street", 83 | "1 Washington Square North", 84 | "715 Broadway", 85 | "111 2Nd Ave", 86 | "719 Broadway", 87 | "48 W 21 St 8Th Fl", 88 | "19 Wash Sq North", 89 | "14A Washington Mews", 90 | "725 Broadway" 91 | ], 92 | "Cliffside Park": ["5 Washington Pl"], 93 | "Brooklyn": [ 94 | "Rogers Hall", 95 | "Jacobs Academic Building", 96 | "2 Metrotech Ctr", 97 | "370 Jay St", 98 | "Sunset Park 5718 Second Avenue", 99 | "370 Jay Street", 100 | "Dibner Building", 101 | "325 Gold Street", 102 | "Dibner" 103 | ], 104 | "Mineola": ["NYU Langone Long Island"] 105 | } 106 | -------------------------------------------------------------------------------- /layouts/ErrorPage.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { PageHTMLHead } from './PageHTMLHead' 3 | 4 | import styles from './styles.module.css' 5 | 6 | export const ErrorPage: React.FC<{ statusCode: number }> = ({ statusCode }) => { 7 | const title = 'Error' 8 | 9 | return ( 10 | <> 11 | 12 | 13 |
    14 |
    15 |

    Error Loading Page

    16 | 17 | {statusCode &&

    Error code: {statusCode}

    } 18 | 19 | {/* eslint-disable-next-line @next/next/no-img-element */} 20 | Error 21 |
    22 |
    23 | 24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /layouts/Layout.tsx: -------------------------------------------------------------------------------- 1 | import Toolbar from '@/components/Toolbar' 2 | import cx from 'clsx' 3 | 4 | import { useState } from 'react' 5 | import Header from './components/Header' 6 | import { Footer } from './components/Footer' 7 | import { NavBar } from './components/NavBar' 8 | import { PageHTMLHead } from './PageHTMLHead' 9 | 10 | export function Layout({ 11 | children, 12 | 13 | site, 14 | breadcrumbs = [], 15 | 16 | title, 17 | description, 18 | tags, 19 | date, 20 | url, 21 | 22 | socialImage, 23 | coverImage, 24 | 25 | noPadding = false, 26 | hasToc = false, 27 | hasComment = false, 28 | }: { 29 | children: React.ReactNode 30 | 31 | site?: any 32 | breadcrumbs?: any 33 | 34 | title?: string 35 | description?: string 36 | tags?: string[] 37 | date?: string 38 | url?: string 39 | socialImage?: string 40 | 41 | coverImage?: { 42 | src: string 43 | dataURIBase64?: string 44 | blurDataURL?: string 45 | } 46 | 47 | noPadding?: boolean 48 | hasComment?: boolean 49 | hasToc?: boolean 50 | }): JSX.Element { 51 | const [showNav, setShowNav] = useState(true) 52 | 53 | return ( 54 |
    55 | 62 | 67 | 68 |
    75 | {/*
    */} 76 |
    85 | {children} 86 |
    87 |
    88 | 89 |
    90 | ) 91 | } 92 | -------------------------------------------------------------------------------- /layouts/NotionPage.tsx: -------------------------------------------------------------------------------- 1 | import cx from 'clsx' 2 | import dynamic from 'next/dynamic' 3 | import Link from 'next/link' 4 | import { useRouter } from 'next/router' 5 | import * as React from 'react' 6 | 7 | // core notion renderer 8 | import { NotionRenderer } from 'react-notion-x' 9 | 10 | // utils 11 | import { mapImageUrl } from 'lib/map-image-url' 12 | import { mapPageUrl } from 'lib/map-page-url' 13 | import * as types from 'lib/types' 14 | import type { TableOfContentsEntry } from 'notion-utils' 15 | 16 | // components 17 | import Loading from '@/components/Loading' 18 | import NextImage from 'next/legacy/image' 19 | import { Page404 } from './Page404' 20 | import { TableOfContent } from './components/TableOfContent' 21 | // ----------------------------------------------------------------------------- 22 | // dynamic imports for optional components 23 | // ----------------------------------------------------------------------------- 24 | 25 | const Comment = dynamic(() => import('@/layouts/components/Giscus'), { 26 | ssr: false, 27 | }) 28 | 29 | const Code = dynamic(() => 30 | import('react-notion-x/build/third-party/code').then(async (m) => { 31 | // add / remove any prism syntaxes here 32 | await Promise.all([ 33 | import('prismjs/components/prism-markup-templating.js'), 34 | import('prismjs/components/prism-markup.js'), 35 | import('prismjs/components/prism-bash.js'), 36 | import('prismjs/components/prism-c.js'), 37 | import('prismjs/components/prism-cpp.js'), 38 | import('prismjs/components/prism-csharp.js'), 39 | import('prismjs/components/prism-docker.js'), 40 | import('prismjs/components/prism-java.js'), 41 | import('prismjs/components/prism-js-templates.js'), 42 | import('prismjs/components/prism-coffeescript.js'), 43 | import('prismjs/components/prism-diff.js'), 44 | import('prismjs/components/prism-git.js'), 45 | import('prismjs/components/prism-go.js'), 46 | import('prismjs/components/prism-graphql.js'), 47 | import('prismjs/components/prism-handlebars.js'), 48 | import('prismjs/components/prism-less.js'), 49 | import('prismjs/components/prism-makefile.js'), 50 | import('prismjs/components/prism-markdown.js'), 51 | import('prismjs/components/prism-objectivec.js'), 52 | import('prismjs/components/prism-ocaml.js'), 53 | import('prismjs/components/prism-python.js'), 54 | import('prismjs/components/prism-reason.js'), 55 | import('prismjs/components/prism-rust.js'), 56 | import('prismjs/components/prism-sass.js'), 57 | import('prismjs/components/prism-scss.js'), 58 | import('prismjs/components/prism-solidity.js'), 59 | import('prismjs/components/prism-sql.js'), 60 | import('prismjs/components/prism-stylus.js'), 61 | import('prismjs/components/prism-swift.js'), 62 | import('prismjs/components/prism-wasm.js'), 63 | import('prismjs/components/prism-yaml.js'), 64 | ]) 65 | return m.Code 66 | }) 67 | ) 68 | 69 | const Collection = dynamic(() => 70 | import('react-notion-x/build/third-party/collection').then( 71 | (m) => m.Collection 72 | ) 73 | ) 74 | const Equation = dynamic(() => 75 | import('react-notion-x/build/third-party/equation').then((m) => m.Equation) 76 | ) 77 | const Pdf = dynamic( 78 | () => import('react-notion-x/build/third-party/pdf').then((m) => m.Pdf), 79 | { 80 | ssr: false, 81 | } 82 | ) 83 | const Modal = dynamic( 84 | () => 85 | import('react-notion-x/build/third-party/modal').then((m) => { 86 | m.Modal.setAppElement('.notion-viewport') 87 | return m.Modal 88 | }), 89 | { 90 | ssr: false, 91 | } 92 | ) 93 | 94 | export const NotionPage: React.FC< 95 | types.PageProps & { 96 | title: string 97 | description: string 98 | canonicalPageUrl: string 99 | socialImage: string 100 | tableOfContent: TableOfContentsEntry[] 101 | noBg: boolean 102 | } 103 | > = ({ tableOfContent, recordMap, pageId, site, error, noBg = false }) => { 104 | const router = useRouter() 105 | 106 | const components = React.useMemo( 107 | () => ({ 108 | nextImage: NextImage, 109 | nextLink: Link, 110 | Code, 111 | Collection, 112 | Equation, 113 | Pdf, 114 | Modal, 115 | }), 116 | [] 117 | ) 118 | 119 | const siteMapPageUrl = React.useMemo(() => { 120 | return mapPageUrl(site, recordMap) 121 | }, [site, recordMap]) 122 | 123 | if (router.isFallback) { 124 | return 125 | } 126 | 127 | if (error) { 128 | return 129 | } 130 | 131 | return ( 132 | <> 133 |
    134 | 151 |
    158 | 159 |
    160 |
    161 | {tableOfContent.length > 0 && ( 162 | 163 | //
    164 | )} 165 | 166 | ) 167 | } 168 | -------------------------------------------------------------------------------- /layouts/Page404.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import * as types from 'lib/types' 3 | import { PageHTMLHead } from './PageHTMLHead' 4 | 5 | import styles from './styles.module.css' 6 | 7 | export const Page404: React.FC = ({ site, pageId, error }) => { 8 | const title = site?.name || 'Notion Page Not Found' 9 | 10 | return ( 11 | <> 12 | 13 | 14 |
    15 |
    16 |

    Notion Page Not Found

    17 | 18 | {error ? ( 19 |

    {error.message}

    20 | ) : ( 21 | pageId && ( 22 |

    23 | Make sure that Notion page "{pageId}" is publicly 24 | accessible. 25 |

    26 | ) 27 | )} 28 | 29 | {/* eslint-disable-next-line @next/next/no-img-element */} 30 | 404 Not Found 35 |
    36 |
    37 | 38 | ) 39 | } 40 | -------------------------------------------------------------------------------- /layouts/PageHTMLHead.tsx: -------------------------------------------------------------------------------- 1 | import Head from 'next/head' 2 | import * as React from 'react' 3 | 4 | import * as types from 'lib/types' 5 | import * as config from '@/lib/config' 6 | 7 | export const PageHTMLHead: React.FC< 8 | types.PageProps & { 9 | title?: string 10 | description?: string 11 | socialImage?: string 12 | url?: string 13 | } 14 | > = ({ site, title, description, socialImage, url }) => { 15 | const rssFeedUrl = `${config.host}/feed` 16 | 17 | title = title ?? site?.name 18 | description = description ?? site?.description 19 | 20 | return ( 21 | 22 | 23 | 24 | 28 | 29 | 30 | 31 | 32 | {site && ( 33 | <> 34 | 35 | 36 | 37 | )} 38 | 39 | {config.twitter && ( 40 | 41 | )} 42 | 43 | {description && ( 44 | <> 45 | 46 | 47 | 48 | 49 | )} 50 | 51 | {socialImage ? ( 52 | <> 53 | 54 | 55 | 56 | 57 | ) : ( 58 | 59 | )} 60 | 61 | {url && ( 62 | <> 63 | 64 | 65 | 66 | 67 | )} 68 | 69 | 75 | 76 | 81 | {/* eslint-disable-next-line @next/next/no-page-custom-font */} 82 | 83 | 84 | 85 | {`${title} | ${site?.name}` || 'Loading...'} 86 | 87 | ) 88 | } 89 | -------------------------------------------------------------------------------- /layouts/components/BlogPostCard.jsx: -------------------------------------------------------------------------------- 1 | import Icon from '@/components/Icon' 2 | import Image from 'next/legacy/image' 3 | import Link from 'next/link' 4 | import React from 'react' 5 | import TagItemMini from '../../components/TagItemMini' 6 | 7 | const BlogPostCard = ({ 8 | date, 9 | description, 10 | index, 11 | url, 12 | title, 13 | tags, 14 | coverImage, 15 | icon, 16 | }) => { 17 | const [mounted, setMounted] = React.useState(false) 18 | React.useEffect(() => { 19 | setMounted(true) 20 | }, []) 21 | return ( 22 | 26 |
    69 |
    70 | {title} 78 |
    79 | 80 | ) 81 | } 82 | 83 | export default BlogPostCard 84 | -------------------------------------------------------------------------------- /layouts/components/Footer.tsx: -------------------------------------------------------------------------------- 1 | import config from '@/site.config' 2 | import Link from 'next/link' 3 | import { BsHeartFill } from 'react-icons/bs' 4 | import { BiCopyright } from 'react-icons/bi' 5 | export const Footer = () => { 6 | const d = new Date() 7 | const currentYear = d.getFullYear() 8 | return ( 9 |
    10 |
    16 |
    17 | 18 | {config.yearStarted}-{currentYear} 19 |
    20 |
    21 | 22 |
    23 |
    {config.author}
    24 |
    25 | 26 | Acknowledgement / Legal Information 27 | 28 |
    29 | ) 30 | } 31 | -------------------------------------------------------------------------------- /layouts/components/Giscus.jsx: -------------------------------------------------------------------------------- 1 | import 'giscus' 2 | import { useDarkMode } from 'lib/use-dark-mode' 3 | 4 | const GiscusComponent = ({ className = undefined }) => { 5 | const { isDarkMode } = useDarkMode() 6 | 7 | return ( 8 |
    9 | 22 |
    23 | ) 24 | } 25 | 26 | export default GiscusComponent 27 | -------------------------------------------------------------------------------- /layouts/components/Header.tsx: -------------------------------------------------------------------------------- 1 | import defaultCoverImage from '@/data/defaultCoverImage' 2 | import cx from 'clsx' 3 | import dynamic from 'next/dynamic' 4 | import Image from 'next/legacy/image' 5 | import { useEffect, useState } from 'react' 6 | import { RiCalendarLine } from 'react-icons/ri' 7 | 8 | const ReadingTime = dynamic(() => import('./ReadingTime')) 9 | 10 | const Tags = ({ tags, className }) => ( 11 |
      12 | {tags?.map((tag) => ( 13 |
    • 17 | {tag} 18 |
    • 19 | ))} 20 |
    21 | ) 22 | 23 | function Header({ 24 | coverImage = defaultCoverImage, 25 | title, 26 | date, 27 | tags, 28 | description, 29 | }: { 30 | coverImage?: 31 | | { 32 | src: string 33 | dataURIBase64?: string 34 | blurDataURL?: string 35 | } 36 | | string 37 | title?: string 38 | description?: string 39 | date?: string 40 | tags?: string[] 41 | }) { 42 | const { 43 | src, 44 | dataURIBase64 = undefined, 45 | blurDataURL = undefined, 46 | } = typeof coverImage === 'object' ? coverImage : { src: coverImage } 47 | const [mounted, setMounted] = useState(false) 48 | useEffect(() => { 49 | setMounted(true) 50 | }, []) 51 | return ( 52 |
    53 | Page cover image 64 |
    70 |
    71 |

    72 | {title} 73 |

    74 | {description && ( 75 |
    76 | {description} 77 |
    78 | )} 79 |
    80 | 81 |
      82 | {date && ( 83 |
    • 84 | 85 | 88 |
    • 89 | )} 90 | 91 |
    92 |
    93 |
    94 |
    95 |
    96 | ) 97 | } 98 | 99 | export default Header 100 | -------------------------------------------------------------------------------- /layouts/components/InfoCard.tsx: -------------------------------------------------------------------------------- 1 | import * as config from '@/lib/config' 2 | import SocialButton from './SocialButton' 3 | // import MenuGroupCard from './MenuGroupCard' 4 | import Link from 'next/link' 5 | import avatar from '@/public/avatar.jpg' 6 | import Image from 'next/legacy/image' 7 | 8 | export function InfoCard({ className = '' }) { 9 | return ( 10 |
    17 | 22 | avatar 29 | 30 | 35 | {config.author} 36 | 37 |
    {config.description}
    38 | {/* */} 39 | 40 |
    41 | ) 42 | } 43 | -------------------------------------------------------------------------------- /layouts/components/NavBar.tsx: -------------------------------------------------------------------------------- 1 | import BreadCrumbs from '@/components/BreadCrumbs' 2 | import Button from '@/components/Button' 3 | import Collapse from '@/components/Collapse' 4 | import Rotate from '@/components/Rotate' 5 | import ToggleThemeButton from '@/components/ToggleThemeButton' 6 | import { navigationLinks } from '@/lib/config' 7 | import Avatar from '@/public/avatar.webp' 8 | import useClickOutside from '@/utils/useClickOutside' 9 | import cx from 'clsx' 10 | import throttle from 'lodash.throttle' 11 | import Image from 'next/legacy/image' 12 | import * as React from 'react' 13 | import { RiCloseFill, RiMenuFill } from 'react-icons/ri' 14 | 15 | let windowTop = 0 16 | 17 | const AvatarIcon = ({ className = '' }) => ( 18 | 38 | ) 39 | 40 | export const NavBar = ({ breadcrumbs = [], showNav, setShowNav }) => { 41 | const navRef = React.useRef() 42 | const breadCrumbRef = React.useRef() 43 | 44 | const [transparent, setTransparent] = React.useState(true) 45 | const [showMenu, setShowMenu] = React.useState(false) 46 | 47 | const closeMenu = () => setShowMenu(false) 48 | 49 | useClickOutside(navRef, closeMenu) 50 | 51 | // 监听滚动 52 | React.useEffect(() => { 53 | const contentTop = document 54 | .getElementById('main-container') 55 | .getBoundingClientRect().top 56 | 57 | const scrollTrigger = throttle(() => { 58 | const scrollS = window.scrollY 59 | const showNav = scrollS <= windowTop || scrollS < contentTop // 非首页无大图时影藏顶部 滚动条置顶时隐藏 60 | const navTransparent = scrollS < contentTop / 2 // 透明导航条的条件 61 | 62 | setTransparent(navTransparent) 63 | setShowNav(showNav) 64 | if (!showNav) closeMenu() 65 | windowTop = scrollS 66 | }, 200) 67 | scrollTrigger() 68 | document.addEventListener('scroll', scrollTrigger) 69 | return () => { 70 | document.removeEventListener('scroll', scrollTrigger) 71 | } 72 | }, [setShowNav]) 73 | 74 | const links = React.useMemo( 75 | () => 76 | navigationLinks 77 | ?.map(({ icon, url, title }, index) => ( 78 | 92 | )) 93 | .filter(Boolean), 94 | [] 95 | ) 96 | 97 | return ( 98 | 152 | ) 153 | } 154 | -------------------------------------------------------------------------------- /layouts/components/ReadingTime.tsx: -------------------------------------------------------------------------------- 1 | import { useRouter } from 'next/router' 2 | import { useEffect, useState } from 'react' 3 | import { RiTimeLine } from 'react-icons/ri' 4 | import readingTime from 'reading-time' 5 | 6 | function ReadingTime() { 7 | const { isFallback } = useRouter() 8 | const [readTime, setReadTime] = useState('') 9 | 10 | useEffect(() => { 11 | if (isFallback) return 12 | setTimeout(() => { 13 | const text = document.querySelector('main')?.textContent as string 14 | if (!text || text.length === 0) return 15 | const { minutes } = readingTime(text) 16 | const i18n = new Intl.NumberFormat(undefined, { 17 | unit: 'minute', 18 | style: 'unit', 19 | unitDisplay: 'short', 20 | }) 21 | 22 | const minuteText = i18n.format(Math.ceil(minutes)) 23 | setReadTime(minuteText) 24 | }, 1) 25 | }, [isFallback]) 26 | 27 | if (!readTime) return null 28 | 29 | return ( 30 |
  • 31 | 32 | {readTime} 33 |
  • 34 | ) 35 | } 36 | 37 | export default ReadingTime 38 | -------------------------------------------------------------------------------- /layouts/components/SocialButton.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | RiGithubFill, 3 | RiLinkedinBoxFill, 4 | RiMailFill, 5 | RiRssFill, 6 | RiTelegramFill, 7 | } from 'react-icons/ri' 8 | /** 9 | * 社交联系方式按钮组 10 | * @returns {JSX.Element} 11 | * @constructor 12 | */ 13 | 14 | const SocialButton = ({ config }) => { 15 | return ( 16 |
    17 | {config.github && ( 18 | 24 | 25 | 26 | )} 27 | {config.linkedin && ( 28 | 34 | 35 | 36 | )} 37 | {config.telegram && ( 38 | 44 | 45 | 46 | )} 47 | {config.email && ( 48 | 54 | 55 | 56 | )} 57 | 58 | 59 | 60 |
    61 | ) 62 | } 63 | export default SocialButton 64 | -------------------------------------------------------------------------------- /layouts/components/TableOfContent.tsx: -------------------------------------------------------------------------------- 1 | import Fade from '@/components/Fade' 2 | import { InfoCard } from '@/layouts/components/InfoCard' 3 | import useGlobal from '@/utils/useGlobal' 4 | import { useMinWidth } from '@/utils/useMediaQuery' 5 | import cs from 'clsx' 6 | import debounce from 'lodash.debounce' 7 | import throttle from 'lodash.throttle' 8 | import type { TableOfContentsEntry } from 'notion-utils' 9 | import React, { useEffect, useRef, useState } from 'react' 10 | import { RiListCheck } from 'react-icons/ri' 11 | 12 | export const TableOfContent: React.FC<{ 13 | tableOfContent: Array 14 | className?: string 15 | mobile?: boolean 16 | }> = ({ tableOfContent }) => { 17 | const { isMobileTocVisible } = useGlobal() 18 | const isDesktop = useMinWidth('lg') 19 | 20 | const [activeSection, setActiveSection] = useState(null) 21 | const [percent, changePercent] = useState(0) 22 | const tocRef = useRef(null) 23 | 24 | const throttleMs = 25 25 | const debounceMs = 500 26 | 27 | const adjustTocScroll = debounce( 28 | () => { 29 | if (!tocRef.current) return 30 | const activeElement = document.querySelector( 31 | `.notion-table-of-contents-active-item` 32 | ) as HTMLElement 33 | // if activeElement is not in view, scroll to it 34 | if (!activeElement) return 35 | const tocRect = tocRef.current.getBoundingClientRect() 36 | const activeRect = activeElement.getBoundingClientRect() 37 | if (activeRect.top < tocRect.top || activeRect.bottom > tocRect.bottom) { 38 | tocRef.current.scrollTo({ 39 | top: activeElement.offsetTop - tocRect.height / 2 - activeRect.height, 40 | behavior: 'smooth', 41 | }) 42 | } 43 | }, 44 | debounceMs, 45 | { leading: false, trailing: true } 46 | ) 47 | 48 | useEffect(() => { 49 | const actionSectionScrollSpy = throttle((event = {}) => { 50 | if (event.isTrusted && event.eventPhase === 0) return 51 | const target = 52 | typeof window !== 'undefined' && 53 | document.getElementById('main-container') 54 | if (!target) return 55 | 56 | // Update progress bar 57 | const clientHeight = target.clientHeight 58 | const scrollY = window.pageYOffset 59 | const fullHeight = clientHeight - window.outerHeight 60 | let per = parseFloat(((scrollY / fullHeight) * 100).toFixed(0)) 61 | if (per > 100) per = 100 62 | if (per < 0) per = 0 63 | changePercent(per) 64 | 65 | // Update active section in toc 66 | const sections = document.getElementsByClassName('notion-h') 67 | if (!sections || !sections.length) return 68 | 69 | let minDist = 1000 70 | let minDistSectionIdx = null 71 | for (let i = 0; i < sections.length; i++) { 72 | const section = sections[i] 73 | const bbox = section.getBoundingClientRect() 74 | const absDist = Math.abs(bbox.top) 75 | if (absDist < minDist && bbox.top > 0) { 76 | minDist = absDist 77 | minDistSectionIdx = i 78 | } 79 | } 80 | if (minDistSectionIdx === null) return 81 | 82 | setActiveSection(sections[minDistSectionIdx].getAttribute('data-id')) 83 | adjustTocScroll() 84 | }, throttleMs) 85 | document.addEventListener('scroll', actionSectionScrollSpy) 86 | 87 | actionSectionScrollSpy() 88 | 89 | return () => { 90 | document.removeEventListener('scroll', actionSectionScrollSpy) 91 | } 92 | // eslint-disable-next-line react-hooks/exhaustive-deps 93 | }, []) 94 | 95 | return ( 96 |
    102 | 103 | 104 |
    117 |

    118 | 119 | Table of Content 120 |

    121 |
    122 |
    126 |
    127 | {percent}% 128 |
    129 |
    130 |
    131 | 168 |
    169 |
    170 |
    171 | ) 172 | } 173 | -------------------------------------------------------------------------------- /layouts/index.ts: -------------------------------------------------------------------------------- 1 | export * from './NotionPage' 2 | export * from './Page404' 3 | export * from './ErrorPage' 4 | export * from './Layout' 5 | -------------------------------------------------------------------------------- /layouts/styles.module.css: -------------------------------------------------------------------------------- 1 | @keyframes spinner { 2 | to { 3 | transform: rotate(360deg); 4 | } 5 | } 6 | 7 | .container { 8 | position: absolute; 9 | top: 0; 10 | left: 0; 11 | right: 0; 12 | bottom: 0; 13 | display: flex; 14 | justify-content: center; 15 | align-items: center; 16 | padding: 2vmin; 17 | 18 | font-size: 16px; 19 | line-height: 1.5; 20 | color: rgb(55, 53, 47); 21 | caret-color: rgb(55, 53, 47); 22 | } 23 | 24 | .loadingIcon { 25 | animation: spinner 0.6s linear infinite; 26 | display: block; 27 | width: 24px; 28 | height: 24px; 29 | color: rgba(55, 53, 47, 0.4); 30 | } 31 | 32 | .main { 33 | display: flex; 34 | flex-direction: column; 35 | justify-content: center; 36 | align-items: center; 37 | } 38 | 39 | .errorImage { 40 | max-width: 100%; 41 | width: 640px; 42 | } 43 | -------------------------------------------------------------------------------- /lib/config/get-config-value.ts: -------------------------------------------------------------------------------- 1 | import rawSiteConfig from '@/site.config' 2 | import * as types from '@/lib/types' 3 | 4 | export interface SiteConfig { 5 | rootNotionPageId: string 6 | rootNotionSpaceId?: string 7 | 8 | name: string 9 | domain: string 10 | author: string 11 | description?: string 12 | language?: string 13 | 14 | twitter?: string 15 | github?: string 16 | linkedin?: string 17 | telegram?: string 18 | email?: string 19 | newsletter?: string 20 | youtube?: string 21 | zhihu?: string 22 | 23 | defaultPageIcon?: string | null 24 | defaultPageCover?: string | null 25 | defaultPageCoverPosition?: number | null 26 | 27 | isPreviewImageSupportEnabled?: boolean 28 | isTweetEmbedSupportEnabled?: boolean 29 | isRedisEnabled?: boolean 30 | isSearchEnabled?: boolean 31 | 32 | includeNotionIdInUrls?: boolean 33 | pageUrlOverrides?: types.PageUrlOverridesMap 34 | pageUrlAdditions?: types.PageUrlOverridesMap 35 | 36 | projects?: { 37 | title: string 38 | description?: string 39 | href?: string 40 | coverImage?: string 41 | icon?: string 42 | }[] 43 | 44 | [key: string]: any 45 | } 46 | 47 | export interface NavigationLink { 48 | title: string 49 | pageId?: string 50 | url?: string 51 | } 52 | 53 | if (!rawSiteConfig) { 54 | throw new Error(`Config error: invalid site.config.ts`) 55 | } 56 | 57 | // allow environment variables to override site.config.ts 58 | let siteConfigOverrides: SiteConfig 59 | 60 | try { 61 | if (process.env.NEXT_PUBLIC_SITE_CONFIG) { 62 | siteConfigOverrides = JSON.parse(process.env.NEXT_PUBLIC_SITE_CONFIG) 63 | } 64 | } catch (err) { 65 | console.error('Invalid config "NEXT_PUBLIC_SITE_CONFIG" failed to parse') 66 | throw err 67 | } 68 | 69 | const siteConfig: SiteConfig = { 70 | ...rawSiteConfig, 71 | ...siteConfigOverrides, 72 | } 73 | 74 | export function getSiteConfig(key: string, defaultValue?: T): T { 75 | const value = siteConfig[key] 76 | 77 | if (value !== undefined) { 78 | return value 79 | } 80 | 81 | if (defaultValue !== undefined) { 82 | return defaultValue 83 | } 84 | 85 | throw new Error(`Config error: missing required site config value "${key}"`) 86 | } 87 | 88 | export function getEnv( 89 | key: string, 90 | defaultValue?: string, 91 | env = process.env 92 | ): string { 93 | const value = env[key] 94 | 95 | if (value !== undefined) { 96 | return value 97 | } 98 | 99 | if (defaultValue !== undefined) { 100 | return defaultValue 101 | } 102 | 103 | throw new Error(`Config error: missing required env variable "${key}"`) 104 | } 105 | -------------------------------------------------------------------------------- /lib/config/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Site-wide app configuration. 3 | * 4 | * This file pulls from the root "site.config.ts" as well as environment variables 5 | * for optional depenencies. 6 | */ 7 | 8 | import { parsePageId } from 'notion-utils' 9 | import { getEnv, getSiteConfig } from './get-config-value' 10 | import { 11 | PageUrlOverridesInverseMap, 12 | PageUrlOverridesMap, 13 | Site, 14 | } from '@/lib/types' 15 | 16 | export const rootNotionPageId: string = parsePageId( 17 | getSiteConfig('rootNotionPageId'), 18 | { uuid: false } 19 | ) 20 | 21 | if (!rootNotionPageId) { 22 | throw new Error('Config error invalid "rootNotionPageId"') 23 | } 24 | 25 | // if you want to restrict pages to a single notion workspace (optional) 26 | export const rootNotionSpaceId: string | null = parsePageId( 27 | getSiteConfig('rootNotionSpaceId', null), 28 | { uuid: true } 29 | ) 30 | 31 | export const pageUrlOverrides = cleanPageUrlMap( 32 | { 33 | ...getSiteConfig('pageUrlOverrides', {}), 34 | }, 35 | { label: 'pageUrlOverrides' } 36 | ) 37 | 38 | export const pageUrlAdditions = cleanPageUrlMap( 39 | getSiteConfig('pageUrlAdditions', {}), 40 | { label: 'pageUrlAdditions' } 41 | ) 42 | 43 | export const inversePageUrlOverrides = invertPageUrlOverrides(pageUrlOverrides) 44 | 45 | export const environment = process.env.NODE_ENV || 'development' 46 | export const isDev = environment === 'development' 47 | 48 | // general site config 49 | export const name: string = getSiteConfig('name') 50 | export const author: string = getSiteConfig('author') 51 | export const domain: string = getSiteConfig('domain') 52 | export const description: string = getSiteConfig('description', 'Notion Blog') 53 | export const language: string = getSiteConfig('language', 'en') 54 | 55 | // social accounts 56 | export const twitter: string | null = getSiteConfig('twitter', null) 57 | export const github: string | null = getSiteConfig('github', null) 58 | export const youtube: string | null = getSiteConfig('youtube', null) 59 | export const linkedin: string | null = getSiteConfig('linkedin', null) 60 | export const newsletter: string | null = getSiteConfig('newsletter', null) 61 | export const email: string | null = getSiteConfig('email', null) 62 | export const telegram: string | null = getSiteConfig('telegram', null) 63 | export const zhihu: string | null = getSiteConfig('zhihu', null) 64 | 65 | // default notion values for site-wide consistency (optional; may be overridden on a per-page basis) 66 | export const defaultPageIcon: string | null = getSiteConfig( 67 | 'defaultPageIcon', 68 | null 69 | ) 70 | export const defaultPageCover: string | null = getSiteConfig( 71 | 'defaultPageCover', 72 | null 73 | ) 74 | export const defaultPageCoverPosition: number = getSiteConfig( 75 | 'defaultPageCoverPosition', 76 | 0.5 77 | ) 78 | 79 | // Optional whether or not to enable support for LQIP preview images 80 | export const isPreviewImageSupportEnabled: boolean = getSiteConfig( 81 | 'isPreviewImageSupportEnabled', 82 | false 83 | ) 84 | 85 | // Optional whether or not to include the Notion ID in page URLs or just use slugs 86 | export const includeNotionIdInUrls: boolean = getSiteConfig( 87 | 'includeNotionIdInUrls', 88 | !!isDev 89 | ) 90 | 91 | // Optional site search 92 | export const isSearchEnabled: boolean = getSiteConfig('isSearchEnabled', true) 93 | 94 | // ---------------------------------------------------------------------------- 95 | 96 | // Optional redis instance for persisting preview images 97 | export const isRedisEnabled: boolean = 98 | getSiteConfig('isRedisEnabled', false) || !!getEnv('REDIS_ENABLED', null) 99 | 100 | // (if you want to enable redis, only REDIS_HOST and REDIS_PASSWORD are required) 101 | // we recommend that you store these in a local `.env` file 102 | export const redisHost: string | null = getEnv('REDIS_HOST', null) 103 | export const redisPassword: string | null = getEnv('REDIS_PASSWORD', null) 104 | export const redisUser: string = getEnv('REDIS_USER', 'default') 105 | export const redisUrl = getEnv( 106 | 'REDIS_URL', 107 | `redis://${redisUser}:${redisPassword}@${redisHost}` 108 | ) 109 | export const redisNamespace: string | null = getEnv( 110 | 'REDIS_NAMESPACE', 111 | 'preview-images' 112 | ) 113 | 114 | // ---------------------------------------------------------------------------- 115 | 116 | export const isServer = typeof window === 'undefined' 117 | 118 | export const port = getEnv('PORT', '3000') 119 | export const host = isDev ? `http://localhost:${port}` : `https://${domain}` 120 | 121 | export const apiBaseUrl = `/api` 122 | 123 | export const api = { 124 | searchNotion: `${apiBaseUrl}/search-notion`, 125 | getSocialImage: `${apiBaseUrl}/social-image`, 126 | } 127 | 128 | // ---------------------------------------------------------------------------- 129 | 130 | export const site: Site = { 131 | domain, 132 | name, 133 | rootNotionPageId, 134 | rootNotionSpaceId, 135 | description, 136 | } 137 | 138 | export const posthogId = process.env.NEXT_PUBLIC_POSTHOG_ID 139 | export const posthogConfig = { 140 | api_host: 'https://app.posthog.com', 141 | } 142 | 143 | export const navigationLinks = getSiteConfig('navigationLinks', []) 144 | 145 | function cleanPageUrlMap( 146 | pageUrlMap: PageUrlOverridesMap, 147 | { 148 | label, 149 | }: { 150 | label: string 151 | } 152 | ): PageUrlOverridesMap { 153 | return Object.keys(pageUrlMap).reduce((acc, uri) => { 154 | const pageId = pageUrlMap[uri] 155 | const uuid = parsePageId(pageId, { uuid: false }) 156 | 157 | if (!uuid) { 158 | throw new Error(`Invalid ${label} page id "${pageId}"`) 159 | } 160 | 161 | if (!uri) { 162 | throw new Error(`Missing ${label} value for page "${pageId}"`) 163 | } 164 | 165 | if (!uri.startsWith('/')) { 166 | throw new Error( 167 | `Invalid ${label} value for page "${pageId}": value "${uri}" should be a relative URI that starts with "/"` 168 | ) 169 | } 170 | 171 | const path = uri.slice(1) 172 | 173 | return { 174 | ...acc, 175 | [path]: uuid, 176 | } 177 | }, {}) 178 | } 179 | 180 | function invertPageUrlOverrides( 181 | pageUrlOverrides: PageUrlOverridesMap 182 | ): PageUrlOverridesInverseMap { 183 | return Object.keys(pageUrlOverrides).reduce((acc, uri) => { 184 | const pageId = pageUrlOverrides[uri] 185 | 186 | return { 187 | ...acc, 188 | [pageId]: uri, 189 | } 190 | }, {}) 191 | } 192 | -------------------------------------------------------------------------------- /lib/dayjs.ts: -------------------------------------------------------------------------------- 1 | import dayjs from 'dayjs' 2 | import isoWeek from 'dayjs/plugin/isoWeek' 3 | dayjs.extend(isoWeek) 4 | 5 | export default dayjs 6 | -------------------------------------------------------------------------------- /lib/db.ts: -------------------------------------------------------------------------------- 1 | import Keyv from 'keyv' 2 | import { KeyvFile } from 'keyv-file' 3 | import { isDev } from './config' 4 | 5 | const db = new Keyv({ 6 | // use file store in dev 7 | store: isDev 8 | ? new KeyvFile({ 9 | filename: 'data/cache.json', // file name 10 | }) 11 | : undefined, 12 | ttl: 1000 * 60 * 60 * 1, // 1 hour, 13 | }) 14 | 15 | export default db 16 | -------------------------------------------------------------------------------- /lib/get-canonical-page-id.ts: -------------------------------------------------------------------------------- 1 | import { ExtendedRecordMap } from 'notion-types' 2 | import { 3 | parsePageId, 4 | getCanonicalPageId as getCanonicalPageIdImpl, 5 | } from 'notion-utils' 6 | 7 | import { inversePageUrlOverrides } from './config' 8 | 9 | export function getCanonicalPageId( 10 | pageId: string, 11 | recordMap: ExtendedRecordMap, 12 | { uuid = true }: { uuid?: boolean } = {} 13 | ): string | null { 14 | const cleanPageId = parsePageId(pageId, { uuid: false }) 15 | if (!cleanPageId) { 16 | return null 17 | } 18 | 19 | const override = inversePageUrlOverrides[cleanPageId] 20 | if (override) { 21 | return override 22 | } else { 23 | return getCanonicalPageIdImpl(pageId, recordMap, { 24 | uuid, 25 | }) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /lib/get-icon.ts: -------------------------------------------------------------------------------- 1 | import { isUrl } from '@/utils/link' 2 | import { mapImageUrl } from '@/lib/map-image-url' 3 | 4 | const getIcon = (icon, block) => { 5 | if (!icon) return null 6 | 7 | if (icon.length === 1) { 8 | return icon 9 | } 10 | if (isUrl(icon)) { 11 | return mapImageUrl(icon, block) 12 | } 13 | return icon 14 | } 15 | 16 | export default getIcon 17 | -------------------------------------------------------------------------------- /lib/get-site-map.ts: -------------------------------------------------------------------------------- 1 | import { getAllPagesInSpace } from 'notion-utils' 2 | import pMemoize from 'p-memoize' 3 | 4 | import * as config from './config' 5 | import { includeNotionIdInUrls } from './config' 6 | import { getCanonicalPageId } from './get-canonical-page-id' 7 | import notion from './notion' 8 | import * as types from './types' 9 | 10 | const uuid = !!includeNotionIdInUrls 11 | 12 | export async function getSiteMap(): Promise { 13 | const partialSiteMap = await getAllPages( 14 | config.rootNotionPageId, 15 | config.rootNotionSpaceId 16 | ) 17 | 18 | return { 19 | site: config.site, 20 | ...partialSiteMap, 21 | } as types.SiteMap 22 | } 23 | 24 | const getAllPages = pMemoize(getAllPagesImpl, { 25 | cacheKey: (...args) => JSON.stringify(args), 26 | }) 27 | 28 | async function getAllPagesImpl( 29 | rootNotionPageId: string, 30 | rootNotionSpaceId: string 31 | ): Promise> { 32 | console.log('getAllPagesImpl', rootNotionPageId, rootNotionSpaceId) 33 | const getPage = async (pageId: string, ...args) => { 34 | const page = await notion.getPage(pageId, ...args) 35 | return page 36 | } 37 | 38 | const pageMap = await getAllPagesInSpace( 39 | rootNotionPageId, 40 | rootNotionSpaceId, 41 | getPage 42 | ) 43 | 44 | const canonicalPageMap = Object.keys(pageMap).reduce( 45 | (map, pageId: string) => { 46 | const recordMap = pageMap[pageId] 47 | if (!recordMap) { 48 | throw new Error(`Error loading page "${pageId}"`) 49 | } 50 | 51 | const canonicalPageId = getCanonicalPageId(pageId, recordMap, { 52 | uuid, 53 | }) 54 | 55 | if (map[canonicalPageId]) { 56 | // you can have multiple pages in different collections that have the same id 57 | // TODO: we may want to error if neither entry is a collection page 58 | console.warn('error duplicate canonical page id', { 59 | canonicalPageId, 60 | pageId, 61 | existingPageId: map[canonicalPageId], 62 | }) 63 | 64 | return map 65 | } else { 66 | return { 67 | ...map, 68 | [canonicalPageId]: pageId, 69 | } 70 | } 71 | }, 72 | {} 73 | ) 74 | 75 | return { 76 | pageMap, 77 | canonicalPageMap, 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /lib/get-social-image-url.ts: -------------------------------------------------------------------------------- 1 | import { api, host } from './config' 2 | 3 | export function getSocialImageUrl(pageId: string) { 4 | try { 5 | const url = new URL(api.getSocialImage, host) 6 | 7 | if (pageId) { 8 | url.searchParams.set('id', pageId) 9 | return url.toString() 10 | } 11 | } catch (err) { 12 | console.warn('error invalid social image url', pageId, err.message) 13 | } 14 | 15 | return null 16 | } 17 | -------------------------------------------------------------------------------- /lib/map-image-url.ts: -------------------------------------------------------------------------------- 1 | import { Block } from 'notion-types' 2 | import { defaultMapImageUrl } from 'react-notion-x' 3 | 4 | import { defaultPageIcon, defaultPageCover } from './config' 5 | 6 | export const mapImageUrl = (url: string, block: Block) => { 7 | if (url === defaultPageCover || url === defaultPageIcon) { 8 | return url 9 | } 10 | 11 | return defaultMapImageUrl(url, block as any) 12 | } 13 | -------------------------------------------------------------------------------- /lib/map-page-url.ts: -------------------------------------------------------------------------------- 1 | import { ExtendedRecordMap } from 'notion-types' 2 | import { uuidToId, parsePageId } from 'notion-utils' 3 | 4 | import { Site } from './types' 5 | import { includeNotionIdInUrls } from './config' 6 | import { getCanonicalPageId } from './get-canonical-page-id' 7 | 8 | // include UUIDs in page URLs during local development but not in production 9 | // (they're nice for debugging and speed up local dev) 10 | const uuid = !!includeNotionIdInUrls 11 | 12 | export const mapPageUrl = 13 | (site: Site, recordMap: ExtendedRecordMap, searchParams?: URLSearchParams) => 14 | (pageId = '') => { 15 | const pageUuid = parsePageId(pageId, { uuid: true }) 16 | 17 | if (uuidToId(pageUuid) === site.rootNotionPageId) { 18 | return createUrl('/', searchParams) 19 | } else { 20 | return createUrl( 21 | `/${getCanonicalPageId(pageUuid, recordMap, { uuid })}`, 22 | searchParams 23 | ) 24 | } 25 | } 26 | 27 | export const getCanonicalPageUrl = 28 | (site: Site, recordMap: ExtendedRecordMap) => 29 | (pageId = '') => { 30 | const pageUuid = parsePageId(pageId, { uuid: true }) 31 | 32 | if (uuidToId(pageId) === site.rootNotionPageId) { 33 | return `https://${site.domain}` 34 | } else { 35 | return `https://${site.domain}/${getCanonicalPageId(pageUuid, recordMap, { 36 | uuid, 37 | })}` 38 | } 39 | } 40 | 41 | function createUrl(path: string, searchParams?: URLSearchParams) { 42 | if (!searchParams) return path 43 | 44 | return [path, searchParams.toString()].filter(Boolean).join('?') 45 | } 46 | -------------------------------------------------------------------------------- /lib/notion.ts: -------------------------------------------------------------------------------- 1 | import { NotionAPI } from 'notion-client' 2 | import { ExtendedRecordMap, SearchParams, SearchResults } from 'notion-types' 3 | import { mergeRecordMaps } from 'notion-utils' 4 | import pMap from 'p-map' 5 | import { isPreviewImageSupportEnabled, navigationLinks } from './config' 6 | import db from './db' 7 | import { getPreviewImageMap } from './preview-images' 8 | 9 | class Notion extends NotionAPI { 10 | navigationLinkRecordMaps: ExtendedRecordMap[] = [] 11 | navigationLinkRecordMapsFetched = false 12 | 13 | async getNavigationLinkPages() { 14 | const navigationLinkPageIds = navigationLinks 15 | .map((link) => link.pageId) 16 | .filter(Boolean) 17 | 18 | if (navigationLinkPageIds.length) { 19 | this.navigationLinkRecordMaps = await pMap( 20 | navigationLinkPageIds, 21 | async (navigationLinkPageId) => 22 | super.getPage(navigationLinkPageId, { 23 | chunkLimit: 1, 24 | fetchMissingBlocks: false, 25 | fetchCollections: false, 26 | signFileUrls: false, 27 | }), 28 | { 29 | concurrency: 4, 30 | } 31 | ) 32 | } 33 | } 34 | 35 | async getPage(pageId: string, options?: any) { 36 | console.time(`📄 getPage ${pageId}`) 37 | 38 | const cacheKey = `notion-page-id:${pageId}` 39 | const cachedPage = await db.get(cacheKey) 40 | if (cachedPage) { 41 | console.timeEnd(`📄 getPage ${pageId}`) 42 | return cachedPage 43 | } 44 | 45 | if (!this.navigationLinkRecordMapsFetched) { 46 | await this.getNavigationLinkPages() 47 | this.navigationLinkRecordMapsFetched = true 48 | } 49 | 50 | let recordMap = await super.getPage(pageId, options) 51 | 52 | // const navigationLinkRecordMaps = await this.getNavigationLinkPages() 53 | 54 | if (this.navigationLinkRecordMaps?.length) { 55 | recordMap = this.navigationLinkRecordMaps.reduce( 56 | (map, navigationLinkRecordMap) => 57 | mergeRecordMaps(map, navigationLinkRecordMap), 58 | recordMap 59 | ) 60 | } 61 | if (isPreviewImageSupportEnabled) { 62 | const previewImageMap = await getPreviewImageMap(recordMap) 63 | ;(recordMap as any).preview_images = previewImageMap 64 | } 65 | await db.set(cacheKey, recordMap) 66 | 67 | console.timeEnd(`📄 getPage ${pageId}`) 68 | return recordMap 69 | } 70 | async search(params: SearchParams): Promise { 71 | return super.search(params) 72 | } 73 | } 74 | 75 | const notion = new Notion({ 76 | apiBaseUrl: process.env.NOTION_API_BASE_URL, 77 | }) 78 | 79 | export default notion 80 | -------------------------------------------------------------------------------- /lib/preview-images.ts: -------------------------------------------------------------------------------- 1 | import got from 'got' 2 | import lqip from 'lqip-modern' 3 | import pMap from 'p-map' 4 | import pMemoize from 'p-memoize' 5 | import { ExtendedRecordMap, PreviewImage, PreviewImageMap } from 'notion-types' 6 | import { getPageImageUrls, normalizeUrl } from 'notion-utils' 7 | 8 | import { defaultPageIcon, defaultPageCover } from './config' 9 | import db from './db' 10 | import { mapImageUrl } from './map-image-url' 11 | 12 | export async function getPreviewImageMap( 13 | recordMap: ExtendedRecordMap 14 | ): Promise { 15 | const urls: string[] = getPageImageUrls(recordMap, { 16 | mapImageUrl, 17 | }) 18 | .concat([defaultPageIcon, defaultPageCover]) 19 | .filter(Boolean) 20 | 21 | const previewImagesMap = Object.fromEntries( 22 | await pMap( 23 | urls, 24 | async (url) => { 25 | const cacheKey = normalizeUrl(url) 26 | return [cacheKey, await getPreviewImage(url, { cacheKey })] 27 | }, 28 | { 29 | concurrency: 8, 30 | } 31 | ) 32 | ) 33 | 34 | return previewImagesMap 35 | } 36 | 37 | async function createPreviewImage( 38 | url: string, 39 | { cacheKey }: { cacheKey: string } 40 | ): Promise { 41 | try { 42 | const cachedPreviewImage = await db.get(cacheKey) 43 | if (cachedPreviewImage) return cachedPreviewImage 44 | 45 | const { body } = await got(url, { responseType: 'buffer' }) 46 | const result = await lqip(body) 47 | 48 | const previewImage = { 49 | originalWidth: result.metadata.originalWidth, 50 | originalHeight: result.metadata.originalHeight, 51 | dataURIBase64: result.metadata.dataURIBase64, 52 | } 53 | await db.set(cacheKey, previewImage) 54 | return previewImage 55 | } catch (err) { 56 | console.warn('failed to create preview image', url, err.message) 57 | return null 58 | } 59 | } 60 | 61 | export const getPreviewImage = pMemoize(createPreviewImage) 62 | -------------------------------------------------------------------------------- /lib/resolve-notion-page.ts: -------------------------------------------------------------------------------- 1 | import { ExtendedRecordMap } from 'notion-types' 2 | import { parsePageId } from 'notion-utils' 3 | import { environment, pageUrlAdditions, pageUrlOverrides, site } from './config' 4 | import db from './db' 5 | import { getSiteMap } from './get-site-map' 6 | import notion from './notion' 7 | import { PageProps } from './types' 8 | 9 | export async function errorReport({ 10 | site, 11 | recordMap, 12 | pageId, 13 | }: PageProps): Promise { 14 | if (!site) { 15 | return { 16 | error: { 17 | statusCode: 404, 18 | message: 'Unable to resolve notion site', 19 | }, 20 | } 21 | } 22 | 23 | if (!recordMap) { 24 | return { 25 | error: { 26 | statusCode: 404, 27 | message: `Unable to resolve page for domain "${site.domain}". Notion page "${pageId}" not found.`, 28 | }, 29 | } 30 | } 31 | 32 | const keys = Object.keys(recordMap.block) 33 | const rootKey = keys[0] 34 | 35 | if (!rootKey) { 36 | return { 37 | error: { 38 | statusCode: 404, 39 | message: `Unable to resolve page for domain "${site.domain}". Notion page "${pageId}" invalid data.`, 40 | }, 41 | } 42 | } 43 | 44 | const rootValue = recordMap.block[rootKey]?.value 45 | const rootSpaceId = rootValue?.space_id 46 | 47 | if ( 48 | rootSpaceId && 49 | site.rootNotionSpaceId && 50 | rootSpaceId !== site.rootNotionSpaceId 51 | ) { 52 | if (process.env.NODE_ENV) { 53 | return { 54 | error: { 55 | statusCode: 404, 56 | message: `Notion page "${pageId}" doesn't belong to the Notion workspace owned by "${site.domain}".`, 57 | }, 58 | } 59 | } 60 | } 61 | } 62 | 63 | export async function resolveNotionPage(domain: string, rawPageId?: string) { 64 | let pageId: string 65 | let recordMap: ExtendedRecordMap 66 | 67 | if (rawPageId && rawPageId !== 'index') { 68 | pageId = parsePageId(rawPageId) 69 | 70 | if (!pageId) { 71 | // check if the site configuration provides an override or a fallback for 72 | // the page's URI 73 | const override = 74 | pageUrlOverrides[rawPageId] || pageUrlAdditions[rawPageId] 75 | 76 | if (override) { 77 | pageId = parsePageId(override) 78 | } 79 | } 80 | 81 | const useUriToPageIdCache = true 82 | const cacheKey = `uri-to-page-id:${domain}:${environment}:${rawPageId}` 83 | // TODO: should we use a TTL for these mappings or make them permanent? 84 | // const cacheTTL = 8.64e7 // one day in milliseconds 85 | const cacheTTL = undefined // disable cache TTL 86 | 87 | if (!pageId && useUriToPageIdCache) { 88 | try { 89 | // check if the database has a cached mapping of this URI to page ID 90 | pageId = await db.get(cacheKey) 91 | 92 | // console.log(`redis get "${cacheKey}"`, pageId) 93 | } catch (err) { 94 | // ignore redis errors 95 | console.warn(`redis error get "${cacheKey}"`, err.message) 96 | } 97 | } 98 | 99 | if (pageId) { 100 | recordMap = await notion.getPage(pageId) 101 | } else { 102 | // handle mapping of user-friendly canonical page paths to Notion page IDs 103 | // e.g., /developer-x-entrepreneur versus /71201624b204481f862630ea25ce62fe 104 | const siteMap = await getSiteMap() 105 | pageId = siteMap?.canonicalPageMap[rawPageId] 106 | 107 | if (pageId) { 108 | // TODO: we're not re-using the page recordMap from siteMaps because it is 109 | // cached aggressively 110 | // recordMap = siteMap.pageMap[pageId] 111 | 112 | recordMap = await notion.getPage(pageId) 113 | 114 | if (useUriToPageIdCache) { 115 | try { 116 | // update the database mapping of URI to pageId 117 | await db.set(cacheKey, pageId, cacheTTL) 118 | 119 | // console.log(`redis set "${cacheKey}"`, pageId, { cacheTTL }) 120 | } catch (err) { 121 | // ignore redis errors 122 | console.warn(`redis error set "${cacheKey}"`, err.message) 123 | } 124 | } 125 | } else { 126 | // note: we're purposefully not caching URI to pageId mappings for 404s 127 | return { 128 | error: { 129 | message: `Not found "${rawPageId}"`, 130 | statusCode: 404, 131 | }, 132 | site, 133 | recordMap, 134 | pageId: rawPageId, 135 | } 136 | } 137 | } 138 | } else { 139 | pageId = site.rootNotionPageId 140 | 141 | // console.log(site) 142 | recordMap = await notion.getPage(pageId) 143 | } 144 | 145 | const props = { site, recordMap, pageId } 146 | return { ...props, ...errorReport(props) } 147 | } 148 | -------------------------------------------------------------------------------- /lib/types.ts: -------------------------------------------------------------------------------- 1 | import { ExtendedRecordMap, PageMap } from 'notion-types' 2 | import { ParsedUrlQuery } from 'querystring' 3 | 4 | export * from 'notion-types' 5 | 6 | export interface PageError { 7 | message?: string 8 | statusCode: number 9 | } 10 | 11 | export interface PageProps { 12 | site?: Site 13 | recordMap?: ExtendedRecordMap 14 | pageId?: string 15 | error?: PageError 16 | } 17 | 18 | export interface Params extends ParsedUrlQuery { 19 | pageId: string 20 | } 21 | 22 | export interface Site { 23 | name: string 24 | domain: string 25 | 26 | rootNotionPageId: string 27 | rootNotionSpaceId: string 28 | 29 | // settings 30 | html?: string 31 | fontFamily?: string 32 | darkMode?: boolean 33 | previewImages?: boolean 34 | 35 | // opengraph metadata 36 | description?: string 37 | image?: string 38 | } 39 | 40 | export interface SiteMap { 41 | site: Site 42 | pageMap: PageMap 43 | canonicalPageMap: CanonicalPageMap 44 | } 45 | 46 | export interface CanonicalPageMap { 47 | [canonicalPageId: string]: string 48 | } 49 | 50 | export interface PageUrlOverridesMap { 51 | // maps from a URL path to the notion page id the page should be resolved to 52 | // (this overrides the built-in URL path generation for these pages) 53 | [pagePath: string]: string 54 | } 55 | 56 | export interface PageUrlOverridesInverseMap { 57 | // maps from a notion page id to the URL path the page should be resolved to 58 | // (this overrides the built-in URL path generation for these pages) 59 | [pageId: string]: string 60 | } 61 | -------------------------------------------------------------------------------- /lib/use-dark-mode.ts: -------------------------------------------------------------------------------- 1 | // import useDarkModeImpl from '@fisch0920/use-dark-mode' 2 | 3 | // export function useDarkMode() { 4 | // const darkMode = useDarkModeImpl(false, { classNameDark: 'dark-mode' }) 5 | 6 | // return { 7 | // isDarkMode: darkMode.value, 8 | // toggleDarkMode: darkMode.toggle, 9 | // } 10 | // } 11 | 12 | import { useTheme } from 'next-themes' 13 | 14 | export function useDarkMode() { 15 | const { resolvedTheme, systemTheme, setTheme } = useTheme() 16 | 17 | const toggleDarkMode = () => { 18 | if (systemTheme !== resolvedTheme) { 19 | setTheme('system') 20 | } else { 21 | setTheme(resolvedTheme === 'dark' ? 'light' : 'dark') 22 | } 23 | } 24 | 25 | return { 26 | isDarkMode: resolvedTheme === 'dark', 27 | toggleDarkMode, 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Jerry Jia 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /next-sitemap.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next-sitemap').IConfig} */ 2 | module.exports = { 3 | siteUrl: 'www.jerrykjia.com', 4 | generateRobotsTxt: true, // (optional) 5 | generateIndexSitemap: false, // (optional) 6 | // ...other options 7 | } 8 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | const withBundleAnalyzer = require('@next/bundle-analyzer')({ 2 | enabled: process.env.ANALYZE === 'true', 3 | }) 4 | 5 | module.exports = withBundleAnalyzer({ 6 | staticPageGenerationTimeout: 300, 7 | images: { 8 | domains: [ 9 | 'www.notion.so', 10 | 'notion.so', 11 | 'images.unsplash.com', 12 | 'pbs.twimg.com', 13 | 'abs.twimg.com', 14 | 's3.us-west-2.amazonaws.com', 15 | ], 16 | formats: ['image/avif', 'image/webp'], 17 | dangerouslyAllowSVG: true, 18 | contentSecurityPolicy: "default-src 'self'; script-src 'none'; sandbox;", 19 | }, 20 | // i18n: { 21 | // // These are all the locales you want to support in 22 | // // your application 23 | // locales: ['en-US', 'zh-CN'], 24 | // // This is the default locale you want to be used when visiting 25 | // // a non-locale prefixed path e.g. `/hello` 26 | // defaultLocale: 'en-US', 27 | // }, 28 | }) 29 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "blog", 3 | "version": "1.0.0", 4 | "private": true, 5 | "description": "Jerry K Jia's Blog", 6 | "author": "Jkker ", 7 | "repository": "Jkker/blog", 8 | "license": "MIT", 9 | "scripts": { 10 | "dev": "next dev", 11 | "build": "next build", 12 | "postbuild": "next-sitemap", 13 | "start": "next start", 14 | "deploy": "vercel deploy", 15 | "deps": "run-s deps:*", 16 | "deps:update": "[ -z $GITHUB_ACTIONS ] && yarn add notion-client notion-types notion-utils react-notion-x || echo 'Skipping deps:update on CI'", 17 | "deps:link": "[ -z $GITHUB_ACTIONS ] && yarn link notion-client notion-types notion-utils react-notion-x || echo 'Skipping deps:link on CI'", 18 | "analyze": "cross-env ANALYZE=true next build", 19 | "analyze:server": "cross-env BUNDLE_ANALYZE=server next build", 20 | "analyze:browser": "cross-env BUNDLE_ANALYZE=browser next build", 21 | "test": "run-p test:*", 22 | "test:lint": "eslint '**/*.{js,jsx,ts,tsx}'", 23 | "test:prettier": "prettier '**/*.{js,jsx,ts,tsx}' --check" 24 | }, 25 | "dependencies": { 26 | "@fontsource/jetbrains-mono": "^4.5.12", 27 | "@fontsource/lexend": "^4.5.15", 28 | "@headlessui/react": "^1.7.13", 29 | "@vercel/analytics": "^0.1.11", 30 | "clsx": "^1.2.1", 31 | "dayjs": "^1.11.7", 32 | "file-saver": "^2.0.5", 33 | "giscus": "^1.2.8", 34 | "got": "^12.6.0", 35 | "jszip": "^3.10.1", 36 | "keyv": "^4.5.2", 37 | "keyv-file": "^0.2.0", 38 | "lodash.debounce": "^4.0.8", 39 | "lodash.throttle": "^4.1.1", 40 | "lqip-modern": "^2.0.0", 41 | "next": "^13.2.4", 42 | "next-sitemap": "^4.0.5", 43 | "next-themes": "^0.2.1", 44 | "notion-client": "^6.16.0", 45 | "notion-types": "^6.16.0", 46 | "notion-utils": "^6.16.0", 47 | "p-map": "^5.5.0", 48 | "p-memoize": "^7.1.1", 49 | "react": "^18.2.0", 50 | "react-dom": "^18.2.0", 51 | "react-geolocated": "^4.0.3", 52 | "react-icons": "^4.8.0", 53 | "react-notion-x": "^6.16.0", 54 | "reading-time": "^1.5.0", 55 | "rss": "^1.2.2" 56 | }, 57 | "devDependencies": { 58 | "@next/bundle-analyzer": "^13.2.4", 59 | "@types/lodash.throttle": "^4.1.7", 60 | "@types/node": "^18.15.0", 61 | "@types/react": "^18.0.28", 62 | "autoprefixer": "^10.4.14", 63 | "cross-env": "^7.0.3", 64 | "eslint": "^8.36.0", 65 | "eslint-config-next": "13.2.4", 66 | "eslint-config-prettier": "^8.7.0", 67 | "npm-run-all": "^4.1.5", 68 | "postcss": "^8.4.21", 69 | "postcss-import": "^15.1.0", 70 | "prettier": "^2.8.4", 71 | "stylelint": "^15.2.0", 72 | "stylelint-config-standard": "^30.0.1", 73 | "stylelint-config-tailwindcss": "^0.0.7", 74 | "tailwindcss": "^3.2.7", 75 | "typescript": "^4.9.5" 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /pages/404.tsx: -------------------------------------------------------------------------------- 1 | import { Page404 } from '@/layouts' 2 | 3 | export default Page404 4 | -------------------------------------------------------------------------------- /pages/[pageId].tsx: -------------------------------------------------------------------------------- 1 | import defaultCoverImage from '@/data/defaultCoverImage' 2 | import { Layout, NotionPage } from '@/layouts' 3 | import getIcon from '@/lib/get-icon' 4 | import * as config from '@/lib/config' 5 | import { domain, isDev } from '@/lib/config' 6 | import { getSiteMap } from 'lib/get-site-map' 7 | import { mapImageUrl } from 'lib/map-image-url' 8 | import { getCanonicalPageUrl, mapPageUrl } from 'lib/map-page-url' 9 | import { resolveNotionPage } from 'lib/resolve-notion-page' 10 | import { PageBlock, PageProps, Params } from 'lib/types' 11 | import { GetStaticProps } from 'next' 12 | import { useRouter } from 'next/router' 13 | import { 14 | getBlockTitle, 15 | getPageBreadcrumbs, 16 | getPageProperty, 17 | getPageTableOfContents, 18 | normalizeUrl, 19 | parsePageId, 20 | uuidToId, 21 | } from 'notion-utils' 22 | import { useEffect } from 'react' 23 | 24 | export const getStaticProps: GetStaticProps = async ( 25 | context 26 | ) => { 27 | const rawPageId = context.params.pageId as string 28 | 29 | try { 30 | const { site, recordMap, pageId, error } = await resolveNotionPage( 31 | domain, 32 | rawPageId 33 | ) 34 | 35 | const canonicalPageUrl = 36 | !config.isDev && getCanonicalPageUrl(site, recordMap)(pageId) 37 | 38 | const keys = Object.keys(recordMap?.block || {}) 39 | const block = recordMap?.block?.[keys[0]]?.value 40 | if (!block) { 41 | console.error('no block', rawPageId) 42 | } 43 | 44 | const socialImage = mapImageUrl( 45 | getPageProperty('Social Image', block, recordMap) || 46 | block?.format?.page_cover || 47 | config.defaultPageCover, 48 | block 49 | ) 50 | 51 | const description = getPageProperty('Description', block, recordMap) 52 | const noBg = getPageProperty('noBg', block, recordMap) 53 | const tableOfContent = getPageTableOfContents( 54 | block as PageBlock, 55 | recordMap 56 | ).map(({ id, text, indentLevel }) => ({ 57 | id: uuidToId(id), 58 | text, 59 | indentLevel, 60 | })) 61 | const title = getBlockTitle(block, recordMap) || site.name 62 | 63 | const breadcrumbs = getPageBreadcrumbs(recordMap, pageId) 64 | .filter( 65 | ({ pageId }) => !(pageId === parsePageId(config.rootNotionPageId)) 66 | ) 67 | .map(({ title = '', icon, active, pageId }) => ({ 68 | title, 69 | icon: getIcon(icon, block), 70 | active, 71 | url: mapPageUrl(site, recordMap)(pageId), 72 | pageId, 73 | })) 74 | 75 | const coverImageSrc = mapImageUrl(block?.format?.page_cover, block) 76 | const coverImage = coverImageSrc 77 | ? { 78 | src: coverImageSrc, 79 | ...(recordMap?.preview_images?.[coverImageSrc] ?? 80 | recordMap?.preview_images?.[normalizeUrl(coverImageSrc)]), 81 | } 82 | : defaultCoverImage 83 | const tags = 84 | getPageProperty('Tags', block, recordMap)?.filter?.( 85 | (t) => t && t.length > 0 86 | ) ?? [] 87 | 88 | const date = 89 | getPageProperty('Published', block, recordMap) ?? 90 | block.created_time ?? 91 | new Date().getTime() 92 | const is404 = error || !site || !block 93 | 94 | return { 95 | props: { 96 | title, 97 | description, 98 | canonicalPageUrl, 99 | socialImage, 100 | tableOfContent, 101 | keys, 102 | recordMap, 103 | site, 104 | breadcrumbs, 105 | is404, 106 | coverImage, 107 | tags, 108 | date, 109 | noBg, 110 | }, 111 | revalidate: 10, 112 | } 113 | } catch (err) { 114 | console.error('page error', domain, rawPageId, err) 115 | 116 | // we don't want to publish the error version of this page, so 117 | // let next.js know explicitly that incremental SSG failed 118 | throw err 119 | } 120 | } 121 | 122 | export async function getStaticPaths() { 123 | if (isDev) { 124 | return { 125 | paths: [], 126 | fallback: true, 127 | } 128 | } 129 | 130 | // console.log('getStaticPaths') 131 | const siteMap = await getSiteMap() 132 | // console.log('siteMap', siteMap) 133 | 134 | const staticPaths = { 135 | paths: Object.keys(siteMap.canonicalPageMap).map((pageId) => ({ 136 | params: { 137 | pageId, 138 | }, 139 | })), 140 | // paths: [], 141 | fallback: true, 142 | } 143 | 144 | return staticPaths 145 | } 146 | 147 | export default function NotionDomainDynamicPage(props) { 148 | const router = useRouter() 149 | useEffect(() => { 150 | if (router.isReady) { 151 | const { r } = router.query 152 | if (r) { 153 | window.location.href = `/api/revalidate?path=${window.location.pathname}&secret=${r}` 154 | } 155 | } 156 | }, [router.isReady, router.query]) 157 | 158 | // console.log('NotionDomainDynamicPage', props) 159 | return ( 160 | 173 | 174 | 175 | ) 176 | } 177 | -------------------------------------------------------------------------------- /pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import '@/styles/global.css' 2 | import '@fontsource/jetbrains-mono/400.css' 3 | import '@fontsource/lexend/400.css' 4 | import '@fontsource/lexend/700.css' 5 | import { ThemeProvider } from 'next-themes' 6 | 7 | import { Analytics } from '@vercel/analytics/react' 8 | import type { AppProps } from 'next/app' 9 | 10 | import { GlobalContextProvider } from '@/utils/useGlobal' 11 | import { ModalProvider } from '@/utils/useModal' 12 | 13 | export default function App({ Component, pageProps }: AppProps) { 14 | return ( 15 | 16 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | ) 30 | } 31 | -------------------------------------------------------------------------------- /pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import Document, { Head, Html, Main, NextScript } from 'next/document' 2 | 3 | export default class MyDocument extends Document { 4 | render() { 5 | return ( 6 | 7 | 8 | 9 | 10 | 11 | {/* */} 12 | 13 | 14 | 15 | {/* */} 19 | 25 |
    26 | 27 | 28 | 29 | 30 | ) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /pages/_error.tsx: -------------------------------------------------------------------------------- 1 | import { ErrorPage } from '@/layouts' 2 | 3 | export default ErrorPage 4 | -------------------------------------------------------------------------------- /pages/api/revalidate.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from 'next' 2 | 3 | export default async function handler( 4 | req: NextApiRequest, 5 | res: NextApiResponse 6 | ) { 7 | // Check for secret to confirm this is a valid request 8 | if (req.query.secret !== process.env.REVALIDATE_SECRET) { 9 | return res.status(401).send('Invalid token') 10 | } 11 | console.log('🔁 Revalidating', req.query.path) 12 | 13 | try { 14 | await res.revalidate(req.query.path as string) 15 | return res.send(`Successfully revalidated ${req.query.path}`) 16 | } catch (err) { 17 | // If there was an error, Next.js will continue 18 | // to show the last successfully generated page 19 | return res.status(500).send('Error revalidating') 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /pages/api/sunset.ts: -------------------------------------------------------------------------------- 1 | const handler = async (req: any, res: any) => { 2 | const { lat = '', lng = '', h = '' } = req.query 3 | const url = `${process.env.API_SERVER_BASE_URL}/sunset/duration?lat=${lat}&lng=${lng}&h=${h}` 4 | const response = await fetch(url, { 5 | method: 'GET', 6 | headers: { 7 | 'x-forwarded-for': req.headers['x-forwarded-for'], 8 | }, 9 | }) 10 | const data = await response.json() 11 | res.json(data) 12 | } 13 | 14 | export default handler 15 | -------------------------------------------------------------------------------- /pages/feed.tsx: -------------------------------------------------------------------------------- 1 | import RSS from 'rss' 2 | import type { GetServerSideProps } from 'next' 3 | import { 4 | getBlockParentPage, 5 | getBlockTitle, 6 | getPageProperty, 7 | idToUuid, 8 | } from 'notion-utils' 9 | import { ExtendedRecordMap } from 'notion-types' 10 | 11 | import * as config from '@/lib/config' 12 | import { getSiteMap } from 'lib/get-site-map' 13 | import { getCanonicalPageUrl } from 'lib/map-page-url' 14 | import { getSocialImageUrl } from 'lib/get-social-image-url' 15 | 16 | export const getServerSideProps: GetServerSideProps = async ({ req, res }) => { 17 | if (req.method !== 'GET') { 18 | res.statusCode = 405 19 | res.setHeader('Content-Type', 'application/json') 20 | res.write(JSON.stringify({ error: 'method not allowed' })) 21 | res.end() 22 | return { props: {} } 23 | } 24 | 25 | const siteMap = await getSiteMap() 26 | const ttlMinutes = 24 * 60 // 24 hours 27 | const ttlSeconds = ttlMinutes * 60 28 | 29 | const feed = new RSS({ 30 | title: config.name, 31 | site_url: config.host, 32 | feed_url: `${config.host}/feed.xml`, 33 | language: config.language, 34 | ttl: ttlMinutes, 35 | }) 36 | 37 | for (const pagePath of Object.keys(siteMap.canonicalPageMap)) { 38 | const pageId = siteMap.canonicalPageMap[pagePath] 39 | const recordMap = siteMap.pageMap[pageId] as ExtendedRecordMap 40 | if (!recordMap) continue 41 | 42 | const keys = Object.keys(recordMap?.block || {}) 43 | const block = recordMap?.block?.[keys[0]]?.value 44 | if (!block) continue 45 | 46 | const parentPage = getBlockParentPage(block, recordMap) 47 | const isBlogPost = 48 | block.type === 'page' && 49 | block.parent_table === 'collection' && 50 | parentPage?.id === idToUuid(config.rootNotionPageId) 51 | if (!isBlogPost) { 52 | continue 53 | } 54 | 55 | const title = getBlockTitle(block, recordMap) || config.name 56 | const description = 57 | getPageProperty('Description', block, recordMap) || 58 | config.description 59 | const url = getCanonicalPageUrl(config.site, recordMap)(pageId) 60 | const lastUpdatedTime = getPageProperty( 61 | 'Last Updated', 62 | block, 63 | recordMap 64 | ) 65 | const publishedTime = getPageProperty('Published', block, recordMap) 66 | const date = lastUpdatedTime 67 | ? new Date(lastUpdatedTime) 68 | : publishedTime 69 | ? new Date(publishedTime) 70 | : undefined 71 | const socialImageUrl = getSocialImageUrl(pageId) 72 | 73 | feed.item({ 74 | title, 75 | url, 76 | date, 77 | description, 78 | enclosure: socialImageUrl 79 | ? { 80 | url: socialImageUrl, 81 | type: 'image/jpeg', 82 | } 83 | : undefined, 84 | }) 85 | } 86 | 87 | const feedText = feed.xml({ indent: true }) 88 | 89 | res.setHeader( 90 | 'Cache-Control', 91 | `public, max-age=${ttlSeconds}, stale-while-revalidate=${ttlSeconds}` 92 | ) 93 | res.setHeader('Content-Type', 'text/xml; charset=utf-8') 94 | res.write(feedText) 95 | res.end() 96 | 97 | return { props: {} } 98 | } 99 | 100 | export default () => null 101 | -------------------------------------------------------------------------------- /pages/index.tsx: -------------------------------------------------------------------------------- 1 | import BlogPostCard from '@/layouts/components/BlogPostCard' 2 | import defaultCoverImage from '@/data/defaultCoverImage' 3 | import { Layout } from '@/layouts' 4 | import { getCanonicalPageId } from '@/lib/get-canonical-page-id' 5 | import getIcon from '@/lib/get-icon' 6 | import { PageProps } from '@/lib/types' 7 | import cover from '@/public/images/city.webp' 8 | import config from '@/site.config' 9 | import { domain } from '@/lib/config' 10 | import { mapImageUrl } from 'lib/map-image-url' 11 | import { resolveNotionPage } from 'lib/resolve-notion-page' 12 | import { 13 | getBlockIcon, 14 | getBlockTitle, 15 | getPageProperty, 16 | normalizeUrl, 17 | parsePageId, 18 | } from 'notion-utils' 19 | 20 | export const getStaticProps = async () => { 21 | try { 22 | const props = (await resolveNotionPage(domain)) as PageProps & { 23 | postList: any[] 24 | tagSchema: any 25 | } 26 | if (props.error) { 27 | throw props.error 28 | } 29 | const collectionId = parsePageId(config.postsCollectionId) 30 | const recordMap = props.recordMap 31 | const getUrl = (pageId) => 32 | getCanonicalPageId(parsePageId(pageId, { uuid: true }), recordMap, { 33 | uuid: process.env.NODE_ENV && process.env.NODE_ENV === 'development', 34 | }) 35 | 36 | const schema = recordMap.collection?.[collectionId]?.value?.schema 37 | const tagSchemaOptions = Object.values(schema).find( 38 | (x) => x.name === 'Tags' 39 | ).options 40 | const tagColorMap = Object.fromEntries( 41 | tagSchemaOptions.map((x) => [x.value, x.color]) 42 | ) 43 | 44 | const postList = Object.entries(props.recordMap.block) 45 | .map(([id, { value: block }]) => { 46 | if (parsePageId(block.parent_id) !== collectionId) return false 47 | const isPublic = getPageProperty('Public', block, recordMap) 48 | if (!isPublic) return false 49 | 50 | const title = getBlockTitle(block, props.recordMap) 51 | const icon = getIcon(getBlockIcon(block, props.recordMap), block) 52 | 53 | const description = getPageProperty( 54 | 'Description', 55 | block, 56 | recordMap 57 | ) 58 | const tags = getPageProperty( 59 | 'Tags', 60 | block, 61 | recordMap 62 | )?.filter?.((t) => t && t.length > 0) 63 | const date = 64 | getPageProperty('Published', block, recordMap) ?? 65 | block.created_time 66 | const coverImageSrc = mapImageUrl(block?.format?.page_cover, block) 67 | 68 | const coverImage = coverImageSrc 69 | ? { 70 | src: coverImageSrc, 71 | ...(recordMap?.preview_images?.[coverImageSrc] ?? 72 | recordMap?.preview_images?.[normalizeUrl(coverImageSrc)]), 73 | } 74 | : defaultCoverImage 75 | 76 | return { 77 | id, 78 | collectionId, 79 | title, 80 | icon, 81 | coverImage, 82 | tags: tags.map((t) => ({ 83 | name: t, 84 | color: tagColorMap[t], 85 | })), 86 | date, 87 | block, 88 | description, 89 | url: getUrl(id), 90 | } 91 | }) 92 | .filter(Boolean) 93 | 94 | props.postList = postList 95 | 96 | return { props, revalidate: 10 } 97 | } catch (err) { 98 | console.error('page error', domain, err) 99 | 100 | // we don't want to publish the error version of this page, so 101 | // let next.js know explicitly that incremental SSG failed 102 | throw err 103 | } 104 | } 105 | 106 | export default function NotionDomainPage(props) { 107 | return ( 108 | 116 |
    117 | {props.postList.map((post) => ( 118 | 119 | ))} 120 |
    121 |
    122 | ) 123 | } 124 | -------------------------------------------------------------------------------- /pages/projects/brightspace.tsx: -------------------------------------------------------------------------------- 1 | import AutoFocus from '@/components/AutoFocus' 2 | import { Layout } from '@/layouts' 3 | import config from '@/site.config' 4 | import { saveAs } from 'file-saver' 5 | import JSZip from 'jszip' 6 | import { GetStaticProps } from 'next' 7 | import dynamic from 'next/dynamic' 8 | import React, { ReactNode, useState } from 'react' 9 | import { BiArchiveOut } from 'react-icons/bi' 10 | import { IoArrowDown } from 'react-icons/io5' 11 | 12 | const Comment = dynamic(() => import('@/layouts/components/Giscus'), { 13 | ssr: false, 14 | }) 15 | 16 | const FILENAME_CHAR_MAP = { 17 | '<': '(', 18 | '>': ')', 19 | ':': '-', 20 | '"': "'", 21 | '/': '-', 22 | '\\': ',', 23 | '|': '-', 24 | '?': '', 25 | '*': '', 26 | '@': 'at', 27 | } 28 | 29 | type ProcessedFileList = { 30 | courseName: string 31 | folderName: string 32 | filePaths: string[] 33 | }[] 34 | 35 | function sanitizeFilename(filename: string) { 36 | let sanitized = filename 37 | Object.entries(FILENAME_CHAR_MAP).forEach(([key, value]) => { 38 | sanitized = sanitized.replaceAll(key, value) 39 | }) 40 | return sanitized.replace(/\s+/g, ' ').trim() 41 | } 42 | 43 | const sortAlphabetically = (list, property = undefined) => 44 | list.sort((a, b) => 45 | (property ? a[property] : a).localeCompare(property ? b[property] : b) 46 | ) 47 | 48 | function ContentOrganizer() { 49 | const [warnings, setWarnings] = useState([]) 50 | const [processed, setProcessed] = useState([]) 51 | const [files, setFiles] = useState([]) 52 | 53 | const processFile = async (file: File, output: JSZip) => { 54 | try { 55 | const zip = await JSZip.loadAsync(file) 56 | setFiles((files) => [...files, file.name]) 57 | const toc = await zip.file('Table of Contents.html')?.async('string') 58 | if (!toc) { 59 | setWarnings((warnings) => [ 60 | ...warnings, 61 |
  • 62 | No Table of Contents found in 63 | {file.name} 64 |
  • , 65 | ]) 66 | return 67 | } 68 | 69 | const tocDoc = new DOMParser().parseFromString(toc, 'text/html') 70 | 71 | const title = 72 | tocDoc.querySelector('font.title > strong')?.textContent || 'Untitled' 73 | const folderName = sanitizeFilename(title.split(' - ').at(-1) as string) 74 | 75 | const courseName = sanitizeFilename(title.split(',').at(0) as string) 76 | 77 | const filePaths = await Promise.all( 78 | Array.from( 79 | tocDoc.querySelectorAll('p.d2l > a'), 80 | async (a) => { 81 | const filename = a.textContent && sanitizeFilename(a.textContent) 82 | const href = a.getAttribute('href') || '' 83 | const file = await zip.file(href)?.async('blob') 84 | const ext = href.split('.').pop() 85 | 86 | const filePath = `${courseName}/${folderName}/${filename}.${ext}` 87 | await output.file(filePath, file) 88 | return filePath 89 | } 90 | ) 91 | ) 92 | return { 93 | courseName, 94 | folderName, 95 | filePaths, 96 | } 97 | } catch (e: any) { 98 | setWarnings((warnings) => [ 99 | ...warnings, 100 |
  • 101 | {file.name} is not a valid Brightspace export file. 102 |
  • , 103 | ]) 104 | } 105 | } 106 | 107 | const onFileSelect = async (e: React.ChangeEvent) => { 108 | setWarnings([]) 109 | setProcessed([]) 110 | setFiles([]) 111 | const output = new JSZip() 112 | 113 | const files = e.target.files 114 | if (!files) return 115 | 116 | const processed = ( 117 | await Promise.all( 118 | Array.from(files).map((file) => processFile(file, output)) 119 | ) 120 | ).filter((p) => p !== undefined) as ProcessedFileList 121 | 122 | setProcessed(sortAlphabetically(processed, 'folderName')) 123 | if (!processed.length) { 124 | setWarnings(['No files processed. Please check the console for errors.']) 125 | return 126 | } 127 | 128 | const content = await output.generateAsync({ type: 'blob' }) 129 | saveAs(content, 'output.zip') 130 | } 131 | 132 | return ( 133 |
    134 |

    135 | Brightspace File Organizer 136 |

    137 |

    138 | The zip files downloaded from Brightspace are quite a mess. This tool 139 | will help you rename and organize the files into folders based on the 140 | course name and the table of content. 141 |

    142 | 173 | 174 | {warnings.length > 0 && ( 175 |
      {warnings}
    176 | )} 177 | {processed.length > 0 && ( 178 | <> 179 | 180 | 181 |
    182 | 186 | Processed Files 187 | 188 |
      189 | {processed.map(({ folderName, filePaths, courseName }) => ( 190 |
    • 191 |

      {folderName}

      192 |
        193 | {filePaths.map((filePath) => ( 194 |
      • {filePath.split('/').at(2)}
      • 195 | ))} 196 |
      197 |
    • 198 | ))} 199 |
    200 |
    201 | 202 | )} 203 |

    204 | Don't worry about privacy! This tool does everything in your 205 | browser. 206 |

    207 |
    208 | ) 209 | } 210 | 211 | export default function BrightSpaceTools(props) { 212 | return ( 213 | 214 |
    215 | 216 |
    217 | 218 |
    219 |
    220 |
    221 | ) 222 | } 223 | 224 | export const getStaticProps: GetStaticProps = () => { 225 | return { 226 | props: { 227 | ...config.projects.find( 228 | (tool) => tool.title === 'Brightspace File Organizer' 229 | ), 230 | }, 231 | } 232 | } 233 | -------------------------------------------------------------------------------- /pages/projects/index.tsx: -------------------------------------------------------------------------------- 1 | import { ProjectCard as Card } from '@/components/Card' 2 | import { Layout } from '@/layouts' 3 | import Cover from '@/public/images/tools.webp' 4 | import config from '@/site.config' 5 | import React from 'react' 6 | 7 | export default function Projects(props) { 8 | return ( 9 | 10 |
    11 | {config.projects.map((d) => ( 12 | 19 | ))} 20 |
    21 |
    22 | ) 23 | } 24 | -------------------------------------------------------------------------------- /pages/projects/nyu-academic-calendar.tsx: -------------------------------------------------------------------------------- 1 | import Button from '@/components/Button' 2 | import { Layout } from '@/layouts' 3 | import config from '@/site.config' 4 | import dynamic from 'next/dynamic' 5 | import { GetStaticProps } from 'next' 6 | import React, { useState } from 'react' 7 | import { 8 | FaCopy as CopyIcon, 9 | FaDownload as DownloadIcon, 10 | FaGithub, 11 | FaGoogle, 12 | } from 'react-icons/fa' 13 | 14 | const Comment = dynamic(() => import('@/layouts/components/Giscus'), { 15 | ssr: false, 16 | }) 17 | 18 | export default function NyuCal(props) { 19 | const [copied, setCopied] = useState(false) 20 | return ( 21 | 22 |
    23 |
    24 | 34 | 58 | 74 | 84 |
    85 |