@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.
Files changed (163) hide show
  1. package/.env.example +22 -0
  2. package/LICENSE +21 -0
  3. package/README.md +286 -0
  4. package/app/api/[[...path]]/route.ts +69 -0
  5. package/app/archives/page.tsx +28 -0
  6. package/app/article/[id]/page.tsx +92 -0
  7. package/app/category/[category]/page.tsx +60 -0
  8. package/app/globals.css +4969 -0
  9. package/app/knowledge/[pId]/[id]/page.tsx +54 -0
  10. package/app/knowledge/[pId]/page.tsx +47 -0
  11. package/app/knowledge/page.tsx +29 -0
  12. package/app/layout.tsx +76 -0
  13. package/app/llms.txt/route.ts +26 -0
  14. package/app/login/page.tsx +17 -0
  15. package/app/nav/[id]/page.tsx +44 -0
  16. package/app/nav/page.tsx +5 -0
  17. package/app/not-found.tsx +21 -0
  18. package/app/page/[id]/page.tsx +57 -0
  19. package/app/page.tsx +61 -0
  20. package/app/register/page.tsx +17 -0
  21. package/app/robots.ts +14 -0
  22. package/app/rss/route.ts +82 -0
  23. package/app/search/page.tsx +46 -0
  24. package/app/sitemap.ts +38 -0
  25. package/app/suggestions/page.tsx +47 -0
  26. package/app/tag/[tag]/page.tsx +58 -0
  27. package/app/tags/page.tsx +18 -0
  28. package/app/template.tsx +6 -0
  29. package/components/article/ArchiveBanner.tsx +40 -0
  30. package/components/article/ArticleActionsBar.tsx +53 -0
  31. package/components/article/ArticleCarousel.tsx +237 -0
  32. package/components/article/ArticleCopyrightBar.tsx +128 -0
  33. package/components/article/ArticleEndBar.tsx +19 -0
  34. package/components/article/ArticleFeedSection.tsx +59 -0
  35. package/components/article/ArticleList.tsx +170 -0
  36. package/components/article/ArticleReadingChrome.tsx +104 -0
  37. package/components/article/ArticleReadingProgress.tsx +75 -0
  38. package/components/article/ArticleRecommend.tsx +362 -0
  39. package/components/article/ArticleTocPanel.tsx +19 -0
  40. package/components/article/ArticleViewClient.tsx +221 -0
  41. package/components/article/CategoryMenu.tsx +67 -0
  42. package/components/article/CmsPageClient.tsx +92 -0
  43. package/components/article/LoadMore.tsx +59 -0
  44. package/components/article/ReadingContent.tsx +51 -0
  45. package/components/auth/AuthPageClient.tsx +314 -0
  46. package/components/comment/CommentEditor.tsx +290 -0
  47. package/components/comment/CommentEmoji.tsx +110 -0
  48. package/components/comment/CommentIconButton.tsx +37 -0
  49. package/components/comment/CommentItem.tsx +135 -0
  50. package/components/comment/CommentSection.tsx +123 -0
  51. package/components/comment/emojis.ts +152 -0
  52. package/components/knowledge/KnowledgeList.tsx +70 -0
  53. package/components/layout/BackToTop.tsx +27 -0
  54. package/components/layout/ConditionalSiteFooter.tsx +29 -0
  55. package/components/layout/ContentStylesLoader.tsx +9 -0
  56. package/components/layout/DoubleColumnLayout.tsx +171 -0
  57. package/components/layout/HeaderSearch.tsx +23 -0
  58. package/components/layout/LocaleSwitcher.tsx +24 -0
  59. package/components/layout/PageContainer.tsx +11 -0
  60. package/components/layout/PageLoadProgress.tsx +169 -0
  61. package/components/layout/SiteFooter.tsx +75 -0
  62. package/components/layout/SiteHeader.tsx +312 -0
  63. package/components/layout/SystemNotification.tsx +176 -0
  64. package/components/layout/ThemeSwitch.tsx +19 -0
  65. package/components/layout/UserAuth.tsx +130 -0
  66. package/components/search/SearchHero.tsx +323 -0
  67. package/components/search/SearchQuickLinks.tsx +285 -0
  68. package/components/shared/DevChunkRecovery.tsx +8 -0
  69. package/components/shared/Link.tsx +30 -0
  70. package/components/shared/SectionHeading.tsx +38 -0
  71. package/components/shared/ThemeImage.tsx +13 -0
  72. package/components/shared/logo.svg +7 -0
  73. package/components/views/ArchivesClient.tsx +137 -0
  74. package/components/views/CategoryClient.tsx +87 -0
  75. package/components/views/HomeClient.tsx +79 -0
  76. package/components/views/KnowledgeBookClient.tsx +127 -0
  77. package/components/views/KnowledgeChapterClient.tsx +175 -0
  78. package/components/views/KnowledgeClient.tsx +69 -0
  79. package/components/views/LoginClient.tsx +80 -0
  80. package/components/views/NavDetailClient.tsx +177 -0
  81. package/components/views/SearchClient.tsx +100 -0
  82. package/components/views/TagArchiveClient.tsx +105 -0
  83. package/components/views/TagsClient.tsx +46 -0
  84. package/components/widgets/AboutUs.tsx +180 -0
  85. package/components/widgets/ArticleActionButton.tsx +95 -0
  86. package/components/widgets/BookmarkWidget.tsx +61 -0
  87. package/components/widgets/CategoriesWidget.tsx +47 -0
  88. package/components/widgets/HomeSidebar.tsx +40 -0
  89. package/components/widgets/LikesWidget.tsx +77 -0
  90. package/components/widgets/ShareWidget.tsx +120 -0
  91. package/components/widgets/TagCloud.tsx +94 -0
  92. package/components/widgets/TagsWidget.tsx +160 -0
  93. package/eslint.config.mjs +80 -0
  94. package/lib/auth/githubOAuth.ts +26 -0
  95. package/lib/auth/sessionUser.ts +41 -0
  96. package/lib/mock-api/auth.ts +129 -0
  97. package/lib/mock-api/config.ts +11 -0
  98. package/lib/mock-api/cors.ts +53 -0
  99. package/lib/mock-api/data.ts +998 -0
  100. package/lib/mock-api/match-route.ts +202 -0
  101. package/lib/mock-api/proxy.ts +28 -0
  102. package/lib/providers/client.ts +58 -0
  103. package/lib/providers/materializeProvider.ts +9 -0
  104. package/lib/providers/server.ts +8 -0
  105. package/lib/reactpress/appearance.ts +69 -0
  106. package/lib/reactpress/bootstrap.ts +48 -0
  107. package/lib/reactpress/coercePublishedPage.ts +12 -0
  108. package/lib/reactpress/contentSeo.ts +123 -0
  109. package/lib/reactpress/env.ts +31 -0
  110. package/lib/reactpress/feedFooterPlacement.ts +16 -0
  111. package/lib/reactpress/fetchSetting.ts +19 -0
  112. package/lib/reactpress/homeFooter.tsx +27 -0
  113. package/lib/reactpress/layout-shell.tsx +19 -0
  114. package/lib/reactpress/materializeBootstrapCatalog.ts +31 -0
  115. package/lib/reactpress/normalizeApiResponse.ts +25 -0
  116. package/lib/reactpress/providers.tsx +199 -0
  117. package/lib/reactpress/resolveSiteNav.ts +56 -0
  118. package/lib/reactpress/seoMetadata.ts +82 -0
  119. package/lib/reactpress/serverLocale.ts +43 -0
  120. package/lib/reactpress/siteMetadata.ts +151 -0
  121. package/lib/reactpress/staticParams.ts +46 -0
  122. package/lib/reactpress/suggestionsContent.ts +48 -0
  123. package/lib/reactpress/useFeedFooterPlacement.ts +22 -0
  124. package/lib/reactpress/useSiteNavConfig.ts +39 -0
  125. package/lib/utils/archiveBanner.ts +8 -0
  126. package/lib/utils/colors.ts +119 -0
  127. package/lib/utils/comment.ts +46 -0
  128. package/lib/utils/headerPosition.ts +55 -0
  129. package/lib/utils/icons.tsx +238 -0
  130. package/lib/utils/reading.ts +16 -0
  131. package/lib/utils/scroll.ts +8 -0
  132. package/lib/utils/tagCloud.ts +222 -0
  133. package/next.config.js +103 -0
  134. package/package.json +98 -0
  135. package/postcss.config.js +5 -0
  136. package/prettier.config.js +10 -0
  137. package/public/apple-touch-icon.png +0 -0
  138. package/public/cli.png +0 -0
  139. package/public/favicon-32.png +0 -0
  140. package/public/favicon.ico +0 -0
  141. package/public/home-dark.png +0 -0
  142. package/public/home-light.png +0 -0
  143. package/public/icon-192.png +0 -0
  144. package/public/icon-512.png +0 -0
  145. package/public/logo-400.png +0 -0
  146. package/public/logo.png +0 -0
  147. package/public/mobile.png +0 -0
  148. package/public/pad.png +0 -0
  149. package/public/site.webmanifest +19 -0
  150. package/scripts/build-mock.mjs +139 -0
  151. package/scripts/dev.mjs +221 -0
  152. package/scripts/ensure-env.mjs +100 -0
  153. package/scripts/remote-dev.mjs +143 -0
  154. package/scripts/resolve-api-env.mjs +114 -0
  155. package/scripts/set-publish-version.mjs +31 -0
  156. package/scripts/smoke-pages.mjs +113 -0
  157. package/scripts/start-mock.mjs +79 -0
  158. package/scripts/verify-toolkit.mjs +37 -0
  159. package/svg.d.ts +6 -0
  160. package/theme.json +123 -0
  161. package/theme.manifest.schema.json +265 -0
  162. package/tsconfig.json +41 -0
  163. package/vercel.json +12 -0
