mirror of
https://github.com/d0zingcat/nextjs-notion-starter-kit.git
synced 2026-05-13 15:09:47 +00:00
434 lines
13 KiB
TypeScript
434 lines
13 KiB
TypeScript
import cs from 'classnames'
|
|
import dynamic from 'next/dynamic'
|
|
import Image from 'next/legacy/image'
|
|
import Link from 'next/link'
|
|
import { useRouter } from 'next/router'
|
|
import { type PageBlock } from 'notion-types'
|
|
import { formatDate, getBlockTitle, getPageProperty } from 'notion-utils'
|
|
import * as React from 'react'
|
|
import BodyClassName from 'react-body-classname'
|
|
import {
|
|
type NotionComponents,
|
|
NotionRenderer,
|
|
useNotionContext
|
|
} from 'react-notion-x'
|
|
import { EmbeddedTweet, TweetNotFound, TweetSkeleton } from 'react-tweet'
|
|
import { useSearchParam } from 'react-use'
|
|
|
|
import type * as types from '@/lib/types'
|
|
import * as config from '@/lib/config'
|
|
import { mapImageUrl } from '@/lib/map-image-url'
|
|
import { getCanonicalPageUrl, mapPageUrl } from '@/lib/map-page-url'
|
|
import { searchNotion } from '@/lib/search-notion'
|
|
import { useDarkMode } from '@/lib/use-dark-mode'
|
|
|
|
import { Footer } from './Footer'
|
|
import { GitHubShareButton } from './GitHubShareButton'
|
|
import { Loading } from './Loading'
|
|
import { NotionPageHeader } from './NotionPageHeader'
|
|
import { Page404 } from './Page404'
|
|
import { PageAside } from './PageAside'
|
|
import { PageHead } from './PageHead'
|
|
import styles from './styles.module.css'
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// dynamic imports for optional components
|
|
// -----------------------------------------------------------------------------
|
|
|
|
const Code = dynamic(() =>
|
|
import('react-notion-x/build/third-party/code').then(async (m) => {
|
|
// add / remove any prism syntaxes here
|
|
await Promise.allSettled([
|
|
// @ts-expect-error Ignore prisma types
|
|
import('prismjs/components/prism-markup-templating.js'),
|
|
// @ts-expect-error Ignore prisma types
|
|
import('prismjs/components/prism-markup.js'),
|
|
// @ts-expect-error Ignore prisma types
|
|
import('prismjs/components/prism-bash.js'),
|
|
// @ts-expect-error Ignore prisma types
|
|
import('prismjs/components/prism-c.js'),
|
|
// @ts-expect-error Ignore prisma types
|
|
import('prismjs/components/prism-cpp.js'),
|
|
// @ts-expect-error Ignore prisma types
|
|
import('prismjs/components/prism-csharp.js'),
|
|
// @ts-expect-error Ignore prisma types
|
|
import('prismjs/components/prism-docker.js'),
|
|
// @ts-expect-error Ignore prisma types
|
|
import('prismjs/components/prism-java.js'),
|
|
// @ts-expect-error Ignore prisma types
|
|
import('prismjs/components/prism-js-templates.js'),
|
|
// @ts-expect-error Ignore prisma types
|
|
import('prismjs/components/prism-coffeescript.js'),
|
|
// @ts-expect-error Ignore prisma types
|
|
import('prismjs/components/prism-diff.js'),
|
|
// @ts-expect-error Ignore prisma types
|
|
import('prismjs/components/prism-git.js'),
|
|
// @ts-expect-error Ignore prisma types
|
|
import('prismjs/components/prism-go.js'),
|
|
// @ts-expect-error Ignore prisma types
|
|
import('prismjs/components/prism-graphql.js'),
|
|
// @ts-expect-error Ignore prisma types
|
|
import('prismjs/components/prism-handlebars.js'),
|
|
// @ts-expect-error Ignore prisma types
|
|
import('prismjs/components/prism-less.js'),
|
|
// @ts-expect-error Ignore prisma types
|
|
import('prismjs/components/prism-makefile.js'),
|
|
// @ts-expect-error Ignore prisma types
|
|
import('prismjs/components/prism-markdown.js'),
|
|
// @ts-expect-error Ignore prisma types
|
|
import('prismjs/components/prism-objectivec.js'),
|
|
// @ts-expect-error Ignore prisma types
|
|
import('prismjs/components/prism-ocaml.js'),
|
|
// @ts-expect-error Ignore prisma types
|
|
import('prismjs/components/prism-python.js'),
|
|
// @ts-expect-error Ignore prisma types
|
|
import('prismjs/components/prism-reason.js'),
|
|
// @ts-expect-error Ignore prisma types
|
|
import('prismjs/components/prism-rust.js'),
|
|
// @ts-expect-error Ignore prisma types
|
|
import('prismjs/components/prism-sass.js'),
|
|
// @ts-expect-error Ignore prisma types
|
|
import('prismjs/components/prism-scss.js'),
|
|
// @ts-expect-error Ignore prisma types
|
|
import('prismjs/components/prism-solidity.js'),
|
|
// @ts-expect-error Ignore prisma types
|
|
import('prismjs/components/prism-sql.js'),
|
|
// @ts-expect-error Ignore prisma types
|
|
import('prismjs/components/prism-stylus.js'),
|
|
// @ts-expect-error Ignore prisma types
|
|
import('prismjs/components/prism-swift.js'),
|
|
// @ts-expect-error Ignore prisma types
|
|
import('prismjs/components/prism-wasm.js'),
|
|
// @ts-expect-error Ignore prisma types
|
|
import('prismjs/components/prism-yaml.js')
|
|
])
|
|
return m.Code
|
|
})
|
|
)
|
|
|
|
const Collection = dynamic(() =>
|
|
import('react-notion-x/build/third-party/collection').then(
|
|
(m) => m.Collection
|
|
)
|
|
)
|
|
const Equation = dynamic(() =>
|
|
import('react-notion-x/build/third-party/equation').then((m) => m.Equation)
|
|
)
|
|
const Pdf = dynamic(
|
|
() => import('react-notion-x/build/third-party/pdf').then((m) => m.Pdf),
|
|
{
|
|
ssr: false
|
|
}
|
|
)
|
|
const Modal = dynamic(
|
|
() =>
|
|
import('react-notion-x/build/third-party/modal').then((m) => {
|
|
m.Modal.setAppElement('.notion-viewport')
|
|
return m.Modal
|
|
}),
|
|
{
|
|
ssr: false
|
|
}
|
|
)
|
|
|
|
// Helper function to filter recordMap by language
|
|
function filterRecordMapByLanguage(
|
|
recordMap: types.ExtendedRecordMap,
|
|
targetLang: string
|
|
): types.ExtendedRecordMap {
|
|
// Deep clone collection_query to avoid mutating the original
|
|
const newRecordMap: types.ExtendedRecordMap = {
|
|
...recordMap,
|
|
collection_query: structuredClone(
|
|
recordMap.collection_query
|
|
) as types.ExtendedRecordMap['collection_query']
|
|
}
|
|
|
|
for (const collectionId of Object.keys(newRecordMap.collection_query || {})) {
|
|
const collection = newRecordMap.collection[collectionId]?.value
|
|
if (!collection) continue
|
|
|
|
const schema = collection.schema
|
|
if (!schema) continue
|
|
|
|
const langPropId = Object.keys(schema).find(
|
|
(key) => schema[key]?.name?.toLowerCase() === 'language'
|
|
)
|
|
|
|
if (!langPropId) continue
|
|
|
|
const views = newRecordMap.collection_query[collectionId]
|
|
if (!views) continue
|
|
|
|
for (const viewId of Object.keys(views)) {
|
|
const view = views[viewId]
|
|
if (view?.collection_group_results?.blockIds) {
|
|
const originalBlockIds = view.collection_group_results.blockIds
|
|
const filteredBlockIds = originalBlockIds.filter((blockId: string) => {
|
|
const block = newRecordMap.block[blockId]?.value
|
|
if (!block) return false
|
|
|
|
const propValue = block.properties?.[langPropId]
|
|
|
|
// If no language property is set, show the post
|
|
if (!propValue) return true
|
|
|
|
// Validate the property format
|
|
if (
|
|
!Array.isArray(propValue) ||
|
|
propValue.length === 0 ||
|
|
!Array.isArray(propValue[0]) ||
|
|
typeof propValue[0][0] !== 'string'
|
|
) {
|
|
// If the language property is present but not in the expected format,
|
|
// treat it as if no specific language is set and keep the block.
|
|
return true
|
|
}
|
|
|
|
const langText = propValue[0][0].toLowerCase().trim()
|
|
|
|
// Show if language matches or is set to 'all'
|
|
return langText.includes(targetLang) || langText === 'all'
|
|
})
|
|
|
|
view.collection_group_results.blockIds = filteredBlockIds
|
|
}
|
|
}
|
|
}
|
|
|
|
return newRecordMap
|
|
}
|
|
|
|
function Tweet({ id }: { id: string }) {
|
|
const { recordMap } = useNotionContext()
|
|
const tweet = (recordMap as types.ExtendedTweetRecordMap)?.tweets?.[id]
|
|
|
|
return (
|
|
<React.Suspense fallback={<TweetSkeleton />}>
|
|
{tweet ? <EmbeddedTweet tweet={tweet} /> : <TweetNotFound />}
|
|
</React.Suspense>
|
|
)
|
|
}
|
|
|
|
const propertyLastEditedTimeValue = (
|
|
{ block, pageHeader }: any,
|
|
defaultFn: () => React.ReactNode
|
|
) => {
|
|
if (pageHeader && block?.last_edited_time) {
|
|
return `Last updated ${formatDate(block?.last_edited_time, {
|
|
month: 'long'
|
|
})}`
|
|
}
|
|
|
|
return defaultFn()
|
|
}
|
|
|
|
const propertyDateValue = (
|
|
{ data, schema, pageHeader }: any,
|
|
defaultFn: () => React.ReactNode
|
|
) => {
|
|
if (pageHeader && schema?.name?.toLowerCase() === 'published') {
|
|
const publishDate = data?.[0]?.[1]?.[0]?.[1]?.start_date
|
|
|
|
if (publishDate) {
|
|
return `${formatDate(publishDate, {
|
|
month: 'long'
|
|
})}`
|
|
}
|
|
}
|
|
|
|
return defaultFn()
|
|
}
|
|
|
|
const propertyTextValue = (
|
|
{ schema, pageHeader }: any,
|
|
defaultFn: () => React.ReactNode
|
|
) => {
|
|
if (pageHeader && schema?.name?.toLowerCase() === 'author') {
|
|
return <b>{defaultFn()}</b>
|
|
}
|
|
|
|
return defaultFn()
|
|
}
|
|
|
|
export function NotionPage({
|
|
site,
|
|
recordMap,
|
|
error,
|
|
pageId
|
|
}: types.PageProps) {
|
|
const router = useRouter()
|
|
const lite = useSearchParam('lite')
|
|
|
|
// Use state to store filtered recordMap, initialized with original
|
|
const [filteredRecordMap, setFilteredRecordMap] =
|
|
React.useState<types.ExtendedRecordMap | undefined>(() => {
|
|
if (config.isI18nEnabled && recordMap) {
|
|
return filterRecordMapByLanguage(recordMap, 'en')
|
|
}
|
|
return recordMap
|
|
})
|
|
|
|
// Apply language filtering only after mount to avoid hydration mismatch
|
|
React.useEffect(() => {
|
|
if (!config.isI18nEnabled || !recordMap) {
|
|
setFilteredRecordMap(recordMap)
|
|
return
|
|
}
|
|
|
|
const browserLang =
|
|
typeof navigator !== 'undefined' && navigator.language
|
|
? navigator.language
|
|
: 'en'
|
|
|
|
if (!browserLang) {
|
|
setFilteredRecordMap(recordMap)
|
|
return
|
|
}
|
|
|
|
const langCode = (browserLang.split('-')[0] || 'en').toLowerCase()
|
|
|
|
setFilteredRecordMap(filterRecordMapByLanguage(recordMap, langCode))
|
|
}, [recordMap, config.isI18nEnabled])
|
|
|
|
const components = React.useMemo<Partial<NotionComponents>>(
|
|
() => ({
|
|
nextLegacyImage: Image,
|
|
nextLink: Link,
|
|
Code,
|
|
Collection,
|
|
Equation,
|
|
Pdf,
|
|
Modal,
|
|
Tweet,
|
|
Header: NotionPageHeader,
|
|
propertyLastEditedTimeValue,
|
|
propertyTextValue,
|
|
propertyDateValue
|
|
}),
|
|
[]
|
|
)
|
|
|
|
// lite mode is for oembed
|
|
const isLiteMode = lite === 'true'
|
|
|
|
const { isDarkMode } = useDarkMode()
|
|
|
|
const siteMapPageUrl = React.useMemo(() => {
|
|
const params: any = {}
|
|
if (lite) params.lite = lite
|
|
|
|
const searchParams = new URLSearchParams(params)
|
|
return site ? mapPageUrl(site, filteredRecordMap!, searchParams) : undefined
|
|
}, [site, filteredRecordMap, lite])
|
|
|
|
const keys = Object.keys(filteredRecordMap?.block || {})
|
|
const block = filteredRecordMap?.block?.[keys[0]!]?.value
|
|
|
|
// const isRootPage =
|
|
// parsePageId(block?.id) === parsePageId(site?.rootNotionPageId)
|
|
const isBlogPost =
|
|
block?.type === 'page' && block?.parent_table === 'collection'
|
|
|
|
const showTableOfContents = !!isBlogPost
|
|
const minTableOfContentsItems = 3
|
|
|
|
const pageAside = React.useMemo(
|
|
() => (
|
|
<PageAside
|
|
block={block!}
|
|
recordMap={filteredRecordMap!}
|
|
isBlogPost={isBlogPost}
|
|
/>
|
|
),
|
|
[block, filteredRecordMap, isBlogPost]
|
|
)
|
|
|
|
const footer = React.useMemo(() => <Footer />, [])
|
|
|
|
if (router.isFallback) {
|
|
return <Loading />
|
|
}
|
|
|
|
if (error || !site || !block) {
|
|
return <Page404 site={site} pageId={pageId} error={error} />
|
|
}
|
|
|
|
const title = getBlockTitle(block, filteredRecordMap) || site.name
|
|
|
|
console.log('notion page', {
|
|
isDev: config.isDev,
|
|
title,
|
|
pageId,
|
|
rootNotionPageId: site.rootNotionPageId,
|
|
recordMap: filteredRecordMap
|
|
})
|
|
|
|
if (!config.isServer) {
|
|
// add important objects to the window global for easy debugging
|
|
const g = window as any
|
|
g.pageId = pageId
|
|
g.recordMap = filteredRecordMap
|
|
g.block = block
|
|
}
|
|
|
|
const canonicalPageUrl = config.isDev
|
|
? undefined
|
|
: getCanonicalPageUrl(site, filteredRecordMap)(pageId)
|
|
|
|
const socialImage = mapImageUrl(
|
|
getPageProperty<string>('Social Image', block, filteredRecordMap) ||
|
|
(block as PageBlock).format?.page_cover ||
|
|
config.defaultPageCover,
|
|
block
|
|
)
|
|
|
|
const socialDescription =
|
|
getPageProperty<string>('Description', block, filteredRecordMap) ||
|
|
config.description
|
|
|
|
return (
|
|
<>
|
|
<PageHead
|
|
pageId={pageId}
|
|
site={site}
|
|
title={title}
|
|
description={socialDescription}
|
|
image={socialImage}
|
|
url={canonicalPageUrl}
|
|
isBlogPost={isBlogPost}
|
|
/>
|
|
|
|
{isLiteMode && <BodyClassName className='notion-lite' />}
|
|
{isDarkMode && <BodyClassName className='dark-mode' />}
|
|
|
|
<NotionRenderer
|
|
bodyClassName={cs(
|
|
styles.notion,
|
|
pageId === site.rootNotionPageId && 'index-page'
|
|
)}
|
|
darkMode={isDarkMode}
|
|
components={components}
|
|
recordMap={filteredRecordMap}
|
|
rootPageId={site.rootNotionPageId}
|
|
rootDomain={site.domain}
|
|
fullPage={!isLiteMode}
|
|
previewImages={!!filteredRecordMap.preview_images}
|
|
showCollectionViewDropdown={false}
|
|
showTableOfContents={showTableOfContents}
|
|
minTableOfContentsItems={minTableOfContentsItems}
|
|
defaultPageIcon={config.defaultPageIcon}
|
|
defaultPageCover={config.defaultPageCover}
|
|
defaultPageCoverPosition={config.defaultPageCoverPosition}
|
|
mapPageUrl={siteMapPageUrl}
|
|
mapImageUrl={mapImageUrl}
|
|
searchNotion={config.isSearchEnabled ? searchNotion : undefined}
|
|
pageAside={pageAside}
|
|
footer={footer}
|
|
/>
|
|
|
|
<GitHubShareButton />
|
|
</>
|
|
)
|
|
}
|