@fecommunity/reactpress-theme-starter 1.0.0-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +22 -0
- package/LICENSE +21 -0
- package/README.md +286 -0
- package/app/api/[[...path]]/route.ts +69 -0
- package/app/archives/page.tsx +28 -0
- package/app/article/[id]/page.tsx +92 -0
- package/app/category/[category]/page.tsx +60 -0
- package/app/globals.css +4969 -0
- package/app/knowledge/[pId]/[id]/page.tsx +54 -0
- package/app/knowledge/[pId]/page.tsx +47 -0
- package/app/knowledge/page.tsx +29 -0
- package/app/layout.tsx +76 -0
- package/app/llms.txt/route.ts +26 -0
- package/app/login/page.tsx +17 -0
- package/app/nav/[id]/page.tsx +44 -0
- package/app/nav/page.tsx +5 -0
- package/app/not-found.tsx +21 -0
- package/app/page/[id]/page.tsx +57 -0
- package/app/page.tsx +61 -0
- package/app/register/page.tsx +17 -0
- package/app/robots.ts +14 -0
- package/app/rss/route.ts +82 -0
- package/app/search/page.tsx +46 -0
- package/app/sitemap.ts +38 -0
- package/app/suggestions/page.tsx +47 -0
- package/app/tag/[tag]/page.tsx +58 -0
- package/app/tags/page.tsx +18 -0
- package/app/template.tsx +6 -0
- package/components/article/ArchiveBanner.tsx +40 -0
- package/components/article/ArticleActionsBar.tsx +53 -0
- package/components/article/ArticleCarousel.tsx +237 -0
- package/components/article/ArticleCopyrightBar.tsx +128 -0
- package/components/article/ArticleEndBar.tsx +19 -0
- package/components/article/ArticleFeedSection.tsx +59 -0
- package/components/article/ArticleList.tsx +170 -0
- package/components/article/ArticleReadingChrome.tsx +104 -0
- package/components/article/ArticleReadingProgress.tsx +75 -0
- package/components/article/ArticleRecommend.tsx +362 -0
- package/components/article/ArticleTocPanel.tsx +19 -0
- package/components/article/ArticleViewClient.tsx +221 -0
- package/components/article/CategoryMenu.tsx +67 -0
- package/components/article/CmsPageClient.tsx +92 -0
- package/components/article/LoadMore.tsx +59 -0
- package/components/article/ReadingContent.tsx +51 -0
- package/components/auth/AuthPageClient.tsx +314 -0
- package/components/comment/CommentEditor.tsx +290 -0
- package/components/comment/CommentEmoji.tsx +110 -0
- package/components/comment/CommentIconButton.tsx +37 -0
- package/components/comment/CommentItem.tsx +135 -0
- package/components/comment/CommentSection.tsx +123 -0
- package/components/comment/emojis.ts +152 -0
- package/components/knowledge/KnowledgeList.tsx +70 -0
- package/components/layout/BackToTop.tsx +27 -0
- package/components/layout/ConditionalSiteFooter.tsx +29 -0
- package/components/layout/ContentStylesLoader.tsx +9 -0
- package/components/layout/DoubleColumnLayout.tsx +171 -0
- package/components/layout/HeaderSearch.tsx +23 -0
- package/components/layout/LocaleSwitcher.tsx +24 -0
- package/components/layout/PageContainer.tsx +11 -0
- package/components/layout/PageLoadProgress.tsx +169 -0
- package/components/layout/SiteFooter.tsx +75 -0
- package/components/layout/SiteHeader.tsx +312 -0
- package/components/layout/SystemNotification.tsx +176 -0
- package/components/layout/ThemeSwitch.tsx +19 -0
- package/components/layout/UserAuth.tsx +130 -0
- package/components/search/SearchHero.tsx +323 -0
- package/components/search/SearchQuickLinks.tsx +285 -0
- package/components/shared/DevChunkRecovery.tsx +8 -0
- package/components/shared/Link.tsx +30 -0
- package/components/shared/SectionHeading.tsx +38 -0
- package/components/shared/ThemeImage.tsx +13 -0
- package/components/shared/logo.svg +7 -0
- package/components/views/ArchivesClient.tsx +137 -0
- package/components/views/CategoryClient.tsx +87 -0
- package/components/views/HomeClient.tsx +79 -0
- package/components/views/KnowledgeBookClient.tsx +127 -0
- package/components/views/KnowledgeChapterClient.tsx +175 -0
- package/components/views/KnowledgeClient.tsx +69 -0
- package/components/views/LoginClient.tsx +80 -0
- package/components/views/NavDetailClient.tsx +177 -0
- package/components/views/SearchClient.tsx +100 -0
- package/components/views/TagArchiveClient.tsx +105 -0
- package/components/views/TagsClient.tsx +46 -0
- package/components/widgets/AboutUs.tsx +180 -0
- package/components/widgets/ArticleActionButton.tsx +95 -0
- package/components/widgets/BookmarkWidget.tsx +61 -0
- package/components/widgets/CategoriesWidget.tsx +47 -0
- package/components/widgets/HomeSidebar.tsx +40 -0
- package/components/widgets/LikesWidget.tsx +77 -0
- package/components/widgets/ShareWidget.tsx +120 -0
- package/components/widgets/TagCloud.tsx +94 -0
- package/components/widgets/TagsWidget.tsx +160 -0
- package/eslint.config.mjs +80 -0
- package/lib/auth/githubOAuth.ts +26 -0
- package/lib/auth/sessionUser.ts +41 -0
- package/lib/mock-api/auth.ts +129 -0
- package/lib/mock-api/config.ts +11 -0
- package/lib/mock-api/cors.ts +53 -0
- package/lib/mock-api/data.ts +998 -0
- package/lib/mock-api/match-route.ts +202 -0
- package/lib/mock-api/proxy.ts +28 -0
- package/lib/providers/client.ts +58 -0
- package/lib/providers/materializeProvider.ts +9 -0
- package/lib/providers/server.ts +8 -0
- package/lib/reactpress/appearance.ts +69 -0
- package/lib/reactpress/bootstrap.ts +48 -0
- package/lib/reactpress/coercePublishedPage.ts +12 -0
- package/lib/reactpress/contentSeo.ts +123 -0
- package/lib/reactpress/env.ts +31 -0
- package/lib/reactpress/feedFooterPlacement.ts +16 -0
- package/lib/reactpress/fetchSetting.ts +19 -0
- package/lib/reactpress/homeFooter.tsx +27 -0
- package/lib/reactpress/layout-shell.tsx +19 -0
- package/lib/reactpress/materializeBootstrapCatalog.ts +31 -0
- package/lib/reactpress/normalizeApiResponse.ts +25 -0
- package/lib/reactpress/providers.tsx +199 -0
- package/lib/reactpress/resolveSiteNav.ts +56 -0
- package/lib/reactpress/seoMetadata.ts +82 -0
- package/lib/reactpress/serverLocale.ts +43 -0
- package/lib/reactpress/siteMetadata.ts +151 -0
- package/lib/reactpress/staticParams.ts +46 -0
- package/lib/reactpress/suggestionsContent.ts +48 -0
- package/lib/reactpress/useFeedFooterPlacement.ts +22 -0
- package/lib/reactpress/useSiteNavConfig.ts +39 -0
- package/lib/utils/archiveBanner.ts +8 -0
- package/lib/utils/colors.ts +119 -0
- package/lib/utils/comment.ts +46 -0
- package/lib/utils/headerPosition.ts +55 -0
- package/lib/utils/icons.tsx +238 -0
- package/lib/utils/reading.ts +16 -0
- package/lib/utils/scroll.ts +8 -0
- package/lib/utils/tagCloud.ts +222 -0
- package/next.config.js +103 -0
- package/package.json +98 -0
- package/postcss.config.js +5 -0
- package/prettier.config.js +10 -0
- package/public/apple-touch-icon.png +0 -0
- package/public/cli.png +0 -0
- package/public/favicon-32.png +0 -0
- package/public/favicon.ico +0 -0
- package/public/home-dark.png +0 -0
- package/public/home-light.png +0 -0
- package/public/icon-192.png +0 -0
- package/public/icon-512.png +0 -0
- package/public/logo-400.png +0 -0
- package/public/logo.png +0 -0
- package/public/mobile.png +0 -0
- package/public/pad.png +0 -0
- package/public/site.webmanifest +19 -0
- package/scripts/build-mock.mjs +139 -0
- package/scripts/dev.mjs +221 -0
- package/scripts/ensure-env.mjs +100 -0
- package/scripts/remote-dev.mjs +143 -0
- package/scripts/resolve-api-env.mjs +114 -0
- package/scripts/set-publish-version.mjs +31 -0
- package/scripts/smoke-pages.mjs +113 -0
- package/scripts/start-mock.mjs +79 -0
- package/scripts/verify-toolkit.mjs +37 -0
- package/svg.d.ts +6 -0
- package/theme.json +123 -0
- package/theme.manifest.schema.json +265 -0
- package/tsconfig.json +41 -0
- package/vercel.json +12 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useLayoutEffect } from 'react'
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
shouldPlaceFooterInSidebar,
|
|
7
|
+
type FeedFooterPlacementOptions,
|
|
8
|
+
} from '@/lib/reactpress/feedFooterPlacement'
|
|
9
|
+
import { useHomeFooter } from '@/lib/reactpress/homeFooter'
|
|
10
|
+
|
|
11
|
+
export function useFeedFooterPlacement(options: FeedFooterPlacementOptions) {
|
|
12
|
+
const { setShowLayoutFooter } = useHomeFooter()
|
|
13
|
+
const footerInSidebar = shouldPlaceFooterInSidebar(options)
|
|
14
|
+
const footerAtBottom = !footerInSidebar
|
|
15
|
+
|
|
16
|
+
useLayoutEffect(() => {
|
|
17
|
+
setShowLayoutFooter(footerAtBottom)
|
|
18
|
+
return () => setShowLayoutFooter(false)
|
|
19
|
+
}, [footerAtBottom, setShowLayoutFooter])
|
|
20
|
+
|
|
21
|
+
return { footerInSidebar, footerAtBottom }
|
|
22
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { fetchVisitorSetting } from '@/lib/reactpress/fetchSetting'
|
|
4
|
+
import { resolveSiteNavConfig, type SiteNavBlocks } from '@/lib/reactpress/resolveSiteNav'
|
|
5
|
+
import themeManifest from '../../theme.json'
|
|
6
|
+
import { safeJsonParse } from '@fecommunity/reactpress-toolkit/theme'
|
|
7
|
+
import { useLocale } from '@fecommunity/reactpress-toolkit/ui'
|
|
8
|
+
import { useEffect, useRef, useState } from 'react'
|
|
9
|
+
|
|
10
|
+
export function useSiteNavConfig(initialNav?: Partial<SiteNavBlocks>) {
|
|
11
|
+
const { locale } = useLocale()
|
|
12
|
+
const initialLocaleRef = useRef(locale)
|
|
13
|
+
const skipInitialFetchRef = useRef(Boolean(initialNav?.urlConfig?.length))
|
|
14
|
+
const [nav, setNav] = useState<SiteNavBlocks>(() => ({
|
|
15
|
+
urlConfig: initialNav?.urlConfig ?? [],
|
|
16
|
+
searchCategories: initialNav?.searchCategories ?? { categories: [], subCategories: {} },
|
|
17
|
+
}))
|
|
18
|
+
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
if (skipInitialFetchRef.current && locale === initialLocaleRef.current) {
|
|
21
|
+
skipInitialFetchRef.current = false
|
|
22
|
+
return
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
let cancelled = false
|
|
26
|
+
|
|
27
|
+
fetchVisitorSetting().then((setting) => {
|
|
28
|
+
if (cancelled || !setting) return
|
|
29
|
+
const raw = safeJsonParse(setting.globalSetting, {})
|
|
30
|
+
setNav(resolveSiteNavConfig(raw, locale, themeManifest.id))
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
return () => {
|
|
34
|
+
cancelled = true
|
|
35
|
+
}
|
|
36
|
+
}, [locale])
|
|
37
|
+
|
|
38
|
+
return nav
|
|
39
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
const TAG_COLORS = [
|
|
2
|
+
'#389e0d',
|
|
3
|
+
'#cf1322',
|
|
4
|
+
'#096dd9',
|
|
5
|
+
'#d48806',
|
|
6
|
+
'#c41d7f',
|
|
7
|
+
'#531dab',
|
|
8
|
+
'#a8071a',
|
|
9
|
+
'#006d75',
|
|
10
|
+
'#237804',
|
|
11
|
+
'#d4380d',
|
|
12
|
+
'#434343',
|
|
13
|
+
'#ad8b00',
|
|
14
|
+
'#595959',
|
|
15
|
+
'#08979c',
|
|
16
|
+
'#3f8600',
|
|
17
|
+
'#0050b3',
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
function parseHexColor(hex: string): [number, number, number] | null {
|
|
21
|
+
const normalized = hex.replace('#', '').trim()
|
|
22
|
+
if (normalized.length === 3) {
|
|
23
|
+
return [
|
|
24
|
+
parseInt(normalized[0] + normalized[0], 16),
|
|
25
|
+
parseInt(normalized[1] + normalized[1], 16),
|
|
26
|
+
parseInt(normalized[2] + normalized[2], 16),
|
|
27
|
+
]
|
|
28
|
+
}
|
|
29
|
+
if (normalized.length === 6) {
|
|
30
|
+
return [
|
|
31
|
+
parseInt(normalized.slice(0, 2), 16),
|
|
32
|
+
parseInt(normalized.slice(2, 4), 16),
|
|
33
|
+
parseInt(normalized.slice(4, 6), 16),
|
|
34
|
+
]
|
|
35
|
+
}
|
|
36
|
+
return null
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function toHex(r: number, g: number, b: number): string {
|
|
40
|
+
const clamp = (value: number) => Math.max(0, Math.min(255, Math.round(value)))
|
|
41
|
+
return `#${[clamp(r), clamp(g), clamp(b)]
|
|
42
|
+
.map((value) => value.toString(16).padStart(2, '0'))
|
|
43
|
+
.join('')}`
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function relativeLuminance(r: number, g: number, b: number): number {
|
|
47
|
+
const [rs, gs, bs] = [r, g, b].map((channel) => {
|
|
48
|
+
const normalized = channel / 255
|
|
49
|
+
return normalized <= 0.03928 ? normalized / 12.92 : Math.pow((normalized + 0.055) / 1.055, 2.4)
|
|
50
|
+
})
|
|
51
|
+
return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function contrastBetween(bg: string, text: string): number {
|
|
55
|
+
const bgRgb = parseHexColor(bg)
|
|
56
|
+
const textRgb = parseHexColor(text)
|
|
57
|
+
if (!bgRgb || !textRgb) return 1
|
|
58
|
+
const bgLum = relativeLuminance(...bgRgb)
|
|
59
|
+
const textLum = relativeLuminance(...textRgb)
|
|
60
|
+
const lighter = Math.max(bgLum, textLum)
|
|
61
|
+
const darker = Math.min(bgLum, textLum)
|
|
62
|
+
return (lighter + 0.05) / (darker + 0.05)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function getColorFromNumber(num: number): string {
|
|
66
|
+
return TAG_COLORS[num % TAG_COLORS.length]
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function getReadableTextColor(backgroundColor: string): '#ffffff' | '#1a1a1a' {
|
|
70
|
+
const whiteContrast = contrastBetween(backgroundColor, '#ffffff')
|
|
71
|
+
const blackContrast = contrastBetween(backgroundColor, '#1a1a1a')
|
|
72
|
+
return whiteContrast >= blackContrast ? '#ffffff' : '#1a1a1a'
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function adjustTagColors(
|
|
76
|
+
backgroundColor: string,
|
|
77
|
+
textColor: '#ffffff' | '#1a1a1a',
|
|
78
|
+
factor: number
|
|
79
|
+
): { backgroundColor: string; color: string } | null {
|
|
80
|
+
let bg = backgroundColor
|
|
81
|
+
|
|
82
|
+
for (let attempt = 0; attempt < 16; attempt += 1) {
|
|
83
|
+
if (contrastBetween(bg, textColor) >= 4.5) {
|
|
84
|
+
return { backgroundColor: bg, color: textColor }
|
|
85
|
+
}
|
|
86
|
+
const rgb = parseHexColor(bg)
|
|
87
|
+
if (!rgb) break
|
|
88
|
+
bg = toHex(rgb[0] * factor, rgb[1] * factor, rgb[2] * factor)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return null
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function getTagStyle(backgroundColor: string): { backgroundColor: string; color: string } {
|
|
95
|
+
const whitePath = adjustTagColors(backgroundColor, '#ffffff', 0.85)
|
|
96
|
+
const blackPath = adjustTagColors(backgroundColor, '#1a1a1a', 1.12)
|
|
97
|
+
|
|
98
|
+
if (whitePath && blackPath) {
|
|
99
|
+
const originalContrast = Math.max(
|
|
100
|
+
contrastBetween(backgroundColor, '#ffffff'),
|
|
101
|
+
contrastBetween(backgroundColor, '#1a1a1a')
|
|
102
|
+
)
|
|
103
|
+
const whiteDelta = Math.abs(
|
|
104
|
+
contrastBetween(whitePath.backgroundColor, '#ffffff') - originalContrast
|
|
105
|
+
)
|
|
106
|
+
const blackDelta = Math.abs(
|
|
107
|
+
contrastBetween(blackPath.backgroundColor, '#1a1a1a') - originalContrast
|
|
108
|
+
)
|
|
109
|
+
return whiteDelta <= blackDelta ? whitePath : blackPath
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return (
|
|
113
|
+
whitePath ??
|
|
114
|
+
blackPath ?? {
|
|
115
|
+
backgroundColor,
|
|
116
|
+
color: getReadableTextColor(backgroundColor),
|
|
117
|
+
}
|
|
118
|
+
)
|
|
119
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { IComment } from '@fecommunity/reactpress-toolkit/types'
|
|
2
|
+
|
|
3
|
+
export type CommentNode = IComment & { children?: CommentNode[] }
|
|
4
|
+
|
|
5
|
+
const AVATAR_COLORS = [
|
|
6
|
+
'#52c41a',
|
|
7
|
+
'#f5222d',
|
|
8
|
+
'#1890ff',
|
|
9
|
+
'#faad14',
|
|
10
|
+
'#ff0064',
|
|
11
|
+
'#722ed1',
|
|
12
|
+
'#dc3545',
|
|
13
|
+
'#17a2b8',
|
|
14
|
+
'#00b74a',
|
|
15
|
+
'#fc651f',
|
|
16
|
+
'#6c757d',
|
|
17
|
+
'#f5c800',
|
|
18
|
+
'#808695',
|
|
19
|
+
'#2db7f5',
|
|
20
|
+
'#87d068',
|
|
21
|
+
'#108ee9',
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
const colorCache: Record<string, string> = {}
|
|
25
|
+
|
|
26
|
+
export function getRandomColor(key: string): string {
|
|
27
|
+
if (!colorCache[key]) {
|
|
28
|
+
colorCache[key] = AVATAR_COLORS[Math.floor(Math.random() * AVATAR_COLORS.length)]
|
|
29
|
+
}
|
|
30
|
+
return colorCache[key]
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
type SessionUser = {
|
|
34
|
+
name?: string
|
|
35
|
+
email?: string
|
|
36
|
+
token?: string
|
|
37
|
+
avatar?: string
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function isLoggedInUser(
|
|
41
|
+
user: SessionUser | null | undefined
|
|
42
|
+
): user is SessionUser & { name: string; token: string } {
|
|
43
|
+
return Boolean(user?.name?.trim() && user?.token?.trim())
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const COMMENT_DOM_ID = 'js-comment-id'
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react'
|
|
4
|
+
|
|
5
|
+
export const HEADER_BAR_SELECTOR = '.rp-header-bar'
|
|
6
|
+
|
|
7
|
+
export function measureHeaderTop(): number {
|
|
8
|
+
const header = document.querySelector(HEADER_BAR_SELECTOR)
|
|
9
|
+
if (!header) return 0
|
|
10
|
+
const { top } = header.getBoundingClientRect()
|
|
11
|
+
// Header visible: align with its top edge. Header hidden: stay at viewport top.
|
|
12
|
+
return Math.max(0, top)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function useHeaderTop() {
|
|
16
|
+
const [top, setTop] = useState(0)
|
|
17
|
+
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
let frame = 0
|
|
20
|
+
|
|
21
|
+
const update = () => {
|
|
22
|
+
frame = 0
|
|
23
|
+
setTop(measureHeaderTop())
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const onScroll = () => {
|
|
27
|
+
if (frame) return
|
|
28
|
+
frame = window.requestAnimationFrame(update)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
update()
|
|
32
|
+
window.addEventListener('scroll', onScroll, { passive: true })
|
|
33
|
+
window.addEventListener('resize', onScroll, { passive: true })
|
|
34
|
+
|
|
35
|
+
const header = document.querySelector(HEADER_BAR_SELECTOR)
|
|
36
|
+
header?.addEventListener('transitionend', onScroll)
|
|
37
|
+
|
|
38
|
+
const onHeaderState = (event: MessageEvent) => {
|
|
39
|
+
if (event.origin !== window.location.origin) return
|
|
40
|
+
if (event.data?.id !== 'header-state') return
|
|
41
|
+
onScroll()
|
|
42
|
+
}
|
|
43
|
+
window.addEventListener('message', onHeaderState)
|
|
44
|
+
|
|
45
|
+
return () => {
|
|
46
|
+
if (frame) cancelAnimationFrame(frame)
|
|
47
|
+
window.removeEventListener('scroll', onScroll)
|
|
48
|
+
window.removeEventListener('resize', onScroll)
|
|
49
|
+
header?.removeEventListener('transitionend', onScroll)
|
|
50
|
+
window.removeEventListener('message', onHeaderState)
|
|
51
|
+
}
|
|
52
|
+
}, [])
|
|
53
|
+
|
|
54
|
+
return top
|
|
55
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import type { ReactNode, SVGProps } from 'react'
|
|
2
|
+
|
|
3
|
+
type IconProps = SVGProps<SVGSVGElement> & { size?: number }
|
|
4
|
+
|
|
5
|
+
function SvgIcon({ size = 20, children, ...rest }: IconProps & { children: ReactNode }) {
|
|
6
|
+
return (
|
|
7
|
+
<svg
|
|
8
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
9
|
+
width={size}
|
|
10
|
+
height={size}
|
|
11
|
+
viewBox="0 0 24 24"
|
|
12
|
+
fill="none"
|
|
13
|
+
stroke="currentColor"
|
|
14
|
+
strokeWidth={1.8}
|
|
15
|
+
strokeLinecap="round"
|
|
16
|
+
strokeLinejoin="round"
|
|
17
|
+
aria-hidden
|
|
18
|
+
{...rest}
|
|
19
|
+
>
|
|
20
|
+
{children}
|
|
21
|
+
</svg>
|
|
22
|
+
)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function HomeIcon(props: IconProps) {
|
|
26
|
+
return (
|
|
27
|
+
<SvgIcon {...props}>
|
|
28
|
+
<path d="M3 10.5 12 3l9 7.5" />
|
|
29
|
+
<path d="M5 9.5V20h14V9.5" />
|
|
30
|
+
</SvgIcon>
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function NavIcon(props: IconProps) {
|
|
35
|
+
return (
|
|
36
|
+
<SvgIcon {...props}>
|
|
37
|
+
<circle cx="12" cy="12" r="9" />
|
|
38
|
+
<path d="M2 12h20M12 2a15 15 0 0 1 0 20M12 2a15 15 0 0 0 0 20" />
|
|
39
|
+
</SvgIcon>
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function ArchivesIcon(props: IconProps) {
|
|
44
|
+
return (
|
|
45
|
+
<SvgIcon {...props}>
|
|
46
|
+
<circle cx="12" cy="12" r="9" />
|
|
47
|
+
<path d="M12 7v5l3 2" />
|
|
48
|
+
</SvgIcon>
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function FolderIcon(props: IconProps) {
|
|
53
|
+
return (
|
|
54
|
+
<SvgIcon {...props}>
|
|
55
|
+
<path d="M3 7h6l2 2h10v10H3z" />
|
|
56
|
+
</SvgIcon>
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function HeartIcon(props: IconProps) {
|
|
61
|
+
return (
|
|
62
|
+
<SvgIcon {...props}>
|
|
63
|
+
<path d="M12 20s-7-4.5-7-10a4 4 0 0 1 7-2 4 4 0 0 1 7 2c0 5.5-7 10-7 10z" />
|
|
64
|
+
</SvgIcon>
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function EyeIcon(props: IconProps) {
|
|
69
|
+
return (
|
|
70
|
+
<SvgIcon {...props}>
|
|
71
|
+
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7z" />
|
|
72
|
+
<circle cx="12" cy="12" r="3" />
|
|
73
|
+
</SvgIcon>
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function ClockIcon(props: IconProps) {
|
|
78
|
+
return (
|
|
79
|
+
<SvgIcon {...props}>
|
|
80
|
+
<circle cx="12" cy="12" r="9" />
|
|
81
|
+
<path d="M12 7v5l3 2" />
|
|
82
|
+
</SvgIcon>
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function TagIcon(props: IconProps) {
|
|
87
|
+
return (
|
|
88
|
+
<SvgIcon {...props}>
|
|
89
|
+
<path d="M3 11v-2l8-6 8 6v2l-8 10z" />
|
|
90
|
+
<circle cx="7.5" cy="9.5" r="1" fill="currentColor" stroke="none" />
|
|
91
|
+
</SvgIcon>
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function UserIcon(props: IconProps) {
|
|
96
|
+
return (
|
|
97
|
+
<SvgIcon {...props}>
|
|
98
|
+
<circle cx="12" cy="8" r="4" />
|
|
99
|
+
<path d="M4 20c1.5-4 6-6 8-6s6.5 2 8 6" />
|
|
100
|
+
</SvgIcon>
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function BellIcon(props: IconProps) {
|
|
105
|
+
return (
|
|
106
|
+
<SvgIcon {...props}>
|
|
107
|
+
<path d="M18 16H6l2-2V10a4 4 0 0 1 8 0v4z" />
|
|
108
|
+
<path d="M10 18a2 2 0 0 0 4 0" />
|
|
109
|
+
</SvgIcon>
|
|
110
|
+
)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function FlameIcon(props: IconProps) {
|
|
114
|
+
return (
|
|
115
|
+
<SvgIcon {...props}>
|
|
116
|
+
<path d="M12 3c2 4 5 5 5 9a5 5 0 0 1-10 0c0-3 2-4 3-7 1 2 1 3 2 5z" />
|
|
117
|
+
</SvgIcon>
|
|
118
|
+
)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function SearchIcon(props: IconProps) {
|
|
122
|
+
return (
|
|
123
|
+
<SvgIcon {...props}>
|
|
124
|
+
<circle cx="11" cy="11" r="7" />
|
|
125
|
+
<path d="m20 20-4-4" />
|
|
126
|
+
</SvgIcon>
|
|
127
|
+
)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function ChevronRightIcon(props: IconProps) {
|
|
131
|
+
return (
|
|
132
|
+
<SvgIcon {...props}>
|
|
133
|
+
<path d="m9 6 6 6-6 6" />
|
|
134
|
+
</SvgIcon>
|
|
135
|
+
)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function ChevronLeftIcon(props: IconProps) {
|
|
139
|
+
return (
|
|
140
|
+
<SvgIcon {...props}>
|
|
141
|
+
<path d="m15 18-6-6 6-6" />
|
|
142
|
+
</SvgIcon>
|
|
143
|
+
)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function MessageIcon(props: IconProps) {
|
|
147
|
+
return (
|
|
148
|
+
<SvgIcon {...props}>
|
|
149
|
+
<path d="M4 5h16v11H7l-3 3z" />
|
|
150
|
+
</SvgIcon>
|
|
151
|
+
)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function MenuFoldIcon(props: IconProps) {
|
|
155
|
+
return (
|
|
156
|
+
<SvgIcon {...props}>
|
|
157
|
+
<path d="M4 6h16M4 12h10M4 18h16" />
|
|
158
|
+
</SvgIcon>
|
|
159
|
+
)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function MenuUnfoldIcon(props: IconProps) {
|
|
163
|
+
return (
|
|
164
|
+
<SvgIcon {...props}>
|
|
165
|
+
<path d="M4 6h16M10 12h10M4 18h16" />
|
|
166
|
+
</SvgIcon>
|
|
167
|
+
)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function ToolIcon(props: IconProps) {
|
|
171
|
+
return (
|
|
172
|
+
<SvgIcon {...props}>
|
|
173
|
+
<path d="M14.7 6.3a4.5 4.5 0 0 0-6.1 6.1L3 18l3 3 5.6-5.6a4.5 4.5 0 0 0 6.1-6.1z" />
|
|
174
|
+
<path d="M15 5l4 4" />
|
|
175
|
+
</SvgIcon>
|
|
176
|
+
)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function CodeIcon(props: IconProps) {
|
|
180
|
+
return (
|
|
181
|
+
<SvgIcon {...props}>
|
|
182
|
+
<path d="M9 8 4 12l5 4" />
|
|
183
|
+
<path d="M15 8l5 4-5 4" />
|
|
184
|
+
<path d="M11 20 13 4" />
|
|
185
|
+
</SvgIcon>
|
|
186
|
+
)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
import type { JSX } from 'react'
|
|
190
|
+
|
|
191
|
+
const iconMap: Record<string, (props: IconProps) => JSX.Element> = {
|
|
192
|
+
HomeOutlined: HomeIcon,
|
|
193
|
+
home: HomeIcon,
|
|
194
|
+
GlobalOutlined: NavIcon,
|
|
195
|
+
nav: NavIcon,
|
|
196
|
+
ToolOutlined: ToolIcon,
|
|
197
|
+
tool: ToolIcon,
|
|
198
|
+
CodeOutlined: CodeIcon,
|
|
199
|
+
code: CodeIcon,
|
|
200
|
+
HistoryOutlined: ArchivesIcon,
|
|
201
|
+
archives: ArchivesIcon,
|
|
202
|
+
FolderOutlined: FolderIcon,
|
|
203
|
+
folder: FolderIcon,
|
|
204
|
+
TagOutlined: TagIcon,
|
|
205
|
+
tag: TagIcon,
|
|
206
|
+
UserOutlined: UserIcon,
|
|
207
|
+
user: UserIcon,
|
|
208
|
+
SearchOutlined: SearchIcon,
|
|
209
|
+
search: SearchIcon,
|
|
210
|
+
MessageOutlined: MessageIcon,
|
|
211
|
+
message: MessageIcon,
|
|
212
|
+
suggestions: MessageIcon,
|
|
213
|
+
ProfileOutlined: UserIcon,
|
|
214
|
+
profile: UserIcon,
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function getIconByName(name?: string | ReactNode) {
|
|
218
|
+
if (!name || typeof name !== 'string') return null
|
|
219
|
+
return iconMap[name] ?? null
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Nav category icons — fallback to folder when name is unknown. */
|
|
223
|
+
export function getNavIconByName(name?: string) {
|
|
224
|
+
if (!name) return FolderIcon
|
|
225
|
+
return iconMap[name] ?? FolderIcon
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export function getFirstLevelRoute(path: string, locales: string[] = ['zh', 'en']) {
|
|
229
|
+
if (!path.startsWith('/')) path = `/${path}`
|
|
230
|
+
if (path.endsWith('/') && path.length > 1) path = path.slice(0, -1)
|
|
231
|
+
const segments = path.split('/').filter(Boolean)
|
|
232
|
+
if (segments.length > 0 && locales.includes(segments[0])) {
|
|
233
|
+
segments.shift()
|
|
234
|
+
path = segments.length ? `/${segments.join('/')}` : '/'
|
|
235
|
+
}
|
|
236
|
+
const secondSlashIndex = path.indexOf('/', 1)
|
|
237
|
+
return secondSlashIndex === -1 ? path : path.slice(0, secondSlashIndex)
|
|
238
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** Estimate reading time from HTML content (CJK-aware). */
|
|
2
|
+
export function estimateReadMinutes(html: string): number {
|
|
3
|
+
const text = html
|
|
4
|
+
.replace(/<[^>]*>/g, ' ')
|
|
5
|
+
.replace(/\s+/g, ' ')
|
|
6
|
+
.trim()
|
|
7
|
+
if (!text) return 1
|
|
8
|
+
|
|
9
|
+
const cjkChars = (text.match(/[\u4e00-\u9fff\u3040-\u30ff\uac00-\ud7af]/g) || []).length
|
|
10
|
+
if (cjkChars > text.length * 0.25) {
|
|
11
|
+
return Math.max(1, Math.ceil(text.length / 400))
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const words = text.split(/\s+/).filter(Boolean).length
|
|
15
|
+
return Math.max(1, Math.ceil(words / 220))
|
|
16
|
+
}
|