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