@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,47 @@
1
+ import CmsPageClient from '@/components/article/CmsPageClient'
2
+ import { buildCmsPageMetadata, parseSiteSeoContext } from '@/lib/reactpress/contentSeo'
3
+ import { coercePublishedPage } from '@/lib/reactpress/coercePublishedPage'
4
+ import { buildListPageMetadata } from '@/lib/reactpress/siteMetadata'
5
+ import { tServer } from '@/lib/reactpress/serverLocale'
6
+ import {
7
+ fetchCmsPageProps,
8
+ fetchSiteMeta,
9
+ themeApi,
10
+ unwrapSetting,
11
+ withApiRetry,
12
+ } from '@fecommunity/reactpress-toolkit/theme/server'
13
+ import type { Metadata } from 'next'
14
+ import { notFound } from 'next/navigation'
15
+
16
+ export const revalidate = 60
17
+
18
+ export async function generateMetadata(): Promise<Metadata> {
19
+ try {
20
+ const [{ page: rawPage }, siteMeta, settingRow] = await Promise.all([
21
+ withApiRetry(() => fetchCmsPageProps(themeApi, 'suggestions')),
22
+ withApiRetry(() => fetchSiteMeta(themeApi)),
23
+ withApiRetry(() => themeApi.setting.findAll()).then(unwrapSetting),
24
+ ])
25
+ const page = coercePublishedPage(rawPage)
26
+ if (page) {
27
+ return buildCmsPageMetadata(page, parseSiteSeoContext(settingRow, siteMeta))
28
+ }
29
+ } catch {
30
+ // fall through
31
+ }
32
+ return buildListPageMetadata(await tServer('suggestions'), undefined, '/suggestions')
33
+ }
34
+
35
+ export default async function SuggestionsPage() {
36
+ try {
37
+ const { page: rawPage } = await withApiRetry(() => fetchCmsPageProps(themeApi, 'suggestions'))
38
+ const page = coercePublishedPage(rawPage)
39
+ if (!page) {
40
+ notFound()
41
+ }
42
+ return <CmsPageClient page={page} />
43
+ } catch (error) {
44
+ console.error('[reactpress-theme-starter] suggestions page fetch failed', error)
45
+ notFound()
46
+ }
47
+ }
@@ -0,0 +1,58 @@
1
+ import TagArchiveClient from '@/components/views/TagArchiveClient'
2
+ import { buildLocalizedListPageMetadata } from '@/lib/reactpress/siteMetadata'
3
+ import { generateTagStaticParams } from '@/lib/reactpress/staticParams'
4
+ import {
5
+ fetchTagArchivePageProps,
6
+ themeApi,
7
+ withApiRetry,
8
+ } from '@fecommunity/reactpress-toolkit/theme/server'
9
+ import type { Metadata } from 'next'
10
+ import { notFound } from 'next/navigation'
11
+
12
+ export const revalidate = 60
13
+
14
+ export async function generateStaticParams() {
15
+ return generateTagStaticParams()
16
+ }
17
+
18
+ interface PageProps {
19
+ params: Promise<{ tag: string }>
20
+ }
21
+
22
+ export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
23
+ const { tag } = await params
24
+ const tagValue = decodeURIComponent(tag)
25
+
26
+ try {
27
+ const data = await withApiRetry(() => fetchTagArchivePageProps(themeApi, tagValue))
28
+ const label = data.tag?.label || tagValue
29
+ return buildLocalizedListPageMetadata(
30
+ 'pageTitleTag',
31
+ { label },
32
+ {
33
+ path: `/tag/${encodeURIComponent(tagValue)}`,
34
+ }
35
+ )
36
+ } catch {
37
+ return buildLocalizedListPageMetadata(
38
+ 'pageTitleTag',
39
+ { label: tagValue },
40
+ {
41
+ path: `/tag/${encodeURIComponent(tagValue)}`,
42
+ }
43
+ )
44
+ }
45
+ }
46
+
47
+ export default async function TagArchivePage({ params }: PageProps) {
48
+ const { tag } = await params
49
+ const tagValue = decodeURIComponent(tag)
50
+
51
+ try {
52
+ const data = await withApiRetry(() => fetchTagArchivePageProps(themeApi, tagValue))
53
+ return <TagArchiveClient initialArticles={data.articles} total={data.total} tag={data.tag} />
54
+ } catch (error) {
55
+ console.error('[reactpress-theme-starter] tag archive fetch failed', error)
56
+ notFound()
57
+ }
58
+ }
@@ -0,0 +1,18 @@
1
+ import TagsClient from '@/components/views/TagsClient'
2
+ import { loadAppBootstrap } from '@/lib/reactpress/bootstrap'
3
+ import { buildLocalizedListPageMetadata } from '@/lib/reactpress/siteMetadata'
4
+ import type { ITag } from '@fecommunity/reactpress-toolkit/types'
5
+ import type { Metadata } from 'next'
6
+
7
+ export const revalidate = 60
8
+
9
+ export async function generateMetadata(): Promise<Metadata> {
10
+ return buildLocalizedListPageMetadata('tagTitle', undefined, { path: '/tags' })
11
+ }
12
+
13
+ export default async function TagsPage() {
14
+ const bootstrap = await loadAppBootstrap('/tags')
15
+ const tags = (bootstrap.tags ?? []) as ITag[]
16
+
17
+ return <TagsClient tags={tags} />
18
+ }
@@ -0,0 +1,6 @@
1
+ import type { ReactNode } from 'react'
2
+
3
+ /** Re-mounts on navigation — drives page enter animation. */
4
+ export default function Template({ children }: { children: ReactNode }) {
5
+ return <div className="rp-page-enter">{children}</div>
6
+ }
@@ -0,0 +1,40 @@
1
+ import ThemeImage from '@/components/shared/ThemeImage'
2
+ import type { ReactNode } from 'react'
3
+
4
+ interface ArchiveBannerProps {
5
+ title: ReactNode
6
+ subtitle: ReactNode
7
+ imageUrl: string
8
+ isBrandFallback?: boolean
9
+ className?: string
10
+ }
11
+
12
+ export default function ArchiveBanner({
13
+ title,
14
+ subtitle,
15
+ imageUrl,
16
+ isBrandFallback = false,
17
+ className = '',
18
+ }: ArchiveBannerProps) {
19
+ return (
20
+ <div
21
+ className={`rp-archive-banner rp-cover-zoom-host mb-5 h-[200px] w-full overflow-hidden rounded-xl bg-[var(--bg-second)] text-center shadow-[var(--box-shadow)] ring-1 ring-black/5 md:h-[280px] dark:ring-white/5 ${className}`}
22
+ >
23
+ <ThemeImage
24
+ src={imageUrl}
25
+ alt=""
26
+ fill
27
+ sizes="100vw"
28
+ className={`rp-cover-zoom ${isBrandFallback ? 'object-contain' : 'object-cover'} object-center`}
29
+ />
30
+ <div className="rp-archive-banner__content">
31
+ <p className="m-0 mt-12 text-2xl font-medium text-[var(--font-color-base,#fff)] [text-shadow:0_1px_3px_rgba(0,0,0,0.45)] md:mt-20 md:text-[2rem]">
32
+ {title}
33
+ </p>
34
+ <p className="mt-3 mb-0 text-lg text-white/85 [text-shadow:0_1px_2px_rgba(0,0,0,0.4)] md:text-2xl">
35
+ {subtitle}
36
+ </p>
37
+ </div>
38
+ </div>
39
+ )
40
+ }
@@ -0,0 +1,53 @@
1
+ 'use client'
2
+
3
+ import BookmarkWidget from '@/components/widgets/BookmarkWidget'
4
+ import LikesWidget from '@/components/widgets/LikesWidget'
5
+ import { COMMENT_DOM_ID } from '@/lib/utils/comment'
6
+ import { useLocale } from '@fecommunity/reactpress-toolkit/ui'
7
+
8
+ interface ArticleActionsBarProps {
9
+ articleId: string
10
+ likes: number
11
+ likesApi: (id: string, type: 'like' | 'dislike') => Promise<number>
12
+ showCommentLink?: boolean
13
+ }
14
+
15
+ export default function ArticleActionsBar({
16
+ articleId,
17
+ likes,
18
+ likesApi,
19
+ showCommentLink = false,
20
+ }: ArticleActionsBarProps) {
21
+ const { t } = useLocale()
22
+
23
+ return (
24
+ <div className="rp-article-actions-bar">
25
+ <LikesWidget defaultCount={likes} id={articleId} api={likesApi} variant="inline" />
26
+ <BookmarkWidget id={articleId} variant="inline" />
27
+ {showCommentLink ? (
28
+ <button
29
+ type="button"
30
+ className="rp-action-btn rp-action-btn--inline"
31
+ aria-label={t('comment')}
32
+ onClick={() => {
33
+ document.getElementById(COMMENT_DOM_ID)?.scrollIntoView({ behavior: 'smooth' })
34
+ }}
35
+ >
36
+ <span className="rp-action-btn__icon" aria-hidden>
37
+ <svg
38
+ viewBox="0 0 24 24"
39
+ width="20"
40
+ height="20"
41
+ fill="none"
42
+ stroke="currentColor"
43
+ strokeWidth="1.8"
44
+ >
45
+ <path d="M21 15a4 4 0 0 1-4 4H8l-5 3V7a4 4 0 0 1 4-4h10a4 4 0 0 1 4 4z" />
46
+ </svg>
47
+ </span>
48
+ <span className="rp-action-btn__label">{t('comment')}</span>
49
+ </button>
50
+ ) : null}
51
+ </div>
52
+ )
53
+ }
@@ -0,0 +1,237 @@
1
+ 'use client'
2
+
3
+ import { resolveImageUrl, type CarouselArticle } from '@fecommunity/reactpress-toolkit/theme'
4
+ import { useLocale } from '@fecommunity/reactpress-toolkit/ui'
5
+ import { LocaleTime } from '@fecommunity/reactpress-toolkit/ui/content'
6
+ import Link from '@/components/shared/Link'
7
+ import ThemeImage from '@/components/shared/ThemeImage'
8
+ import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react'
9
+
10
+ const AUTOPLAY_MS = 5000
11
+
12
+ interface ArticleCarouselProps {
13
+ articles?: CarouselArticle[]
14
+ }
15
+
16
+ function ChevronIcon({ direction }: { direction: 'left' | 'right' }) {
17
+ return (
18
+ <svg
19
+ xmlns="http://www.w3.org/2000/svg"
20
+ width={16}
21
+ height={16}
22
+ viewBox="0 0 24 24"
23
+ fill="none"
24
+ stroke="currentColor"
25
+ strokeWidth={2.5}
26
+ strokeLinecap="round"
27
+ strokeLinejoin="round"
28
+ aria-hidden
29
+ >
30
+ {direction === 'left' ? <path d="m15 18-6-6 6-6" /> : <path d="m9 18 6-6-6-6" />}
31
+ </svg>
32
+ )
33
+ }
34
+
35
+ export default function ArticleCarousel({ articles = [] }: ArticleCarouselProps) {
36
+ const { t, locale } = useLocale()
37
+ const slides = useMemo(
38
+ () => (articles || []).filter((article) => article.cover).slice(0, 6),
39
+ [articles]
40
+ )
41
+ const slideIdsKey = useMemo(() => slides.map((article) => article.id).join(','), [slides])
42
+ const [active, setActive] = useState(0)
43
+ const [loadedSlides, setLoadedSlides] = useState<Set<number>>(() => new Set([0]))
44
+ const [paused, setPaused] = useState(false)
45
+ const [progressKey, setProgressKey] = useState(0)
46
+ const timerRef = useRef<number | null>(null)
47
+
48
+ const clearAutoplay = useCallback(() => {
49
+ if (timerRef.current !== null) {
50
+ window.clearInterval(timerRef.current)
51
+ timerRef.current = null
52
+ }
53
+ }, [])
54
+
55
+ const goTo = useCallback(
56
+ (index: number) => {
57
+ if (slides.length <= 1) return
58
+ const normalized = ((index % slides.length) + slides.length) % slides.length
59
+ setActive(normalized)
60
+ setProgressKey((k) => k + 1)
61
+ },
62
+ [slides.length]
63
+ )
64
+
65
+ const goPrev = useCallback(() => goTo(active - 1), [active, goTo])
66
+ const goNext = useCallback(() => goTo(active + 1), [active, goTo])
67
+
68
+ useEffect(() => {
69
+ setLoadedSlides((prev) => {
70
+ if (prev.has(active)) return prev
71
+ const next = new Set(prev)
72
+ next.add(active)
73
+ return next
74
+ })
75
+ }, [active])
76
+
77
+ useEffect(() => {
78
+ setActive(0)
79
+ setLoadedSlides(new Set([0]))
80
+ setProgressKey((k) => k + 1)
81
+ }, [slideIdsKey])
82
+
83
+ useEffect(() => {
84
+ clearAutoplay()
85
+ if (slides.length <= 1 || paused) return undefined
86
+
87
+ timerRef.current = window.setInterval(() => {
88
+ setActive((current) => (current + 1) % slides.length)
89
+ setProgressKey((k) => k + 1)
90
+ }, AUTOPLAY_MS)
91
+
92
+ return clearAutoplay
93
+ }, [clearAutoplay, paused, slideIdsKey, slides.length])
94
+
95
+ if (!slides.length) return null
96
+
97
+ const showControls = slides.length > 1
98
+ const navBtnClass =
99
+ 'rp-carousel-nav absolute top-1/2 z-20 flex h-9 w-9 -translate-y-1/2 cursor-pointer items-center justify-center rounded-full p-0 text-white transition-all duration-300 hover:scale-105 hover:bg-white/22 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white/80 md:opacity-0 md:group-hover/carousel:opacity-100 md:group-focus-within/carousel:opacity-100'
100
+
101
+ return (
102
+ <section
103
+ className={`rp-carousel group/carousel relative isolate overflow-hidden rounded-xl bg-[var(--bg-second)] shadow-[var(--box-shadow)] ring-1 ring-black/5 dark:ring-white/5${paused ? 'is-paused' : ''}`}
104
+ aria-roledescription="carousel"
105
+ aria-label={t('recommendToReading')}
106
+ style={{ '--rp-autoplay-ms': `${AUTOPLAY_MS}ms` } as CSSProperties}
107
+ onMouseEnter={() => setPaused(true)}
108
+ onMouseLeave={() => setPaused(false)}
109
+ onFocusCapture={() => setPaused(true)}
110
+ onBlurCapture={(event) => {
111
+ if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
112
+ setPaused(false)
113
+ }
114
+ }}
115
+ >
116
+ <div
117
+ className="relative h-[320px] min-[1360px]:h-[480px] sm:h-[340px] md:h-[300px] lg:h-[380px] xl:h-[420px]"
118
+ aria-live="polite"
119
+ >
120
+ {slides.map((article, index) => {
121
+ const coverSrc = resolveImageUrl(article.cover, index === 0 ? 'medium' : 'thumb')
122
+ const isActive = index === active
123
+ const shouldLoadImage = loadedSlides.has(index)
124
+
125
+ return (
126
+ <article
127
+ key={article.id}
128
+ className={`rp-carousel-slide absolute inset-0 overflow-hidden ${isActive ? 'is-active' : ''}`}
129
+ style={{ pointerEvents: isActive ? 'auto' : 'none' }}
130
+ aria-hidden={!isActive}
131
+ >
132
+ {shouldLoadImage ? (
133
+ <ThemeImage
134
+ key={isActive ? `cover-${progressKey}` : article.id}
135
+ src={coverSrc}
136
+ alt=""
137
+ fill
138
+ sizes="(max-width: 768px) 100vw, 1200px"
139
+ className="rp-cover-zoom rp-carousel-cover-motion bg-[var(--bg-second)] object-contain object-center"
140
+ priority={index === 0}
141
+ loading={index === 0 ? 'eager' : 'lazy'}
142
+ />
143
+ ) : (
144
+ <div className="absolute inset-0 bg-[var(--bg-second)]" aria-hidden />
145
+ )}
146
+
147
+ <div
148
+ className="rp-carousel-overlay pointer-events-none absolute inset-0"
149
+ aria-hidden
150
+ />
151
+
152
+ <Link
153
+ href={`/article/${article.id}`}
154
+ tabIndex={isActive ? 0 : -1}
155
+ className="absolute inset-0 z-10 flex flex-col justify-end no-underline"
156
+ >
157
+ <div
158
+ key={isActive ? `${article.id}-content` : `${article.id}-hidden`}
159
+ className={`rp-carousel-caption px-5 pt-16 pb-8 md:px-7 md:pb-9 lg:px-8 ${
160
+ isActive ? 'rp-carousel-text-enter' : 'opacity-0'
161
+ }`}
162
+ >
163
+ <div className="min-w-0">
164
+ <h2 className="m-0 mb-2 line-clamp-2 max-w-xl text-[1.05rem] leading-snug font-semibold tracking-tight text-white md:max-w-2xl md:text-xl lg:text-[1.3rem]">
165
+ {article.title}
166
+ </h2>
167
+ <p className="m-0 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-white/75 md:text-[0.8125rem]">
168
+ <LocaleTime date={article.publishAt} timeago locale={locale} />
169
+ <span className="text-white/40" aria-hidden>
170
+ ·
171
+ </span>
172
+ <span>
173
+ {article.views} {t('readingCountTemplate')}
174
+ </span>
175
+ </p>
176
+ </div>
177
+ </div>
178
+ </Link>
179
+ </article>
180
+ )
181
+ })}
182
+ </div>
183
+
184
+ {showControls ? (
185
+ <>
186
+ <button
187
+ type="button"
188
+ className={`${navBtnClass} left-3 opacity-90 max-md:left-2.5`}
189
+ aria-label={t('carouselPrev')
190
+ .replace('{current}', String(active + 1))
191
+ .replace('{total}', String(slides.length))}
192
+ onClick={goPrev}
193
+ >
194
+ <ChevronIcon direction="left" />
195
+ </button>
196
+ <button
197
+ type="button"
198
+ className={`${navBtnClass} right-3 opacity-90 max-md:right-2.5`}
199
+ aria-label={t('carouselNext')
200
+ .replace('{current}', String(active + 1))
201
+ .replace('{total}', String(slides.length))}
202
+ onClick={goNext}
203
+ >
204
+ <ChevronIcon direction="right" />
205
+ </button>
206
+
207
+ <div
208
+ className="absolute right-5 bottom-3.5 z-20 hidden items-center gap-1 md:bottom-9 md:flex lg:right-8"
209
+ role="tablist"
210
+ aria-label={t('recommendToReading')}
211
+ >
212
+ {slides.map((article, index) => {
213
+ const isDotActive = index === active
214
+ return (
215
+ <button
216
+ key={article.id}
217
+ type="button"
218
+ role="tab"
219
+ aria-selected={isDotActive}
220
+ aria-label={`${index + 1} / ${slides.length}`}
221
+ className="flex min-h-6 min-w-6 cursor-pointer items-center justify-center border-0 bg-transparent p-1"
222
+ onClick={() => goTo(index)}
223
+ >
224
+ <span className={`rp-carousel-dot ${isDotActive ? 'is-active h-1.5' : ''}`} />
225
+ </button>
226
+ )
227
+ })}
228
+ </div>
229
+
230
+ <div className="rp-carousel-progress-track absolute right-0 bottom-0 left-0 z-20">
231
+ <div key={progressKey} className="rp-carousel-progress-meteor" aria-hidden />
232
+ </div>
233
+ </>
234
+ ) : null}
235
+ </section>
236
+ )
237
+ }
@@ -0,0 +1,128 @@
1
+ 'use client'
2
+
3
+ import Link from '@/components/shared/Link'
4
+ import ThemeImage from '@/components/shared/ThemeImage'
5
+ import { getRandomColor } from '@/lib/utils/comment'
6
+ import { useSiteCatalog } from '@fecommunity/reactpress-toolkit/theme'
7
+ import { useLocale } from '@fecommunity/reactpress-toolkit/ui'
8
+ import { LocaleTime } from '@fecommunity/reactpress-toolkit/ui/content'
9
+ import { resolveImageUrl, useSiteSetting } from '@fecommunity/reactpress-toolkit/theme'
10
+
11
+ const CC_LICENSE_URL = 'https://creativecommons.org/licenses/by-nc/3.0/cn/deed.zh'
12
+
13
+ interface ArticleCopyrightBarProps {
14
+ publishAt: string
15
+ className?: string
16
+ }
17
+
18
+ function isImageLogo(logo: string) {
19
+ return (
20
+ logo.startsWith('http') ||
21
+ logo.startsWith('/') ||
22
+ /\.(svg|png|jpe?g|gif|webp)(\?.*)?$/i.test(logo)
23
+ )
24
+ }
25
+
26
+ function AuthorAvatar({ name, logo }: { name: string; logo?: string }) {
27
+ const initial = name.trim().charAt(0).toUpperCase() || 'A'
28
+
29
+ if (logo && logo.includes('<')) {
30
+ return (
31
+ <span
32
+ className="rp-article-author-avatar__brand"
33
+ dangerouslySetInnerHTML={{ __html: logo }}
34
+ />
35
+ )
36
+ }
37
+
38
+ if (logo && isImageLogo(logo)) {
39
+ return (
40
+ <ThemeImage
41
+ src={resolveImageUrl(logo, 'medium')}
42
+ alt=""
43
+ width={48}
44
+ height={48}
45
+ className="rp-article-author-avatar__img"
46
+ />
47
+ )
48
+ }
49
+
50
+ return (
51
+ <span
52
+ className="rp-article-author-avatar__fallback"
53
+ style={{ backgroundColor: getRandomColor(name) }}
54
+ aria-hidden
55
+ >
56
+ {initial}
57
+ </span>
58
+ )
59
+ }
60
+
61
+ export default function ArticleCopyrightBar({
62
+ publishAt,
63
+ className = '',
64
+ }: ArticleCopyrightBarProps) {
65
+ const { t } = useLocale()
66
+ const { locale } = useSiteCatalog()
67
+ const setting = useSiteSetting()
68
+
69
+ const authorName = setting.systemTitle?.trim() || 'ReactPress'
70
+ const authorBio = setting.systemSubTitle?.trim()
71
+ const authorLogo = setting.systemLogo?.trim()
72
+
73
+ return (
74
+ <div className={`rp-article-author-footer ${className}`.trim()}>
75
+ <div className="rp-article-author-panel">
76
+ <div className="rp-article-author-main">
77
+ <Link href="/" className="rp-article-author-avatar" aria-label={authorName}>
78
+ <AuthorAvatar name={authorName} logo={authorLogo} />
79
+ </Link>
80
+
81
+ <div className="rp-article-author-info">
82
+ <span className="rp-article-author-label">{locale === 'zh' ? '作者' : 'Author'}</span>
83
+ <Link href="/" className="rp-article-author-name">
84
+ {authorName}
85
+ </Link>
86
+ {authorBio ? <p className="rp-article-author-bio">{authorBio}</p> : null}
87
+ </div>
88
+
89
+ <Link href="/" className="rp-article-author-cta">
90
+ {t('home')}
91
+ <svg
92
+ viewBox="0 0 24 24"
93
+ width="14"
94
+ height="14"
95
+ fill="none"
96
+ stroke="currentColor"
97
+ strokeWidth="2"
98
+ aria-hidden
99
+ >
100
+ <path d="M5 12h14M13 6l6 6-6 6" strokeLinecap="round" strokeLinejoin="round" />
101
+ </svg>
102
+ </Link>
103
+ </div>
104
+
105
+ <div className="rp-article-author-meta">
106
+ <span>
107
+ {t('publishAt')}
108
+ <LocaleTime date={publishAt} locale={locale} />
109
+ </span>
110
+ <span className="rp-article-author-meta__sep" aria-hidden>
111
+ ·
112
+ </span>
113
+ <span>
114
+ {t('copyrightInfo')}
115
+ <a
116
+ href={CC_LICENSE_URL}
117
+ target="_blank"
118
+ rel="noopener noreferrer"
119
+ className="rp-article-author-license"
120
+ >
121
+ {t('copyrightContent')}
122
+ </a>
123
+ </span>
124
+ </div>
125
+ </div>
126
+ </div>
127
+ )
128
+ }
@@ -0,0 +1,19 @@
1
+ 'use client'
2
+
3
+ import BookmarkWidget from '@/components/widgets/BookmarkWidget'
4
+ import LikesWidget from '@/components/widgets/LikesWidget'
5
+
6
+ interface ArticleEndBarProps {
7
+ articleId: string
8
+ likes: number
9
+ likesApi: (id: string, type: 'like' | 'dislike') => Promise<number>
10
+ }
11
+
12
+ export default function ArticleEndBar({ articleId, likes, likesApi }: ArticleEndBarProps) {
13
+ return (
14
+ <div className="rp-article-end-bar">
15
+ <LikesWidget defaultCount={likes} id={articleId} api={likesApi} variant="inline" />
16
+ <BookmarkWidget id={articleId} variant="inline" />
17
+ </div>
18
+ )
19
+ }
@@ -0,0 +1,59 @@
1
+ 'use client'
2
+
3
+ import CategoryMenu from '@/components/article/CategoryMenu'
4
+ import LoadMore from '@/components/article/LoadMore'
5
+ import { useLocale } from '@fecommunity/reactpress-toolkit/ui'
6
+ import { useSiteCatalog } from '@fecommunity/reactpress-toolkit/theme'
7
+ import type { ReactNode } from 'react'
8
+
9
+ interface ArticleFeedSectionProps {
10
+ children: ReactNode
11
+ pageStart?: number
12
+ loadMore: (page: number) => void | Promise<void>
13
+ hasMore: boolean
14
+ showCategoryMenu?: boolean
15
+ }
16
+
17
+ /** Shared home/category article list block — matches twentytwentyfive `leftWrap`. */
18
+ export default function ArticleFeedSection({
19
+ children,
20
+ pageStart = 1,
21
+ loadMore,
22
+ hasMore,
23
+ showCategoryMenu = true,
24
+ }: ArticleFeedSectionProps) {
25
+ const { t } = useLocale()
26
+ const { categories } = useSiteCatalog()
27
+
28
+ return (
29
+ <div className="rp-page-enter-delayed w-full overflow-hidden rounded-xl">
30
+ {showCategoryMenu ? (
31
+ <header className="overflow-hidden rounded-t-xl shadow-[var(--box-shadow)] ring-1 ring-black/5 dark:ring-white/5">
32
+ <CategoryMenu categories={categories as Array<{ value: string; label: string }>} />
33
+ </header>
34
+ ) : null}
35
+ <main className="mb-16 w-full pt-4 [&_.rp-article-list]:w-full [&_.rp-article-list]:rounded-none [&_.rp-article-list]:shadow-none">
36
+ <LoadMore
37
+ pageStart={pageStart}
38
+ loadMore={loadMore}
39
+ hasMore={hasMore}
40
+ loader={
41
+ <div
42
+ className="loading flex items-center justify-center gap-2 py-8 text-sm text-[var(--second-text-color)]"
43
+ key={0}
44
+ >
45
+ <span className="rp-load-indicator" aria-hidden>
46
+ <span />
47
+ <span />
48
+ <span />
49
+ </span>
50
+ {t('gettingArticle')}
51
+ </div>
52
+ }
53
+ >
54
+ {children}
55
+ </LoadMore>
56
+ </main>
57
+ </div>
58
+ )
59
+ }