├── .prettierignore ├── .dockerignore ├── public ├── css │ ├── custom.css │ ├── img-shadow.css │ ├── theme-simple.css │ └── prism-mac-style.css ├── js │ └── custom.js ├── favicon.ico ├── webfonts │ ├── fa-solid-900.ttf │ ├── fa-brands-400.ttf │ ├── fa-brands-400.woff2 │ ├── fa-regular-400.ttf │ ├── fa-solid-900.woff2 │ ├── fa-regular-400.woff2 │ ├── fa-v4compatibility.ttf │ └── fa-v4compatibility.woff2 └── favicon.svg ├── lib ├── notion.js ├── datasource │ ├── flowus │ │ ├── index.js │ │ ├── utils.js │ │ ├── getPageProperties.js │ │ └── getPageData.js │ ├── notion │ │ ├── index.js │ │ ├── utils.js │ │ ├── getPageData.js │ │ └── getPageProperties.js │ ├── utils.js │ ├── index.js │ └── website.js ├── lang │ ├── zh-HK.js │ ├── zh-TW.js │ ├── zh-CN.js │ ├── en-US.js │ └── fr-FR.js ├── notion │ ├── getMetadata.js │ ├── mapImage.js │ ├── getAllPageIds.js │ ├── getAllTags.js │ ├── getAllLinks.js │ ├── getPageTableOfContents.js │ └── getPageProperties.js ├── cache │ ├── memory_cache.js │ ├── cache_manager.js │ ├── local_file_cache.js │ └── mongo_db_cache.js ├── gtag.js ├── robots.txt.js ├── font.js ├── formatDate.js ├── sitemap.xml.js ├── lang.js ├── theme.js ├── global.js ├── busuanzi.js └── utils.js ├── themes ├── plain │ ├── config.js │ ├── Layout404.js │ ├── LayoutIndex.js │ ├── LayoutPage.js │ ├── index.js │ ├── components │ │ ├── ArticlePage.js │ │ ├── ListEmpty.js │ │ ├── LinkMenuList.js │ │ ├── LinkTag.js │ │ ├── AsideLeft.js │ │ ├── Logo.js │ │ ├── SiteFooter.js │ │ ├── ListPage.js │ │ ├── TopNav.js │ │ ├── LinkMenuItemDrop.js │ │ ├── MenuItemDrop.js │ │ ├── LinkCard.js │ │ ├── MenuItemCollapse.js │ │ └── SearchInput.js │ ├── icons │ │ └── logo.js │ └── LayoutBase.js └── index.js ├── vercel.json ├── components ├── comment │ ├── Valine.js │ ├── ValinePanel.js │ ├── ValineCount.js │ ├── Twikoo.js │ ├── Giscus.js │ ├── Utterances.js │ ├── ValineComponent.js │ ├── WalineComponent.js │ └── WebMention.js ├── Gtag.js ├── NotionIcon.js ├── Busuanzi.js ├── GoogleAdsense.js ├── DarkModeButton.js ├── Select.js ├── ExternalScript.js ├── ThemeSwitch.js ├── KatexReact.js ├── Tabs.js ├── SideBarDrawer.js ├── Collapse.js ├── CommonHead.js ├── NotionPage.js ├── CommonScript.js ├── PrismMac.js ├── Draggable.js ├── DebugPanel.js └── Vercel.js ├── postcss.config.js ├── .prettierrc.json ├── .env.example ├── .github ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── feature_request.md │ ├── deployment-error.md │ └── bug_report.md ├── stale.yml └── workflows │ ├── docker-ghcr.yaml │ └── codeql-analysis.yml ├── jsconfig.json ├── next-sitemap.config.js ├── pages ├── api │ └── revalidate.js ├── 404.js ├── sitemap.xml.js ├── _document.js ├── index.js ├── page │ └── [page].js ├── [prefix] │ └── [page].js └── _app.js ├── .gitignore ├── .eslintrc.js ├── Dockerfile ├── README.md ├── tailwind.config.js ├── LICENSE ├── core ├── settings.js └── notion.js ├── .prettierrc.js ├── next.config.js ├── styles ├── nprogress.css ├── prism-theme.css └── globals.css ├── package.json └── config.js /.prettierignore: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .next* -------------------------------------------------------------------------------- /public/css/custom.css: -------------------------------------------------------------------------------- 1 | /* 静态文件导入 自定义样式*/ 2 | 3 | -------------------------------------------------------------------------------- /public/js/custom.js: -------------------------------------------------------------------------------- 1 | // 这里编写自定义js脚本;将被静态引入到页面中 2 | -------------------------------------------------------------------------------- /lib/notion.js: -------------------------------------------------------------------------------- 1 | export { getAllTags } from './notion/getAllTags' 2 | -------------------------------------------------------------------------------- /themes/plain/config.js: -------------------------------------------------------------------------------- 1 | const CONFIG = {} 2 | export default CONFIG 3 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/liudding/notion-portal/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "cleanUrls": true, 3 | "trailingSlash": false, 4 | "headers": [] 5 | } 6 | -------------------------------------------------------------------------------- /components/comment/Valine.js: -------------------------------------------------------------------------------- 1 | import { Valine } from 'react-valine' 2 | 3 | export default Valine 4 | -------------------------------------------------------------------------------- /components/comment/ValinePanel.js: -------------------------------------------------------------------------------- 1 | import { ValinePanel } from 'react-valine' 2 | 3 | export default ValinePanel 4 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {} 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /public/webfonts/fa-solid-900.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/liudding/notion-portal/HEAD/public/webfonts/fa-solid-900.ttf -------------------------------------------------------------------------------- /public/webfonts/fa-brands-400.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/liudding/notion-portal/HEAD/public/webfonts/fa-brands-400.ttf -------------------------------------------------------------------------------- /public/webfonts/fa-brands-400.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/liudding/notion-portal/HEAD/public/webfonts/fa-brands-400.woff2 -------------------------------------------------------------------------------- /public/webfonts/fa-regular-400.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/liudding/notion-portal/HEAD/public/webfonts/fa-regular-400.ttf -------------------------------------------------------------------------------- /public/webfonts/fa-solid-900.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/liudding/notion-portal/HEAD/public/webfonts/fa-solid-900.woff2 -------------------------------------------------------------------------------- /public/webfonts/fa-regular-400.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/liudding/notion-portal/HEAD/public/webfonts/fa-regular-400.woff2 -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "semi": false, 4 | "trailingComma": "none", 5 | "arrowParens": "avoid" 6 | } 7 | -------------------------------------------------------------------------------- /public/webfonts/fa-v4compatibility.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/liudding/notion-portal/HEAD/public/webfonts/fa-v4compatibility.ttf -------------------------------------------------------------------------------- /public/webfonts/fa-v4compatibility.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/liudding/notion-portal/HEAD/public/webfonts/fa-v4compatibility.woff2 -------------------------------------------------------------------------------- /components/comment/ValineCount.js: -------------------------------------------------------------------------------- 1 | import { ValineCount } from 'react-valine' 2 | 3 | /** 4 | * 显示评论数 5 | */ 6 | export default ValineCount 7 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # 环境变量 @see https://www.nextjs.cn/docs/basic-features/environment-variables 2 | NEXT_PUBLIC_VERSION=3.13.3 3 | NEXT_PUBLIC_REVALIDATE_SECOND=600000 -------------------------------------------------------------------------------- /lib/datasource/flowus/index.js: -------------------------------------------------------------------------------- 1 | import { getPageData } from './getPageData' 2 | import getPageProperties from './getPageProperties' 3 | 4 | export { 5 | getPageData, 6 | getPageProperties 7 | } 8 | -------------------------------------------------------------------------------- /lib/datasource/notion/index.js: -------------------------------------------------------------------------------- 1 | import { getPageData } from './getPageData' 2 | import getPageProperties from './getPageProperties' 3 | 4 | export { 5 | getPageData, 6 | getPageProperties 7 | } 8 | -------------------------------------------------------------------------------- /public/css/img-shadow.css: -------------------------------------------------------------------------------- 1 | /* 图片阴影 */ 2 | #notion-article img{ 3 | box-shadow: rgba(50, 50, 93, 0.25) 0px 13px 27px -5px, rgba(0, 0, 0, 0.3) 0px 8px 16px -8px; 4 | border-radius: 0.5rem; 5 | } 6 | -------------------------------------------------------------------------------- /themes/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 切换主题请修改 config.js 中的 THEME 字段 3 | */ 4 | import * as plain from './plain' 5 | 6 | export const ALL_THEME = [ 7 | 'plain' 8 | ] 9 | export { 10 | plain 11 | } 12 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Join Notion CN Community 4 | url: https://t.me/Notionso 5 | about: Ask and discuss Notion and Nobelium with other community members. 6 | -------------------------------------------------------------------------------- /themes/plain/Layout404.js: -------------------------------------------------------------------------------- 1 | import LayoutBase from './LayoutBase' 2 | 3 | export const Layout404 = props => { 4 | return 5 |
404
6 |
7 | } 8 | -------------------------------------------------------------------------------- /themes/plain/LayoutIndex.js: -------------------------------------------------------------------------------- 1 | import ListPage from './components/ListPage' 2 | import LayoutBase from './LayoutBase' 3 | 4 | export const LayoutIndex = (props) => { 5 | return 6 | 7 | 8 | } 9 | -------------------------------------------------------------------------------- /themes/plain/LayoutPage.js: -------------------------------------------------------------------------------- 1 | import ListPage from './components/ListPage' 2 | import LayoutBase from './LayoutBase' 3 | 4 | export const LayoutPage = (props) => { 5 | return 6 | 7 | 8 | } 9 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "paths": { 5 | "@/*": ["./*"], 6 | "@/components/*": ["components/*"], 7 | "@/data/*": ["data/*"], 8 | "@/lib/*": ["lib/*"], 9 | "@/styles/*": ["styles/*"] 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /themes/plain/index.js: -------------------------------------------------------------------------------- 1 | import CONFIG from './config' 2 | import { LayoutIndex } from './LayoutIndex' 3 | import { Layout404 } from './Layout404' 4 | import { LayoutPage } from './LayoutPage' 5 | 6 | export { 7 | CONFIG as THEME_CONFIG, 8 | LayoutIndex, 9 | Layout404, 10 | LayoutPage 11 | } 12 | -------------------------------------------------------------------------------- /next-sitemap.config.js: -------------------------------------------------------------------------------- 1 | const CONFIG = require('./config') 2 | 3 | module.exports = { 4 | siteUrl: CONFIG.LINK, 5 | changefreq: 'daily', 6 | priority: 0.7, 7 | generateRobotsTxt: true, 8 | sitemapSize: 7000 9 | // ...other options 10 | // https://github.com/iamvishnusankar/next-sitemap#configuration-options 11 | } 12 | -------------------------------------------------------------------------------- /lib/lang/zh-HK.js: -------------------------------------------------------------------------------- 1 | export default { 2 | NAV: { 3 | INDEX: '網誌', 4 | RSS: '訂閱', 5 | SEARCH: '搜尋', 6 | ABOUT: '關於', 7 | MAIL: '電郵' 8 | }, 9 | PAGINATION: { 10 | PREV: '上一頁', 11 | NEXT: '下一頁' 12 | }, 13 | SEARCH: { 14 | ARTICLES: '搜尋文章', 15 | TAGS: '搜尋標簽' 16 | }, 17 | POST: { 18 | BACK: '返回', 19 | TOP: '回到頂端' 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /pages/api/revalidate.js: -------------------------------------------------------------------------------- 1 | export default async function handler(req, res) { 2 | if (req.query.secret !== process.env.REVALIDATE_SECRET) { 3 | return res.status(401).json({ message: 'Invalid secret' }) 4 | } 5 | 6 | try { 7 | await res.revalidate('/') 8 | return res.json({ revalidated: true }) 9 | } catch (err) { 10 | return res.status(500).send('Error revalidating') 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /themes/plain/components/ArticlePage.js: -------------------------------------------------------------------------------- 1 | import NotionPage from '@/components/NotionPage' 2 | 3 | /** 4 | * 详情文章页 5 | * @returns {JSX.Element} 6 | * @constructor 7 | */ 8 | const ArticlePage = ({ 9 | page, 10 | configs 11 | }) => { 12 | return ( 13 |
14 | 15 |
16 | ) 17 | } 18 | 19 | export default ArticlePage 20 | -------------------------------------------------------------------------------- /themes/plain/components/ListEmpty.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * 空白博客 列表 4 | * @returns {JSX.Element} 5 | * @constructor 6 | */ 7 | const ListEmpty = ({ currentSearch }) => { 8 | return
9 |

没有找到 {(currentSearch &&

{currentSearch}
)}

10 |
11 | } 12 | export default ListEmpty 13 | -------------------------------------------------------------------------------- /lib/notion/getMetadata.js: -------------------------------------------------------------------------------- 1 | export default function getMetadata (rawMetadata) { 2 | const metadata = { 3 | locked: rawMetadata?.format?.block_locked, 4 | page_full_width: rawMetadata?.format?.page_full_width, 5 | page_font: rawMetadata?.format?.page_font, 6 | page_small_text: rawMetadata?.format?.page_small_text, 7 | created_time: rawMetadata.created_time, 8 | last_edited_time: rawMetadata.last_edited_time 9 | } 10 | return metadata 11 | } 12 | -------------------------------------------------------------------------------- /lib/datasource/utils.js: -------------------------------------------------------------------------------- 1 | import CONFIG from '@/config' 2 | import { idToUuid } from 'notion-utils' 3 | 4 | export function getWebsiteConfigPageId() { 5 | if (CONFIG.DATASOURCE === 'notion') { 6 | return idToUuid(CONFIG.WEBSITE_NOTION_PAGE_ID) 7 | } 8 | 9 | return CONFIG.WEBSITE_NOTION_PAGE_ID 10 | } 11 | 12 | export function getLinksPageId() { 13 | if (CONFIG.DATASOURCE === 'notion') { 14 | return idToUuid(CONFIG.LINKS_NOTION_PAGE_ID) 15 | } 16 | 17 | return CONFIG.LINKS_NOTION_PAGE_ID 18 | } 19 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request (新特性建议) 3 | about: Suggest an idea for Nobelium. 4 | title: '' 5 | labels: enhancement 6 | assignees: tangly1024 7 | --- 8 | 9 | 13 | 14 | **为什么提出这个新的特性改动** 15 | 简要说明此特性解决的问题,例如,『博客站点的读者互动性不够强,和读者无法建立紧密的联系...』 16 | 17 | **描述一下你推荐的解决方案** 18 | 简要说明你的解决方案建议,例如,『Giscus评论插件功能更加强大,用户只需留言既可在你的邮箱收到通知。。。』 19 | 20 | **描述一下你考虑过的其它替代解决方案** 21 | 简要说明你所有想过的有可能解决此问题的方案。 22 | 23 | **补充说明** 24 | 补充与此特性相关的内容 25 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/deployment-error.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Deployment error (部署错误) 3 | about: 在安装部署NotionNext时需要什么帮助吗 4 | title: '' 5 | labels: deployment 6 | assignees: tangly1024 7 | --- 8 | 9 | 10 | 14 | 15 | **描述遇到的问题** 16 | 简单说明你遇到的问题,相关的日志、错误信息 17 | 18 | **相应配置** 19 | 相关的配置,例如notion_page_id;你的网站地址 20 | 21 | **截图** 22 | 相关的页面,应该用结果 23 | 24 | **环境** 25 | 26 | - 操作系统: [例如. iOS, Android, macOS, windows] 27 | - 浏览器 [例如. chrome, safari, firefox] 28 | - 版本 [e.g. 22] 29 | -------------------------------------------------------------------------------- /lib/cache/memory_cache.js: -------------------------------------------------------------------------------- 1 | import cache from 'memory-cache' 2 | import CONFIG from '@/config' 3 | 4 | const cacheTime = CONFIG.isProd ? 10 * 60 : 120 * 60 // 120 minutes for dev,10 minutes for prod 5 | 6 | export async function getCache(key, options) { 7 | return await cache.get(key) 8 | } 9 | 10 | export async function setCache(key, data) { 11 | await cache.put(key, data, cacheTime * 1000) 12 | } 13 | 14 | export async function delCache(key) { 15 | await cache.del(key) 16 | } 17 | 18 | export default { getCache, setCache, delCache } 19 | -------------------------------------------------------------------------------- /components/Gtag.js: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react' 2 | import { useRouter } from 'next/router' 3 | import * as gtag from '@/lib/gtag' 4 | 5 | const Gtag = () => { 6 | const router = useRouter() 7 | useEffect(() => { 8 | const gtagRouteChange = url => { 9 | gtag.pageview(url) 10 | } 11 | router.events.on('routeChangeComplete', gtagRouteChange) 12 | return () => { 13 | router.events.off('routeChangeComplete', gtagRouteChange) 14 | } 15 | }, [router.events]) 16 | return null 17 | } 18 | export default Gtag 19 | -------------------------------------------------------------------------------- /lib/gtag.js: -------------------------------------------------------------------------------- 1 | import CONFIG from '@/config' 2 | 3 | // https://developers.google.com/analytics/devguides/collection/gtagjs/pages 4 | export const pageview = url => { 5 | window.gtag('config', CONFIG.ANALYTICS_GOOGLE_ID, { 6 | page_path: url 7 | }) 8 | } 9 | 10 | // https://developers.google.com/analytics/devguides/collection/gtagjs/events 11 | export const event = ({ action, category, label, value }) => { 12 | window.gtag('event', action, { 13 | event_category: category, 14 | event_label: label, 15 | value: value 16 | }) 17 | } 18 | -------------------------------------------------------------------------------- /components/NotionIcon.js: -------------------------------------------------------------------------------- 1 | /** 2 | * notion的图标icon 3 | * 可能是emoji 可能是 svg 也可能是 图片 4 | * @returns 5 | */ 6 | const NotionIcon = ({ icon }) => { 7 | if (!icon) { 8 | return <> 9 | } 10 | 11 | if (icon.startsWith('http') || icon.startsWith('data:')) { 12 | // return 13 | // eslint-disable-next-line @next/next/no-img-element 14 | return 15 | } 16 | 17 | return {icon} 18 | } 19 | 20 | export default NotionIcon 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report (Bug反馈) 3 | about: 报告一个软件的BUG来让NotionNext变得更好 4 | title: '' 5 | labels: bug 6 | assignees: tangly1024 7 | --- 8 | 9 | 13 | 14 | **描述bug** 15 | 简单说明bug的现象、相关的错误提示、日志等 16 | 17 | **复现步骤** 18 | 出现这个bug的操作步骤 19 | 20 | **期望的正常结果** 21 | 希望按这个步骤,正常操作结果是什么 22 | 23 | **截图** 24 | 相关的页面,应该用结果 25 | 26 | **环境** 27 | 28 | - 操作系统: [例如. iOS, Android, macOS, windows] 29 | - 浏览器 [例如. chrome, safari, firefox] 30 | - 版本 [e.g. 22] 31 | 32 | **补充说明** 33 | 与问题相关的其它说明 -------------------------------------------------------------------------------- /themes/plain/components/LinkMenuList.js: -------------------------------------------------------------------------------- 1 | import { LinkMenuItemDrop } from './LinkMenuItemDrop' 2 | 3 | export const LinkMenuList = (props) => { 4 | const { categories } = props 5 | 6 | return (<> 7 | 10 | 13 | 14 | 15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /lib/datasource/flowus/utils.js: -------------------------------------------------------------------------------- 1 | 2 | export function getTextContent(nodes) { 3 | return (nodes || []).reduce((carry, item) => { 4 | return carry + (item.text || '') 5 | }, '') 6 | } 7 | 8 | export function getImageUrl(path) { 9 | if (!path) { 10 | return null 11 | } 12 | 13 | if (path.startsWith('http')) { 14 | return path 15 | } 16 | 17 | if (path.startsWith('/')) { 18 | return 'https://cdn.flowus.cn' + path 19 | } 20 | 21 | return 'https://cdn.flowus.cn/' + path 22 | } 23 | 24 | export function getIconUrl(path) { 25 | return 'https://baiyunshan.flowus.cn/assets/byte-icon/dark' + path 26 | } 27 | -------------------------------------------------------------------------------- /lib/robots.txt.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs' 2 | import { getGlobalSettings } from '@/core/settings' 3 | 4 | export async function generateRobotsTxt() { 5 | const settings = await getGlobalSettings() 6 | const content = ` 7 | # * 8 | User-agent: * 9 | Allow: / 10 | 11 | # Host 12 | Host: ${settings.WEBSITE_URL} 13 | 14 | # Sitemaps 15 | Sitemap: ${settings.WEBSITE_URL}/sitemap.xml 16 | 17 | ` 18 | try { 19 | fs.mkdirSync('./public', { recursive: true }) 20 | fs.writeFileSync('./public/robots.txt', content) 21 | } catch (error) { 22 | // 在vercel运行环境是只读的,这里会报错; 23 | // 但在vercel编译阶段、或VPS等其他平台这行代码会成功执行 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /components/Busuanzi.js: -------------------------------------------------------------------------------- 1 | import busuanzi from '@/lib/busuanzi' 2 | import { useRouter } from 'next/router' 3 | import { useGlobal } from '@/lib/global' 4 | // import { useRouter } from 'next/router' 5 | import React from 'react' 6 | 7 | let path = '' 8 | 9 | export default function Busuanzi () { 10 | const { theme } = useGlobal() 11 | const Router = useRouter() 12 | Router.events.on('routeChangeComplete', (url, option) => { 13 | if (url !== path) { 14 | path = url 15 | busuanzi.fetch() 16 | } 17 | }) 18 | 19 | // 更换主题时更新 20 | React.useEffect(() => { 21 | if (theme) { 22 | busuanzi.fetch() 23 | } 24 | }, [theme]) 25 | return null 26 | } 27 | -------------------------------------------------------------------------------- /pages/404.js: -------------------------------------------------------------------------------- 1 | import * as ThemeMap from '@/themes' 2 | import { useGlobal } from '@/lib/global' 3 | import { getWebsiteConfigs } from '@/lib/datasource/website' 4 | 5 | /** 6 | * 404 7 | * @param {*} props 8 | * @returns 9 | */ 10 | const NoFound = props => { 11 | const { theme, siteInfo } = useGlobal() 12 | const ThemeComponents = ThemeMap[theme] 13 | const meta = { title: `${props?.siteInfo?.title} | 页面找不到啦`, image: siteInfo?.pageCover } 14 | return 15 | } 16 | 17 | export async function getStaticProps () { 18 | const props = (await getWebsiteConfigs()) || {} 19 | return { props } 20 | } 21 | 22 | export default NoFound 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env.local 29 | .env.development.local 30 | .env.test.local 31 | .env.production.local 32 | 33 | # vercel 34 | .vercel 35 | 36 | # dev 37 | /data.json 38 | /pnpm-lock.yaml 39 | .idea 40 | .vscode 41 | 42 | 43 | # sitemap 44 | /public/robots.txt 45 | /public/sitemap.xml 46 | /public/rss/* -------------------------------------------------------------------------------- /pages/sitemap.xml.js: -------------------------------------------------------------------------------- 1 | // pages/sitemap.xml.js 2 | import { getServerSideSitemap } from 'next-sitemap' 3 | import { getGlobalSettings } from '@/core/settings' 4 | 5 | export const getServerSideProps = async (ctx) => { 6 | const settings = await getGlobalSettings() 7 | const fields = [ 8 | { 9 | loc: `${settings.WEBSITE_URL}`, 10 | lastmod: new Date().toISOString().split('T')[0], 11 | changefreq: 'daily', 12 | priority: '0.7' 13 | } 14 | ] 15 | 16 | // 缓存 17 | ctx.res.setHeader( 18 | 'Cache-Control', 19 | 'public, max-age=3600, stale-while-revalidate=59' 20 | ) 21 | 22 | return getServerSideSitemap(ctx, fields) 23 | } 24 | 25 | export default () => { 26 | } 27 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | # Number of days of inactivity before an issue becomes stale 2 | daysUntilStale: 7 3 | # Number of days of inactivity before a stale issue is closed 4 | daysUntilClose: 3 5 | # Issues with these labels will never be considered stale 6 | exemptLabels: 7 | - pinned 8 | - security 9 | # Label to use when marking an issue as stale 10 | staleLabel: wontfix 11 | # Comment to post when marking an issue as stale. Set to `false` to disable 12 | markComment: > 13 | This issue has been automatically marked as stale because it has not had 14 | recent activity. It will be closed if no further activity occurs. 15 | # Comment to post when closing a stale issue. Set to `false` to disable 16 | closeComment: false 17 | -------------------------------------------------------------------------------- /themes/plain/components/LinkTag.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | const tagColors = { 4 | gray: 'rgba(120, 119, 116, 1)', 5 | brown: 'rgba(159, 107, 83, 1)', 6 | orange: 'rgba(217, 115, 13, 1)', 7 | yellow: 'rgba(203, 145, 47, 1)', 8 | green: 'rgba(68, 131, 97, 1)', 9 | purple: 'rgba(144, 101, 176, 1)', 10 | pink: 'rgba(193, 76, 138, 1)', 11 | red: 'rgba(212, 76, 71, 1)' 12 | } 13 | 14 | const LinkTag = ({ tag }) => { 15 | const color = tagColors[tag.color] 16 | 17 | return ( 18 |
19 | {tag.name} 20 |
21 | ) 22 | } 23 | 24 | export default LinkTag 25 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es2021: true, 5 | node: true 6 | }, 7 | extends: [ 8 | 'plugin:react/recommended', 9 | 'plugin:@next/next/recommended', 10 | 'standard' 11 | ], 12 | parserOptions: { 13 | ecmaFeatures: { 14 | jsx: true 15 | }, 16 | ecmaVersion: 12, 17 | sourceType: 'module' 18 | }, 19 | plugins: [ 20 | 'react', 21 | 'react-hooks' 22 | ], 23 | settings: { 24 | react: { 25 | version: 'detect' 26 | } 27 | }, 28 | rules: { 29 | 'react/prop-types': 'off', 30 | 'space-before-function-paren': 0, 31 | 'react-hooks/rules-of-hooks': 'error' // Checks rules of Hooks 32 | }, 33 | globals: { 34 | React: true 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /themes/plain/components/AsideLeft.js: -------------------------------------------------------------------------------- 1 | import Logo from './Logo' 2 | import { LinkMenuList } from './LinkMenuList' 3 | 4 | function AsideLeft(props) { 5 | return
6 | 16 |
17 | } 18 | 19 | export default AsideLeft 20 | -------------------------------------------------------------------------------- /public/css/theme-simple.css: -------------------------------------------------------------------------------- 1 | #theme-simple #announcement-content { 2 | /* background-color: #f6f6f6; */ 3 | } 4 | 5 | #theme-simple #blog-item-title { 6 | color: #276077; 7 | } 8 | 9 | .dark #theme-simple #blog-item-title { 10 | color: #d1d5db; 11 | } 12 | 13 | .notion { 14 | margin-top: 0 !important; 15 | margin-bottom: 0 !important; 16 | } 17 | 18 | 19 | /* 菜单下划线动画 */ 20 | #theme-simple .menu-link { 21 | text-decoration: none; 22 | background-image: linear-gradient(#dd3333, #dd3333); 23 | background-repeat: no-repeat; 24 | background-position: bottom center; 25 | background-size: 0 2px; 26 | transition: background-size 100ms ease-in-out; 27 | } 28 | 29 | #theme-simple .menu-link:hover { 30 | background-size: 100% 2px; 31 | color: #dd3333; 32 | } 33 | 34 | -------------------------------------------------------------------------------- /lib/datasource/notion/utils.js: -------------------------------------------------------------------------------- 1 | // import { defaultMapImageUrl } from 'react-notion-x' 2 | 3 | export function getImageUrl(imgObj, block) { 4 | if (!imgObj) { 5 | return null 6 | } 7 | if (imgObj.startsWith('/')) { 8 | return 'https://www.notion.so' + imgObj // notion内部图片转相对路径为绝对路径 9 | } 10 | 11 | if (block?.type === 'bookmark') { 12 | return imgObj 13 | } 14 | 15 | if (imgObj.startsWith('http')) { 16 | if (imgObj.indexOf('secure.notion-static.com') > 0 || imgObj.indexOf('prod-files-secure') > 0) { 17 | let table = 'block' 18 | if (!block.type && block.schema) { 19 | table = 'collection' 20 | } 21 | 22 | return 'https://www.notion.so/image/' + encodeURIComponent(imgObj) + '?table=' + table + '&id=' + block.id 23 | } 24 | } 25 | 26 | // 其他图片链接 或 emoji 27 | return imgObj 28 | } 29 | -------------------------------------------------------------------------------- /themes/plain/components/Logo.js: -------------------------------------------------------------------------------- 1 | import Link from 'next/link' 2 | import LogoIcon from '../icons/logo' 3 | 4 | const Logo = props => { 5 | const { siteInfo } = props 6 | return ( 7 |
8 | 11 | 12 | {siteInfo.icon 13 | // eslint-disable-next-line @next/next/no-img-element 14 | ? logo 16 | : } 17 |
{siteInfo?.title}
18 | 19 |
20 | ) 21 | } 22 | 23 | export default Logo 24 | -------------------------------------------------------------------------------- /pages/_document.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @next/next/no-document-import-in-page 2 | import Document, { Html, Head, Main, NextScript } from 'next/document' 3 | import CONFIG from '@/config' 4 | import CommonScript from '@/components/CommonScript' 5 | 6 | class MyDocument extends Document { 7 | static async getInitialProps(ctx) { 8 | const initialProps = await Document.getInitialProps(ctx) 9 | return { ...initialProps } 10 | } 11 | 12 | render() { 13 | return ( 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 | 23 | 24 | 25 | ) 26 | } 27 | } 28 | 29 | export default MyDocument 30 | -------------------------------------------------------------------------------- /themes/plain/components/SiteFooter.js: -------------------------------------------------------------------------------- 1 | function SiteFooter({ configs }) { 2 | const d = new Date() 3 | const currentYear = d.getFullYear() 4 | const copyrightDate = (function () { 5 | if (configs.COPYRIGHT_SINCE && configs.COPYRIGHT_SINCE !== currentYear) { 6 | return configs.COPYRIGHT_SINCE + '-' + currentYear 7 | } 8 | return currentYear 9 | })() 10 | 11 | return ( 12 |
13 |
14 |
Copyright © {copyrightDate} {configs.COPYRIGHT || ''}
15 |
16 | {configs.BEI_AN && <> {configs.BEI_AN}} 17 |
18 |
19 |
20 | ) 21 | } 22 | export default SiteFooter 23 | -------------------------------------------------------------------------------- /lib/notion/mapImage.js: -------------------------------------------------------------------------------- 1 | import CONFIG from '@/config' 2 | 3 | /** 4 | * Notion图片映射处理有emoji的图标 5 | * @param {*} img 6 | * @param {*} value 7 | * @returns 8 | */ 9 | const mapImgUrl = (img, block, type = 'block') => { 10 | let ret = null 11 | if (!img) { 12 | return ret 13 | } 14 | // 相对目录,则视为notion的自带图片 15 | if (img.startsWith('/')) ret = 'https://www.notion.so' + img 16 | 17 | // 书签的地址本身就是永久链接,无需处理 18 | if (!ret && block?.type === 'bookmark') { 19 | ret = img 20 | } 21 | 22 | // notion永久图床地址 23 | if (!ret && img.indexOf('secure.notion-static.com') > 0 && (CONFIG.IMG_URL_TYPE === 'Notion' || type !== 'block')) { 24 | ret = 'https://www.notion.so/image/' + encodeURIComponent(img) + '?table=' + type + '&id=' + block.id 25 | } 26 | 27 | // 剩余的是第三方图片url或emoji 28 | if (!ret) { 29 | ret = img 30 | } 31 | 32 | return ret 33 | } 34 | 35 | export { mapImgUrl } 36 | -------------------------------------------------------------------------------- /components/GoogleAdsense.js: -------------------------------------------------------------------------------- 1 | import { useRouter } from 'next/router' 2 | import { useEffect } from 'react' 3 | 4 | export default function GoogleAdsense () { 5 | const initGoogleAdsense = () => { 6 | const ads = document.getElementsByClassName('adsbygoogle').length 7 | const newAdsCount = ads 8 | if (newAdsCount > 0) { 9 | for (let i = 0; i <= newAdsCount; i++) { 10 | try { 11 | // eslint-disable-next-line no-undef 12 | (adsbygoogle = window.adsbygoogle || []).push({}) 13 | } catch (e) { 14 | 15 | } 16 | } 17 | } 18 | } 19 | 20 | const router = useRouter() 21 | useEffect(() => { 22 | initGoogleAdsense() 23 | router.events.on('routeChangeComplete', initGoogleAdsense) 24 | return () => { 25 | router.events.off('routeChangeComplete', initGoogleAdsense) 26 | } 27 | }, [router.events]) 28 | return null 29 | } 30 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ARG WEBSITE_NOTION_PAGE_ID 2 | # Install dependencies only when needed 3 | FROM node:14-alpine AS deps 4 | # Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed. 5 | RUN apk add --no-cache libc6-compat 6 | WORKDIR /app 7 | COPY package.json ./ 8 | RUN yarn install --frozen-lockfile 9 | 10 | # Rebuild the source code only when needed 11 | FROM node:14-alpine AS builder 12 | ARG WEBSITE_NOTION_PAGE_ID 13 | WORKDIR /app 14 | COPY --from=deps /app/node_modules ./node_modules 15 | COPY . . 16 | RUN yarn build 17 | 18 | ENV NODE_ENV production 19 | 20 | EXPOSE 3000 21 | 22 | # Next.js collects completely anonymous telemetry data about general usage. 23 | # Learn more here: https://nextjs.org/telemetry 24 | # Uncomment the following line in case you want to disable telemetry. 25 | # ENV NEXT_TELEMETRY_DISABLED 1 26 | 27 | CMD ["yarn", "start"] 28 | -------------------------------------------------------------------------------- /components/DarkModeButton.js: -------------------------------------------------------------------------------- 1 | import { useGlobal } from '@/lib/global' 2 | import { saveDarkModeToCookies } from '@/lib/theme' 3 | 4 | const DarkModeButton = (props) => { 5 | const { isDarkMode, updateDarkMode } = useGlobal() 6 | // 用户手动设置主题 7 | const handleChangeDarkMode = () => { 8 | const newStatus = !isDarkMode 9 | saveDarkModeToCookies(newStatus) 10 | updateDarkMode(newStatus) 11 | const htmlElement = document.getElementsByTagName('html')[0] 12 | htmlElement.classList?.remove(newStatus ? 'light' : 'dark') 13 | htmlElement.classList?.add(newStatus ? 'dark' : 'light') 14 | } 15 | 16 | return
17 | 19 |
20 | } 21 | export default DarkModeButton 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Notion Portal 2 | 3 | ## 部署 4 | 5 | ### 部署到 Vercel 6 | 7 | 1. 点击按钮 [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fliudding%2Fnotion-portal), 开始部署 8 | 1. 如果没有 Vercel 账户需要先注册,然后授权访问 Github 9 | 2. 跟随 Vercel 引导,fork 项目,并等待最终部署成功 10 | 2. 准备网站数据模版 11 | 1. 点击打开 Notion 页面:[模版页面](https://dingliu.notion.site/NotionPortal-Template-354428ac80374a9d84a1890225578a0b) 12 | 2. 点击右上角的 "Duplicate" 将模版复制到自己的 Notion 空间 13 | 3. 在 “**NotionPortal**” 页面中设置网站的名称等网站信息 14 | 4. 在 “**我的链接**” 页面中存放你收集的网站 15 | 5. 点击右上角 Share 将页面 Publish to Web 16 | 3. 配置网站 17 | 1. 回到你 fork 的 Github 项目 18 | 2. 根据你的需要修改 config.js 中的配置 19 | 1. 网站信息页面的 ID 配置为 `WEBSITE_NOTION_PAGE_ID` 20 | 2. 链接页面的 ID 配置为 `LINKS_NOTION_PAGE_ID` 21 | 4. 也可以在 Vercel 环境变量中配置站点 22 | 1. 回到 Vercel,进入【Settings --> Environment Variables】配置环境变量 23 | 2. 将第 2 步中的两个页面 ID 配置到环境变量 24 | 5. 完成,可以访问你的导航网站了 25 | -------------------------------------------------------------------------------- /lib/notion/getAllPageIds.js: -------------------------------------------------------------------------------- 1 | 2 | export default function getAllPageIds (collectionQuery, collectionId, collectionView, viewIds) { 3 | if (!collectionQuery && !collectionView) { 4 | return [] 5 | } 6 | let pageIds = [] 7 | if (collectionQuery && Object.values(collectionQuery).length > 0) { 8 | const pageSet = new Set() 9 | Object.values(collectionQuery[collectionId]).forEach(view => { 10 | view?.blockIds?.forEach(id => pageSet.add(id)) // group视图 11 | view?.collection_group_results?.blockIds?.forEach(id => pageSet.add(id)) // table视图 12 | }) 13 | pageIds = [...pageSet] 14 | // console.log('PageIds: 从collectionQuery获取', collectionQuery, pageIds.length) 15 | } else if (viewIds && viewIds.length > 0) { 16 | const ids = collectionView[viewIds[0]].value.page_sort 17 | // console.log('PageIds: 从viewId获取', viewIds) 18 | for (const id of ids) { 19 | pageIds.push(id) 20 | } 21 | } 22 | return pageIds 23 | } 24 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | const CONFIG = require('./config') 2 | const { fontFamilies } = require('./lib/font') 3 | 4 | module.exports = { 5 | content: ['./pages/**/*.js', './components/**/*.js', './layouts/**/*.js', './themes/**/*.js'], 6 | darkMode: CONFIG.APPEARANCE === 'class' ? 'media' : 'class', // or 'media' or 'class' 7 | theme: { 8 | fontFamily: fontFamilies, 9 | extend: { 10 | colors: { 11 | day: { 12 | DEFAULT: CONFIG.BACKGROUND_LIGHT || '#ffffff' 13 | }, 14 | night: { 15 | DEFAULT: CONFIG.BACKGROUND_DARK || '#111827' 16 | }, 17 | hexo: { 18 | 'background-gray': '#f5f5f5', 19 | 'black-gray': '#101414', 20 | 'light-gray': '#e5e5e5' 21 | } 22 | }, 23 | maxWidth: { 24 | side: '14rem', 25 | '9/10': '90%', 26 | '8xl': '90rem' 27 | } 28 | } 29 | }, 30 | variants: { 31 | extend: {} 32 | }, 33 | plugins: [] 34 | } 35 | -------------------------------------------------------------------------------- /components/comment/Twikoo.js: -------------------------------------------------------------------------------- 1 | import CONFIG from '@/config' 2 | import React from 'react' 3 | import twikoo from 'twikoo' 4 | 5 | /** 6 | * Giscus评论 @see https://giscus.app/zh-CN 7 | * Contribute by @txs https://github.com/txs/NotionNext/commit/1bf7179d0af21fb433e4c7773504f244998678cb 8 | * @returns {JSX.Element} 9 | * @constructor 10 | */ 11 | 12 | const Twikoo = ({ isDarkMode }) => { 13 | React.useEffect(() => { 14 | twikoo({ 15 | envId: CONFIG.COMMENT_TWIKOO_ENV_ID, // 腾讯云环境填 envId;Vercel 环境填地址(https://xxx.vercel.app) 16 | el: '#twikoo', // 容器元素 17 | lang: CONFIG.LANG // 用于手动设定评论区语言,支持的语言列表 https://github.com/imaegoo/twikoo/blob/main/src/client/utils/i18n/index.js 18 | // region: 'ap-guangzhou', // 环境地域,默认为 ap-shanghai,腾讯云环境填 ap-shanghai 或 ap-guangzhou;Vercel 环境不填 19 | // path: location.pathname, // 用于区分不同文章的自定义 js 路径,如果您的文章路径不是 location.pathname,需传此参数 20 | }) 21 | }) 22 | return ( 23 |
24 | ) 25 | } 26 | 27 | export default Twikoo 28 | -------------------------------------------------------------------------------- /components/Select.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | /** 4 | * 下拉单选框 5 | */ 6 | class Select extends React.Component { 7 | constructor (props) { 8 | super(props) 9 | this.handleChange = this.handleChange.bind(this) 10 | } 11 | 12 | handleChange (event) { 13 | const { onChange } = this.props 14 | onChange(event.target.value) 15 | } 16 | 17 | render () { 18 | return ( 19 |
20 | 21 | 28 |
29 | ) 30 | } 31 | } 32 | Select.defaultProps = { 33 | label: '', 34 | value: '1', 35 | options: [ 36 | { value: '1', text: '选项1' }, 37 | { value: '2', text: '选项2' } 38 | ] 39 | } 40 | export default Select 41 | -------------------------------------------------------------------------------- /lib/font.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 在此处配置字体 3 | */ 4 | const CONFIG = require('../config') 5 | 6 | // const { fontFamily } = require('tailwindcss/defaultTheme') 7 | 8 | function CJK() { 9 | switch (CONFIG.LANG.toLowerCase()) { 10 | case 'zh-cn': 11 | case 'zh-sg': 12 | return 'SC' 13 | case 'zh': 14 | case 'zh-hk': 15 | case 'zh-tw': 16 | return 'TC' 17 | case 'ja': 18 | case 'ja-jp': 19 | return 'JP' 20 | case 'ko': 21 | case 'ko-kr': 22 | return 'KR' 23 | default: 24 | return null 25 | } 26 | } 27 | 28 | const fontSansCJK = !CJK() 29 | ? [] 30 | : [`"Noto Sans CJK ${CJK()}"`, `"Noto Sans ${CJK()}"`] 31 | const fontSerifCJK = !CJK() 32 | ? [] 33 | : [`"Noto Serif CJK ${CJK()}"`, `"Noto Serif ${CJK()}"`] 34 | 35 | const fontFamilies = { 36 | sans: [...CONFIG.FONT_SANS, ...fontSansCJK], 37 | serif: [...CONFIG.FONT_SERIF, ...fontSerifCJK], 38 | noEmoji: [ 39 | 'ui-sans-serif', 40 | 'system-ui', 41 | '-apple-system', 42 | 'BlinkMacSystemFont', 43 | 'sans-serif' 44 | ] 45 | } 46 | module.exports = { fontFamilies } 47 | -------------------------------------------------------------------------------- /public/css/prism-mac-style.css: -------------------------------------------------------------------------------- 1 | /** 2 | * @author https://github.com/txs 3 | * 当配置文件 CODE_MAC_BAR 开启时,此样式会被动态引入,将开启代码组件左上角的mac图标 4 | **/ 5 | .code-toolbar { 6 | position: relative; 7 | padding-top: 0 !important; 8 | padding-bottom: 0 !important; 9 | width: 100%; 10 | border-radius: 0.5rem; 11 | margin-bottom: 0.5rem; 12 | } 13 | 14 | .toolbar-item{ 15 | white-space: nowrap; 16 | } 17 | 18 | .toolbar-item > button { 19 | margin-top: -0.1rem; 20 | } 21 | 22 | pre[class*='language-'] { 23 | margin-top: 0rem !important; 24 | margin-bottom: 0rem !important; 25 | padding-top: 1.5rem !important; 26 | 27 | } 28 | 29 | .pre-mac { 30 | position: absolute; 31 | left: 0.9rem; 32 | top: 0.5rem; 33 | z-index: 10; 34 | } 35 | 36 | .pre-mac > span { 37 | width: 10px; 38 | height: 10px; 39 | border-radius: 50%; 40 | margin-right: 5px; 41 | float: left; 42 | } 43 | 44 | .pre-mac > span:nth-child(1) { 45 | background: red; 46 | } 47 | 48 | .pre-mac > span:nth-child(2) { 49 | background: sandybrown; 50 | } 51 | 52 | .pre-mac > span:nth-child(3) { 53 | background: limegreen; 54 | } 55 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021-present, tangly1024 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 | -------------------------------------------------------------------------------- /components/comment/Giscus.js: -------------------------------------------------------------------------------- 1 | import CONFIG from '@/config' 2 | import { useGlobal } from '@/lib/global' 3 | import Giscus from '@giscus/react' 4 | 5 | /** 6 | * Giscus评论 @see https://giscus.app/zh-CN 7 | * Contribute by @txs https://github.com/txs/NotionNext/commit/1bf7179d0af21fb433e4c7773504f244998678cb 8 | * @returns {JSX.Element} 9 | * @constructor 10 | */ 11 | 12 | const GiscusComponent = () => { 13 | const { isDarkMode } = useGlobal() 14 | const theme = isDarkMode ? 'dark' : 'light' 15 | 16 | return ( 17 | 30 | ) 31 | } 32 | 33 | export default GiscusComponent 34 | -------------------------------------------------------------------------------- /components/comment/Utterances.js: -------------------------------------------------------------------------------- 1 | import CONFIG from '@/config' 2 | import { useEffect } from 'react' 3 | 4 | /** 5 | * 评论插件 6 | * @param issueTerm 7 | * @param layout 8 | * @returns {JSX.Element} 9 | * @constructor 10 | */ 11 | const Utterances = ({ issueTerm, layout }) => { 12 | useEffect(() => { 13 | const theme = 14 | CONFIG.APPEARANCE === 'auto' 15 | ? 'preferred-color-scheme' 16 | : CONFIG.APPEARANCE === 'light' 17 | ? 'github-light' 18 | : 'github-dark' 19 | const script = document.createElement('script') 20 | const anchor = document.getElementById('comments') 21 | script.setAttribute('src', 'https://utteranc.es/client.js') 22 | script.setAttribute('crossorigin', 'anonymous') 23 | script.setAttribute('async', true) 24 | script.setAttribute('repo', CONFIG.COMMENT_UTTERRANCES_REPO) 25 | script.setAttribute('issue-term', 'title') 26 | script.setAttribute('theme', theme) 27 | anchor.appendChild(script) 28 | return () => { 29 | anchor.innerHTML = '' 30 | } 31 | }) 32 | return
33 |
34 | } 35 | 36 | export default Utterances 37 | -------------------------------------------------------------------------------- /pages/index.js: -------------------------------------------------------------------------------- 1 | import CONFIG from '@/config' 2 | 3 | import * as ThemeMap from '@/themes' 4 | import { useGlobal } from '@/lib/global' 5 | import { generateRobotsTxt } from '@/lib/robots.txt' 6 | import { getLinks, getWebsiteConfigs } from '@/lib/datasource/website' 7 | const Index = props => { 8 | const { theme } = useGlobal() 9 | const ThemeComponents = ThemeMap[theme] 10 | return 11 | } 12 | 13 | export async function getStaticProps() { 14 | const { 15 | siteInfo, 16 | meta, 17 | configs 18 | } = await getWebsiteConfigs() 19 | 20 | meta.title = `${siteInfo?.title} | ${siteInfo?.description}` 21 | meta.description = meta.description || siteInfo?.description || '' 22 | meta.image = siteInfo?.pageCover || null 23 | meta.type = 'website' 24 | 25 | const links = await getLinks() 26 | 27 | // 生成robotTxt 28 | generateRobotsTxt() 29 | 30 | return { 31 | props: { 32 | configs, 33 | meta, 34 | 35 | siteInfo, 36 | categories: links.categories, 37 | links: links.links 38 | }, 39 | revalidate: parseInt(CONFIG.NEXT_REVALIDATE_SECOND) 40 | } 41 | } 42 | 43 | export default Index 44 | -------------------------------------------------------------------------------- /components/ExternalScript.js: -------------------------------------------------------------------------------- 1 | import CONFIG from '@/config' 2 | import { loadExternalResource } from '@/lib/utils' 3 | import { useEffect } from 'react' 4 | 5 | /** 6 | * 自定义引入外部JS 和 CSS 7 | * @returns 8 | */ 9 | const ExternalScript = () => { 10 | useEffect(() => { 11 | // 静态导入本地自定义样式 12 | loadExternalResource(CONFIG.FONT_AWESOME, 'css') 13 | loadExternalResource('/css/custom.css', 'css') 14 | loadExternalResource('/js/custom.js', 'js') 15 | 16 | // 自动添加图片阴影 17 | if (CONFIG.IMG_SHADOW) { 18 | loadExternalResource('/css/img-shadow.css', 'css') 19 | } 20 | 21 | if (CONFIG.CUSTOM_EXTERNAL_JS && CONFIG.CUSTOM_EXTERNAL_JS.length > 0) { 22 | for (const url of CONFIG.CUSTOM_EXTERNAL_JS) { 23 | loadExternalResource(url, 'js') 24 | } 25 | } 26 | if (CONFIG.CUSTOM_EXTERNAL_CSS && CONFIG.CUSTOM_EXTERNAL_CSS.length > 0) { 27 | for (const url of CONFIG.CUSTOM_EXTERNAL_CSS) { 28 | loadExternalResource(url, 'css') 29 | } 30 | } 31 | // 渲染所有字体 32 | CONFIG.FONT_URL?.forEach(e => { 33 | loadExternalResource(e, 'css') 34 | }) 35 | }, []) 36 | 37 | return null 38 | } 39 | 40 | export default ExternalScript 41 | -------------------------------------------------------------------------------- /lib/notion/getAllTags.js: -------------------------------------------------------------------------------- 1 | import { isIterable } from '../utils' 2 | 3 | /** 4 | * 获取所有文章的标签 5 | * @param allPosts 6 | * @param sliceCount 默认截取数量为12,若为0则返回全部 7 | * @param tagOptions tags的下拉选项 8 | * @returns {Promise<{}|*[]>} 9 | */ 10 | export function getAllTags({ allPages, sliceCount = 0, tagOptions }) { 11 | const allPosts = allPages.filter(page => page.type === 'Post' && page.status === 'Published') 12 | 13 | if (!allPosts || !tagOptions) { 14 | return [] 15 | } 16 | // 计数 17 | let tags = allPosts.map(p => p.tags) 18 | tags = [...tags.flat()] 19 | const tagObj = {} 20 | tags.forEach(tag => { 21 | if (tag in tagObj) { 22 | tagObj[tag]++ 23 | } else { 24 | tagObj[tag] = 1 25 | } 26 | }) 27 | const list = [] 28 | if (isIterable(tagOptions)) { 29 | tagOptions.forEach(c => { 30 | const count = tagObj[c.value] 31 | if (count) { 32 | list.push({ id: c.id, name: c.value, color: c.color, count }) 33 | } 34 | }) 35 | } 36 | 37 | // 按照数量排序 38 | // list.sort((a, b) => b.count - a.count) 39 | if (sliceCount && sliceCount > 0) { 40 | return list.slice(0, sliceCount) 41 | } else { 42 | return list 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /themes/plain/components/ListPage.js: -------------------------------------------------------------------------------- 1 | import * as Scroll from 'react-scroll' 2 | import ListEmpty from './ListEmpty' 3 | import LinkCard from './LinkCard' 4 | 5 | /** 6 | * 列表页 7 | * @returns {JSX.Element} 8 | * @constructor 9 | */ 10 | const ListPage = ({ 11 | links = [], 12 | categories, 13 | configs 14 | }) => { 15 | if (!links || links.length === 0) { 16 | return 17 | } 18 | 19 | return ( 20 |
21 | {categories?.map(category => { 22 | return ( 23 | 24 | {category.children.map((sub) => ( 25 |
{sub.title}
26 |
27 | {sub.children.map(item => ( 28 | 29 | ))} 30 |
31 |
))} 32 |
33 | ) 34 | })} 35 |
36 | ) 37 | } 38 | 39 | export default ListPage 40 | -------------------------------------------------------------------------------- /lib/lang/zh-TW.js: -------------------------------------------------------------------------------- 1 | export default { 2 | LOCALE: 'zh-TW', 3 | NAV: { 4 | INDEX: '部落格', 5 | RSS: '訂閱', 6 | SEARCH: '搜尋', 7 | ABOUT: '關於', 8 | NAVIGATOR: '導航', 9 | MAIL: '電郵', 10 | ARCHIVE: '封存' 11 | }, 12 | COMMON: { 13 | MORE: '更多', 14 | NO_MORE: '沒有更多了', 15 | LATEST_POSTS: '最新文章', 16 | TAGS: '標籤', 17 | NO_TAG: '無標籤', 18 | CATEGORY: '分類', 19 | SHARE: '分享', 20 | SCAN_QR_CODE: 'QRCode', 21 | URL_COPIED: '連結已複製!', 22 | TABLE_OF_CONTENTS: '目錄', 23 | RELATE_POSTS: '相關文章', 24 | COPYRIGHT: '著作權', 25 | AUTHOR: '作者', 26 | URL: '連結', 27 | ANALYTICS: '分析', 28 | POSTS: '篇文章', 29 | ARTICLE: '文章', 30 | VISITORS: '位訪客', 31 | VIEWS: '次查看', 32 | COPYRIGHT_NOTICE: '本文採用 CC BY-NC-SA 4.0 許可協議,轉載請註明出處。', 33 | RESULT_OF_SEARCH: '篇搜尋到的结果', 34 | ARTICLE_DETAIL: '完整文章', 35 | PASSWORD_ERROR: '密碼錯誤!', 36 | ARTICLE_LOCK_TIPS: '文章已上鎖,請輸入訪問密碼', 37 | SUBMIT: '提交', 38 | POST_TIME: '发布于', 39 | LAST_EDITED_TIME: '最后更新' 40 | }, 41 | PAGINATION: { 42 | PREV: '上一頁', 43 | NEXT: '下一頁' 44 | }, 45 | SEARCH: { 46 | ARTICLES: '搜尋文章', 47 | TAGS: '搜尋標籤' 48 | }, 49 | POST: { 50 | BACK: '返回', 51 | TOP: '回到頂端' 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /lib/formatDate.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 格式化日期 3 | * @param date 4 | * @param local 5 | * @returns {string} 6 | */ 7 | export default function formatDate (date, local) { 8 | if (!date) return '' 9 | const d = new Date(date) 10 | const options = { year: 'numeric', month: 'short', day: 'numeric' } 11 | const res = d.toLocaleDateString(local, options) 12 | return local.slice(0, 2).toLowerCase() === 'zh' 13 | ? res.replace('年', '-').replace('月', '-').replace('日', '') 14 | : res 15 | } 16 | 17 | export function formatDateFmt (timestamp, fmt) { 18 | const date = new Date(timestamp) 19 | const o = { 20 | 'M+': date.getMonth() + 1, // 月份 21 | 'd+': date.getDate(), // 日 22 | 'h+': date.getHours(), // 小时 23 | 'm+': date.getMinutes(), // 分 24 | 's+': date.getSeconds(), // 秒 25 | 'q+': Math.floor((date.getMonth() + 3) / 3), // 季度 26 | S: date.getMilliseconds() // 毫秒 27 | } 28 | if (/(y+)/.test(fmt)) { 29 | fmt = fmt.replace(RegExp.$1, (date.getFullYear() + '').substr(4 - RegExp.$1.length)) 30 | } 31 | for (const k in o) { 32 | if (new RegExp('(' + k + ')').test(fmt)) { 33 | fmt = fmt.replace(RegExp.$1, (RegExp.$1.length === 1) ? (o[k]) : (('00' + o[k]).substr(('' + o[k]).length))) 34 | } 35 | } 36 | return fmt.trim() 37 | } 38 | -------------------------------------------------------------------------------- /components/ThemeSwitch.js: -------------------------------------------------------------------------------- 1 | import { useGlobal } from '@/lib/global' 2 | import { ALL_THEME } from '@/themes' 3 | import React from 'react' 4 | import { Draggable } from './Draggable' 5 | /** 6 | * 7 | * @returns 主题切换 8 | */ 9 | export function ThemeSwitch() { 10 | const { theme, changeTheme } = useGlobal() 11 | 12 | const onSelectChange = (e) => { 13 | changeTheme(e.target.value) 14 | } 15 | 16 | return (<> 17 | 18 |
19 |
20 | 21 | {/*
{theme}
*/} 22 | 27 |
28 |
29 |
30 | 31 | ) 32 | } 33 | -------------------------------------------------------------------------------- /pages/page/[page].js: -------------------------------------------------------------------------------- 1 | import { useGlobal } from '@/lib/global' 2 | import * as ThemeMap from '@/themes' 3 | import { getGlobalSettings } from '@/core/settings' 4 | import { getWebsiteConfigs } from '@/lib/datasource/website' 5 | 6 | const Page = props => { 7 | const { theme } = useGlobal() 8 | const { siteInfo } = props 9 | 10 | const ThemeComponents = ThemeMap[theme] 11 | if (!siteInfo) { 12 | return <> 13 | } 14 | 15 | const meta = { 16 | title: `${props.page} | Page | ${siteInfo?.title}`, 17 | description: siteInfo?.description, 18 | image: siteInfo?.pageCover, 19 | slug: 'page/' + props.page, 20 | type: 'website' 21 | } 22 | return 23 | } 24 | 25 | export async function getStaticPaths() { 26 | return { 27 | // remove first page, we 're not gonna handle that. 28 | paths: Array.from({ length: 1 }, (_, i) => ({ 29 | params: { page: '' + (i + 2) } 30 | })), 31 | fallback: true 32 | } 33 | } 34 | 35 | export async function getStaticProps({ params: { page } }) { 36 | const props = await getWebsiteConfigs() 37 | const settings = await getGlobalSettings() 38 | 39 | props.page = page 40 | 41 | return { 42 | props, 43 | revalidate: parseInt(settings.REVALIDATE) 44 | } 45 | } 46 | 47 | export default Page 48 | -------------------------------------------------------------------------------- /core/settings.js: -------------------------------------------------------------------------------- 1 | import { getGlobalNotionCollectionData } from '@/lib/notion/getNotionData' 2 | import CONFIG from '@/config' 3 | 4 | let SETTINGS = null 5 | 6 | export function initGlobalSettings(blocks) { 7 | SETTINGS = formatSettings(blocks) 8 | return SETTINGS 9 | } 10 | 11 | export async function getGlobalSettings() { 12 | if (SETTINGS) { 13 | return SETTINGS 14 | } 15 | 16 | const websiteSettings = await getGlobalNotionCollectionData({ 17 | pageId: CONFIG.WEBSITE_NOTION_PAGE_ID 18 | }) 19 | 20 | return initGlobalSettings(websiteSettings.collectionRows) 21 | } 22 | 23 | export function formatSettings(blocks) { 24 | const settings = {} 25 | for (const item of blocks) { 26 | if (!(item.Name in SETTING_KEYS)) { 27 | continue 28 | } 29 | 30 | const key = SETTING_KEYS[item.Name] 31 | if (typeof key !== 'object' || key === null) { 32 | settings[item.Name] = item.Value ?? key 33 | continue 34 | } 35 | 36 | if (key.type === 'array') { 37 | settings[item.Name] = item.Value.split(key.delimiter || ',') 38 | } 39 | } 40 | 41 | return settings 42 | } 43 | 44 | const SETTING_KEYS = { 45 | WEBSITE_URL: {}, 46 | KEYWORDS: '', 47 | CATEGORY_LEVELS: { 48 | type: 'array', 49 | delimiter: ',' 50 | }, 51 | COPYRIGHT_SINCE: '', 52 | APPEARANCE: 'light', 53 | REVALIDATE: 120 54 | } 55 | -------------------------------------------------------------------------------- /public/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /lib/cache/cache_manager.js: -------------------------------------------------------------------------------- 1 | import MemoryCache from './memory_cache' 2 | import FileCache from './local_file_cache' 3 | // import MongoCache from './mongo_db_cache' 4 | 5 | let api 6 | if (process.env.MONGO_DB_URL && process.env.MONGO_DB_NAME) { 7 | // api = MongoCache 8 | } else if (process.env.ENABLE_FILE_CACHE) { 9 | api = FileCache 10 | } else { 11 | api = MemoryCache 12 | } 13 | 14 | /** 15 | * 为减少频繁接口请求,notion数据将被缓存 16 | * @param {*} key 17 | * @param force 18 | * @returns 19 | */ 20 | export async function getDataFromCache(key, force) { 21 | if (process.env.ENABLE_CACHE || force) { 22 | const dataFromCache = await api.getCache(key) 23 | if (JSON.stringify(dataFromCache) === '[]') { 24 | return null 25 | } 26 | return api.getCache(key) 27 | } else { 28 | return null 29 | } 30 | } 31 | 32 | export async function setDataToCache(key, data) { 33 | if (!data) { 34 | return 35 | } 36 | await api.setCache(key, data) 37 | } 38 | 39 | export async function getOrSetFromCache(key, func, force) { 40 | let cacheData = await getDataFromCache(key, force) 41 | if (!cacheData && func) { 42 | cacheData = await func() 43 | await setDataToCache(key, cacheData) 44 | } 45 | return cacheData 46 | } 47 | 48 | export async function delCacheData(key) { 49 | if (!process.env.ENABLE_CACHE) { 50 | return 51 | } 52 | await api.delCache(key) 53 | } 54 | -------------------------------------------------------------------------------- /lib/lang/zh-CN.js: -------------------------------------------------------------------------------- 1 | export default { 2 | LOCALE: 'zh-CN', 3 | NAV: { 4 | INDEX: '首页', 5 | RSS: '订阅', 6 | SEARCH: '搜索', 7 | ABOUT: '关于', 8 | NAVIGATOR: '导航', 9 | MAIL: '邮箱', 10 | ARCHIVE: '归档' 11 | }, 12 | COMMON: { 13 | MORE: '更多', 14 | NO_MORE: '没有更多了', 15 | LATEST_POSTS: '最新文章', 16 | TAGS: '标签', 17 | NO_TAG: 'NoTag', 18 | CATEGORY: '分类', 19 | SHARE: '分享', 20 | SCAN_QR_CODE: '扫一扫二维码', 21 | URL_COPIED: '链接已复制!', 22 | TABLE_OF_CONTENTS: '目录', 23 | RELATE_POSTS: '相关文章', 24 | COPYRIGHT: '声明', 25 | AUTHOR: '作者', 26 | URL: '链接', 27 | ANALYTICS: '统计', 28 | POSTS: '篇文章', 29 | ARTICLE: '文章', 30 | VISITORS: '位访客', 31 | VIEWS: '次查看', 32 | COPYRIGHT_NOTICE: '本文采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处。', 33 | RESULT_OF_SEARCH: '篇搜索到的结果', 34 | ARTICLE_DETAIL: '文章详情', 35 | PASSWORD_ERROR: '密码错误!', 36 | ARTICLE_LOCK_TIPS: '文章已上锁,请输入访问密码', 37 | SUBMIT: '提交', 38 | POST_TIME: '发布于', 39 | LAST_EDITED_TIME: '最后更新', 40 | RECENT_COMMENTS: '最新评论', 41 | DEBUG_OPEN: '开启调试', 42 | DEBUG_CLOSE: '关闭调试', 43 | THEME_SWITCH: '切换主题', 44 | ANNOUNCEMENT: '公告' 45 | }, 46 | PAGINATION: { 47 | PREV: '上一页', 48 | NEXT: '下一页' 49 | }, 50 | SEARCH: { 51 | ARTICLES: '搜索文章', 52 | TAGS: '搜索标签' 53 | }, 54 | POST: { 55 | BACK: '返回上页', 56 | TOP: '回到顶部' 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /components/KatexReact.js: -------------------------------------------------------------------------------- 1 | import KaTeX from 'katex' 2 | import React from 'react' 3 | 4 | const TeX = ({ 5 | children, 6 | math, 7 | block, 8 | errorColor, 9 | renderError, 10 | settings, 11 | as: asComponent, 12 | ...props 13 | }) => { 14 | const Component = asComponent || (block ? 'div' : 'span') 15 | const content = (children ?? math) 16 | const [state, setState] = React.useState({ innerHtml: '' }) 17 | 18 | React.useEffect(() => { 19 | try { 20 | const innerHtml = KaTeX.renderToString(content, { 21 | displayMode: true, 22 | errorColor, 23 | throwOnError: !!renderError, 24 | ...settings 25 | }) 26 | 27 | setState({ innerHtml }) 28 | } catch (error) { 29 | if (error instanceof KaTeX.ParseError || error instanceof TypeError) { 30 | if (renderError) { 31 | setState({ errorElement: renderError(error) }) 32 | } else { 33 | setState({ innerHtml: error.message }) 34 | } 35 | } else { 36 | throw error 37 | } 38 | } 39 | }, [block, content, errorColor, renderError, settings]) 40 | 41 | if ('errorElement' in state) { 42 | return state.errorElement 43 | } 44 | 45 | return ( 46 | 50 | ) 51 | } 52 | 53 | export default React.memo(TeX) 54 | -------------------------------------------------------------------------------- /lib/sitemap.xml.js: -------------------------------------------------------------------------------- 1 | 2 | import fs from 'fs' 3 | import CONFIG from '@/config' 4 | 5 | export async function generateSitemapXml({ allPages }) { 6 | const urls = [{ 7 | loc: `${CONFIG.LINK}`, 8 | lastmod: new Date().toISOString().split('T')[0], 9 | changefreq: 'daily' 10 | }] 11 | 12 | allPages?.forEach(post => { 13 | urls.push({ 14 | loc: `${CONFIG.LINK}/${post.slug}`, 15 | lastmod: new Date(post?.date?.start_date || post?.createdTime).toISOString().split('T')[0], 16 | changefreq: 'daily' 17 | }) 18 | }) 19 | const xml = createSitemapXml(urls) 20 | try { 21 | fs.writeFileSync('sitemap.xml', xml) 22 | fs.writeFileSync('./public/sitemap.xml', xml) 23 | } catch (error) { 24 | console.warn('无法写入文件', error) 25 | } 26 | } 27 | function createSitemapXml(urls) { 28 | let urlsXml = '' 29 | urls.forEach(u => { 30 | urlsXml += ` 31 | ${u.loc} 32 | ${u.lastmod} 33 | ${u.changefreq} 34 | 35 | ` 36 | }) 37 | 38 | return ` 39 | 44 | ${urlsXml} 45 | 46 | ` 47 | } 48 | -------------------------------------------------------------------------------- /pages/[prefix]/[page].js: -------------------------------------------------------------------------------- 1 | import { useGlobal } from '@/lib/global' 2 | import * as ThemeMap from '@/themes' 3 | import { getLinks, getWebsiteConfigs } from '@/lib/datasource/website' 4 | import { getPageData } from '@/lib/datasource' 5 | 6 | const Page = props => { 7 | const { theme } = useGlobal() 8 | const { siteInfo, meta } = props 9 | const ThemeComponents = ThemeMap[theme] 10 | if (!siteInfo) { 11 | return <> 12 | } 13 | 14 | const metadata = { 15 | ...meta, 16 | title: `${props.page} | ${siteInfo?.title}`, 17 | description: meta?.description || siteInfo?.description, 18 | image: siteInfo?.pageCover, 19 | slug: 'page/' + props.page, 20 | type: 'website' 21 | } 22 | return 23 | } 24 | 25 | export async function getStaticPaths(props) { 26 | const { configs } = await getWebsiteConfigs() 27 | const { links } = await getLinks() 28 | 29 | return { 30 | paths: links.map(i => ({ 31 | params: { page: i.id, prefix: configs.DETAIL_PAGE_URL_PREFIX || 'p' } 32 | })), 33 | fallback: true 34 | } 35 | } 36 | 37 | export async function getStaticProps({ params }) { 38 | const { siteInfo, configs } = await getWebsiteConfigs() 39 | const page = await getPageData(params.page) 40 | 41 | return { 42 | props: { siteInfo, configs, params, page: page }, 43 | revalidate: parseInt(configs.REVALIDATE) 44 | } 45 | } 46 | 47 | export default Page 48 | -------------------------------------------------------------------------------- /themes/plain/icons/logo.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | const LogoIcon = () => ( 4 | 5 | 6 | 7 | ) 8 | 9 | export default LogoIcon 10 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | /** 2 | * .prettierrc.js 3 | * 在VSCode中安装prettier插件 打开插件配置填写`.prettierrc.js` 将本文件作为其代码格式化规范 4 | * 在本文件中修改格式化规则,不会同时触发改变ESLint代码检查,所以每次修改本文件需要重启VSCode,ESLint检查才能同步代码格式化 5 | * 需要相应的代码格式化规范请自行查阅配置,下面为默认项目配置 6 | */ 7 | module.exports = { 8 | // 一行最多多少个字符 9 | printWidth: 150, 10 | // 指定每个缩进级别的空格数 11 | tabWidth: 2, 12 | // 使用制表符而不是空格缩进行 13 | useTabs: true, 14 | // 在语句末尾是否需要分号 15 | semi: true, 16 | // 是否使用单引号 17 | singleQuote: true, 18 | // 更改引用对象属性的时间 可选值"" 19 | quoteProps: 'as-needed', 20 | // 多行时尽可能打印尾随逗号。(例如,单行数组永远不会出现逗号结尾。) 可选值"",默认none 21 | trailingComma: 'all', 22 | // 在对象文字中的括号之间打印空格 23 | bracketSpacing: true, 24 | // jsx 中是否使用单引号 25 | jsxSingleQuote: false, 26 | // jsx 标签的反尖括号需要换行 27 | jsxBracketSameLine: false, 28 | // 在单独的箭头函数参数周围包括括号 always:(x) => x \ avoid:x => x 29 | arrowParens: 'always', 30 | // 这两个选项可用于格式化以给定字符偏移量(分别包括和不包括)开始和结束的代码 31 | rangeStart: 0, 32 | rangeEnd: Infinity, 33 | // 指定要使用的解析器,不需要写文件开头的 @prettier 34 | requirePragma: false, 35 | // 不需要自动在文件开头插入 @prettier 36 | insertPragma: false, 37 | // 使用默认的折行标准 always\never\preserve 38 | proseWrap: 'preserve', 39 | // 指定HTML文件的全局空格敏感度 css\strict\ignore 40 | htmlWhitespaceSensitivity: 'css', 41 | // 在 windows 操作系统中换行符通常是回车 (CR) 加换行分隔符 (LF),也就是回车换行(CRLF), 42 | // 然而在 Linux 和 Unix 中只使用简单的换行分隔符 (LF)。 43 | // 对应的控制字符为 "\n" (LF) 和 "\r\n"(CRLF)。auto意为保持现有的行尾 44 | // 换行符使用 lf 结尾是 可选值"" 45 | endOfLine: 'auto', 46 | //保存时自动格式化 47 | formatOnSave: true, 48 | //粘贴时自动格式化 49 | formatOnPaste: true, 50 | }; 51 | -------------------------------------------------------------------------------- /components/comment/ValineComponent.js: -------------------------------------------------------------------------------- 1 | import CONFIG from '@/config' 2 | import { useRouter } from 'next/router' 3 | import React from 'react' 4 | import Valine from 'valine' 5 | 6 | const ValineComponent = (props) => { 7 | const router = useRouter() 8 | const initValine = (url) => { 9 | const valine = new Valine({ 10 | el: '#v-comments', 11 | appId: CONFIG.COMMENT_VALINE_APP_ID, 12 | appKey: CONFIG.COMMENT_VALINE_APP_KEY, 13 | avatar: '', 14 | path: url || router.asPath, 15 | recordIP: true, 16 | placeholder: CONFIG.COMMENT_VALINE_PLACEHOLDER, 17 | serverURLs: CONFIG.COMMENT_VALINE_SERVER_URLS, 18 | visitor: true 19 | }) 20 | if (!valine) { 21 | console.error('valine错误') 22 | } 23 | } 24 | 25 | const updateValine = url => { 26 | // 移除旧的评论区,否则会重复渲染。 27 | const wrapper = document.getElementById('v-wrapper') 28 | const comments = document.getElementById('v-comments') 29 | wrapper.removeChild(comments) 30 | const newComments = document.createElement('div') 31 | newComments.id = 'v-comments' 32 | newComments.name = new Date() 33 | wrapper.appendChild(newComments) 34 | initValine(url) 35 | } 36 | 37 | React.useEffect(() => { 38 | initValine() 39 | router.events.on('routeChangeComplete', updateValine) 40 | return () => { 41 | router.events.off('routeChangeComplete', updateValine) 42 | } 43 | }, []) 44 | return
45 |
46 |
47 | } 48 | 49 | export default ValineComponent 50 | -------------------------------------------------------------------------------- /lib/lang/en-US.js: -------------------------------------------------------------------------------- 1 | export default { 2 | LOCALE: 'en-US', 3 | NAV: { 4 | INDEX: 'Blog', 5 | RSS: 'RSS', 6 | SEARCH: 'Search', 7 | ABOUT: 'About', 8 | MAIL: 'E-Mail', 9 | ARCHIVE: 'Archive' 10 | }, 11 | COMMON: { 12 | MORE: 'More', 13 | NO_MORE: 'No More', 14 | LATEST_POSTS: 'Latest posts', 15 | TAGS: 'Tags', 16 | NO_TAG: 'NoTag', 17 | CATEGORY: 'Category', 18 | SHARE: 'Share', 19 | SCAN_QR_CODE: 'Scan QRCode', 20 | URL_COPIED: 'URL has copied!', 21 | TABLE_OF_CONTENTS: 'Catalog', 22 | RELATE_POSTS: 'Relate Posts', 23 | COPYRIGHT: 'Copyright', 24 | AUTHOR: 'Author', 25 | URL: 'URL', 26 | POSTS: 'Posts', 27 | ARTICLE: 'Article', 28 | VISITORS: 'Visitors', 29 | VIEWS: 'Views', 30 | COPYRIGHT_NOTICE: 'All articles in this blog, except for special statements, adopt BY-NC-SA agreement. Please indicate the source!', 31 | RESULT_OF_SEARCH: 'Results Found', 32 | ARTICLE_DETAIL: 'Article Details', 33 | PASSWORD_ERROR: 'Password Error!', 34 | ARTICLE_LOCK_TIPS: 'Please Enter the password:', 35 | SUBMIT: 'Submit', 36 | POST_TIME: 'Post on', 37 | LAST_EDITED_TIME: 'Last edited', 38 | RECENT_COMMENTS: 'Recent Comments', 39 | DEBUG_OPEN: 'Debug', 40 | DEBUG_CLOSE: 'Close', 41 | THEME_SWITCH: 'Theme Switch', 42 | ANNOUNCEMENT: 'Announcement' 43 | }, 44 | PAGINATION: { 45 | PREV: 'Prev', 46 | NEXT: 'Next' 47 | }, 48 | SEARCH: { 49 | ARTICLES: 'Search Articles', 50 | TAGS: 'Search in' 51 | }, 52 | POST: { 53 | BACK: 'Back', 54 | TOP: 'Top' 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /themes/plain/components/TopNav.js: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | import SideBarDrawer from '@/components/SideBarDrawer' 3 | import Logo from './Logo' 4 | import { LinkMenuList } from './LinkMenuList' 5 | 6 | /** 7 | * 顶部导航 8 | * @param {*} param0 9 | * @returns 10 | */ 11 | const TopNav = props => { 12 | const [isOpen, changeShow] = useState(false) 13 | 14 | const toggleMenuOpen = () => { 15 | changeShow(!isOpen) 16 | } 17 | 18 | return (
19 | 20 | {/* 导航栏 */} 21 |
22 |
23 |
24 | 25 |
26 |
27 |
28 | 29 |
30 |
31 | {isOpen ? : } 32 |
33 |
34 |
35 |
36 | 37 | { changeShow(false) }}> 38 | 43 | 44 | 45 |
) 46 | } 47 | 48 | export default TopNav 49 | -------------------------------------------------------------------------------- /lib/cache/local_file_cache.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs' 2 | 3 | const path = require('path') 4 | // 文件缓存持续10秒 5 | const cacheInvalidSeconds = 1000000000 * 1000 6 | // 文件名 7 | const jsonFile = path.resolve('./data.json') 8 | 9 | export async function getCache (key) { 10 | const exist = await fs.existsSync(jsonFile) 11 | if (!exist) return null 12 | const data = await fs.readFileSync(jsonFile) 13 | let json = null 14 | if (!data) return null 15 | try { 16 | json = JSON.parse(data) 17 | } catch (error) { 18 | console.error('读取JSON缓存文件失败', data) 19 | return null 20 | } 21 | // 缓存超过有效期就作废 22 | const cacheValidTime = new Date(parseInt(json[key + '_expire_time']) + cacheInvalidSeconds) 23 | const currentTime = new Date() 24 | if (!cacheValidTime || cacheValidTime < currentTime) { 25 | return null 26 | } 27 | return json[key] 28 | } 29 | 30 | /** 31 | * 并发请求写文件异常; Vercel生产环境不支持写文件。 32 | * @param key 33 | * @param data 34 | * @returns {Promise} 35 | */ 36 | export async function setCache (key, data) { 37 | const exist = await fs.existsSync(jsonFile) 38 | const json = exist ? JSON.parse(await fs.readFileSync(jsonFile)) : {} 39 | json[key] = data 40 | json[key + '_expire_time'] = new Date().getTime() 41 | fs.writeFileSync(jsonFile, JSON.stringify(json)) 42 | } 43 | 44 | export async function delCache (key, data) { 45 | const exist = await fs.existsSync(jsonFile) 46 | const json = exist ? JSON.parse(await fs.readFileSync(jsonFile)) : {} 47 | delete json.key 48 | json[key + '_expire_time'] = new Date().getTime() 49 | fs.writeFileSync(jsonFile, JSON.stringify(json)) 50 | } 51 | 52 | export default { getCache, setCache, delCache } 53 | -------------------------------------------------------------------------------- /lib/lang/fr-FR.js: -------------------------------------------------------------------------------- 1 | export default { 2 | LOCALE: 'fr-FR', 3 | NAV: { 4 | INDEX: 'Accueil', 5 | RSS: 'RSS', 6 | SEARCH: 'Recherche', 7 | ABOUT: 'À propos', 8 | NAVIGATOR: 'Navigation', 9 | MAIL: 'E-mail', 10 | ARCHIVE: 'Archive' 11 | }, 12 | COMMON: { 13 | MORE: 'Plus', 14 | NO_MORE: 'Aucune donnée', 15 | LATEST_POSTS: 'Nouveaux articles', 16 | TAGS: 'Tags', 17 | NO_TAG: 'Non tag', 18 | CATEGORY: 'Catégorie(s)', 19 | SHARE: 'Partager', 20 | SCAN_QR_CODE: 'Scan QRCode', 21 | URL_COPIED: "L'URL est copé!", 22 | TABLE_OF_CONTENTS: 'Sommaire', 23 | RELATE_POSTS: 'Article similaire', 24 | COPYRIGHT: 'Droit d\'auteur', 25 | AUTHOR: 'Auteur', 26 | URL: 'Link', 27 | ANALYTICS: 'Analytique', 28 | POSTS: 'Articles', 29 | ARTICLE: 'Article(s)', 30 | VISITORS: 'Visiteurs', 31 | VIEWS: 'Views', 32 | COPYRIGHT_NOTICE: 'Attribution - Pas d’Utilisation Commerciale - Partage dans les Mêmes Conditions 4.0 International (CC BY-NC-SA 4.0)', 33 | RESULT_OF_SEARCH: 'Résultats', 34 | ARTICLE_DETAIL: 'Plus de détails', 35 | PASSWORD_ERROR: 'Mot de passe est incorrect!', 36 | ARTICLE_LOCK_TIPS: 'Saisir le mot de passe pour accéder au contenu', 37 | SUBMIT: 'Valider', 38 | POST_TIME: 'Date de publication', 39 | LAST_EDITED_TIME: 'Date de modification', 40 | RECENT_COMMENTS: 'Nouveau commentaire' 41 | }, 42 | PAGINATION: { 43 | PREV: 'PREV', 44 | NEXT: 'NEXT' 45 | }, 46 | SEARCH: { 47 | ARTICLES: 'Recherche les articles', 48 | TAGS: 'Recherche les tags' 49 | }, 50 | POST: { 51 | BACK: 'Page precedente', 52 | TOP: 'Haut' 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | const withBundleAnalyzer = require('@next/bundle-analyzer')({ 2 | enabled: process.env.ANALYZE === 'true' 3 | }) 4 | 5 | module.exports = withBundleAnalyzer({ 6 | images: { 7 | // 图片压缩 8 | dangerouslyAllowSVG: true, 9 | formats: ['image/avif', 'image/webp'], 10 | // 允许next/image加载的图片 域名 11 | remotePatterns: [ 12 | { 13 | protocol: 'https', 14 | hostname: '**' 15 | } 16 | ] 17 | }, 18 | 19 | async rewrites() { 20 | return [ 21 | { 22 | source: '/:path*.html', 23 | destination: '/:path*' 24 | } 25 | ] 26 | }, 27 | async headers() { 28 | return [ 29 | { 30 | source: '/:path*{/}?', 31 | headers: [ 32 | { key: 'Access-Control-Allow-Credentials', value: 'true' }, 33 | { key: 'Access-Control-Allow-Origin', value: '*' }, 34 | { 35 | key: 'Access-Control-Allow-Methods', 36 | value: 'GET,OPTIONS,PATCH,DELETE,POST,PUT' 37 | }, 38 | { 39 | key: 'Access-Control-Allow-Headers', 40 | value: 41 | 'X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version' 42 | } 43 | ] 44 | } 45 | ] 46 | }, 47 | webpack: (config, { dev, isServer }) => { 48 | // Replace React with Preact only in client production build 49 | // if (!dev && !isServer) { 50 | // Object.assign(config.resolve.alias, { 51 | // react: 'preact/compat', 52 | // 'react-dom/test-utils': 'preact/test-utils', 53 | // 'react-dom': 'preact/compat' 54 | // }) 55 | // } 56 | return config 57 | } 58 | }) 59 | -------------------------------------------------------------------------------- /pages/_app.js: -------------------------------------------------------------------------------- 1 | import CONFIG from '@/config' 2 | import React, { useEffect } from 'react' 3 | import dynamic from 'next/dynamic' 4 | 5 | import 'animate.css' 6 | import '@/styles/globals.css' 7 | import '@/styles/nprogress.css' 8 | 9 | // core styles shared by all of react-notion-x (required) 10 | import 'react-notion-x/src/styles.css' 11 | import '@/styles/notion.css' // 重写部分样式 12 | 13 | import { GlobalContextProvider } from '@/lib/global' 14 | import { DebugPanel } from '@/components/DebugPanel' 15 | import { ThemeSwitch } from '@/components/ThemeSwitch' 16 | import ExternalScript from '@/components/ExternalScript' 17 | import smoothscroll from 'smoothscroll-polyfill' 18 | 19 | import AOS from 'aos' 20 | import 'aos/dist/aos.css' // You can also use for styles 21 | import { isMobile } from '@/lib/utils' 22 | 23 | const Gtag = dynamic(() => import('@/components/Gtag'), { ssr: false }) 24 | const Busuanzi = dynamic(() => import('@/components/Busuanzi'), { ssr: false }) 25 | const GoogleAdsense = dynamic(() => import('@/components/GoogleAdsense'), { 26 | ssr: false 27 | }) 28 | 29 | const MyApp = ({ Component, pageProps }) => { 30 | // 外部插件 31 | const externalPlugins = <> 32 | {JSON.parse(CONFIG.THEME_SWITCH) && } 33 | {JSON.parse(CONFIG.DEBUG) && } 34 | {CONFIG.ANALYTICS_GOOGLE_ID && } 35 | {JSON.parse(CONFIG.ANALYTICS_BUSUANZI_ENABLE) && } 36 | {CONFIG.ADSENSE_GOOGLE_ID && } 37 | 38 | 39 | 40 | useEffect(() => { 41 | AOS.init() 42 | if (isMobile()) { 43 | smoothscroll.polyfill() 44 | } 45 | }, []) 46 | 47 | return ( 48 | 49 | 50 | {externalPlugins} 51 | 52 | ) 53 | } 54 | 55 | export default MyApp 56 | -------------------------------------------------------------------------------- /styles/nprogress.css: -------------------------------------------------------------------------------- 1 | /* Make clicks pass-through */ 2 | #nprogress { 3 | pointer-events: none; 4 | } 5 | 6 | #nprogress .bar { 7 | background: #29d; 8 | 9 | position: fixed; 10 | z-index: 1031; 11 | top: 0; 12 | left: 0; 13 | 14 | width: 100%; 15 | height: 2px; 16 | } 17 | 18 | /* Fancy blur effect */ 19 | #nprogress .peg { 20 | display: block; 21 | position: absolute; 22 | right: 0px; 23 | width: 100px; 24 | height: 100%; 25 | box-shadow: 0 0 10px #29d, 0 0 5px #29d; 26 | opacity: 1; 27 | 28 | -webkit-transform: rotate(3deg) translate(0px, -4px); 29 | -ms-transform: rotate(3deg) translate(0px, -4px); 30 | transform: rotate(3deg) translate(0px, -4px); 31 | } 32 | 33 | /* Remove these to get rid of the spinner */ 34 | #nprogress .spinner { 35 | display: block; 36 | position: fixed; 37 | z-index: 1031; 38 | top: 15px; 39 | right: 15px; 40 | } 41 | 42 | #nprogress .spinner-icon { 43 | width: 18px; 44 | height: 18px; 45 | box-sizing: border-box; 46 | 47 | border: solid 2px transparent; 48 | border-top-color: #29d; 49 | border-left-color: #29d; 50 | border-radius: 50%; 51 | 52 | -webkit-animation: nprogress-spinner 400ms linear infinite; 53 | animation: nprogress-spinner 400ms linear infinite; 54 | } 55 | 56 | .nprogress-custom-parent { 57 | overflow: hidden; 58 | position: relative; 59 | } 60 | 61 | .nprogress-custom-parent #nprogress .spinner, 62 | .nprogress-custom-parent #nprogress .bar { 63 | position: absolute; 64 | } 65 | 66 | @-webkit-keyframes nprogress-spinner { 67 | 0% { 68 | -webkit-transform: rotate(0deg); 69 | } 70 | 71 | 100% { 72 | -webkit-transform: rotate(360deg); 73 | } 74 | } 75 | 76 | @keyframes nprogress-spinner { 77 | 0% { 78 | transform: rotate(0deg); 79 | } 80 | 81 | 100% { 82 | transform: rotate(360deg); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /lib/datasource/notion/getPageData.js: -------------------------------------------------------------------------------- 1 | import CONFIG from '@/config' 2 | import { NotionAPI } from 'notion-client' 3 | 4 | export async function getPageData(pageId, slice) { 5 | const authToken = CONFIG.NOTION_ACCESS_TOKEN || null 6 | const api = new NotionAPI({ 7 | authToken, 8 | userTimeZone: 'Asia/ShangHai' 9 | }) 10 | const pageData = await api.getPage(pageId) 11 | console.info('[响应成功]:') 12 | 13 | pageData.block = formatBlocks(pageData.block) 14 | pageData.collection = formatCollections(pageData.collection) 15 | pageData.collection_view = formatCollectionViews(pageData.collection_view) 16 | 17 | return pageData 18 | } 19 | 20 | function formatBlocks(blocks) { 21 | const m = {} 22 | for (const i in blocks) { 23 | const block = blocks[i].value 24 | 25 | delete block.version 26 | delete block.created_by_table 27 | delete block.created_by_id 28 | delete block.last_edited_by_table 29 | delete block.last_edited_by_id 30 | delete block.space_id 31 | 32 | m[block.id] = block 33 | } 34 | 35 | return m 36 | } 37 | 38 | function formatCollections(collections) { 39 | const m = {} 40 | for (const i in collections) { 41 | const c = collections[i].value 42 | 43 | delete c.version 44 | delete c.created_by_table 45 | delete c.created_by_id 46 | delete c.last_edited_by_table 47 | delete c.last_edited_by_id 48 | delete c.space_id 49 | 50 | m[c.id] = c 51 | } 52 | 53 | return m 54 | } 55 | 56 | function formatCollectionViews(views) { 57 | const m = {} 58 | for (const i in views) { 59 | const c = views[i].value 60 | 61 | delete c.version 62 | delete c.created_by_table 63 | delete c.created_by_id 64 | delete c.last_edited_by_table 65 | delete c.last_edited_by_id 66 | delete c.space_id 67 | 68 | m[c.id] = c 69 | } 70 | 71 | return m 72 | } 73 | -------------------------------------------------------------------------------- /components/Tabs.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | 3 | /** 4 | * Tabs切换标签 5 | * @param {*} param0 6 | * @returns 7 | */ 8 | const Tabs = ({ className, children }) => { 9 | const [currentTab, setCurrentTab] = useState(0) 10 | 11 | if (!children) { 12 | return <> 13 | } 14 | 15 | children = children.filter(c => c !== '') 16 | 17 | let count = 0 18 | children.forEach(e => { 19 | if (e) { 20 | count++ 21 | } 22 | }) 23 | 24 | if (count === 0) { 25 | return <> 26 | } 27 | 28 | if (count === 1) { 29 | return
30 | {children} 31 |
32 | } 33 | 34 | function tabClickHandle(i) { 35 | setCurrentTab(i) 36 | } 37 | 38 | return
39 |
    40 | {children.map((item, index) => { 41 | return
  • { 44 | tabClickHandle(index) 45 | }}> 46 | {item?.key} 47 |
  • 48 | })} 49 |
50 |
51 | {children.map((item, index) => { 52 | return
57 | {currentTab === index && item} 58 |
59 | })} 60 |
61 |
62 | } 63 | 64 | export default Tabs 65 | -------------------------------------------------------------------------------- /lib/notion/getAllLinks.js: -------------------------------------------------------------------------------- 1 | import CONFIG from '@/config' 2 | import getAllPageIds from './getAllPageIds' 3 | import getPageProperties from './getPageProperties' 4 | import { getNotionPageData } from '@/lib/notion/getNotionData' 5 | import { delCacheData } from '@/lib/cache/cache_manager' 6 | 7 | /** 8 | * 获取所有链接列表 9 | * @param notionPageData 10 | * @param from 11 | * @param pageType 页面类型数组 ['Post','Page'] 12 | * @returns {Promise<*[]>} 13 | */ 14 | export async function getAllLinks({ notionPageData, from, pageType }) { 15 | if (!notionPageData) { 16 | notionPageData = await getNotionPageData({ from }) 17 | } 18 | if (!notionPageData) { 19 | return [] 20 | } 21 | 22 | const { block, schema, tagOptions, collectionQuery, collectionId, collectionView, viewIds } = notionPageData 23 | const data = [] 24 | const pageIds = getAllPageIds(collectionQuery, collectionId, collectionView, viewIds) 25 | for (let i = 0; i < pageIds.length; i++) { 26 | const id = pageIds[i] 27 | const value = block[id]?.value 28 | if (!value) { 29 | continue 30 | } 31 | const properties = (await getPageProperties(id, block, schema, null, tagOptions, notionPageData.siteInfo)) || null 32 | data.push(properties) 33 | } 34 | 35 | // remove all the the items doesn't meet requirements 36 | const posts = data.filter(post => { 37 | return post.title && post?.status?.[0] === 'Published' && pageType.indexOf(post?.type?.[0]) > -1 38 | }) 39 | 40 | if (!posts || posts.length === 0) { 41 | const cacheKey = 'page_block_' + CONFIG.WEBSITE_NOTION_PAGE_ID 42 | await delCacheData(cacheKey) 43 | } 44 | 45 | // Sort by date 46 | if (CONFIG.POSTS_SORT_BY === 'date') { 47 | posts.sort((a, b) => { 48 | const dateA = new Date(a?.date?.start_date || a.createdTime) 49 | const dateB = new Date(b?.date?.start_date || b.createdTime) 50 | return dateB - dateA 51 | }) 52 | } 53 | return posts 54 | } 55 | -------------------------------------------------------------------------------- /.github/workflows/docker-ghcr.yaml: -------------------------------------------------------------------------------- 1 | name: Docker ghcr.io 2 | 3 | # This workflow uses actions that are not certified by GitHub. 4 | # They are provided by a third-party and are governed by 5 | # separate terms of service, privacy policy, and support 6 | # documentation. 7 | 8 | on: 9 | push: 10 | branches: [main] 11 | # Publish semver tags as releases. 12 | tags: ["v*.*.*"] 13 | pull_request: 14 | branches: [main] 15 | 16 | env: 17 | # Use docker.io for Docker Hub if empty 18 | REGISTRY: ghcr.io 19 | # github.repository as / 20 | IMAGE_NAME: ${{ github.repository }} 21 | 22 | jobs: 23 | build: 24 | runs-on: ubuntu-latest 25 | permissions: 26 | contents: read 27 | packages: write 28 | 29 | steps: 30 | - name: Checkout repository 31 | uses: actions/checkout@v2 32 | 33 | # Login against a Docker registry except on PR 34 | # https://github.com/docker/login-action 35 | - name: Log into registry ${{ env.REGISTRY }} 36 | if: github.event_name != 'pull_request' 37 | uses: docker/login-action@v1 38 | with: 39 | registry: ${{ env.REGISTRY }} 40 | username: ${{ github.actor }} 41 | password: ${{ secrets.GITHUB_TOKEN }} 42 | 43 | # Extract metadata (tags, labels) for Docker 44 | # https://github.com/docker/metadata-action 45 | - name: Extract Docker metadata 46 | id: meta 47 | uses: docker/metadata-action@v3 48 | with: 49 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 50 | 51 | # Build and push Docker image with Buildx (don't push on PR) 52 | # https://github.com/docker/build-push-action 53 | - name: Build and push Docker image 54 | uses: docker/build-push-action@v2 55 | with: 56 | context: . 57 | push: ${{ github.event_name != 'pull_request' }} 58 | tags: ${{ steps.meta.outputs.tags }} 59 | labels: ${{ steps.meta.outputs.labels }} 60 | -------------------------------------------------------------------------------- /lib/cache/mongo_db_cache.js: -------------------------------------------------------------------------------- 1 | const MongoClient = require('mongodb').MongoClient 2 | 3 | const DB_URL = process.env.MONGO_DB_URL // e.g. mongodb+srv://mongo_user:[password]@xxx.mongodb.net//?retryWrites=true&w=majority 4 | const DB_NAME = process.env.MONGO_DB_NAME // e.g. tangly1024 5 | const DB_COLLECTION = 'posts' 6 | 7 | export async function getCache (key) { 8 | const client = await MongoClient.connect(DB_URL).catch(err => { console.error(err) }) 9 | const dbo = client.db(DB_NAME) 10 | const query = { block_id: key } 11 | const res = await dbo.collection('posts').findOne(query).catch(err => { console.error(err) }) 12 | await client.close() 13 | return res 14 | } 15 | 16 | /** 17 | * 并发请求写文件异常; Vercel生产环境不支持写文件。 18 | * @param key 19 | * @param data 20 | * @returns {Promise} 21 | */ 22 | export async function setCache (key, data) { 23 | const client = await MongoClient.connect(DB_URL).catch(err => { console.error(err) }) 24 | const dbo = client.db(DB_NAME) 25 | data.block_id = key 26 | const query = { block_id: key } 27 | const jsonObj = JSON.parse(JSON.stringify(data)) 28 | 29 | const updRes = await dbo.collection(DB_COLLECTION).updateOne(query, { $set: jsonObj }).catch(err => { console.error(err) }) 30 | console.log('更新结果', key, updRes) 31 | if (updRes.matchedCount === 0) { 32 | const insertRes = await dbo.collection(DB_COLLECTION).insertOne(jsonObj).catch(err => { console.error(err) }) 33 | console.log('插入结果', key, insertRes) 34 | } 35 | await client.close() 36 | return data 37 | } 38 | 39 | export async function delCache (key, data) { 40 | const client = await MongoClient.connect(DB_URL).catch(err => { console.error(err) }) 41 | const dbo = client.db(DB_NAME) 42 | const query = { block_id: key } 43 | const res = await dbo.collection('posts').deleteOne(query).catch(err => { console.error(err) }) 44 | console.log('删除结果', key, res) 45 | await client.close() 46 | return null 47 | } 48 | 49 | export default { getCache, setCache, delCache } 50 | -------------------------------------------------------------------------------- /themes/plain/LayoutBase.js: -------------------------------------------------------------------------------- 1 | import CommonHead from '@/components/CommonHead' 2 | import TopNav from './components/TopNav' 3 | import AsideLeft from './components/AsideLeft' 4 | import { useGlobal } from '@/lib/global' 5 | import SiteFooter from './components/SiteFooter' 6 | 7 | /** 8 | * 基础布局 采用左右两侧布局,移动端使用顶部导航栏 9 | * @returns {JSX.Element} 10 | * @constructor 11 | * @param props 12 | */ 13 | const LayoutBase = (props) => { 14 | const { 15 | children, 16 | headerSlot, 17 | meta, 18 | siteInfo, 19 | configs 20 | } = props 21 | const { onLoading } = useGlobal() 22 | 23 | const LoadingCover =
25 |
26 | 27 |
28 |
29 | 30 | return (
31 | 32 | 33 | 34 |
35 | 36 | 37 |
38 | {siteInfo?.pageCover &&
39 | {/* eslint-disable-next-line @next/next/no-img-element */} 40 | 44 |
} 45 | 46 |
47 | {headerSlot &&
{headerSlot}
} 48 |
{onLoading ? LoadingCover : children}
49 |
50 | 51 |
52 |
53 | 54 |
) 55 | } 56 | 57 | export default LayoutBase 58 | -------------------------------------------------------------------------------- /themes/plain/components/LinkMenuItemDrop.js: -------------------------------------------------------------------------------- 1 | import * as Scroll from 'react-scroll' 2 | import { useState } from 'react' 3 | 4 | export const LinkMenuItemDrop = ({ link }) => { 5 | const [show, changeShow] = useState(false) 6 | link.subMenus = link.subMenus || link.children 7 | const hasSubMenu = link?.subMenus?.length > 0 8 | 9 | return
  • 10 | 11 | changeShow(!show)} to={link?.id} spy={true} smooth={true} offset={-25} duration={500} className='w-full my-auto items-center rounded p-2 px-4 hover:bg-gray-100 justify-between flex select-none' > 12 |
    {link.title}
    13 | {link.slot} 14 | {hasSubMenu && 15 |
    16 | 17 |
    18 | } 19 | 20 | 21 | 22 | {/* 子菜单 */} 23 | {hasSubMenu && 24 |
      25 | {link?.subMenus?.map(sLink => { 26 | return
    • 27 | 28 | {sLink.icon && } 29 |
      {sLink.title || sLink.title}
      30 | {sLink.slot} 31 |
      32 |
    • 33 | })} 34 |
    35 | } 36 | 37 |
  • 38 | } 39 | -------------------------------------------------------------------------------- /lib/lang.js: -------------------------------------------------------------------------------- 1 | import zhCN from './lang/zh-CN' 2 | import enUS from './lang/en-US' 3 | import zhHK from './lang/zh-HK' 4 | import zhTW from './lang/zh-TW' 5 | import frFR from '@/lib/lang/fr-FR' 6 | import cookie from 'react-cookies' 7 | import { getQueryVariable, isBrowser, mergeDeep } from './utils' 8 | 9 | const lang = { 10 | 'en-US': enUS, 11 | 'zh-CN': zhCN, 12 | 'zh-HK': zhHK, 13 | 'zh-TW': zhTW, 14 | 'fr-FR': frFR 15 | } 16 | 17 | export default lang 18 | 19 | /** 20 | * 获取当前语言字典 21 | * @returns 不同语言对应字典 22 | */ 23 | export function generateLocaleDict(langString) { 24 | let userLocale = lang['en-US'] 25 | 26 | switch (langString.toLowerCase()) { 27 | case 'zh-cn': 28 | case 'zh-sg': 29 | userLocale = lang['zh-CN'] 30 | break 31 | case 'zh-hk': 32 | userLocale = lang['zh-HK'] 33 | break 34 | case 'zh-tw': 35 | userLocale = lang['zh-TW'] 36 | break 37 | case 'fr-fr': 38 | userLocale = lang['fr-FR'] 39 | break 40 | default: 41 | userLocale = lang['en-US'] 42 | } 43 | return mergeDeep({}, lang['en-US'], userLocale) 44 | } 45 | 46 | /** 47 | * 初始化站点翻译 48 | * 根据用户当前浏览器语言进行切换 49 | */ 50 | export function initLocale(lang, locale, changeLang, changeLocale) { 51 | if (isBrowser()) { 52 | const queryLang = getQueryVariable('lang') || loadLangFromCookies() || window.navigator.language 53 | let currentLang = lang 54 | if (queryLang !== lang) { 55 | currentLang = queryLang 56 | } 57 | changeLang(currentLang) 58 | saveLangToCookies(currentLang) 59 | 60 | const targetLocale = generateLocaleDict(currentLang) 61 | if (JSON.stringify(locale) !== JSON.stringify(currentLang)) { 62 | changeLocale(targetLocale) 63 | } 64 | } 65 | } 66 | /** 67 | * 读取语言 68 | * @returns {*} 69 | */ 70 | export const loadLangFromCookies = () => { 71 | return cookie.load('lang') 72 | } 73 | 74 | /** 75 | * 保存语言 76 | * @param newTheme 77 | */ 78 | export const saveLangToCookies = (lang) => { 79 | cookie.save('lang', lang, { path: '/' }) 80 | } 81 | -------------------------------------------------------------------------------- /components/SideBarDrawer.js: -------------------------------------------------------------------------------- 1 | import { useRouter } from 'next/router' 2 | import { useEffect } from 'react' 3 | 4 | /** 5 | * 侧边栏抽屉面板,可以从侧面拉出 6 | * @returns {JSX.Element} 7 | * @constructor 8 | */ 9 | const SideBarDrawer = ({ children, isOpen, onOpen, onClose, onClosed, className }) => { 10 | const router = useRouter() 11 | useEffect(() => { 12 | const sideBarDrawerRouteListener = () => { 13 | switchSideDrawerVisible(false) 14 | } 15 | router.events.on('routeChangeComplete', sideBarDrawerRouteListener) 16 | return () => { 17 | router.events.off('routeChangeComplete', sideBarDrawerRouteListener) 18 | } 19 | }, [router.events]) 20 | 21 | // 点击按钮更改侧边抽屉状态 22 | const switchSideDrawerVisible = (showStatus) => { 23 | if (showStatus) { 24 | onOpen && onOpen() 25 | } else { 26 | onClose && onClose() 27 | onClosed && setTimeout(() => { 28 | onClosed() 29 | }, 300) 30 | } 31 | const sideBarDrawer = window.document.getElementById('sidebar-drawer') 32 | const sideBarDrawerBackground = window.document.getElementById('sidebar-drawer-background') 33 | 34 | if (showStatus) { 35 | sideBarDrawer.classList.replace('-ml-60', 'ml-0') 36 | sideBarDrawerBackground.classList.replace('hidden', 'block') 37 | } else { 38 | sideBarDrawer.classList.replace('ml-0', '-ml-60') 39 | sideBarDrawerBackground.classList.replace('block', 'hidden') 40 | } 41 | } 42 | 43 | return