@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,290 @@
1
+ 'use client'
2
+
3
+ import CommentEmoji from '@/components/comment/CommentEmoji'
4
+ import ThemeImage from '@/components/shared/ThemeImage'
5
+ import { CommentProvider } from '@/lib/providers/client'
6
+ import { getRandomColor, isLoggedInUser, type CommentNode } from '@/lib/utils/comment'
7
+ import { useLocale } from '@fecommunity/reactpress-toolkit/ui'
8
+ import {
9
+ getCommentEmailError,
10
+ isValidCommentEmail,
11
+ persistCommentAuthor,
12
+ readCommentAuthor,
13
+ resolveImageUrl,
14
+ useAsyncLoading,
15
+ useSiteUser,
16
+ type CommentAuthor,
17
+ } from '@fecommunity/reactpress-toolkit/theme'
18
+ import { useCallback, useEffect, useMemo, useState } from 'react'
19
+
20
+ interface CommentEditorProps {
21
+ hostId: string
22
+ parentComment?: CommentNode
23
+ replyComment?: CommentNode
24
+ small?: boolean
25
+ onOk?: () => void
26
+ onClose?: () => void
27
+ onSuccess?: (message: string) => void
28
+ }
29
+
30
+ function resolveInitialAuthor(user: ReturnType<typeof useSiteUser>['user']): CommentAuthor {
31
+ if (isLoggedInUser(user)) {
32
+ return { name: user.name, email: user.email ?? '' }
33
+ }
34
+ const saved = readCommentAuthor()
35
+ if (saved) return saved
36
+ return {
37
+ name: typeof user?.name === 'string' ? user.name : '',
38
+ email: typeof user?.email === 'string' ? user.email : '',
39
+ }
40
+ }
41
+
42
+ export default function CommentEditor({
43
+ hostId,
44
+ parentComment,
45
+ replyComment,
46
+ small = false,
47
+ onOk,
48
+ onClose,
49
+ onSuccess,
50
+ }: CommentEditorProps) {
51
+ const { t } = useLocale()
52
+ const { user } = useSiteUser()
53
+ const [addComment, loading] = useAsyncLoading(CommentProvider.addComment)
54
+ const [author, setAuthor] = useState<CommentAuthor>(() => resolveInitialAuthor(user))
55
+ const [content, setContent] = useState('')
56
+ const [emailTouched, setEmailTouched] = useState(false)
57
+
58
+ const loggedInAuthor = useMemo(() => (isLoggedInUser(user) ? user : null), [user])
59
+ const accountEmail = loggedInAuthor?.email?.trim() ?? ''
60
+ const nameReadOnly = Boolean(loggedInAuthor)
61
+ const emailReadOnly = Boolean(loggedInAuthor && accountEmail)
62
+
63
+ const effectiveAuthor = useMemo((): CommentAuthor => {
64
+ if (loggedInAuthor) {
65
+ return {
66
+ name: loggedInAuthor.name,
67
+ email: accountEmail || author.email,
68
+ }
69
+ }
70
+ return author
71
+ }, [accountEmail, author, loggedInAuthor])
72
+
73
+ useEffect(() => {
74
+ if (loggedInAuthor) {
75
+ setAuthor({
76
+ name: loggedInAuthor.name,
77
+ email: accountEmail,
78
+ })
79
+ return
80
+ }
81
+
82
+ const saved = readCommentAuthor()
83
+ if (saved) {
84
+ setAuthor(saved)
85
+ return
86
+ }
87
+
88
+ if (user?.name || user?.email) {
89
+ setAuthor((prev) => ({
90
+ name: user.name || prev.name,
91
+ email: user.email || prev.email,
92
+ }))
93
+ }
94
+ }, [accountEmail, loggedInAuthor, user?.email, user?.name])
95
+
96
+ const emailError = useMemo(
97
+ () =>
98
+ emailReadOnly
99
+ ? ''
100
+ : getCommentEmailError(
101
+ author.email,
102
+ {
103
+ required: t('commentNamespace.userInfoEmailValidMsg'),
104
+ invalid: t('commentNamespace.userInfoIllegalEmailValidMsg'),
105
+ },
106
+ { touched: emailTouched }
107
+ ),
108
+ [author.email, emailReadOnly, emailTouched, t]
109
+ )
110
+
111
+ const canSubmit = useMemo(() => {
112
+ if (!content.trim()) return false
113
+ return Boolean(effectiveAuthor.name.trim() && isValidCommentEmail(effectiveAuthor.email))
114
+ }, [content, effectiveAuthor.email, effectiveAuthor.name])
115
+
116
+ const submit = useCallback(() => {
117
+ const trimmedName = effectiveAuthor.name.trim()
118
+ const trimmedEmail = effectiveAuthor.email.trim()
119
+ const trimmedContent = content.trim()
120
+
121
+ if (!emailReadOnly) {
122
+ setEmailTouched(true)
123
+ }
124
+ if (!trimmedName || !isValidCommentEmail(trimmedEmail) || !trimmedContent) {
125
+ return
126
+ }
127
+
128
+ const data: Record<string, string> = {
129
+ hostId,
130
+ name: trimmedName,
131
+ email: trimmedEmail,
132
+ avatar: loggedInAuthor?.avatar || '',
133
+ content: trimmedContent,
134
+ url: window.location.pathname,
135
+ }
136
+
137
+ if (parentComment?.id) {
138
+ data.parentCommentId = parentComment.id
139
+ }
140
+ if (replyComment) {
141
+ data.replyUserName = replyComment.name
142
+ data.replyUserEmail = replyComment.email
143
+ }
144
+
145
+ addComment(data).then(() => {
146
+ if (!loggedInAuthor) {
147
+ persistCommentAuthor({ name: trimmedName, email: trimmedEmail })
148
+ }
149
+ setContent('')
150
+ onSuccess?.(t('commentNamespace.commentSuccess'))
151
+ onOk?.()
152
+ })
153
+ }, [
154
+ addComment,
155
+ content,
156
+ effectiveAuthor.email,
157
+ effectiveAuthor.name,
158
+ emailReadOnly,
159
+ hostId,
160
+ loggedInAuthor,
161
+ onOk,
162
+ onSuccess,
163
+ parentComment?.id,
164
+ replyComment,
165
+ t,
166
+ ])
167
+
168
+ const placeholder = replyComment
169
+ ? `${t('commentNamespace.reply')} ${replyComment.name}`
170
+ : t('commentNamespace.replyPlaceholder')
171
+
172
+ return (
173
+ <div className={`rp-comment-editor${small ? 'is-small' : ''}`}>
174
+ {loggedInAuthor ? (
175
+ <p className="rp-comment-lead">
176
+ {t('commentNamespace.commentingAs').replace('{name}', loggedInAuthor.name)}
177
+ </p>
178
+ ) : !small ? (
179
+ <p className="rp-comment-lead">{t('commentNamespace.guestCommentLead')}</p>
180
+ ) : null}
181
+
182
+ <div className="rp-comment-author-fields">
183
+ <input
184
+ value={effectiveAuthor.name}
185
+ placeholder={t('commentNamespace.userInfoName')}
186
+ readOnly={nameReadOnly}
187
+ disabled={nameReadOnly}
188
+ onChange={(event) => setAuthor((prev) => ({ ...prev, name: event.target.value }))}
189
+ className="rp-comment-input"
190
+ />
191
+ <div className="rp-comment-field">
192
+ <input
193
+ type="email"
194
+ value={effectiveAuthor.email}
195
+ placeholder={t('commentNamespace.userInfoEmail')}
196
+ readOnly={emailReadOnly}
197
+ disabled={emailReadOnly}
198
+ onBlur={() => setEmailTouched(true)}
199
+ onChange={(event) => setAuthor((prev) => ({ ...prev, email: event.target.value }))}
200
+ className={`rp-comment-input${emailError ? 'is-error' : ''}`}
201
+ />
202
+ {emailError ? <span className="rp-comment-field-error">{emailError}</span> : null}
203
+ </div>
204
+ </div>
205
+
206
+ <textarea
207
+ value={content}
208
+ placeholder={placeholder}
209
+ rows={small ? 3 : 4}
210
+ onChange={(event) => setContent(event.target.value)}
211
+ className="rp-comment-textarea"
212
+ />
213
+
214
+ <footer>
215
+ <CommentEmoji onPick={(emoji) => setContent((prev) => `${prev}${emoji}`)}>
216
+ <>
217
+ <svg
218
+ viewBox="0 0 24 24"
219
+ width="18"
220
+ height="18"
221
+ fill="none"
222
+ stroke="currentColor"
223
+ strokeWidth="1.75"
224
+ aria-hidden
225
+ >
226
+ <circle cx="12" cy="12" r="10" />
227
+ <path d="M8 14s1.5 2 4 2 4-2 4-2" strokeLinecap="round" />
228
+ <circle cx="9" cy="9.5" r="0.75" fill="currentColor" stroke="none" />
229
+ <circle cx="15" cy="9.5" r="0.75" fill="currentColor" stroke="none" />
230
+ </svg>
231
+ <span>{t('commentNamespace.emoji')}</span>
232
+ </>
233
+ </CommentEmoji>
234
+
235
+ <div className="rp-comment-actions">
236
+ {onClose ? (
237
+ <button type="button" className="rp-comment-btn" onClick={onClose}>
238
+ {t('commentNamespace.close')}
239
+ </button>
240
+ ) : null}
241
+ <button
242
+ type="button"
243
+ className="rp-comment-btn rp-comment-btn-primary"
244
+ disabled={!canSubmit || loading}
245
+ onClick={submit}
246
+ >
247
+ {loading ? t('loading') : t('commentNamespace.publish')}
248
+ </button>
249
+ </div>
250
+ </footer>
251
+ </div>
252
+ )
253
+ }
254
+
255
+ export function CommentAvatar({
256
+ name,
257
+ avatar,
258
+ size,
259
+ }: {
260
+ name: string
261
+ avatar?: string
262
+ size: number
263
+ }) {
264
+ if (avatar) {
265
+ return (
266
+ <ThemeImage
267
+ src={resolveImageUrl(avatar, 'avatar')}
268
+ alt=""
269
+ width={size}
270
+ height={size}
271
+ className="shrink-0 rounded-full object-cover"
272
+ style={{ width: size, height: size }}
273
+ />
274
+ )
275
+ }
276
+
277
+ return (
278
+ <span
279
+ className="inline-flex shrink-0 items-center justify-center rounded-full text-white"
280
+ style={{
281
+ width: size,
282
+ height: size,
283
+ fontSize: size * 0.45,
284
+ backgroundColor: getRandomColor(name),
285
+ }}
286
+ >
287
+ {name.charAt(0).toUpperCase()}
288
+ </span>
289
+ )
290
+ }
@@ -0,0 +1,110 @@
1
+ 'use client'
2
+
3
+ import { emojis } from '@/components/comment/emojis'
4
+ import { useEffect, useLayoutEffect, useRef, useState } from 'react'
5
+ import { createPortal } from 'react-dom'
6
+
7
+ const PANEL_WIDTH = 320
8
+ const PANEL_HEIGHT = 240
9
+ const PANEL_GAP = 8
10
+
11
+ interface CommentEmojiProps {
12
+ onPick: (emoji: string) => void
13
+ children: React.ReactNode
14
+ }
15
+
16
+ export default function CommentEmoji({ onPick, children }: CommentEmojiProps) {
17
+ const [open, setOpen] = useState(false)
18
+ const [mounted, setMounted] = useState(false)
19
+ const anchorRef = useRef<HTMLDivElement>(null)
20
+ const [panelStyle, setPanelStyle] = useState<React.CSSProperties>({
21
+ visibility: 'hidden',
22
+ })
23
+
24
+ useEffect(() => {
25
+ setMounted(true)
26
+ }, [])
27
+
28
+ useLayoutEffect(() => {
29
+ if (!open || !anchorRef.current) return
30
+
31
+ const updatePosition = () => {
32
+ const anchor = anchorRef.current
33
+ if (!anchor) return
34
+
35
+ const rect = anchor.getBoundingClientRect()
36
+ let left = rect.left
37
+ left = Math.max(PANEL_GAP, Math.min(left, window.innerWidth - PANEL_WIDTH - PANEL_GAP))
38
+
39
+ let top = rect.top - PANEL_HEIGHT - PANEL_GAP
40
+ if (top < PANEL_GAP) {
41
+ top = rect.bottom + PANEL_GAP
42
+ }
43
+
44
+ setPanelStyle({
45
+ position: 'fixed',
46
+ top,
47
+ left,
48
+ width: PANEL_WIDTH,
49
+ maxHeight: PANEL_HEIGHT,
50
+ zIndex: 9999,
51
+ visibility: 'visible',
52
+ })
53
+ }
54
+
55
+ updatePosition()
56
+ window.addEventListener('resize', updatePosition)
57
+ window.addEventListener('scroll', updatePosition, true)
58
+ return () => {
59
+ window.removeEventListener('resize', updatePosition)
60
+ window.removeEventListener('scroll', updatePosition, true)
61
+ }
62
+ }, [open])
63
+
64
+ useEffect(() => {
65
+ if (!open) return undefined
66
+
67
+ const close = (event: MouseEvent) => {
68
+ const target = event.target as Node
69
+ if (anchorRef.current?.contains(target)) return
70
+ if ((target as Element).closest?.('.rp-comment-emoji-panel')) return
71
+ setOpen(false)
72
+ }
73
+
74
+ document.addEventListener('mousedown', close)
75
+ return () => document.removeEventListener('mousedown', close)
76
+ }, [open])
77
+
78
+ const panel =
79
+ open && mounted ? (
80
+ <div className="rp-comment-emoji-panel is-portal" style={panelStyle} role="listbox">
81
+ {Object.entries(emojis).map(([name, emoji]) => (
82
+ <button
83
+ key={name}
84
+ type="button"
85
+ aria-label={emoji}
86
+ onClick={() => {
87
+ onPick(emoji)
88
+ setOpen(false)
89
+ }}
90
+ >
91
+ {emoji}
92
+ </button>
93
+ ))}
94
+ </div>
95
+ ) : null
96
+
97
+ return (
98
+ <div className="rp-comment-emoji-anchor" ref={anchorRef}>
99
+ <button
100
+ type="button"
101
+ className="rp-comment-emoji-trigger"
102
+ aria-expanded={open}
103
+ onClick={() => setOpen((value) => !value)}
104
+ >
105
+ {children}
106
+ </button>
107
+ {panel ? createPortal(panel, document.body) : null}
108
+ </div>
109
+ )
110
+ }
@@ -0,0 +1,37 @@
1
+ 'use client'
2
+
3
+ import ArticleActionButton, {
4
+ type ArticleActionVariant,
5
+ } from '@/components/widgets/ArticleActionButton'
6
+ import { COMMENT_DOM_ID } from '@/lib/utils/comment'
7
+ import { useLocale } from '@fecommunity/reactpress-toolkit/ui'
8
+
9
+ interface CommentIconButtonProps {
10
+ variant?: ArticleActionVariant
11
+ }
12
+
13
+ export default function CommentIconButton({ variant = 'floating' }: CommentIconButtonProps) {
14
+ const { t } = useLocale()
15
+
16
+ return (
17
+ <ArticleActionButton
18
+ label={t('comment')}
19
+ onClick={() => {
20
+ document.getElementById(COMMENT_DOM_ID)?.scrollIntoView({ behavior: 'smooth' })
21
+ }}
22
+ variant={variant}
23
+ >
24
+ <svg
25
+ viewBox="0 0 24 24"
26
+ width="20"
27
+ height="20"
28
+ fill="none"
29
+ stroke="currentColor"
30
+ strokeWidth="1.8"
31
+ aria-hidden
32
+ >
33
+ <path d="M21 15a4 4 0 0 1-4 4H8l-5 3V7a4 4 0 0 1 4-4h10a4 4 0 0 1 4 4z" />
34
+ </svg>
35
+ </ArticleActionButton>
36
+ )
37
+ }
@@ -0,0 +1,135 @@
1
+ 'use client'
2
+
3
+ import CommentEditor, { CommentAvatar } from '@/components/comment/CommentEditor'
4
+ import { type CommentNode } from '@/lib/utils/comment'
5
+ import { useLocale } from '@fecommunity/reactpress-toolkit/ui'
6
+ import { LocaleTime } from '@fecommunity/reactpress-toolkit/ui/content'
7
+ import { useToggle } from '@fecommunity/reactpress-toolkit/theme'
8
+
9
+ interface CommentItemProps {
10
+ comment: CommentNode
11
+ parentComment?: CommentNode
12
+ isChild?: boolean
13
+ isLast?: boolean
14
+ onSuccess?: (message: string) => void
15
+ }
16
+
17
+ export default function CommentItem({
18
+ comment,
19
+ parentComment,
20
+ isChild = false,
21
+ isLast = false,
22
+ onSuccess,
23
+ }: CommentItemProps) {
24
+ const { t, locale } = useLocale()
25
+ const [editorVisible, toggleEditorVisible] = useToggle(false)
26
+ const [showMore, toggleMore] = useToggle(false)
27
+ const avatarSize = isChild ? 20 : 28
28
+ const paddingHorizontal = avatarSize + 4
29
+
30
+ return (
31
+ <div
32
+ className={`rp-comment-item${isChild ? 'is-child' : 'is-parent'}${isChild && isLast ? 'is-last' : ''}`}
33
+ >
34
+ <div>
35
+ <header>
36
+ <CommentAvatar name={comment.name} avatar={comment.avatar} size={avatarSize} />
37
+ <span className="rp-comment-name">
38
+ <strong>{comment.name}</strong>
39
+ {comment.replyUserName ? (
40
+ <>
41
+ <span style={{ margin: '0 8px' }}>{t('commentNamespace.reply')}</span>
42
+ <strong>{comment.replyUserName}</strong>
43
+ </>
44
+ ) : null}
45
+ </span>
46
+ </header>
47
+
48
+ <main style={{ padding: `8px 0 8px ${paddingHorizontal}px` }}>
49
+ <div
50
+ className="rp-rich-text"
51
+ dangerouslySetInnerHTML={{ __html: comment.html || comment.content }}
52
+ />
53
+ </main>
54
+
55
+ <footer style={{ paddingLeft: `${paddingHorizontal}px` }}>
56
+ <div className="rp-comment-meta">
57
+ {comment.userAgent ? (
58
+ <span>
59
+ {comment.userAgent}
60
+ {' · '}
61
+ </span>
62
+ ) : null}
63
+ <LocaleTime date={comment.createAt} timeago locale={locale} />
64
+ <button
65
+ type="button"
66
+ className="rp-comment-reply-btn"
67
+ onClick={() => toggleEditorVisible()}
68
+ >
69
+ {t('commentNamespace.reply')}
70
+ </button>
71
+ </div>
72
+
73
+ {editorVisible ? (
74
+ <div className="rp-comment-reply-editor">
75
+ <CommentEditor
76
+ small
77
+ hostId={comment.hostId}
78
+ parentComment={parentComment}
79
+ replyComment={comment}
80
+ onOk={toggleEditorVisible}
81
+ onClose={toggleEditorVisible}
82
+ onSuccess={onSuccess}
83
+ />
84
+ </div>
85
+ ) : null}
86
+
87
+ {comment.children?.length ? (
88
+ <div className="rp-comment-children">
89
+ <CommentList
90
+ comments={comment.children.slice(0, showMore ? comment.children.length : 2)}
91
+ parentComment={comment}
92
+ isChild
93
+ onSuccess={onSuccess}
94
+ />
95
+ {comment.children.length > 2 ? (
96
+ <button type="button" className="rp-comment-show-more" onClick={() => toggleMore()}>
97
+ {showMore ? t('commentShowLess') : t('commentShowMore')}
98
+ </button>
99
+ ) : null}
100
+ </div>
101
+ ) : null}
102
+ </footer>
103
+ </div>
104
+ </div>
105
+ )
106
+ }
107
+
108
+ interface CommentListProps {
109
+ comments: CommentNode[]
110
+ parentComment?: CommentNode | null
111
+ isChild?: boolean
112
+ onSuccess?: (message: string) => void
113
+ }
114
+
115
+ export function CommentList({
116
+ comments,
117
+ parentComment = null,
118
+ isChild = false,
119
+ onSuccess,
120
+ }: CommentListProps) {
121
+ return (
122
+ <div>
123
+ {comments.map((comment, index) => (
124
+ <CommentItem
125
+ key={`${parentComment?.id || 'root'}-${comment.id}`}
126
+ comment={comment}
127
+ parentComment={parentComment || comment}
128
+ isChild={isChild}
129
+ isLast={index === comments.length - 1}
130
+ onSuccess={onSuccess}
131
+ />
132
+ ))}
133
+ </div>
134
+ )
135
+ }
@@ -0,0 +1,123 @@
1
+ 'use client'
2
+
3
+ import CommentEditor from '@/components/comment/CommentEditor'
4
+ import { CommentList } from '@/components/comment/CommentItem'
5
+ import { CommentProvider } from '@/lib/providers/client'
6
+ import { COMMENT_DOM_ID, type CommentNode } from '@/lib/utils/comment'
7
+ import { useLocale } from '@fecommunity/reactpress-toolkit/ui'
8
+ import { usePagination, useToggle } from '@fecommunity/reactpress-toolkit/theme'
9
+ import { useCallback, useEffect, useRef, useState } from 'react'
10
+
11
+ interface CommentSectionProps {
12
+ hostId: string
13
+ }
14
+
15
+ export default function CommentSection({ hostId }: CommentSectionProps) {
16
+ const { t } = useLocale()
17
+ const ref = useRef<HTMLDivElement>(null)
18
+ const [firstLoad, setFirstLoad] = useToggle(true)
19
+ const [toast, setToast] = useState('')
20
+ const toastTimerRef = useRef<number | null>(null)
21
+
22
+ const {
23
+ data: comments,
24
+ total,
25
+ loading,
26
+ page,
27
+ pageSize,
28
+ setPage,
29
+ refresh,
30
+ } = usePagination<CommentNode>((params) => CommentProvider.getArticleComments(hostId, params), {
31
+ pageSize: 6,
32
+ after: ({ page: currentPage }) => {
33
+ if (currentPage === 1 && firstLoad) {
34
+ setFirstLoad(false)
35
+ return
36
+ }
37
+ Promise.resolve().then(() => {
38
+ ref.current?.scrollIntoView({ behavior: 'smooth' })
39
+ })
40
+ },
41
+ })
42
+
43
+ const showToast = useCallback(
44
+ (message: string) => {
45
+ if (toastTimerRef.current) {
46
+ window.clearTimeout(toastTimerRef.current)
47
+ }
48
+ setToast(message)
49
+ refresh()
50
+ toastTimerRef.current = window.setTimeout(() => {
51
+ setToast('')
52
+ toastTimerRef.current = null
53
+ }, 2000)
54
+ },
55
+ [refresh]
56
+ )
57
+
58
+ useEffect(
59
+ () => () => {
60
+ if (toastTimerRef.current) {
61
+ window.clearTimeout(toastTimerRef.current)
62
+ }
63
+ },
64
+ []
65
+ )
66
+
67
+ const totalPages = Math.max(1, Math.ceil(total / pageSize))
68
+
69
+ return (
70
+ <div id={COMMENT_DOM_ID} ref={ref} className="rp-comment-root">
71
+ <div className="rp-comment-editor-wrap">
72
+ <CommentEditor hostId={hostId} onSuccess={showToast} />
73
+ </div>
74
+
75
+ <CommentList comments={comments} onSuccess={showToast} />
76
+
77
+ <div className="rp-comment-pagination">
78
+ {!loading && total > pageSize ? (
79
+ <div className="rp-comment-pagination-inner">
80
+ <button
81
+ type="button"
82
+ className="rp-comment-pagination-btn"
83
+ disabled={page <= 1}
84
+ onClick={() => setPage(page - 1)}
85
+ aria-label="Previous page"
86
+ >
87
+ ‹
88
+ </button>
89
+ {Array.from({ length: totalPages }, (_, index) => index + 1).map((pageNumber) => (
90
+ <button
91
+ key={pageNumber}
92
+ type="button"
93
+ className={`rp-comment-pagination-btn${pageNumber === page ? 'is-active' : ''}`}
94
+ onClick={() => setPage(pageNumber)}
95
+ >
96
+ {pageNumber}
97
+ </button>
98
+ ))}
99
+ <button
100
+ type="button"
101
+ className="rp-comment-pagination-btn"
102
+ disabled={page >= totalPages}
103
+ onClick={() => setPage(page + 1)}
104
+ aria-label="Next page"
105
+ >
106
+ ›
107
+ </button>
108
+ </div>
109
+ ) : loading ? (
110
+ <button type="button" className="rp-comment-btn rp-comment-btn-primary" disabled>
111
+ {t('loading')}
112
+ </button>
113
+ ) : null}
114
+ </div>
115
+
116
+ {toast ? (
117
+ <div className="rp-comment-toast" role="status" aria-live="polite">
118
+ {toast}
119
+ </div>
120
+ ) : null}
121
+ </div>
122
+ )
123
+ }