mirror of
https://github.com/d0zingcat/NotionNext.git
synced 2026-05-20 07:26:46 +00:00
Code🤣
This commit is contained in:
21
lib/cache/cache_manager.js
vendored
Normal file
21
lib/cache/cache_manager.js
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
import { getCacheFromFile, setCacheToFile } from '@/lib/cache/local_file_cache'
|
||||
import { getCacheFromMemory, setCacheToMemory } from '@/lib/cache/memory_cache'
|
||||
import BLOG from '@/blog.config'
|
||||
|
||||
export async function getDataFromCache (key) {
|
||||
let dataFromCache
|
||||
if (BLOG.isProd) {
|
||||
dataFromCache = await getCacheFromMemory(key)
|
||||
} else {
|
||||
dataFromCache = await getCacheFromFile(key)
|
||||
}
|
||||
return dataFromCache
|
||||
}
|
||||
|
||||
export async function setDataToCache (key, data) {
|
||||
if (BLOG.isProd) {
|
||||
await setCacheToMemory(key, data)
|
||||
} else {
|
||||
await setCacheToFile(key, data)
|
||||
}
|
||||
}
|
||||
36
lib/cache/local_file_cache.js
vendored
Normal file
36
lib/cache/local_file_cache.js
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
import fs from 'fs'
|
||||
import BLOG from '@/blog.config'
|
||||
|
||||
const path = require('path')
|
||||
// 文件缓存持续10秒
|
||||
const cacheInvalidSeconds = 1000000000 * 1000
|
||||
// 文件名
|
||||
const jsonFile = path.resolve('./data.json')
|
||||
|
||||
export async function getCacheFromFile (key) {
|
||||
const exist = await fs.existsSync(jsonFile)
|
||||
if (!exist) return null
|
||||
const data = await fs.readFileSync(jsonFile)
|
||||
const json = data ? JSON.parse(data) : {}
|
||||
// 缓存超过有效期就作废
|
||||
const cacheValidTime = new Date(parseInt(json[key + '_expire_time']) + cacheInvalidSeconds)
|
||||
const currentTime = new Date()
|
||||
if (!cacheValidTime || cacheValidTime < currentTime) {
|
||||
return null
|
||||
}
|
||||
return json[key]
|
||||
}
|
||||
|
||||
/**
|
||||
* 并发请求写文件异常; Vercel生产环境不支持写文件。
|
||||
* @param key
|
||||
* @param data
|
||||
* @returns {Promise<null>}
|
||||
*/
|
||||
export async function setCacheToFile (key, data) {
|
||||
const exist = await fs.existsSync(jsonFile)
|
||||
const json = exist ? JSON.parse(await fs.readFileSync(jsonFile)) : {}
|
||||
json[key] = data
|
||||
json[key + '_expire_time'] = new Date().getTime()
|
||||
fs.writeFileSync(jsonFile, JSON.stringify(json))
|
||||
}
|
||||
9
lib/cache/memory_cache.js
vendored
Normal file
9
lib/cache/memory_cache.js
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
import cache from 'memory-cache'
|
||||
|
||||
export async function getCacheFromMemory (key, options) { // url为缓存标识
|
||||
return cache.get(key)
|
||||
}
|
||||
|
||||
export async function setCacheToMemory (key, data) { // url为缓存标识
|
||||
await cache.put(key, data, 60 * 1000)
|
||||
}
|
||||
21
lib/cjk.js
Normal file
21
lib/cjk.js
Normal file
@@ -0,0 +1,21 @@
|
||||
const BLOG = require('../blog.config')
|
||||
|
||||
module.exports = function () {
|
||||
switch (BLOG.lang.toLowerCase()) {
|
||||
case 'zh-cn':
|
||||
case 'zh-sg':
|
||||
return 'SC'
|
||||
case 'zh':
|
||||
case 'zh-hk':
|
||||
case 'zh-tw':
|
||||
return 'TC'
|
||||
case 'ja':
|
||||
case 'ja-jp':
|
||||
return 'JP'
|
||||
case 'ko':
|
||||
case 'ko-kr':
|
||||
return 'KR'
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
8
lib/formatDate.js
Normal file
8
lib/formatDate.js
Normal file
@@ -0,0 +1,8 @@
|
||||
export default function formatDate(date, local) {
|
||||
const d = new Date(date)
|
||||
const options = { year: 'numeric', month: 'short', day: 'numeric' }
|
||||
const res = d.toLocaleDateString(local, options)
|
||||
return local.slice(0, 2).toLowerCase() === 'zh'
|
||||
? res.replace('年', ' 年 ').replace('月', ' 月 ').replace('日', ' 日')
|
||||
: res
|
||||
}
|
||||
18
lib/gtag.js
Normal file
18
lib/gtag.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import BLOG from '@/blog.config'
|
||||
export const GA_TRACKING_ID = BLOG.analytics.gaConfig.measurementId
|
||||
|
||||
// https://developers.google.com/analytics/devguides/collection/gtagjs/pages
|
||||
export const pageview = url => {
|
||||
window.gtag('config', GA_TRACKING_ID, {
|
||||
page_path: url
|
||||
})
|
||||
}
|
||||
|
||||
// https://developers.google.com/analytics/devguides/collection/gtagjs/events
|
||||
export const event = ({ action, category, label, value }) => {
|
||||
window.gtag('event', action, {
|
||||
event_category: category,
|
||||
event_label: label,
|
||||
value: value
|
||||
})
|
||||
}
|
||||
83
lib/lang.js
Normal file
83
lib/lang.js
Normal file
@@ -0,0 +1,83 @@
|
||||
const lang = {
|
||||
en: {
|
||||
NAV: {
|
||||
INDEX: 'Blog',
|
||||
RSS: 'RSS',
|
||||
SEARCH: 'Search',
|
||||
ABOUT: 'About',
|
||||
NAVGATION: 'NAVGATION'
|
||||
},
|
||||
PAGINATION: {
|
||||
PREV: 'Prev',
|
||||
NEXT: 'Next'
|
||||
},
|
||||
SEARCH: {
|
||||
ARTICLES: 'Search Articles',
|
||||
TAGS: 'Search in'
|
||||
},
|
||||
POST: {
|
||||
BACK: 'Back',
|
||||
TOP: 'Top'
|
||||
}
|
||||
},
|
||||
'zh-CN': {
|
||||
NAV: {
|
||||
INDEX: '首页',
|
||||
RSS: '订阅',
|
||||
SEARCH: '搜索',
|
||||
ABOUT: '关于',
|
||||
NAVGATION: '导航'
|
||||
},
|
||||
PAGINATION: {
|
||||
PREV: '上一页',
|
||||
NEXT: '下一页'
|
||||
},
|
||||
SEARCH: {
|
||||
ARTICLES: '搜索文章',
|
||||
TAGS: '搜索标签'
|
||||
},
|
||||
POST: {
|
||||
BACK: '返回上页',
|
||||
TOP: '回到顶部'
|
||||
}
|
||||
},
|
||||
'zh-HK': {
|
||||
NAV: {
|
||||
INDEX: '網誌',
|
||||
RSS: '訂閱',
|
||||
SEARCH: '搜尋',
|
||||
ABOUT: '關於',
|
||||
NAVGATION: '導航'
|
||||
},
|
||||
PAGINATION: {
|
||||
PREV: '上一頁',
|
||||
NEXT: '下一頁'
|
||||
},
|
||||
SEARCH: {
|
||||
ARTICLES: '搜尋文章',
|
||||
TAGS: '搜尋標簽'
|
||||
},
|
||||
POST: {
|
||||
BACK: '返回',
|
||||
TOP: '回到頂端'
|
||||
}
|
||||
},
|
||||
'zh-TW': {
|
||||
NAV: {
|
||||
INDEX: '部落格',
|
||||
RSS: '訂閱',
|
||||
SEARCH: '搜尋',
|
||||
ABOUT: '關於',
|
||||
NAVGATION: '導航'
|
||||
},
|
||||
PAGINATION: {
|
||||
PREV: '上一頁',
|
||||
NEXT: '下一頁'
|
||||
},
|
||||
POST: {
|
||||
BACK: '返回',
|
||||
TOP: '回到頂端'
|
||||
}
|
||||
}
|
||||
}
|
||||
export default lang
|
||||
34
lib/locale.js
Normal file
34
lib/locale.js
Normal file
@@ -0,0 +1,34 @@
|
||||
import BLOG from '@/blog.config'
|
||||
import lang from './lang'
|
||||
import { useContext, createContext } from 'react'
|
||||
|
||||
let locale = {}
|
||||
if (BLOG.lang.slice(0, 2).toLowerCase() === 'zh') {
|
||||
switch (BLOG.lang.toLowerCase()) {
|
||||
case 'zh-cn':
|
||||
case 'zh-sg':
|
||||
locale = lang['zh-CN']
|
||||
break
|
||||
case 'zh-hk':
|
||||
locale = lang['zh-HK']
|
||||
break
|
||||
case 'zh-tw':
|
||||
locale = lang['zh-TW']
|
||||
break
|
||||
default:
|
||||
locale = lang['zh-TW']
|
||||
break
|
||||
}
|
||||
} else {
|
||||
locale = lang.en
|
||||
}
|
||||
|
||||
const LocaleContext = createContext()
|
||||
|
||||
export function LocaleProvider({ children }) {
|
||||
return (
|
||||
<LocaleContext.Provider value={locale}>{children}</LocaleContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const useLocale = () => useContext(LocaleContext)
|
||||
3
lib/notion.js
Normal file
3
lib/notion.js
Normal file
@@ -0,0 +1,3 @@
|
||||
export { getAllPosts } from './notion/getAllPosts'
|
||||
export { getAllTags } from './notion/getAllTags'
|
||||
export { getPostBlocks } from './notion/getPostBlocks'
|
||||
20
lib/notion/getAllPageIds.js
Normal file
20
lib/notion/getAllPageIds.js
Normal file
@@ -0,0 +1,20 @@
|
||||
import { idToUuid } from 'notion-utils'
|
||||
|
||||
export default function getAllPageIds (collectionQuery, viewId) {
|
||||
const views = Object.values(collectionQuery)[0]
|
||||
if (!views) {
|
||||
return []
|
||||
}
|
||||
let pageIds = []
|
||||
if (viewId) {
|
||||
const vId = idToUuid(viewId)
|
||||
pageIds = views[vId]?.blockIds
|
||||
} else {
|
||||
const pageSet = new Set()
|
||||
Object.values(views).forEach(view => {
|
||||
view?.blockIds?.forEach(id => pageSet.add(id))
|
||||
})
|
||||
pageIds = [...pageSet]
|
||||
}
|
||||
return pageIds
|
||||
}
|
||||
101
lib/notion/getAllPosts.js
Normal file
101
lib/notion/getAllPosts.js
Normal file
@@ -0,0 +1,101 @@
|
||||
import BLOG from '@/blog.config'
|
||||
import { idToUuid } from 'notion-utils'
|
||||
import getAllPageIds from './getAllPageIds'
|
||||
import getPageProperties from './getPageProperties'
|
||||
import { defaultMapImageUrl } from 'react-notion-x'
|
||||
import { getDataFromCache, setDataToCache } from '@/lib/cache/cache_manager'
|
||||
import { getPostBlocks } from '@/lib/notion/getPostBlocks'
|
||||
|
||||
export async function getAllPosts () {
|
||||
const data = await getDataFromCache('posts_list')
|
||||
if (data) {
|
||||
return data
|
||||
}
|
||||
let id = BLOG.notionPageId
|
||||
const pageRecordMap = await getPostBlocks(id)
|
||||
if (!pageRecordMap) {
|
||||
return <>获取数据异常</>
|
||||
}
|
||||
|
||||
id = idToUuid(id)
|
||||
const collection = Object.values(pageRecordMap.collection)[0]?.value
|
||||
const collectionQuery = pageRecordMap.collection_query
|
||||
const block = pageRecordMap.block
|
||||
const schema = collection?.schema
|
||||
|
||||
const rawMetadata = block[id].value
|
||||
|
||||
// Check Type 兼容Page-Database和Inline-Database
|
||||
if (rawMetadata?.type !== 'collection_view_page' && rawMetadata?.type !== 'collection_view') {
|
||||
console.warn(`pageId "${id}" is not a database`)
|
||||
return null
|
||||
} else {
|
||||
// Construct Data
|
||||
const pageIds = getAllPageIds(collectionQuery)
|
||||
const data = []
|
||||
for (let i = 0; i < pageIds.length; i++) {
|
||||
const id = pageIds[i]
|
||||
const properties = (await getPageProperties(id, block, schema)) || null
|
||||
|
||||
// Add fullwidth, createdtime to properties
|
||||
properties.createdTime = new Date(
|
||||
block[id].value?.created_time
|
||||
).toString()
|
||||
properties.fullWidth = block[id].value?.format?.page_full_width ?? false
|
||||
properties.page_cover = getPostCover(id, block, pageRecordMap) ?? getContentFirstImage(id, block, pageRecordMap)
|
||||
properties.content = block[id].value?.content ?? []
|
||||
data.push(properties)
|
||||
}
|
||||
// remove all the the items doesn't meet requirements
|
||||
const posts = data.filter(post => {
|
||||
return (
|
||||
post.title &&
|
||||
post.slug &&
|
||||
post?.status?.[0] === 'Published' &&
|
||||
(post?.type?.[0] === 'Post' || post?.type?.[0] === 'Page')
|
||||
)
|
||||
})
|
||||
|
||||
// Sort by date
|
||||
if (BLOG.sortByDate) {
|
||||
posts.sort((a, b) => {
|
||||
const dateA = new Date(a?.date?.start_date || a.createdTime)
|
||||
const dateB = new Date(b?.date?.start_date || b.createdTime)
|
||||
return dateB - dateA
|
||||
})
|
||||
}
|
||||
if (posts) {
|
||||
await setDataToCache('posts_list', posts)
|
||||
}
|
||||
return posts
|
||||
}
|
||||
}
|
||||
|
||||
// 从Block获取封面图;优先取PageCover,否则取内容图片
|
||||
function getPostCover (id, block, pageRecordMap) {
|
||||
const pageCover = block[id].value?.format?.page_cover
|
||||
if (pageCover) {
|
||||
if (pageCover.startsWith('/')) return 'https://www.notion.so' + pageCover
|
||||
if (pageCover.startsWith('http')) return defaultMapImageUrl(pageCover, block[id].value)
|
||||
}
|
||||
}
|
||||
|
||||
// 取文章的第一个图片内容作为封面
|
||||
function getContentFirstImage (id, block, pageRecordMap) {
|
||||
const pageBlock = block[id]?.value
|
||||
|
||||
const contentBlockId = pageBlock?.content?.find((blockId) => {
|
||||
const block = pageRecordMap.block[blockId]?.value
|
||||
if (block?.type === 'image') {
|
||||
return true
|
||||
}
|
||||
})
|
||||
|
||||
if (contentBlockId) {
|
||||
const contentBlock = pageRecordMap.block[contentBlockId]?.value
|
||||
const source = contentBlock.properties?.source?.[0]?.[0] ??
|
||||
contentBlock.format?.display_source
|
||||
return defaultMapImageUrl(source, contentBlock)
|
||||
}
|
||||
return ''
|
||||
}
|
||||
23
lib/notion/getAllTags.js
Normal file
23
lib/notion/getAllTags.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import { getAllPosts } from './getAllPosts'
|
||||
|
||||
export async function getAllTags (posts) {
|
||||
if (!posts) {
|
||||
const response = await getAllPosts()
|
||||
posts = response.filter(
|
||||
post =>
|
||||
post.status[0] === 'Published' && post.type[0] === 'Post' && post.tags
|
||||
)
|
||||
}
|
||||
|
||||
let tags = posts.map(p => p.tags)
|
||||
tags = [...tags.flat()]
|
||||
const tagObj = {}
|
||||
tags.forEach(tag => {
|
||||
if (tag in tagObj) {
|
||||
tagObj[tag]++
|
||||
} else {
|
||||
tagObj[tag] = 1
|
||||
}
|
||||
})
|
||||
return tagObj
|
||||
}
|
||||
11
lib/notion/getMetadata.js
Normal file
11
lib/notion/getMetadata.js
Normal file
@@ -0,0 +1,11 @@
|
||||
export default function getMetadata(rawMetadata) {
|
||||
const metadata = {
|
||||
locked: rawMetadata?.format?.block_locked,
|
||||
page_full_width: rawMetadata?.format?.page_full_width,
|
||||
page_font: rawMetadata?.format?.page_font,
|
||||
page_small_text: rawMetadata?.format?.page_small_text,
|
||||
created_time: rawMetadata.created_time,
|
||||
last_edited_time: rawMetadata.last_edited_time
|
||||
}
|
||||
return metadata
|
||||
}
|
||||
60
lib/notion/getPageProperties.js
Normal file
60
lib/notion/getPageProperties.js
Normal file
@@ -0,0 +1,60 @@
|
||||
import { getTextContent, getDateValue } from 'notion-utils'
|
||||
import { NotionAPI } from 'notion-client'
|
||||
|
||||
async function getPageProperties (id, block, schema, authToken) {
|
||||
const rawProperties = Object.entries(block?.[id]?.value?.properties || [])
|
||||
const excludeProperties = ['date', 'select', 'multi_select', 'person']
|
||||
const properties = {}
|
||||
for (let i = 0; i < rawProperties.length; i++) {
|
||||
const [key, val] = rawProperties[i]
|
||||
properties.id = id
|
||||
if (schema[key]?.type && !excludeProperties.includes(schema[key].type)) {
|
||||
properties[schema[key].name] = getTextContent(val)
|
||||
} else {
|
||||
switch (schema[key]?.type) {
|
||||
case 'date': {
|
||||
const dateProperty = getDateValue(val)
|
||||
delete dateProperty.type
|
||||
properties[schema[key].name] = dateProperty
|
||||
break
|
||||
}
|
||||
case 'select':
|
||||
case 'multi_select': {
|
||||
const selects = getTextContent(val)
|
||||
if (selects[0]?.length) {
|
||||
properties[schema[key].name] = selects.split(',')
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'person': {
|
||||
const rawUsers = val.flat()
|
||||
const users = []
|
||||
const api = new NotionAPI({ authToken })
|
||||
|
||||
for (let i = 0; i < rawUsers.length; i++) {
|
||||
if (rawUsers[i][0][1]) {
|
||||
const userId = rawUsers[i][0]
|
||||
const res = await api.getUsers(userId)
|
||||
const resValue =
|
||||
res?.recordMapWithRoles?.notion_user?.[userId[1]]?.value
|
||||
const user = {
|
||||
id: resValue?.id,
|
||||
first_name: resValue?.given_name,
|
||||
last_name: resValue?.family_name,
|
||||
profile_photo: resValue?.profile_photo
|
||||
}
|
||||
users.push(user)
|
||||
}
|
||||
}
|
||||
properties[schema[key].name] = users
|
||||
break
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return properties
|
||||
}
|
||||
|
||||
export { getPageProperties as default }
|
||||
69
lib/notion/getPageTableOfContents.js
Normal file
69
lib/notion/getPageTableOfContents.js
Normal file
@@ -0,0 +1,69 @@
|
||||
|
||||
const indentLevels = {
|
||||
header: 0,
|
||||
sub_header: 1,
|
||||
sub_sub_header: 2
|
||||
}
|
||||
|
||||
|
||||
export const getPageTableOfContents = (page,recordMap)=> {
|
||||
// 获取 header sub_header sub_sub_header
|
||||
const toc = (page.content ?? [])
|
||||
.map((blockId) => {
|
||||
const block = recordMap.block[blockId]?.value
|
||||
|
||||
if (block) {
|
||||
const { type } = block
|
||||
|
||||
if (
|
||||
type === 'header' ||
|
||||
type === 'sub_header' ||
|
||||
type === 'sub_sub_header'
|
||||
) {
|
||||
return {
|
||||
id: blockId,
|
||||
type,
|
||||
indentLevel: indentLevels[type]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
.filter(Boolean)
|
||||
|
||||
const indentLevelStack = [
|
||||
{
|
||||
actual: -1,
|
||||
effective: -1
|
||||
}
|
||||
]
|
||||
|
||||
// Adjust indent levels to always change smoothly.
|
||||
// This is a little tricky, but the key is that when increasing indent levels,
|
||||
// they should never jump more than one at a time.
|
||||
for (const tocItem of toc) {
|
||||
const { indentLevel } = tocItem
|
||||
const actual = indentLevel
|
||||
|
||||
do {
|
||||
const prevIndent = indentLevelStack[indentLevelStack.length - 1]
|
||||
const { actual: prevActual, effective: prevEffective } = prevIndent
|
||||
|
||||
if (actual > prevActual) {
|
||||
tocItem.indentLevel = prevEffective + 1
|
||||
indentLevelStack.push({
|
||||
actual,
|
||||
effective: tocItem.indentLevel
|
||||
})
|
||||
} else if (actual === prevActual) {
|
||||
tocItem.indentLevel = prevEffective
|
||||
break
|
||||
} else {
|
||||
indentLevelStack.pop()
|
||||
}
|
||||
} while (true)
|
||||
}
|
||||
|
||||
return toc
|
||||
}
|
||||
17
lib/notion/getPostBlocks.js
Normal file
17
lib/notion/getPostBlocks.js
Normal file
@@ -0,0 +1,17 @@
|
||||
import BLOG from '@/blog.config'
|
||||
import { NotionAPI } from 'notion-client'
|
||||
import { getDataFromCache, setDataToCache } from '@/lib/cache/cache_manager'
|
||||
|
||||
export async function getPostBlocks (id) {
|
||||
let pageBlock = await getDataFromCache('page_block_' + id)
|
||||
if (pageBlock) {
|
||||
return pageBlock
|
||||
}
|
||||
const authToken = BLOG.notionAccessToken || null
|
||||
const api = new NotionAPI({ authToken })
|
||||
pageBlock = await api.getPage(id)
|
||||
if (pageBlock) {
|
||||
await setDataToCache('page_block_' + id, pageBlock)
|
||||
}
|
||||
return pageBlock
|
||||
}
|
||||
30
lib/rss.js
Normal file
30
lib/rss.js
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Feed } from 'feed'
|
||||
import BLOG from '@/blog.config'
|
||||
|
||||
export function generateRss(posts) {
|
||||
const year = new Date().getFullYear()
|
||||
const feed = new Feed({
|
||||
title: BLOG.title,
|
||||
description: BLOG.description,
|
||||
id: `${BLOG.link}/${BLOG.path}`,
|
||||
link: `${BLOG.link}/${BLOG.path}`,
|
||||
language: BLOG.lang,
|
||||
favicon: `${BLOG.link}/favicon.png`,
|
||||
copyright: `All rights reserved ${year}, ${BLOG.author}`,
|
||||
author: {
|
||||
name: BLOG.author,
|
||||
email: BLOG.email,
|
||||
link: BLOG.link
|
||||
}
|
||||
})
|
||||
posts.forEach(post => {
|
||||
feed.addItem({
|
||||
title: post.title,
|
||||
id: `${BLOG.link}/${post.slug}`,
|
||||
link: `${BLOG.link}/${post.slug}`,
|
||||
description: post.summary,
|
||||
date: new Date(post?.date?.start_date || post.createdTime)
|
||||
})
|
||||
})
|
||||
return feed.rss2()
|
||||
}
|
||||
19
lib/theme.js
Normal file
19
lib/theme.js
Normal file
@@ -0,0 +1,19 @@
|
||||
import { useContext, createContext, useState, useEffect } from 'react'
|
||||
import localStorage from 'localStorage'
|
||||
|
||||
const ThemeContext = createContext()
|
||||
|
||||
export function ThemeProvider ({ children }) {
|
||||
// 初始值
|
||||
const defaultTheme = localStorage.getItem('theme')
|
||||
const [theme, changeTheme] = useState()
|
||||
useEffect(() => {
|
||||
changeTheme(defaultTheme)
|
||||
})
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, changeTheme }}>{children}</ThemeContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const useTheme = () => useContext(ThemeContext)
|
||||
Reference in New Issue
Block a user