@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,362 @@
1
+ 'use client'
2
+
3
+ import ArticleList from '@/components/article/ArticleList'
4
+ import Link from '@/components/shared/Link'
5
+ import SectionHeading, { EmptyState } from '@/components/shared/SectionHeading'
6
+ import { ArticleProvider } from '@/lib/providers/client'
7
+ import { normalizeList, normalizePaginated } from '@/lib/reactpress/normalizeApiResponse'
8
+ import { EyeIcon, HeartIcon } from '@/lib/utils/icons'
9
+ import { useLocale } from '@fecommunity/reactpress-toolkit/ui'
10
+ import { slimArticlesForList } from '@fecommunity/reactpress-toolkit/theme'
11
+ import type { IArticle } from '@fecommunity/reactpress-toolkit/types'
12
+ import {
13
+ useCallback,
14
+ useEffect,
15
+ useMemo,
16
+ useRef,
17
+ useState,
18
+ type ReactNode,
19
+ type TransitionEvent,
20
+ } from 'react'
21
+
22
+ interface ArticleRecommendProps {
23
+ articleId?: string | null
24
+ mode?: 'inline' | 'vertical'
25
+ needTitle?: boolean
26
+ deferFetch?: boolean
27
+ /** Render as article page section (heading + panel wrapper). */
28
+ asPageSection?: boolean
29
+ /** Hide entire section when loaded with no articles. */
30
+ hideWhenEmpty?: boolean
31
+ }
32
+
33
+ type RecommendArticle = {
34
+ id: string
35
+ title: string
36
+ views: number
37
+ summary?: string
38
+ likes?: number
39
+ publishAt?: string
40
+ cover?: string
41
+ category?: { value: string; label: string }
42
+ }
43
+
44
+ const SKELETON_ROWS = 5
45
+ const INLINE_VISIBLE = 5
46
+ const INLINE_FETCH_SIZE = 15
47
+ const VERTICAL_FETCH_SIZE = 6
48
+ const SCROLL_ROW_PX = 42
49
+ const SCROLL_INTERVAL_MS = 3600
50
+
51
+ function formatCompactViews(views: number): string {
52
+ if (views >= 10000) {
53
+ return `${(views / 10000).toFixed(1).replace(/\.0$/, '')}w`
54
+ }
55
+ if (views >= 1000) {
56
+ return `${(views / 1000).toFixed(1).replace(/\.0$/, '')}k`
57
+ }
58
+ return String(views)
59
+ }
60
+
61
+ function dedupeById(articles: RecommendArticle[]): RecommendArticle[] {
62
+ const seen = new Set<string>()
63
+ return articles.filter((article) => {
64
+ if (seen.has(article.id)) return false
65
+ seen.add(article.id)
66
+ return true
67
+ })
68
+ }
69
+
70
+ /** Recommend API may still cap at 6 on older backends — pad with hot articles from list API. */
71
+ async function loadRecommendArticles(
72
+ articleId: string | null,
73
+ fetchSize: number
74
+ ): Promise<RecommendArticle[]> {
75
+ const recommended = slimArticlesForList(
76
+ normalizeList<IArticle>(await ArticleProvider.getRecommend(articleId, fetchSize))
77
+ )
78
+ let pool = dedupeById(recommended)
79
+ pool.sort((a, b) => b.views - a.views)
80
+
81
+ if (pool.length >= fetchSize || articleId) {
82
+ return pool.slice(0, fetchSize)
83
+ }
84
+
85
+ const [listRows] = normalizePaginated<IArticle>(
86
+ await ArticleProvider.getArticles({
87
+ page: 1,
88
+ pageSize: Math.max(fetchSize * 2, 24),
89
+ status: 'publish',
90
+ })
91
+ )
92
+
93
+ pool = dedupeById([...pool, ...slimArticlesForList(listRows)])
94
+ pool.sort((a, b) => b.views - a.views)
95
+ return pool.slice(0, fetchSize)
96
+ }
97
+
98
+ function RecommendSkeleton() {
99
+ return (
100
+ <ul className="rp-recommend-list m-0 list-none" aria-hidden>
101
+ {Array.from({ length: SKELETON_ROWS }, (_, i) => (
102
+ <li key={i} className="rp-recommend-item">
103
+ <div className="flex items-center gap-2.5 px-4 py-2.5">
104
+ <span className="h-4 w-5 shrink-0 animate-pulse rounded bg-[var(--border-color)]" />
105
+ <span className="h-3.5 flex-1 animate-pulse rounded bg-[var(--border-color)]" />
106
+ <span className="h-3 w-9 shrink-0 animate-pulse rounded bg-[var(--border-color)]" />
107
+ </div>
108
+ </li>
109
+ ))}
110
+ </ul>
111
+ )
112
+ }
113
+
114
+ function RecommendRow({
115
+ article,
116
+ rank,
117
+ compact = false,
118
+ }: {
119
+ article: RecommendArticle
120
+ rank: number
121
+ compact?: boolean
122
+ }) {
123
+ return (
124
+ <li className={compact ? 'rp-recommend-scroll-row' : 'rp-recommend-item'}>
125
+ <Link
126
+ href={`/article/${article.id}`}
127
+ title={article.title}
128
+ className="rp-recommend-link group flex items-center gap-3 px-4 py-2.5 no-underline"
129
+ >
130
+ <span className="rp-recommend-rank" data-rank={rank} aria-hidden>
131
+ {rank}
132
+ </span>
133
+ <span className="rp-recommend-title min-w-0 flex-1 truncate text-[var(--main-text-color)] transition-colors group-hover:text-[var(--primary-color)]">
134
+ {article.title}
135
+ </span>
136
+ <span className="rp-recommend-views inline-flex shrink-0 items-center gap-1 tabular-nums">
137
+ <EyeIcon size={12} />
138
+ <span>{formatCompactViews(article.views)}</span>
139
+ </span>
140
+ </Link>
141
+ </li>
142
+ )
143
+ }
144
+
145
+ function RecommendInlineScroller({ articles }: { articles: RecommendArticle[] }) {
146
+ const [offset, setOffset] = useState(0)
147
+ const [trackY, setTrackY] = useState(0)
148
+ const [moving, setMoving] = useState(false)
149
+ const [paused, setPaused] = useState(false)
150
+ const [instant, setInstant] = useState(false)
151
+ const [reduceMotion, setReduceMotion] = useState(false)
152
+ const resetTimerRef = useRef<number | null>(null)
153
+
154
+ const rankById = useMemo(() => {
155
+ const map = new Map<string, number>()
156
+ articles.forEach((article, index) => map.set(article.id, index + 1))
157
+ return map
158
+ }, [articles])
159
+
160
+ const loopArticles = useMemo(() => [...articles, ...articles], [articles])
161
+
162
+ const windowItems = useMemo(
163
+ () =>
164
+ loopArticles.slice(offset, offset + INLINE_VISIBLE + 1).map((article, index) => ({
165
+ article,
166
+ rank: rankById.get(article.id) ?? offset + index + 1,
167
+ key: `${article.id}-${offset + index}`,
168
+ })),
169
+ [loopArticles, offset, rankById]
170
+ )
171
+
172
+ useEffect(() => {
173
+ const mq = window.matchMedia('(prefers-reduced-motion: reduce)')
174
+ const sync = () => setReduceMotion(mq.matches)
175
+ sync()
176
+ mq.addEventListener('change', sync)
177
+ return () => mq.removeEventListener('change', sync)
178
+ }, [])
179
+
180
+ const canScroll = articles.length > INLINE_VISIBLE && !reduceMotion
181
+
182
+ const clearResetTimer = useCallback(() => {
183
+ if (resetTimerRef.current !== null) {
184
+ window.clearTimeout(resetTimerRef.current)
185
+ resetTimerRef.current = null
186
+ }
187
+ }, [])
188
+
189
+ useEffect(() => {
190
+ setOffset(0)
191
+ setTrackY(0)
192
+ setMoving(false)
193
+ setInstant(false)
194
+ }, [articles])
195
+
196
+ useEffect(() => {
197
+ if (!canScroll || paused || moving) return undefined
198
+
199
+ const timer = window.setInterval(() => {
200
+ setMoving(true)
201
+ setTrackY(-SCROLL_ROW_PX)
202
+ }, SCROLL_INTERVAL_MS)
203
+
204
+ return () => window.clearInterval(timer)
205
+ }, [canScroll, moving, paused])
206
+
207
+ useEffect(() => clearResetTimer, [clearResetTimer])
208
+
209
+ const handleTrackTransitionEnd = (event: TransitionEvent<HTMLUListElement>) => {
210
+ if (event.target !== event.currentTarget || event.propertyName !== 'transform' || instant) {
211
+ return
212
+ }
213
+ if (!moving) return
214
+
215
+ setInstant(true)
216
+ setOffset((prev) => (prev + 1) % articles.length)
217
+ setTrackY(0)
218
+ setMoving(false)
219
+ resetTimerRef.current = window.setTimeout(() => {
220
+ setInstant(false)
221
+ resetTimerRef.current = null
222
+ }, 24)
223
+ }
224
+
225
+ if (!canScroll) {
226
+ return (
227
+ <ul className="rp-recommend-list rp-recommend-fade-in m-0 list-none">
228
+ {articles.map((article, index) => (
229
+ <RecommendRow key={article.id} article={article} rank={index + 1} />
230
+ ))}
231
+ </ul>
232
+ )
233
+ }
234
+
235
+ return (
236
+ <div
237
+ className={`rp-recommend-scroll rp-recommend-fade-in${paused ? 'is-paused' : ''}`}
238
+ onMouseEnter={() => setPaused(true)}
239
+ onMouseLeave={() => setPaused(false)}
240
+ onFocusCapture={() => setPaused(true)}
241
+ onBlurCapture={(event) => {
242
+ if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
243
+ setPaused(false)
244
+ }
245
+ }}
246
+ >
247
+ <div className={`rp-recommend-scroll-viewport${moving ? 'is-moving' : ''}`} aria-live="off">
248
+ <ul
249
+ className={`rp-recommend-list rp-recommend-scroll-track m-0 list-none ${instant ? 'is-instant' : ''}${moving ? 'is-moving' : ''}`}
250
+ style={{ transform: `translate3d(0, ${trackY}px, 0)` }}
251
+ onTransitionEnd={handleTrackTransitionEnd}
252
+ >
253
+ {windowItems.map(({ article, rank, key }) => (
254
+ <RecommendRow key={key} article={article} rank={rank} compact />
255
+ ))}
256
+ </ul>
257
+ </div>
258
+ </div>
259
+ )
260
+ }
261
+
262
+ export default function ArticleRecommend({
263
+ mode = 'vertical',
264
+ articleId = null,
265
+ needTitle = true,
266
+ deferFetch = false,
267
+ asPageSection = false,
268
+ hideWhenEmpty = false,
269
+ }: ArticleRecommendProps) {
270
+ const { t } = useLocale()
271
+ const requestKey = `${articleId ?? ''}:${mode}`
272
+ const [fetchedKey, setFetchedKey] = useState<string | undefined>(undefined)
273
+ const [articles, setArticles] = useState<RecommendArticle[]>([])
274
+
275
+ useEffect(() => {
276
+ if (fetchedKey === requestKey) return
277
+ let cancelled = false
278
+
279
+ const fetchSize = mode === 'inline' ? INLINE_FETCH_SIZE : VERTICAL_FETCH_SIZE
280
+
281
+ const run = () => {
282
+ loadRecommendArticles(articleId, fetchSize)
283
+ .then((next) => {
284
+ if (cancelled) return
285
+ setArticles(next)
286
+ setFetchedKey(requestKey)
287
+ })
288
+ .catch(() => {
289
+ if (cancelled) return
290
+ setArticles([])
291
+ setFetchedKey(requestKey)
292
+ })
293
+ }
294
+
295
+ if (deferFetch && typeof window !== 'undefined' && 'requestIdleCallback' in window) {
296
+ const id = window.requestIdleCallback(() => run(), { timeout: 2500 })
297
+ return () => {
298
+ cancelled = true
299
+ window.cancelIdleCallback(id)
300
+ }
301
+ }
302
+
303
+ run()
304
+ return () => {
305
+ cancelled = true
306
+ }
307
+ }, [articleId, deferFetch, fetchedKey, requestKey, mode])
308
+
309
+ const ready = fetchedKey === requestKey
310
+ const showSkeleton = !ready && articles.length === 0
311
+ const showEmpty = ready && articles.length === 0
312
+
313
+ if (mode === 'vertical') {
314
+ if (hideWhenEmpty && showEmpty) return null
315
+
316
+ let body: ReactNode
317
+ if (showSkeleton) {
318
+ body = <RecommendSkeleton />
319
+ } else if (showEmpty) {
320
+ body = <EmptyState />
321
+ } else {
322
+ body = (
323
+ <ArticleList
324
+ articles={articles as Parameters<typeof ArticleList>[0]['articles']}
325
+ density="feed"
326
+ />
327
+ )
328
+ }
329
+
330
+ if (asPageSection) {
331
+ return (
332
+ <section className="rp-article-recommend">
333
+ <SectionHeading>{t('recommendToReading')}</SectionHeading>
334
+ <div className="rp-article-recommend-panel">{body}</div>
335
+ </section>
336
+ )
337
+ }
338
+
339
+ return body
340
+ }
341
+
342
+ return (
343
+ <div className="rp-widget-panel rp-sidebar-widget mb-5 overflow-hidden rounded-xl bg-[var(--bg-box)] leading-snug shadow-[var(--box-shadow)] ring-1 ring-black/5 dark:ring-white/5">
344
+ {needTitle ? (
345
+ <div className="rp-sidebar-widget__header">
346
+ <HeartIcon size={16} className="mr-2 inline-block text-[var(--primary-color)]" />
347
+ <span>{t('recommendToReading')}</span>
348
+ </div>
349
+ ) : null}
350
+
351
+ <div className="rp-sidebar-widget__body">
352
+ {showSkeleton ? (
353
+ <RecommendSkeleton />
354
+ ) : showEmpty ? (
355
+ <EmptyState />
356
+ ) : (
357
+ <RecommendInlineScroller articles={articles} />
358
+ )}
359
+ </div>
360
+ </div>
361
+ )
362
+ }
@@ -0,0 +1,19 @@
1
+ 'use client'
2
+
3
+ import { ArticleToc, type TocItem } from '@fecommunity/reactpress-toolkit/ui/content'
4
+ import { useLocale } from '@fecommunity/reactpress-toolkit/ui'
5
+
6
+ interface ArticleTocPanelProps {
7
+ tocs: TocItem[]
8
+ }
9
+
10
+ export default function ArticleTocPanel({ tocs }: ArticleTocPanelProps) {
11
+ const { t } = useLocale()
12
+ if (!tocs.length) return null
13
+
14
+ return (
15
+ <nav className="rp-toc-panel rp-toc-panel--minimal" aria-label={t('toc')}>
16
+ <ArticleToc items={tocs} showTitle={false} maxHeight="none" indentStep={6} />
17
+ </nav>
18
+ )
19
+ }
@@ -0,0 +1,221 @@
1
+ 'use client'
2
+
3
+ import ArticleCopyrightBar from '@/components/article/ArticleCopyrightBar'
4
+ import ArticleReadingProgress from '@/components/article/ArticleReadingProgress'
5
+ import ArticleRecommend from '@/components/article/ArticleRecommend'
6
+ import ArticleTocPanel from '@/components/article/ArticleTocPanel'
7
+ import SectionHeading from '@/components/shared/SectionHeading'
8
+ import CommentSection from '@/components/comment/CommentSection'
9
+ import DoubleColumnLayout from '@/components/layout/DoubleColumnLayout'
10
+ import ReadingContent from '@/components/article/ReadingContent'
11
+ import Link from '@/components/shared/Link'
12
+ import { ArticleProvider } from '@/lib/providers/client'
13
+ import { estimateReadMinutes } from '@/lib/utils/reading'
14
+ import { getColorFromNumber } from '@/lib/utils/colors'
15
+ import { useLocale } from '@fecommunity/reactpress-toolkit/ui'
16
+ import {
17
+ Image,
18
+ ImageViewer,
19
+ LocaleTime,
20
+ parseArticleToc,
21
+ } from '@fecommunity/reactpress-toolkit/ui/content'
22
+ import {
23
+ resolveImageUrl,
24
+ useSiteCatalog,
25
+ useSiteSetting,
26
+ } from '@fecommunity/reactpress-toolkit/theme'
27
+ import type { IArticle } from '@fecommunity/reactpress-toolkit/types'
28
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
29
+
30
+ interface ArticleViewClientProps {
31
+ article: IArticle
32
+ }
33
+
34
+ function tagLabel(tag: string | { label?: string }) {
35
+ return typeof tag === 'string' ? tag : (tag?.label ?? '')
36
+ }
37
+
38
+ function tagValue(tag: string | { value?: string; label?: string }) {
39
+ return typeof tag === 'string' ? tag : (tag?.value ?? tag?.label ?? '')
40
+ }
41
+
42
+ export default function ArticleViewClient({ article: initialArticle }: ArticleViewClientProps) {
43
+ const { t } = useLocale()
44
+ const setting = useSiteSetting()
45
+ const { locale } = useSiteCatalog()
46
+ const [article, setArticle] = useState(initialArticle)
47
+ const [needPassword, setNeedPassword] = useState(Boolean(initialArticle.needPassword))
48
+ const passwdRef = useRef('')
49
+ const tocs = parseArticleToc(article.toc ?? '')
50
+ const readMinutes = useMemo(() => estimateReadMinutes(article.html ?? ''), [article.html])
51
+ const readTimeLabel = locale === 'zh' ? `${readMinutes} 分钟阅读` : `${readMinutes} min read`
52
+
53
+ const likesApi = useCallback(
54
+ (id: string, type: 'like' | 'dislike') =>
55
+ ArticleProvider.updateArticleLikes(id, type).then((res) => res.likes),
56
+ []
57
+ )
58
+
59
+ useEffect(() => {
60
+ setArticle(initialArticle)
61
+ setNeedPassword(Boolean(initialArticle.needPassword))
62
+ }, [initialArticle])
63
+
64
+ useEffect(() => {
65
+ if (!needPassword) {
66
+ ArticleProvider.updateArticleViews(article.id)
67
+ }
68
+ }, [needPassword, article.id])
69
+
70
+ const checkPassword = useCallback(() => {
71
+ ArticleProvider.checkPassword(article.id, passwdRef.current).then((res) => {
72
+ if (res.pass) {
73
+ setArticle((prev) => ({ ...prev, ...res }))
74
+ setNeedPassword(false)
75
+ }
76
+ })
77
+ }, [article.id])
78
+
79
+ if (needPassword) {
80
+ return (
81
+ <div className="mx-auto max-w-lg rounded-lg bg-[var(--bg-box)] p-6 shadow-[var(--box-shadow)]">
82
+ <h2 className="mt-0 text-lg font-semibold">{t('protectedArticleMsg')}</h2>
83
+ <input
84
+ type="password"
85
+ onChange={(e) => {
86
+ passwdRef.current = e.target.value
87
+ }}
88
+ className="mt-4 w-full rounded-md border border-[var(--border-color)] px-3 py-2"
89
+ placeholder={t('passwd')}
90
+ />
91
+ <div className="mt-4 flex gap-3">
92
+ <button
93
+ type="button"
94
+ onClick={checkPassword}
95
+ className="rounded-md bg-[var(--primary-color)] px-4 py-2 text-white"
96
+ >
97
+ {t('confirm')}
98
+ </button>
99
+ <Link
100
+ href="/"
101
+ className="rounded-md border border-[var(--border-color)] px-4 py-2 no-underline"
102
+ >
103
+ {t('backHome')}
104
+ </Link>
105
+ </div>
106
+ </div>
107
+ )
108
+ }
109
+
110
+ const content = (
111
+ <ImageViewer containerSelector="#js-article-wrapper">
112
+ <div className="rp-article-page">
113
+ <article id="js-article-wrapper" className="rp-article-wrap">
114
+ {setting.systemUrl ? (
115
+ <>
116
+ <meta
117
+ itemProp="url"
118
+ content={new URL(`/article/${article.id}`, setting.systemUrl).href}
119
+ />
120
+ <meta itemProp="headline" content={article.title} />
121
+ {article.tags?.length ? (
122
+ <meta itemProp="keywords" content={article.tags.map(tagLabel).join(' ')} />
123
+ ) : null}
124
+ <meta itemProp="datePublished" content={article.publishAt} />
125
+ {article.cover ? (
126
+ <meta itemProp="image" content={resolveImageUrl(article.cover, 'large')} />
127
+ ) : null}
128
+ </>
129
+ ) : null}
130
+
131
+ {article.cover ? (
132
+ <div className="rp-article-cover-hero rp-cover-zoom-host">
133
+ <Image
134
+ url={article.cover}
135
+ size="large"
136
+ alt={t('articleCover')}
137
+ className="rp-cover-zoom h-full w-full object-cover"
138
+ />
139
+ </div>
140
+ ) : null}
141
+
142
+ <div className="rp-article-inner">
143
+ <header className="rp-article-header">
144
+ <div className="rp-article-meta-row">
145
+ <time className="rp-article-meta-chip">
146
+ <LocaleTime date={article.publishAt} locale={locale} />
147
+ </time>
148
+ <span className="rp-article-meta-dot" aria-hidden />
149
+ <span className="rp-article-meta-chip">{readTimeLabel}</span>
150
+ <span className="rp-article-meta-dot" aria-hidden />
151
+ <span className="rp-article-meta-chip">
152
+ {article.views} {t('readings')}
153
+ </span>
154
+ </div>
155
+ <h1 className="rp-article-title">{article.title}</h1>
156
+ {article.tags?.length ? (
157
+ <div className="rp-article-tags rp-article-tags--header">
158
+ {article.tags.map((tag, index) => (
159
+ <Link
160
+ key={tagValue(tag) || index}
161
+ href={`/tag/${tagValue(tag)}`}
162
+ className="rp-article-tag rp-article-tag--hash"
163
+ style={{ color: getColorFromNumber(index) }}
164
+ >
165
+ #{tagLabel(tag)}
166
+ </Link>
167
+ ))}
168
+ </div>
169
+ ) : null}
170
+ </header>
171
+
172
+ <div className="rp-article-body">
173
+ <ReadingContent content={article.html} />
174
+ </div>
175
+
176
+ <footer className="rp-article-footer">
177
+ <ArticleCopyrightBar publishAt={article.publishAt} />
178
+ </footer>
179
+ </div>
180
+ </article>
181
+
182
+ {article.isCommentable ? (
183
+ <section id="rp-comment-section" className="rp-comment-wrap">
184
+ <SectionHeading>{t('comment')}</SectionHeading>
185
+ <div className="rp-comment-panel">
186
+ <CommentSection hostId={article.id} />
187
+ </div>
188
+ </section>
189
+ ) : null}
190
+
191
+ <ArticleRecommend
192
+ articleId={article.id}
193
+ needTitle={false}
194
+ mode="vertical"
195
+ asPageSection
196
+ hideWhenEmpty
197
+ />
198
+ </div>
199
+ </ImageViewer>
200
+ )
201
+
202
+ return (
203
+ <>
204
+ <ArticleReadingProgress />
205
+ <DoubleColumnLayout
206
+ leftNode={content}
207
+ rightNode={tocs.length ? <ArticleTocPanel tocs={tocs} /> : null}
208
+ sidebarCollapsible={tocs.length > 0}
209
+ likesProps={{
210
+ defaultCount: article.likes,
211
+ id: article.id,
212
+ api: likesApi,
213
+ }}
214
+ bookmarkId={article.id}
215
+ showComment={Boolean(article.isCommentable)}
216
+ showShare
217
+ coverPreloadUrl={article.cover ? resolveImageUrl(article.cover, 'large') : undefined}
218
+ />
219
+ </>
220
+ )
221
+ }
@@ -0,0 +1,67 @@
1
+ 'use client'
2
+
3
+ import Link from '@/components/shared/Link'
4
+ import { useLocale } from '@fecommunity/reactpress-toolkit/ui'
5
+ import { usePathname } from 'next/navigation'
6
+ import { useMemo } from 'react'
7
+
8
+ interface Category {
9
+ value: string
10
+ label: string
11
+ }
12
+
13
+ interface CategoryMenuProps {
14
+ categories: Category[]
15
+ }
16
+
17
+ function normalizePath(pathname: string | null) {
18
+ const raw = (pathname ?? '/').replace(/\/$/, '') || '/'
19
+ return raw
20
+ }
21
+
22
+ export default function CategoryMenu({ categories }: CategoryMenuProps) {
23
+ const { t } = useLocale()
24
+ const pathname = usePathname()
25
+ const normalizedPath = normalizePath(pathname)
26
+ const isHome = normalizedPath === '/'
27
+
28
+ const selectedKey = useMemo(() => {
29
+ if (isHome) return 'all'
30
+ const categorySlug = normalizedPath.replace(/^\/category\//, '')
31
+ const active = categories.find((category) => category.value === categorySlug)
32
+ return active?.value ?? ''
33
+ }, [normalizedPath, categories, isHome])
34
+
35
+ return (
36
+ <nav className="rp-category-menu-glass rounded-t-xl" aria-label={t('categoryTitle')}>
37
+ <ul className="m-0 flex [scrollbar-width:none] list-none flex-nowrap gap-0 overflow-x-auto border-b border-[color-mix(in_srgb,var(--border-color)_65%,transparent)] p-2 [-ms-overflow-style:none] md:flex-wrap [&::-webkit-scrollbar]:hidden">
38
+ <li className="shrink-0 md:shrink">
39
+ <Link
40
+ href="/"
41
+ className={`inline-block rounded-lg px-4 leading-10 whitespace-nowrap no-underline transition-all duration-300 ${
42
+ selectedKey === 'all'
43
+ ? 'bg-[color-mix(in_srgb,var(--primary-color)_12%,transparent)] font-medium text-[var(--primary-color)] dark:text-[#ff6659]'
44
+ : 'text-[var(--main-text-color)] hover:bg-[color-mix(in_srgb,var(--primary-color)_8%,transparent)] hover:text-[var(--primary-color)] dark:hover:text-[#ff6659]'
45
+ }`}
46
+ >
47
+ {t('all')}
48
+ </Link>
49
+ </li>
50
+ {categories.map((category) => (
51
+ <li key={category.value} className="shrink-0 md:shrink">
52
+ <Link
53
+ href={`/category/${category.value}`}
54
+ className={`inline-block rounded-lg px-4 leading-10 whitespace-nowrap no-underline transition-all duration-300 ${
55
+ selectedKey === category.value
56
+ ? 'bg-[color-mix(in_srgb,var(--primary-color)_12%,transparent)] font-medium text-[var(--primary-color)] dark:text-[#ff6659]'
57
+ : 'text-[var(--main-text-color)] hover:bg-[color-mix(in_srgb,var(--primary-color)_8%,transparent)] hover:text-[var(--primary-color)] dark:hover:text-[#ff6659]'
58
+ }`}
59
+ >
60
+ {category.label}
61
+ </Link>
62
+ </li>
63
+ ))}
64
+ </ul>
65
+ </nav>
66
+ )
67
+ }