@@ -0,0 +1,152 @@
1
+ export const emojis = {
2
+ grinning: '😀',
3
+ smiley: '😃',
4
+ smile: '😄',
5
+ grin: '😁',
6
+ laughing: '😆',
7
+ satisfied: '😆',
8
+ sweat_smile: '😅',
9
+ joy: '😂',
10
+ wink: '😉',
11
+ blush: '😊',
12
+ innocent: '😇',
13
+ heart_eyes: '😍',
14
+ kissing_heart: '😘',
15
+ kissing: '😗',
16
+ kissing_closed_eyes: '😚',
17
+ kissing_smiling_eyes: '😙',
18
+ yum: '😋',
19
+ stuck_out_tongue: '😛',
20
+ stuck_out_tongue_winking_eye: '😜',
21
+ stuck_out_tongue_closed_eyes: '😝',
22
+ neutral_face: '😐',
23
+ expressionless: '😑',
24
+ no_mouth: '😶',
25
+ smirk: '😏',
26
+ unamused: '😒',
27
+ relieved: '😌',
28
+ pensive: '😔',
29
+ sleepy: '😪',
30
+ sleeping: '😴',
31
+ mask: '😷',
32
+ dizzy_face: '😵',
33
+ sunglasses: '😎',
34
+ confused: '😕',
35
+ worried: '😟',
36
+ open_mouth: '😮',
37
+ hushed: '😯',
38
+ astonished: '😲',
39
+ flushed: '😳',
40
+ frowning: '😦',
41
+ anguished: '😧',
42
+ fearful: '😨',
43
+ cold_sweat: '😰',
44
+ disappointed_relieved: '😥',
45
+ cry: '😢',
46
+ sob: '😭',
47
+ scream: '😱',
48
+ confounded: '😖',
49
+ persevere: '😣',
50
+ disappointed: '😞',
51
+ sweat: '😓',
52
+ weary: '😩',
53
+ tired_face: '😫',
54
+ rage: '😡',
55
+ pout: '😡',
56
+ angry: '😠',
57
+ smiling_imp: '😈',
58
+ smiley_cat: '😺',
59
+ smile_cat: '😸',
60
+ joy_cat: '😹',
61
+ heart_eyes_cat: '😻',
62
+ smirk_cat: '😼',
63
+ kissing_cat: '😽',
64
+ scream_cat: '🙀',
65
+ crying_cat_face: '😿',
66
+ pouting_cat: '😾',
67
+ heart: '❤️',
68
+ hand: '✋',
69
+ raised_hand: '✋',
70
+ v: '✌️',
71
+ point_up: '☝️',
72
+ fist_raised: '✊',
73
+ fist: '✊',
74
+ monkey_face: '🐵',
75
+ cat: '🐱',
76
+ cow: '🐮',
77
+ mouse: '🐭',
78
+ coffee: '☕',
79
+ hotsprings: '♨️',
80
+ anchor: '⚓',
81
+ airplane: '✈️',
82
+ hourglass: '⌛',
83
+ watch: '⌚',
84
+ sunny: '☀️',
85
+ star: '⭐',
86
+ cloud: '☁️',
87
+ umbrella: '☔',
88
+ zap: '⚡',
89
+ snowflake: '❄️',
90
+ sparkles: '✨',
91
+ black_joker: '🃏',
92
+ mahjong: '🀄',
93
+ phone: '☎️',
94
+ telephone: '☎️',
95
+ envelope: '✉️',
96
+ pencil2: '✏️',
97
+ black_nib: '✒️',
98
+ scissors: '✂️',
99
+ wheelchair: '♿',
100
+ warning: '⚠️',
101
+ aries: '♈',
102
+ taurus: '♉',
103
+ gemini: '♊',
104
+ cancer: '♋',
105
+ leo: '♌',
106
+ virgo: '♍',
107
+ libra: '♎',
108
+ scorpius: '♏',
109
+ sagittarius: '♐',
110
+ capricorn: '♑',
111
+ aquarius: '♒',
112
+ pisces: '♓',
113
+ heavy_multiplication_x: '✖️',
114
+ heavy_plus_sign: '➕',
115
+ heavy_minus_sign: '➖',
116
+ heavy_division_sign: '➗',
117
+ bangbang: '‼️',
118
+ interrobang: '⁉️',
119
+ question: '❓',
120
+ grey_question: '❔',
121
+ grey_exclamation: '❕',
122
+ exclamation: '❗',
123
+ heavy_exclamation_mark: '❗',
124
+ wavy_dash: '〰️',
125
+ recycle: '♻️',
126
+ white_check_mark: '✅',
127
+ ballot_box_with_check: '☑️',
128
+ heavy_check_mark: '✔️',
129
+ x: '❌',
130
+ negative_squared_cross_mark: '❎',
131
+ curly_loop: '➰',
132
+ loop: '➿',
133
+ part_alternation_mark: '〽️',
134
+ eight_spoked_asterisk: '✳️',
135
+ eight_pointed_black_star: '✴️',
136
+ sparkle: '❇️',
137
+ copyright: '©️',
138
+ registered: '®️',
139
+ tm: '™️',
140
+ information_source: 'ℹ️',
141
+ m: 'Ⓜ️',
142
+ black_circle: '⚫',
143
+ white_circle: '⚪',
144
+ black_large_square: '⬛',
145
+ white_large_square: '⬜',
146
+ black_medium_square: '◼️',
147
+ white_medium_square: '◻️',
148
+ black_medium_small_square: '◾',
149
+ white_medium_small_square: '◽',
150
+ black_small_square: '▪️',
151
+ white_small_square: '▫️',
152
+ }
@@ -0,0 +1,70 @@
1
+ 'use client'
2
+
3
+ import Link from '@/components/shared/Link'
4
+ import { EyeIcon } from '@/lib/utils/icons'
5
+ import { useLocale } from '@fecommunity/reactpress-toolkit/ui'
6
+ import { Image, LocaleTime } from '@fecommunity/reactpress-toolkit/ui/content'
7
+ import type { IKnowledge } from '@fecommunity/reactpress-toolkit/types'
8
+
9
+ interface KnowledgeListProps {
10
+ knowledges?: Array<IKnowledge & { children?: IKnowledge[] }>
11
+ small?: boolean
12
+ }
13
+
14
+ export default function KnowledgeList({ knowledges = [], small = false }: KnowledgeListProps) {
15
+ const { t, locale } = useLocale()
16
+
17
+ if (!knowledges.length) {
18
+ return (
19
+ <div className="px-4 py-6 text-center text-sm text-[var(--second-text-color)]">
20
+ {t('empty')}
21
+ </div>
22
+ )
23
+ }
24
+
25
+ return (
26
+ <div className="p-0">
27
+ {knowledges.map((knowledge) => (
28
+ <div
29
+ key={knowledge.id}
30
+ className={`border-b border-[var(--border-color)] last:border-b-0 ${
31
+ small ? 'px-3 py-3' : 'px-4 py-4'
32
+ }`}
33
+ >
34
+ <Link
35
+ href={`/knowledge/${knowledge.id}`}
36
+ className="block no-underline hover:text-[var(--primary-color)]"
37
+ >
38
+ <header className="mb-2">
39
+ <div className="font-semibold text-[var(--main-text-color)]">{knowledge.title}</div>
40
+ <div className="mt-1 flex items-center gap-2 text-xs text-[var(--second-text-color)]">
41
+ <span className="h-3 w-px bg-[var(--border-color)]" />
42
+ <LocaleTime date={knowledge.publishAt} timeago locale={locale} />
43
+ </div>
44
+ </header>
45
+ {!small ? (
46
+ <main className="flex gap-4">
47
+ <div className="min-w-0 flex-1">
48
+ {knowledge.summary ? (
49
+ <p className="m-0 line-clamp-2 text-sm text-[var(--second-text-color)]">
50
+ {knowledge.summary}
51
+ </p>
52
+ ) : null}
53
+ <div className="mt-2 flex items-center gap-1 text-xs text-[var(--second-text-color)]">
54
+ <EyeIcon className="h-3.5 w-3.5" />
55
+ <span>{knowledge.views}</span>
56
+ </div>
57
+ </div>
58
+ {knowledge.cover ? (
59
+ <div className="h-16 w-24 shrink-0 overflow-hidden rounded">
60
+ <Image url={knowledge.cover} size="thumb" alt="cover" loading="lazy" />
61
+ </div>
62
+ ) : null}
63
+ </main>
64
+ ) : null}
65
+ </Link>
66
+ </div>
67
+ ))}
68
+ </div>
69
+ )
70
+ }
@@ -0,0 +1,27 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useState } from 'react'
4
+
5
+ export default function BackToTop() {
6
+ const [visible, setVisible] = useState(false)
7
+
8
+ useEffect(() => {
9
+ const onScroll = () => setVisible(window.scrollY > 400)
10
+ window.addEventListener('scroll', onScroll, { passive: true })
11
+ onScroll()
12
+ return () => window.removeEventListener('scroll', onScroll)
13
+ }, [])
14
+
15
+ return (
16
+ <button
17
+ type="button"
18
+ aria-label="Back to top"
19
+ onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}
20
+ className={`rp-back-to-top fixed right-6 bottom-6 z-50 flex h-11 w-11 items-center justify-center rounded-full border border-[var(--border-color)] text-lg text-[var(--second-text-color)] shadow-[var(--box-shadow)] hover:text-[var(--primary-color)] ${
21
+ visible ? 'is-visible' : ''
22
+ }`}
23
+ >
24
+ ↑
25
+ </button>
26
+ )
27
+ }
@@ -0,0 +1,29 @@
1
+ 'use client'
2
+
3
+ import SiteFooter from '@/components/layout/SiteFooter'
4
+ import { useHomeFooter } from '@/lib/reactpress/homeFooter'
5
+ import { usePathname } from 'next/navigation'
6
+
7
+ function isFeedLikePage(pathname: string): boolean {
8
+ const path = (pathname || '/').replace(/\/$/, '') || '/'
9
+ if (path === '/') return true
10
+ if (path.startsWith('/category/')) return true
11
+ if (path.startsWith('/tag/')) return true
12
+ if (path === '/search') return true
13
+ if (path === '/archives') return true
14
+ if (path === '/tags') return true
15
+ return false
16
+ }
17
+
18
+ function shouldHideSiteFooter(pathname: string, showLayoutFooter: boolean): boolean {
19
+ if (!isFeedLikePage(pathname)) return false
20
+ return !showLayoutFooter
21
+ }
22
+
23
+ export default function ConditionalSiteFooter() {
24
+ const pathname = usePathname() ?? '/'
25
+ const { showLayoutFooter } = useHomeFooter()
26
+
27
+ if (shouldHideSiteFooter(pathname, showLayoutFooter)) return null
28
+ return <SiteFooter />
29
+ }
@@ -0,0 +1,9 @@
1
+ 'use client'
2
+
3
+ import 'highlight.js/styles/atom-one-dark.css'
4
+ import 'viewerjs/dist/viewer.css'
5
+
6
+ /** Ensures article content styles (hljs, viewerjs) are bundled on reading routes. */
7
+ export function ContentStylesLoader() {
8
+ return null
9
+ }
@@ -0,0 +1,171 @@
1
+ 'use client'
2
+
3
+ import ArticleReadingChrome from '@/components/article/ArticleReadingChrome'
4
+ import CommentIconButton from '@/components/comment/CommentIconButton'
5
+ import BookmarkWidget from '@/components/widgets/BookmarkWidget'
6
+ import LikesWidget from '@/components/widgets/LikesWidget'
7
+ import ShareWidget from '@/components/widgets/ShareWidget'
8
+ import SystemNotification from '@/components/layout/SystemNotification'
9
+ import { useSiteCatalog } from '@fecommunity/reactpress-toolkit/theme'
10
+ import { type ReactNode, useCallback, useEffect, useState } from 'react'
11
+ import { createPortal } from 'react-dom'
12
+
13
+ interface LikesProps {
14
+ defaultCount?: number
15
+ id: string
16
+ api: (id: string, type: 'like' | 'dislike') => Promise<number>
17
+ }
18
+
19
+ interface DoubleColumnLayoutProps {
20
+ leftNode: ReactNode
21
+ rightNode?: ReactNode | null
22
+ topNode?: ReactNode
23
+ likesProps?: LikesProps
24
+ bookmarkId?: string
25
+ showComment?: boolean
26
+ showShare?: boolean
27
+ coverPreloadUrl?: string
28
+ className?: string
29
+ fillMinHeight?: boolean
30
+ sidebarCollapsible?: boolean
31
+ }
32
+
33
+ const TOC_COLLAPSED_KEY = 'rp-toc-collapsed'
34
+
35
+ export default function DoubleColumnLayout({
36
+ leftNode,
37
+ rightNode,
38
+ topNode,
39
+ likesProps,
40
+ bookmarkId,
41
+ showComment = false,
42
+ showShare = false,
43
+ coverPreloadUrl,
44
+ className = '',
45
+ fillMinHeight = true,
46
+ sidebarCollapsible = false,
47
+ }: DoubleColumnLayoutProps) {
48
+ const { locale } = useSiteCatalog()
49
+ const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
50
+ const [mounted, setMounted] = useState(false)
51
+
52
+ const hasActions = Boolean(likesProps || bookmarkId || showComment || showShare)
53
+ const useFixedSidebar = Boolean(rightNode) && sidebarCollapsible
54
+ const showInlineSidebar = Boolean(rightNode) && !sidebarCollapsible
55
+ const showReadingChrome = hasActions || useFixedSidebar
56
+
57
+ const collapseLabel = locale === 'zh' ? '收起目录' : 'Collapse table of contents'
58
+ const expandLabel = locale === 'zh' ? '展开目录' : 'Expand table of contents'
59
+ const tocLabel = locale === 'zh' ? '目录' : 'TOC'
60
+
61
+ useEffect(() => {
62
+ setMounted(true)
63
+ }, [])
64
+
65
+ useEffect(() => {
66
+ if (!sidebarCollapsible) return
67
+ try {
68
+ setSidebarCollapsed(window.localStorage.getItem(TOC_COLLAPSED_KEY) === '1')
69
+ } catch {
70
+ setSidebarCollapsed(false)
71
+ }
72
+ }, [sidebarCollapsible])
73
+
74
+ const toggleSidebar = useCallback(() => {
75
+ setSidebarCollapsed((prev) => {
76
+ const next = !prev
77
+ try {
78
+ window.localStorage.setItem(TOC_COLLAPSED_KEY, next ? '1' : '0')
79
+ } catch {
80
+ /* ignore */
81
+ }
82
+ return next
83
+ })
84
+ }, [])
85
+
86
+ useEffect(() => {
87
+ if (!coverPreloadUrl) return
88
+ const link = document.createElement('link')
89
+ link.rel = 'preload'
90
+ link.as = 'image'
91
+ link.href = coverPreloadUrl
92
+ document.head.appendChild(link)
93
+ return () => {
94
+ document.head.removeChild(link)
95
+ }
96
+ }, [coverPreloadUrl])
97
+
98
+ const floatingActions = hasActions ? (
99
+ <>
100
+ {likesProps ? <LikesWidget {...likesProps} /> : null}
101
+ {bookmarkId ? <BookmarkWidget id={bookmarkId} /> : null}
102
+ {showComment ? <CommentIconButton /> : null}
103
+ {showShare ? <ShareWidget /> : null}
104
+ </>
105
+ ) : null
106
+
107
+ const mobileActions = hasActions ? (
108
+ <>
109
+ {likesProps ? <LikesWidget {...likesProps} variant="mobile" /> : null}
110
+ {bookmarkId ? <BookmarkWidget id={bookmarkId} variant="mobile" /> : null}
111
+ {showComment ? <CommentIconButton variant="mobile" /> : null}
112
+ {showShare ? <ShareWidget variant="mobile" /> : null}
113
+ </>
114
+ ) : null
115
+
116
+ const layoutClass = [
117
+ 'rp-double-column',
118
+ showReadingChrome ? 'rp-double-column--reading-chrome' : '',
119
+ showInlineSidebar ? 'rp-double-column--sidebar' : '',
120
+ ]
121
+ .filter(Boolean)
122
+ .join(' ')
123
+
124
+ const readingChromeProps = {
125
+ actions: floatingActions,
126
+ mobileActions,
127
+ showToc: useFixedSidebar,
128
+ tocExpanded: !sidebarCollapsed,
129
+ onToggleToc: toggleSidebar,
130
+ tocPanel: rightNode,
131
+ collapseLabel,
132
+ expandLabel,
133
+ tocLabel,
134
+ }
135
+
136
+ const mobileChromePortal =
137
+ showReadingChrome && mounted
138
+ ? createPortal(<ArticleReadingChrome {...readingChromeProps} mode="mobile" />, document.body)
139
+ : null
140
+
141
+ const tocChromePortal =
142
+ useFixedSidebar && mounted
143
+ ? createPortal(<ArticleReadingChrome {...readingChromeProps} mode="toc" />, document.body)
144
+ : null
145
+
146
+ const sideChromePortal =
147
+ showReadingChrome && hasActions && mounted
148
+ ? createPortal(<ArticleReadingChrome {...readingChromeProps} mode="side" />, document.body)
149
+ : null
150
+
151
+ return (
152
+ <div className={`${fillMinHeight ? 'flex min-h-0 flex-1 flex-col' : ''} ${className}`.trim()}>
153
+ <SystemNotification />
154
+ {topNode}
155
+ <div className={`${layoutClass} relative pt-4 pb-8`}>
156
+ <section className="rp-double-column__main w-full min-w-0 flex-1 pt-0 pb-6 min-[1280px]:pb-8">
157
+ {leftNode}
158
+ </section>
159
+
160
+ {showInlineSidebar ? (
161
+ <aside className="rp-double-column__aside rp-sidebar-slot hidden min-[1280px]:block">
162
+ <div className="rp-sidebar-slot__content">{rightNode}</div>
163
+ </aside>
164
+ ) : null}
165
+ </div>
166
+ {sideChromePortal}
167
+ {mobileChromePortal}
168
+ {tocChromePortal}
169
+ </div>
170
+ )
171
+ }
@@ -0,0 +1,23 @@
1
+ 'use client'
2
+
3
+ import Link from '@/components/shared/Link'
4
+ import { SearchIcon, TOOLBAR_ICON_SIZE, useLocale } from '@fecommunity/reactpress-toolkit/ui'
5
+ import { usePathname } from 'next/navigation'
6
+
7
+ export default function HeaderSearch() {
8
+ const { t } = useLocale()
9
+ const pathname = usePathname()
10
+ const isActive = pathname === '/search'
11
+
12
+ return (
13
+ <Link
14
+ href="/search"
15
+ aria-label={t('search')}
16
+ className={`rp-icon-btn inline-flex h-10 w-10 items-center justify-center rounded-full hover:bg-[var(--bg-second)] hover:text-[var(--primary-color)] ${
17
+ isActive ? 'text-[var(--primary-color)]' : 'text-[var(--main-text-color)]'
18
+ }`}
19
+ >
20
+ <SearchIcon size={TOOLBAR_ICON_SIZE} />
21
+ </Link>
22
+ )
23
+ }
@@ -0,0 +1,24 @@
1
+ 'use client'
2
+
3
+ import {
4
+ LocaleToggleButton,
5
+ nextLocale,
6
+ TOOLBAR_ICON_SIZE,
7
+ useLocale,
8
+ } from '@fecommunity/reactpress-toolkit/ui'
9
+
10
+ export default function LocaleSwitcher() {
11
+ const { t, locale, locales = [], setLocale } = useLocale()
12
+ const activeLocale = locale ?? locales[0] ?? 'zh'
13
+ const next = nextLocale(activeLocale, locales)
14
+
15
+ return (
16
+ <LocaleToggleButton
17
+ locale={activeLocale}
18
+ locales={locales}
19
+ onLocaleChange={setLocale}
20
+ size={TOOLBAR_ICON_SIZE}
21
+ aria-label={next === 'en' ? t('locale.switchToEn') : t('locale.switchToZh')}
22
+ />
23
+ )
24
+ }
@@ -0,0 +1,11 @@
1
+ import type { ReactNode } from 'react'
2
+
3
+ interface PageContainerProps {
4
+ children: ReactNode
5
+ className?: string
6
+ }
7
+
8
+ /** Content width aligned with twentytwentyfive `.container` breakpoints. */
9
+ export default function PageContainer({ children, className = '' }: PageContainerProps) {
10
+ return <div className={`rp-container mx-auto w-full px-4 ${className}`}>{children}</div>
11
+ }
@@ -0,0 +1,169 @@
1
+ 'use client'
2
+
3
+ import { usePathname, useSearchParams } from 'next/navigation'
4
+ import { Suspense, useEffect, useLayoutEffect, useRef, useState } from 'react'
5
+ import { createPortal } from 'react-dom'
6
+
7
+ import { useHeaderTop } from '@/lib/utils/headerPosition'
8
+
9
+ const MIN_PROGRESS = 0.08
10
+ const TRICKLE_INC = 0.012
11
+ const TRICKLE_MS = 180
12
+ const HIDE_DELAY_MS = 280
13
+
14
+ function clearLoadingMarker() {
15
+ document.documentElement.removeAttribute('data-rp-loading')
16
+ }
17
+
18
+ function isSameRoute(pathname: string, href: string) {
19
+ try {
20
+ const url = new URL(href, window.location.href)
21
+ if (url.origin !== window.location.origin) return true
22
+ const current = `${pathname}${window.location.search}`
23
+ const next = `${url.pathname}${url.search}`
24
+ return current === next
25
+ } catch {
26
+ return true
27
+ }
28
+ }
29
+
30
+ function PageLoadProgressInner() {
31
+ const headerTop = useHeaderTop()
32
+ const pathname = usePathname()
33
+ const searchParams = useSearchParams()
34
+ const [mounted, setMounted] = useState(false)
35
+ const [visible, setVisible] = useState(false)
36
+ const [progress, setProgress] = useState(0)
37
+
38
+ const progressRef = useRef(0)
39
+ const trickleTimerRef = useRef<ReturnType<typeof setInterval> | undefined>(undefined)
40
+ const hideTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
41
+ const initialLoadDoneRef = useRef(false)
42
+ const navigatingRef = useRef(false)
43
+ const controlsRef = useRef<{ start: () => void; done: () => void } | null>(null)
44
+
45
+ controlsRef.current = {
46
+ start() {
47
+ if (hideTimerRef.current) {
48
+ clearTimeout(hideTimerRef.current)
49
+ hideTimerRef.current = undefined
50
+ }
51
+ progressRef.current = MIN_PROGRESS
52
+ setProgress(MIN_PROGRESS)
53
+ setVisible(true)
54
+
55
+ if (trickleTimerRef.current) {
56
+ clearInterval(trickleTimerRef.current)
57
+ }
58
+ trickleTimerRef.current = setInterval(() => {
59
+ const current = progressRef.current
60
+ if (current >= 0.92) return
61
+ const next = current + TRICKLE_INC * (1 - current)
62
+ progressRef.current = next
63
+ setProgress(next)
64
+ }, TRICKLE_MS)
65
+ },
66
+ done() {
67
+ if (trickleTimerRef.current) {
68
+ clearInterval(trickleTimerRef.current)
69
+ trickleTimerRef.current = undefined
70
+ }
71
+ progressRef.current = 1
72
+ setProgress(1)
73
+ clearLoadingMarker()
74
+ hideTimerRef.current = setTimeout(() => {
75
+ setVisible(false)
76
+ progressRef.current = 0
77
+ setProgress(0)
78
+ }, HIDE_DELAY_MS)
79
+ },
80
+ }
81
+
82
+ useLayoutEffect(() => {
83
+ setMounted(true)
84
+ clearLoadingMarker()
85
+
86
+ if (document.readyState === 'complete') {
87
+ initialLoadDoneRef.current = true
88
+ return
89
+ }
90
+
91
+ controlsRef.current?.start()
92
+ const onLoad = () => {
93
+ initialLoadDoneRef.current = true
94
+ controlsRef.current?.done()
95
+ }
96
+ window.addEventListener('load', onLoad)
97
+ return () => window.removeEventListener('load', onLoad)
98
+ }, [])
99
+
100
+ useEffect(() => {
101
+ if (!mounted || !initialLoadDoneRef.current || !navigatingRef.current) return
102
+ navigatingRef.current = false
103
+ controlsRef.current?.done()
104
+ }, [mounted, pathname, searchParams])
105
+
106
+ useEffect(() => {
107
+ const onClick = (event: MouseEvent) => {
108
+ if (event.defaultPrevented) return
109
+ if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return
110
+
111
+ const target = event.target
112
+ if (!(target instanceof Element)) return
113
+
114
+ const anchor = target.closest('a')
115
+ if (!anchor) return
116
+ if (anchor.target === '_blank' || anchor.hasAttribute('download')) return
117
+
118
+ const href = anchor.getAttribute('href')
119
+ if (!href || href.startsWith('#') || href.startsWith('mailto:') || href.startsWith('tel:')) {
120
+ return
121
+ }
122
+
123
+ if (isSameRoute(pathname, href)) return
124
+
125
+ navigatingRef.current = true
126
+ controlsRef.current?.start()
127
+ }
128
+
129
+ document.addEventListener('click', onClick, true)
130
+ return () => document.removeEventListener('click', onClick, true)
131
+ }, [pathname])
132
+
133
+ useEffect(
134
+ () => () => {
135
+ if (trickleTimerRef.current) clearInterval(trickleTimerRef.current)
136
+ if (hideTimerRef.current) clearTimeout(hideTimerRef.current)
137
+ },
138
+ []
139
+ )
140
+
141
+ if (!mounted || !visible) return null
142
+
143
+ const percent = Math.round(progress * 100)
144
+
145
+ return createPortal(
146
+ <div
147
+ className="rp-page-load-progress"
148
+ style={{ top: headerTop }}
149
+ role="progressbar"
150
+ aria-valuenow={percent}
151
+ aria-valuemin={0}
152
+ aria-valuemax={100}
153
+ aria-label="Page load progress"
154
+ >
155
+ <div className="rp-page-load-progress__track">
156
+ <div className="rp-page-load-progress__bar" style={{ width: `${percent}%` }} />
157
+ </div>
158
+ </div>,
159
+ document.documentElement
160
+ )
161
+ }
162
+
163
+ export default function PageLoadProgress() {
164
+ return (
165
+ <Suspense fallback={null}>
166
+ <PageLoadProgressInner />
167
+ </Suspense>
168
+ )
169
+ }