@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,285 @@
1
+ 'use client'
2
+
3
+ import Link from '@/components/shared/Link'
4
+ import ThemeImage from '@/components/shared/ThemeImage'
5
+ import { getNavIconByName } from '@/lib/utils/icons'
6
+ import { useLocale } from '@fecommunity/reactpress-toolkit/ui'
7
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
8
+
9
+ interface NavChild {
10
+ key: string
11
+ label: string
12
+ description?: string
13
+ url?: string
14
+ icon?: string
15
+ }
16
+
17
+ interface NavGroup {
18
+ key: string
19
+ label: string
20
+ icon?: string
21
+ children?: NavChild[]
22
+ }
23
+
24
+ interface SearchQuickLinksProps {
25
+ dataSource?: NavGroup[]
26
+ }
27
+
28
+ function getHostname(url?: string) {
29
+ if (!url?.trim()) return ''
30
+ try {
31
+ return new URL(url.trim()).hostname
32
+ } catch {
33
+ return ''
34
+ }
35
+ }
36
+
37
+ function getSiteFaviconUrl(url?: string) {
38
+ if (!url?.trim()) return ''
39
+ try {
40
+ return `${new URL(url.trim()).origin}/favicon.ico`
41
+ } catch {
42
+ return ''
43
+ }
44
+ }
45
+
46
+ function getGoogleFaviconUrl(url?: string) {
47
+ const host = getHostname(url)
48
+ if (!host) return ''
49
+ return `https://www.google.com/s2/favicons?domain=${encodeURIComponent(host)}&sz=64`
50
+ }
51
+
52
+ function getIconSources(item: { icon?: string; url?: string }) {
53
+ if (item?.icon?.trim()) {
54
+ return { primary: item.icon.trim(), fallback: '' }
55
+ }
56
+ return {
57
+ primary: getSiteFaviconUrl(item?.url),
58
+ fallback: getGoogleFaviconUrl(item?.url),
59
+ }
60
+ }
61
+
62
+ function getInitial(label: string) {
63
+ const trimmed = label.trim()
64
+ if (!trimmed) return '?'
65
+ return trimmed.charAt(0).toUpperCase()
66
+ }
67
+
68
+ function plainDescription(value?: string) {
69
+ if (!value?.trim()) return ''
70
+ return value
71
+ .replace(/<[^>]+>/g, ' ')
72
+ .replace(/\s+/g, ' ')
73
+ .trim()
74
+ }
75
+
76
+ function sectionId(key: string) {
77
+ return `rp-nav-portal-${key}`
78
+ }
79
+
80
+ function getScrollOffset(tabsHeight: number) {
81
+ const headerOffset = parseFloat(
82
+ getComputedStyle(document.documentElement).getPropertyValue('--rp-header-offset') || '0'
83
+ )
84
+ return headerOffset + tabsHeight + 12
85
+ }
86
+
87
+ function SiteIcon({ child }: { child: NavChild }) {
88
+ const { primary, fallback } = getIconSources(child)
89
+ const [src, setSrc] = useState(primary)
90
+ const [loaded, setLoaded] = useState(false)
91
+ const [failed, setFailed] = useState(false)
92
+ const initial = getInitial(child.label)
93
+ const showFallback = failed || !loaded
94
+
95
+ useEffect(() => {
96
+ setSrc(primary)
97
+ setLoaded(false)
98
+ setFailed(false)
99
+ }, [primary, child.key])
100
+
101
+ const handleError = () => {
102
+ setLoaded(false)
103
+ if (fallback && src !== fallback) {
104
+ setSrc(fallback)
105
+ return
106
+ }
107
+ setFailed(true)
108
+ }
109
+
110
+ return (
111
+ <span className="rp-nav-portal__site-icon-stack" aria-hidden>
112
+ {showFallback ? <span className="rp-nav-portal__site-fallback">{initial}</span> : null}
113
+ {src && !failed ? (
114
+ <ThemeImage
115
+ key={src}
116
+ src={src}
117
+ alt=""
118
+ width={34}
119
+ height={34}
120
+ loading="lazy"
121
+ className={`rp-nav-portal__site-img${loaded ? 'is-visible' : ''}`}
122
+ onLoad={() => setLoaded(true)}
123
+ onLoadingComplete={() => setLoaded(true)}
124
+ onError={handleError}
125
+ />
126
+ ) : null}
127
+ </span>
128
+ )
129
+ }
130
+
131
+ function SiteTile({ child }: { child: NavChild }) {
132
+ const tip = plainDescription(child.description) || child.label
133
+
134
+ return (
135
+ <li className="rp-nav-portal__site-item">
136
+ <Link href={`/nav/${child.key}/`} title={tip} className="rp-nav-portal__site">
137
+ <span className="rp-nav-portal__site-icon">
138
+ <SiteIcon child={child} />
139
+ </span>
140
+ <span className="rp-nav-portal__site-name">{child.label}</span>
141
+ </Link>
142
+ </li>
143
+ )
144
+ }
145
+
146
+ export default function SearchQuickLinks({ dataSource = [] }: SearchQuickLinksProps) {
147
+ const { t } = useLocale()
148
+ const groups = useMemo(
149
+ () => dataSource.filter((group) => (group.children?.length ?? 0) > 0),
150
+ [dataSource]
151
+ )
152
+ const [activeKey, setActiveKey] = useState(groups[0]?.key ?? '')
153
+ const [flashKey, setFlashKey] = useState<string | null>(null)
154
+ const scrollingRef = useRef(false)
155
+ const tabsRef = useRef<HTMLDivElement>(null)
156
+ const tabRefs = useRef<Record<string, HTMLButtonElement | null>>({})
157
+
158
+ const scrollToGroup = useCallback((key: string) => {
159
+ const section = document.getElementById(sectionId(key))
160
+ if (!section) return
161
+
162
+ setActiveKey(key)
163
+ setFlashKey(key)
164
+ scrollingRef.current = true
165
+
166
+ const tabsHeight = tabsRef.current?.offsetHeight ?? 0
167
+ const top = section.getBoundingClientRect().top + window.scrollY - getScrollOffset(tabsHeight)
168
+ window.scrollTo({ top: Math.max(0, top), behavior: 'smooth' })
169
+
170
+ tabRefs.current[key]?.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' })
171
+
172
+ window.setTimeout(() => {
173
+ scrollingRef.current = false
174
+ }, 700)
175
+ window.setTimeout(() => {
176
+ setFlashKey((current) => (current === key ? null : current))
177
+ }, 900)
178
+ }, [])
179
+
180
+ useEffect(() => {
181
+ if (groups.length <= 1) return undefined
182
+
183
+ const sections = groups
184
+ .map((group) => document.getElementById(sectionId(group.key)))
185
+ .filter((node): node is HTMLElement => Boolean(node))
186
+
187
+ if (!sections.length) return undefined
188
+
189
+ const observer = new IntersectionObserver(
190
+ (entries) => {
191
+ if (scrollingRef.current) return
192
+ const visible = entries
193
+ .filter((entry) => entry.isIntersecting)
194
+ .sort((a, b) => b.intersectionRatio - a.intersectionRatio)[0]
195
+ if (!visible?.target?.id) return
196
+ const key = visible.target.id.replace('rp-nav-portal-', '')
197
+ setActiveKey(key)
198
+ tabRefs.current[key]?.scrollIntoView({
199
+ behavior: 'smooth',
200
+ block: 'nearest',
201
+ inline: 'center',
202
+ })
203
+ },
204
+ {
205
+ rootMargin: '-18% 0px -52% 0px',
206
+ threshold: [0, 0.2, 0.45],
207
+ }
208
+ )
209
+
210
+ sections.forEach((section) => observer.observe(section))
211
+ return () => observer.disconnect()
212
+ }, [groups])
213
+
214
+ if (!groups.length) return null
215
+
216
+ const showTabs = groups.length > 1
217
+
218
+ return (
219
+ <section className="rp-nav-portal rp-page-enter-delayed" aria-label={t('nav')}>
220
+ <div className="rp-nav-portal__card">
221
+ {showTabs ? (
222
+ <div ref={tabsRef} className="rp-nav-portal__tabs-wrap">
223
+ <nav className="rp-nav-portal__tabs-track" aria-label={t('nav')}>
224
+ {groups.map((group) => {
225
+ const Icon = getNavIconByName(group.icon)
226
+ const isActive = activeKey === group.key
227
+ return (
228
+ <button
229
+ key={group.key}
230
+ ref={(node) => {
231
+ tabRefs.current[group.key] = node
232
+ }}
233
+ type="button"
234
+ aria-current={isActive ? 'true' : undefined}
235
+ onClick={() => scrollToGroup(group.key)}
236
+ className={`rp-nav-portal__tab ${isActive ? 'is-active' : ''}`}
237
+ >
238
+ <Icon size={14} className="rp-nav-portal__tab-icon" />
239
+ <span className="rp-nav-portal__tab-label">{group.label}</span>
240
+ </button>
241
+ )
242
+ })}
243
+ </nav>
244
+ </div>
245
+ ) : null}
246
+
247
+ <div className="rp-nav-portal__body">
248
+ {groups.map((group, index) => {
249
+ const Icon = getNavIconByName(group.icon)
250
+ const isFlashing = flashKey === group.key
251
+ return (
252
+ <section
253
+ key={group.key}
254
+ id={sectionId(group.key)}
255
+ className={[
256
+ 'rp-nav-portal__section',
257
+ index > 0 ? 'rp-nav-portal__section--gap' : '',
258
+ activeKey === group.key && showTabs ? 'is-active' : '',
259
+ isFlashing ? 'is-flash' : '',
260
+ ]
261
+ .filter(Boolean)
262
+ .join(' ')}
263
+ >
264
+ <header
265
+ className={`rp-nav-portal__section-head${showTabs ? 'rp-nav-portal__section-head--compact' : ''}`}
266
+ >
267
+ <Icon size={16} className="rp-nav-portal__section-icon" />
268
+ <h3 className="rp-nav-portal__section-title">{group.label}</h3>
269
+ <span className="rp-nav-portal__section-count">
270
+ {(group.children ?? []).length}
271
+ </span>
272
+ </header>
273
+ <ul className="rp-nav-portal__grid">
274
+ {(group.children ?? []).map((child, childIndex) => (
275
+ <SiteTile key={child.key ?? childIndex} child={child} />
276
+ ))}
277
+ </ul>
278
+ </section>
279
+ )
280
+ })}
281
+ </div>
282
+ </div>
283
+ </section>
284
+ )
285
+ }
@@ -0,0 +1,8 @@
1
+ 'use client'
2
+
3
+ import { DevChunkRecovery as ToolkitDevChunkRecovery } from '@fecommunity/reactpress-toolkit/app'
4
+
5
+ /** Dev-only: auto-reload when on-demand route chunks are stale after compilation. */
6
+ export default function DevChunkRecovery() {
7
+ return <ToolkitDevChunkRecovery />
8
+ }
@@ -0,0 +1,30 @@
1
+ /* eslint-disable jsx-a11y/anchor-has-content */
2
+ import NextLink from 'next/link'
3
+ import type { AnchorHTMLAttributes } from 'react'
4
+
5
+ type LinkProps = AnchorHTMLAttributes<HTMLAnchorElement> & {
6
+ href?: string
7
+ }
8
+
9
+ const CustomLink = ({ href, ...rest }: LinkProps) => {
10
+ if (!href) {
11
+ return <a className="break-words" {...rest} />
12
+ }
13
+
14
+ const isInternalLink = href.startsWith('/')
15
+ const isAnchorLink = href.startsWith('#')
16
+
17
+ if (isInternalLink) {
18
+ return <NextLink href={href} className="break-words" {...rest} />
19
+ }
20
+
21
+ if (isAnchorLink) {
22
+ return <a className="break-words" href={href} {...rest} />
23
+ }
24
+
25
+ return (
26
+ <a className="break-words" target="_blank" rel="noopener noreferrer" href={href} {...rest} />
27
+ )
28
+ }
29
+
30
+ export default CustomLink
@@ -0,0 +1,38 @@
1
+ 'use client'
2
+
3
+ import type { ReactNode } from 'react'
4
+ import { useLocale } from '@fecommunity/reactpress-toolkit/ui'
5
+
6
+ interface SectionHeadingProps {
7
+ children: ReactNode
8
+ className?: string
9
+ }
10
+
11
+ export default function SectionHeading({ children, className = '' }: SectionHeadingProps) {
12
+ return (
13
+ <div className={`rp-section-heading ${className}`.trim()}>
14
+ <h2 className="rp-section-heading__title">{children}</h2>
15
+ </div>
16
+ )
17
+ }
18
+
19
+ export function EmptyState({ message }: { message?: string }) {
20
+ const { t } = useLocale()
21
+ return (
22
+ <div className="rp-empty-state" role="status">
23
+ <svg
24
+ className="rp-empty-state__icon"
25
+ viewBox="0 0 24 24"
26
+ width="32"
27
+ height="32"
28
+ fill="none"
29
+ stroke="currentColor"
30
+ strokeWidth="1.5"
31
+ aria-hidden
32
+ >
33
+ <path d="M9 12h6M12 9v6M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
34
+ </svg>
35
+ <p className="rp-empty-state__text">{message ?? t('empty')}</p>
36
+ </div>
37
+ )
38
+ }
@@ -0,0 +1,13 @@
1
+ 'use client'
2
+
3
+ import Image, { type ImageProps } from 'next/image'
4
+ import { forwardRef } from 'react'
5
+
6
+ const ThemeImage = forwardRef<HTMLImageElement, ImageProps>(function ThemeImage(
7
+ { alt = '', ...props },
8
+ ref
9
+ ) {
10
+ return <Image ref={ref} alt={alt} {...props} />
11
+ })
12
+
13
+ export default ThemeImage
@@ -0,0 +1,7 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 112 102" fill="none" role="img" aria-label="ReactPress">
2
+ <title>ReactPress</title>
3
+ <g><path fill="none" stroke="#087ea4" stroke-width="5.333" stroke-linecap="round" d="M56 75.165c29.455 0 53.333-10.745 53.333-24s-23.878-24-53.333-24-53.334 10.745-53.334 24 23.879 24 53.334 24Z"/>
4
+ <path fill="none" stroke="#087ea4" stroke-width="5.333" stroke-linecap="round" d="M35.215 63.165c14.728 25.509 35.972 40.815 47.451 34.188 11.48-6.628 8.846-32.68-5.882-58.188-14.727-25.51-35.972-40.816-47.45-34.188-11.48 6.627-8.846 32.679 5.881 58.188Z"/>
5
+ <path fill="none" stroke="#087ea4" stroke-width="5.333" stroke-linecap="round" d="M35.215 39.165c-14.727 25.509-17.36 51.56-5.882 58.188 11.48 6.627 32.724-8.68 47.451-34.188 14.728-25.51 17.362-51.56 5.883-58.188-11.48-6.628-32.724 8.679-47.452 34.188Z"/></g>
6
+ <text x="56" y="52" text-anchor="middle" dominant-baseline="central" fill="#087ea4" font-family="Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif" font-size="33" font-weight="600" letter-spacing="-0.03em">P</text>
7
+ </svg>
@@ -0,0 +1,137 @@
1
+ 'use client'
2
+
3
+ import DoubleColumnLayout from '@/components/layout/DoubleColumnLayout'
4
+ import HomeSidebar from '@/components/widgets/HomeSidebar'
5
+ import Link from '@/components/shared/Link'
6
+ import { FEED_PAGE_SIZE } from '@/lib/reactpress/feedFooterPlacement'
7
+ import { useFeedFooterPlacement } from '@/lib/reactpress/useFeedFooterPlacement'
8
+ import { useLocale } from '@fecommunity/reactpress-toolkit/ui'
9
+ import {
10
+ countArchiveArticles,
11
+ formatArchiveDay,
12
+ sortedArchiveYears,
13
+ type ArchiveArticle,
14
+ type ArchiveTree,
15
+ } from '@fecommunity/reactpress-toolkit/theme'
16
+ import { useMemo, useState } from 'react'
17
+
18
+ const INITIAL_OPEN_YEARS = 2
19
+
20
+ interface ArchivesClientProps {
21
+ articles: ArchiveTree
22
+ }
23
+
24
+ function ArchiveMonthList({ month, articles }: { month: string; articles: ArchiveArticle[] }) {
25
+ return (
26
+ <div className="px-4 py-2">
27
+ <h3 className="m-0 text-base text-[var(--second-text-color)]">{month}</h3>
28
+ <ul className="m-0 list-none pl-10">
29
+ {articles.map((article) => (
30
+ <li key={article.id} className="relative py-2 hover:text-[var(--primary-color)]">
31
+ <span
32
+ className="absolute top-[17px] -left-4 h-1.5 w-1.5 rounded-full bg-[var(--disable-text-color,#999)]"
33
+ aria-hidden
34
+ />
35
+ <Link
36
+ href={`/article/${article.id}`}
37
+ aria-label={article.title}
38
+ className="inline-flex w-full gap-3 overflow-hidden text-ellipsis whitespace-nowrap no-underline hover:text-inherit"
39
+ >
40
+ <span className="shrink-0 text-sm text-[var(--second-text-color)]">
41
+ {formatArchiveDay(article.publishAt)}
42
+ </span>
43
+ <span className="truncate font-semibold text-[var(--main-text-color)]">
44
+ {article.title}
45
+ </span>
46
+ </Link>
47
+ </li>
48
+ ))}
49
+ </ul>
50
+ </div>
51
+ )
52
+ }
53
+
54
+ export default function ArchivesClient({ articles }: ArchivesClientProps) {
55
+ const { t } = useLocale()
56
+ const years = useMemo(() => sortedArchiveYears(articles), [articles])
57
+ const total = useMemo(() => countArchiveArticles(articles), [articles])
58
+ const { footerInSidebar, footerAtBottom } = useFeedFooterPlacement({
59
+ itemCount: total,
60
+ pageSize: FEED_PAGE_SIZE,
61
+ })
62
+ const [openYears, setOpenYears] = useState<Set<string>>(
63
+ () => new Set(years.slice(0, INITIAL_OPEN_YEARS))
64
+ )
65
+
66
+ const toggleYear = (year: string) => {
67
+ setOpenYears((prev) => {
68
+ const next = new Set(prev)
69
+ if (next.has(year)) next.delete(year)
70
+ else next.add(year)
71
+ return next
72
+ })
73
+ }
74
+
75
+ return (
76
+ <DoubleColumnLayout
77
+ fillMinHeight={footerAtBottom}
78
+ leftNode={
79
+ <div className="overflow-hidden rounded-lg bg-[var(--bg-box)] shadow-[var(--box-shadow)]">
80
+ <div className="border-b border-[var(--border-color)] px-4 py-6 text-center">
81
+ <p className="m-0 text-2xl font-medium text-[var(--primary-color)]">{t('archives')}</p>
82
+ <p className="mt-2 mb-0 text-lg text-[var(--primary-color)]">
83
+ {t('total')} <span>{total}</span> {t('piece')}
84
+ </p>
85
+ </div>
86
+ {years.map((year) => {
87
+ const isOpen = openYears.has(year)
88
+ const monthKeys = Object.keys(articles[year])
89
+ return (
90
+ <section key={year} className="px-5 py-4">
91
+ <button
92
+ type="button"
93
+ className="flex w-full cursor-pointer items-center justify-between border-0 bg-transparent p-0 text-left"
94
+ aria-expanded={isOpen}
95
+ onClick={() => toggleYear(year)}
96
+ >
97
+ <h2 className="m-0 text-2xl text-[var(--main-text-color)]">{year}</h2>
98
+ <span className="text-xl text-[var(--second-text-color)]">
99
+ {isOpen ? '−' : '+'}
100
+ </span>
101
+ </button>
102
+ {isOpen
103
+ ? monthKeys.map((month) => (
104
+ <ArchiveMonthList
105
+ key={`${year}-${month}`}
106
+ month={month}
107
+ articles={articles[year][month]}
108
+ />
109
+ ))
110
+ : null}
111
+ </section>
112
+ )
113
+ })}
114
+ {openYears.size < years.length ? (
115
+ <div className="px-5 pb-6 text-center">
116
+ <button
117
+ type="button"
118
+ onClick={() => setOpenYears(new Set(years))}
119
+ className="cursor-pointer rounded-lg border border-[var(--border-color)] bg-[var(--bg)] px-4 py-2 text-sm text-[var(--main-text-color)] hover:border-[var(--primary-color)] hover:text-[var(--primary-color)]"
120
+ >
121
+ {t('all')} {t('archives')}
122
+ </button>
123
+ </div>
124
+ ) : null}
125
+ </div>
126
+ }
127
+ rightNode={
128
+ <HomeSidebar
129
+ showTags={false}
130
+ showCategories
131
+ showAboutUs={footerInSidebar}
132
+ deferRecommend={false}
133
+ />
134
+ }
135
+ />
136
+ )
137
+ }
@@ -0,0 +1,87 @@
1
+ 'use client'
2
+
3
+ import ArchiveBanner from '@/components/article/ArchiveBanner'
4
+ import ArticleFeedSection from '@/components/article/ArticleFeedSection'
5
+ import ArticleList from '@/components/article/ArticleList'
6
+ import DoubleColumnLayout from '@/components/layout/DoubleColumnLayout'
7
+ import HomeSidebar from '@/components/widgets/HomeSidebar'
8
+ import { FEED_PAGE_SIZE } from '@/lib/reactpress/feedFooterPlacement'
9
+ import { useFeedFooterPlacement } from '@/lib/reactpress/useFeedFooterPlacement'
10
+ import { ArticleProvider } from '@/lib/providers/client'
11
+ import { getArchiveBannerImage } from '@/lib/utils/archiveBanner'
12
+ import { useLocale } from '@fecommunity/reactpress-toolkit/ui'
13
+ import { slimArticlesForList, type ListArticle } from '@fecommunity/reactpress-toolkit/theme'
14
+ import { useCallback, useEffect, useMemo, useState } from 'react'
15
+
16
+ interface CategoryClientProps {
17
+ initialArticles: ListArticle[]
18
+ total: number
19
+ category: { value: string; label: string }
20
+ }
21
+
22
+ const pageSize = FEED_PAGE_SIZE
23
+
24
+ export default function CategoryClient({
25
+ initialArticles = [],
26
+ total = 0,
27
+ category,
28
+ }: CategoryClientProps) {
29
+ const { t } = useLocale()
30
+ const [page, setPage] = useState(1)
31
+ const [articles, setArticles] = useState<ListArticle[]>(initialArticles)
32
+ const banner = useMemo(() => getArchiveBannerImage(articles), [articles])
33
+ const hasMore = page * pageSize < total
34
+ const { footerInSidebar, footerAtBottom } = useFeedFooterPlacement({
35
+ hasMore,
36
+ itemCount: articles.length,
37
+ pageSize,
38
+ })
39
+
40
+ useEffect(() => {
41
+ setArticles(initialArticles)
42
+ setPage(1)
43
+ }, [initialArticles])
44
+
45
+ const getArticles = useCallback(
46
+ (nextPage: number) => {
47
+ return ArticleProvider.getArticlesByCategory(category.value, {
48
+ page: nextPage,
49
+ pageSize,
50
+ status: 'publish',
51
+ }).then((res) => {
52
+ setPage(nextPage)
53
+ setArticles((prev) => [...prev, ...slimArticlesForList(res[0])])
54
+ })
55
+ },
56
+ [category.value]
57
+ )
58
+
59
+ return (
60
+ <DoubleColumnLayout
61
+ fillMinHeight={footerAtBottom}
62
+ leftNode={
63
+ <>
64
+ <ArchiveBanner
65
+ className="mb-4"
66
+ imageUrl={banner.url}
67
+ isBrandFallback={banner.isBrandFallback}
68
+ title={
69
+ <>
70
+ <span>{category.label}</span> {t('categoryArticle')}
71
+ </>
72
+ }
73
+ subtitle={
74
+ <>
75
+ {t('totalSearch')} <span>{total}</span> {t('piece')}
76
+ </>
77
+ }
78
+ />
79
+ <ArticleFeedSection pageStart={1} loadMore={getArticles} hasMore={hasMore}>
80
+ <ArticleList articles={articles} />
81
+ </ArticleFeedSection>
82
+ </>
83
+ }
84
+ rightNode={<HomeSidebar showAboutUs={footerInSidebar} />}
85
+ />
86
+ )
87
+ }
@@ -0,0 +1,79 @@
1
+ 'use client'
2
+
3
+ import ArticleCarousel from '@/components/article/ArticleCarousel'
4
+ import ArticleFeedSection from '@/components/article/ArticleFeedSection'
5
+ import ArticleList from '@/components/article/ArticleList'
6
+ import DoubleColumnLayout from '@/components/layout/DoubleColumnLayout'
7
+ import HomeSidebar from '@/components/widgets/HomeSidebar'
8
+ import { FEED_PAGE_SIZE } from '@/lib/reactpress/feedFooterPlacement'
9
+ import { useFeedFooterPlacement } from '@/lib/reactpress/useFeedFooterPlacement'
10
+ import { ArticleProvider } from '@/lib/providers/client'
11
+ import {
12
+ slimArticlesForList,
13
+ type CarouselArticle,
14
+ type ListArticle,
15
+ } from '@fecommunity/reactpress-toolkit/theme'
16
+ import { useCallback, useEffect, useState } from 'react'
17
+
18
+ interface HomeClientProps {
19
+ initialArticles: ListArticle[]
20
+ total: number
21
+ recommendedArticles: CarouselArticle[]
22
+ }
23
+
24
+ const pageSize = FEED_PAGE_SIZE
25
+
26
+ export default function HomeClient({
27
+ initialArticles = [],
28
+ recommendedArticles = [],
29
+ total = 0,
30
+ }: HomeClientProps) {
31
+ const [page, setPage] = useState(1)
32
+ const [articles, setArticles] = useState<ListArticle[]>(initialArticles)
33
+ const hasMore = page * pageSize < total
34
+ const { footerInSidebar, footerAtBottom } = useFeedFooterPlacement({
35
+ hasMore,
36
+ itemCount: articles.length,
37
+ pageSize,
38
+ })
39
+
40
+ useEffect(() => {
41
+ setArticles(initialArticles)
42
+ setPage(1)
43
+ }, [initialArticles])
44
+
45
+ const getArticles = useCallback((nextPage: number) => {
46
+ return ArticleProvider.getArticles({
47
+ page: nextPage,
48
+ pageSize,
49
+ status: 'publish',
50
+ }).then((res) => {
51
+ setPage(nextPage)
52
+ setArticles((prev) => [...prev, ...slimArticlesForList(res[0])])
53
+ })
54
+ }, [])
55
+
56
+ const hasCarousel = recommendedArticles?.some((article) => article.cover)
57
+
58
+ return (
59
+ <div className="flex flex-1 flex-col">
60
+ <DoubleColumnLayout
61
+ className="flex-1"
62
+ fillMinHeight={footerAtBottom}
63
+ leftNode={
64
+ <>
65
+ {hasCarousel ? (
66
+ <div className="mb-5">
67
+ <ArticleCarousel articles={recommendedArticles} />
68
+ </div>
69
+ ) : null}
70
+ <ArticleFeedSection pageStart={1} loadMore={getArticles} hasMore={hasMore}>
71
+ <ArticleList articles={articles} />
72
+ </ArticleFeedSection>
73
+ </>
74
+ }
75
+ rightNode={<HomeSidebar showAboutUs={footerInSidebar} />}
76
+ />
77
+ </div>
78
+ )
79
+ }