@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,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
|
+
}
|