@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,100 @@
1
+ 'use client'
2
+
3
+ import ArticleList from '@/components/article/ArticleList'
4
+ import DoubleColumnLayout from '@/components/layout/DoubleColumnLayout'
5
+ import SearchHero from '@/components/search/SearchHero'
6
+ import SearchQuickLinks from '@/components/search/SearchQuickLinks'
7
+ import { useFeedFooterPlacement } from '@/lib/reactpress/useFeedFooterPlacement'
8
+ import { useSiteNavConfig } from '@/lib/reactpress/useSiteNavConfig'
9
+ import { useLocale } from '@fecommunity/reactpress-toolkit/ui'
10
+ import type { ListArticle } from '@fecommunity/reactpress-toolkit/theme'
11
+ import { useRouter } from 'next/navigation'
12
+ import { useCallback, useEffect, useState } from 'react'
13
+
14
+ interface SearchClientProps {
15
+ keyword: string
16
+ articles: ListArticle[]
17
+ navConfig?: {
18
+ urlConfig?: unknown[]
19
+ searchCategories?: {
20
+ categories?: Array<{ key: string; label: string }>
21
+ subCategories?: Record<string, Array<{ key: string; label: string; url?: string }>>
22
+ }
23
+ }
24
+ }
25
+
26
+ export default function SearchClient({
27
+ keyword: initialKeyword = '',
28
+ articles: initialArticles = [],
29
+ navConfig,
30
+ }: SearchClientProps) {
31
+ const { t, locale } = useLocale()
32
+ const router = useRouter()
33
+ const [articles, setArticles] = useState(initialArticles)
34
+ const hasSearchResults = Boolean(initialKeyword.trim())
35
+ const { footerAtBottom } = useFeedFooterPlacement({
36
+ itemCount: hasSearchResults ? articles.length : 0,
37
+ })
38
+
39
+ const { urlConfig, searchCategories } = useSiteNavConfig(navConfig)
40
+ const quickLinks = urlConfig as Parameters<typeof SearchQuickLinks>[0]['dataSource']
41
+
42
+ useEffect(() => {
43
+ setArticles(initialArticles)
44
+ }, [initialArticles])
45
+
46
+ const runSearch = useCallback(
47
+ (value: string) => {
48
+ const trimmed = value.trim()
49
+ if (!trimmed) {
50
+ router.push('/search')
51
+ return
52
+ }
53
+ router.push(`/search?keyword=${encodeURIComponent(trimmed)}`)
54
+ },
55
+ [router]
56
+ )
57
+
58
+ return (
59
+ <DoubleColumnLayout
60
+ className="rp-search-page"
61
+ fillMinHeight={footerAtBottom}
62
+ topNode={
63
+ <SearchHero
64
+ key={locale}
65
+ searchCategories={searchCategories}
66
+ initialKeyword={initialKeyword}
67
+ onLocalSearch={runSearch}
68
+ compact={hasSearchResults}
69
+ />
70
+ }
71
+ leftNode={
72
+ hasSearchResults ? (
73
+ <div className="rp-search-results">
74
+ <div className="rp-search-results__summary">
75
+ <p className="m-0 text-sm text-[var(--second-text-color)]">
76
+ {t('totalSearch')}{' '}
77
+ <span className="font-semibold text-[var(--primary-color)]">{articles.length}</span>{' '}
78
+ {t('piece')}
79
+ </p>
80
+ <p className="m-0 mt-1 text-base font-medium text-[var(--main-text-color)]">
81
+ &ldquo;{initialKeyword}&rdquo;
82
+ </p>
83
+ </div>
84
+ <main className="rp-search-results__list">
85
+ {articles.length ? (
86
+ <ArticleList articles={articles} />
87
+ ) : (
88
+ <div className="rounded-xl bg-[var(--bg-box)] p-10 text-center text-[var(--second-text-color)] ring-1 ring-black/5 dark:ring-white/5">
89
+ {t('empty')}
90
+ </div>
91
+ )}
92
+ </main>
93
+ </div>
94
+ ) : (
95
+ <SearchQuickLinks key={locale} dataSource={quickLinks} />
96
+ )
97
+ }
98
+ />
99
+ )
100
+ }
@@ -0,0 +1,105 @@
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 TagsWidget from '@/components/widgets/TagsWidget'
9
+ import { FEED_PAGE_SIZE } from '@/lib/reactpress/feedFooterPlacement'
10
+ import { useFeedFooterPlacement } from '@/lib/reactpress/useFeedFooterPlacement'
11
+ import { ArticleProvider } from '@/lib/providers/client'
12
+ import { getArchiveBannerImage } from '@/lib/utils/archiveBanner'
13
+ import { useLocale } from '@fecommunity/reactpress-toolkit/ui'
14
+ import {
15
+ slimArticlesForList,
16
+ useSiteCatalog,
17
+ type ListArticle,
18
+ } from '@fecommunity/reactpress-toolkit/theme'
19
+ import { useCallback, useEffect, useMemo, useState } from 'react'
20
+
21
+ interface TagArchiveClientProps {
22
+ initialArticles: ListArticle[]
23
+ total: number
24
+ tag: { value: string; label: string }
25
+ }
26
+
27
+ const pageSize = FEED_PAGE_SIZE
28
+
29
+ export default function TagArchiveClient({
30
+ initialArticles = [],
31
+ total = 0,
32
+ tag,
33
+ }: TagArchiveClientProps) {
34
+ const { t } = useLocale()
35
+ const { tags } = useSiteCatalog()
36
+ const [page, setPage] = useState(1)
37
+ const [articles, setArticles] = useState(initialArticles)
38
+ const banner = useMemo(() => getArchiveBannerImage(articles), [articles])
39
+ const hasMore = page * pageSize < total
40
+ const { footerInSidebar, footerAtBottom } = useFeedFooterPlacement({
41
+ hasMore,
42
+ itemCount: articles.length,
43
+ pageSize,
44
+ })
45
+
46
+ useEffect(() => {
47
+ setArticles(initialArticles)
48
+ setPage(1)
49
+ }, [initialArticles])
50
+
51
+ const getArticles = useCallback(
52
+ (nextPage: number) => {
53
+ return ArticleProvider.getArticlesByTag(tag.value, {
54
+ page: nextPage,
55
+ pageSize,
56
+ status: 'publish',
57
+ }).then((res) => {
58
+ setPage(nextPage)
59
+ setArticles((prev) => [...prev, ...slimArticlesForList(res[0])])
60
+ })
61
+ },
62
+ [tag.value]
63
+ )
64
+
65
+ return (
66
+ <DoubleColumnLayout
67
+ fillMinHeight={footerAtBottom}
68
+ leftNode={
69
+ <>
70
+ <ArchiveBanner
71
+ imageUrl={banner.url}
72
+ isBrandFallback={banner.isBrandFallback}
73
+ title={
74
+ <>
75
+ {t('yu')} <span>{tag.label}</span> {t('tagRelativeArticles')}
76
+ </>
77
+ }
78
+ subtitle={
79
+ <>
80
+ {t('totalSearch')} <span>{total}</span> {t('piece')}
81
+ </>
82
+ }
83
+ />
84
+ <TagsWidget tags={tags as Parameters<typeof TagsWidget>[0]['tags']} />
85
+ <ArticleFeedSection
86
+ showCategoryMenu={false}
87
+ pageStart={1}
88
+ loadMore={getArticles}
89
+ hasMore={hasMore}
90
+ >
91
+ <ArticleList articles={articles} />
92
+ </ArticleFeedSection>
93
+ </>
94
+ }
95
+ rightNode={
96
+ <HomeSidebar
97
+ showTags={false}
98
+ showCategories
99
+ showAboutUs={footerInSidebar}
100
+ deferRecommend={false}
101
+ />
102
+ }
103
+ />
104
+ )
105
+ }
@@ -0,0 +1,46 @@
1
+ 'use client'
2
+
3
+ import DoubleColumnLayout from '@/components/layout/DoubleColumnLayout'
4
+ import HomeSidebar from '@/components/widgets/HomeSidebar'
5
+ import TagsWidget from '@/components/widgets/TagsWidget'
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 type { ITag } from '@fecommunity/reactpress-toolkit/types'
10
+
11
+ interface TagsClientProps {
12
+ tags: ITag[]
13
+ }
14
+
15
+ export default function TagsClient({ tags }: TagsClientProps) {
16
+ const { t } = useLocale()
17
+ const { footerInSidebar, footerAtBottom } = useFeedFooterPlacement({
18
+ itemCount: tags.length,
19
+ pageSize: FEED_PAGE_SIZE,
20
+ })
21
+
22
+ return (
23
+ <DoubleColumnLayout
24
+ fillMinHeight={footerAtBottom}
25
+ leftNode={
26
+ <div className="overflow-hidden rounded-lg bg-[var(--bg-box)] p-6 shadow-[var(--box-shadow)]">
27
+ <h1 className="m-0 text-2xl font-semibold text-[var(--main-text-color)]">
28
+ {t('tagTitle')}
29
+ </h1>
30
+ <p className="mt-2 text-sm text-[var(--second-text-color)]">{tags.length} tags</p>
31
+ <div className="mt-6">
32
+ <TagsWidget tags={tags} needTitle={false} />
33
+ </div>
34
+ </div>
35
+ }
36
+ rightNode={
37
+ <HomeSidebar
38
+ showTags={false}
39
+ showCategories
40
+ showAboutUs={footerInSidebar}
41
+ deferRecommend={false}
42
+ />
43
+ }
44
+ />
45
+ )
46
+ }
@@ -0,0 +1,180 @@
1
+ 'use client'
2
+
3
+ import ThemeImage from '@/components/shared/ThemeImage'
4
+ import type { ReactNode } from 'react'
5
+ import { useLocale } from '@fecommunity/reactpress-toolkit/ui'
6
+ import { useSiteSetting } from '@fecommunity/reactpress-toolkit/theme'
7
+
8
+ function trimUrl(value?: string) {
9
+ return (value ?? '').trim()
10
+ }
11
+
12
+ function QrPopover({
13
+ imageUrl,
14
+ label,
15
+ children,
16
+ }: {
17
+ imageUrl: string
18
+ label: string
19
+ children: ReactNode
20
+ }) {
21
+ return (
22
+ <span className="group relative inline-flex">
23
+ {children}
24
+ <span
25
+ role="tooltip"
26
+ className="rp-glass pointer-events-none absolute bottom-full left-1/2 z-20 mb-2 hidden -translate-x-1/2 rounded-xl p-2 shadow-lg group-hover:block"
27
+ >
28
+ <ThemeImage
29
+ src={imageUrl}
30
+ alt={label}
31
+ width={300}
32
+ height={200}
33
+ className="h-auto max-h-[200px] w-auto max-w-[300px]"
34
+ />
35
+ </span>
36
+ </span>
37
+ )
38
+ }
39
+
40
+ function ContactInfo() {
41
+ const { t } = useLocale()
42
+ const setting = useSiteSetting()
43
+ const githubUrl = trimUrl(setting?.aboutUsGithubUrl)
44
+ const commentQr = trimUrl(setting?.aboutUsCommentQr)
45
+ const wechatQr = trimUrl(setting?.aboutUsWechatQr)
46
+
47
+ const items = [
48
+ <a
49
+ key="rss"
50
+ href="/rss"
51
+ target="_blank"
52
+ rel="noopener noreferrer"
53
+ aria-label="rss"
54
+ title={t('rssSubscribe')}
55
+ className="inline-flex text-[var(--main-text-color)] hover:text-[var(--primary-color)]"
56
+ >
57
+ <svg viewBox="0 0 1024 1024" width="24" height="24" fill="currentColor" aria-hidden>
58
+ <path d="M512 0C230.4 0 0 230.4 0 512s230.4 512 512 512 512-230.4 512-512S793.6 0 512 0z m-182.4 768C288 768 256 736 256 694.4s32-73.6 73.6-73.6 73.6 32 73.6 73.6-32 73.6-73.6 73.6z m185.6 0c0-144-115.2-259.2-259.2-259.2v-80c185.6 0 339.2 150.4 339.2 339.2h-80z m172.8 0c0-240-195.2-432-432-432V256c281.6 0 512 230.4 512 512h-80z" />
59
+ </svg>
60
+ </a>,
61
+ githubUrl ? (
62
+ <a
63
+ key="github"
64
+ href={githubUrl}
65
+ target="_blank"
66
+ rel="noopener noreferrer nofollow"
67
+ aria-label="Github"
68
+ title="Github"
69
+ className="inline-flex text-[var(--main-text-color)] hover:text-[var(--primary-color)]"
70
+ >
71
+ <svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor" aria-hidden>
72
+ <path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z" />
73
+ </svg>
74
+ </a>
75
+ ) : null,
76
+ commentQr ? (
77
+ <QrPopover key="comment" imageUrl={commentQr} label="comment">
78
+ <span
79
+ role="img"
80
+ aria-label="comment"
81
+ title={t('comment')}
82
+ className="inline-flex cursor-pointer text-2xl text-[var(--main-text-color)] hover:text-[var(--primary-color)]"
83
+ >
84
+ 💬
85
+ </span>
86
+ </QrPopover>
87
+ ) : null,
88
+ wechatQr ? (
89
+ <QrPopover key="wechat" imageUrl={wechatQr} label="wechat">
90
+ <span
91
+ role="img"
92
+ aria-label="wechat"
93
+ title="WeChat"
94
+ className="inline-flex cursor-pointer text-2xl text-[var(--main-text-color)] hover:text-[var(--primary-color)]"
95
+ >
96
+ 💚
97
+ </span>
98
+ </QrPopover>
99
+ ) : null,
100
+ ].filter(Boolean)
101
+
102
+ if (!items.length) return null
103
+
104
+ return (
105
+ <div className="mt-4 border-t border-dashed border-[var(--border-color)] pt-4">
106
+ <ul className="m-0 flex list-none items-center justify-between p-0">
107
+ {items.map((item, index) => (
108
+ <li
109
+ key={index}
110
+ className={index > 0 ? 'ml-3 border-l border-[var(--border-color)] pl-3' : undefined}
111
+ >
112
+ {item}
113
+ </li>
114
+ ))}
115
+ </ul>
116
+ </div>
117
+ )
118
+ }
119
+
120
+ interface AboutUsProps {
121
+ variant?: 'sidebar' | 'default' | 'bottom'
122
+ }
123
+
124
+ function AboutUsBody() {
125
+ const { t } = useLocale()
126
+ const setting = useSiteSetting()
127
+
128
+ return (
129
+ <>
130
+ {setting?.systemSubTitle ? (
131
+ <p className="mt-0 mb-3 leading-relaxed">{setting.systemSubTitle}</p>
132
+ ) : null}
133
+ {setting?.systemFooterInfo ? (
134
+ <div
135
+ className="rp-rich-text leading-relaxed"
136
+ dangerouslySetInnerHTML={{ __html: setting.systemFooterInfo }}
137
+ />
138
+ ) : (
139
+ <p className="m-0 leading-relaxed">{t('aboutUsFallback')}</p>
140
+ )}
141
+ <ContactInfo />
142
+ </>
143
+ )
144
+ }
145
+
146
+ export default function AboutUs({ variant = 'default' }: AboutUsProps) {
147
+ const { t } = useLocale()
148
+ const isSidebar = variant === 'sidebar'
149
+ const isBottom = variant === 'bottom'
150
+
151
+ if (isBottom) {
152
+ return (
153
+ <div className="rp-widget-panel overflow-hidden rounded-xl bg-[var(--bg-box)] text-[var(--second-text-color)] shadow-[var(--box-shadow)] ring-1 ring-black/5 dark:ring-white/5">
154
+ <div className="flex flex-col gap-4 p-4 md:flex-row md:items-start md:gap-8 md:p-6">
155
+ <h3 className="m-0 shrink-0 text-base font-bold text-[var(--main-text-color)] md:w-40">
156
+ {t('aboutUs')}
157
+ </h3>
158
+ <div className="min-w-0 flex-1 text-sm">
159
+ <AboutUsBody />
160
+ </div>
161
+ </div>
162
+ </div>
163
+ )
164
+ }
165
+
166
+ return (
167
+ <div
168
+ className={`rp-widget-panel overflow-hidden rounded-xl bg-[var(--bg-box)] shadow-[var(--box-shadow)] ring-1 ring-black/5 dark:ring-white/5 ${
169
+ isSidebar ? 'border-t-0 text-[var(--second-text-color)]' : ''
170
+ }`}
171
+ >
172
+ <div className="border-b border-[var(--border-color)] p-4 font-bold text-[var(--main-text-color)]">
173
+ {t('aboutUs')}
174
+ </div>
175
+ <div className="p-4 text-sm text-[var(--second-text-color)]">
176
+ <AboutUsBody />
177
+ </div>
178
+ </div>
179
+ )
180
+ }
@@ -0,0 +1,95 @@
1
+ 'use client'
2
+
3
+ import type { ReactNode } from 'react'
4
+
5
+ export type ArticleActionVariant = 'floating' | 'inline' | 'mobile'
6
+
7
+ interface ArticleActionButtonProps {
8
+ active?: boolean
9
+ disabled?: boolean
10
+ label: string
11
+ count?: number
12
+ onClick: () => void
13
+ variant?: ArticleActionVariant
14
+ activeClassName?: string
15
+ children: ReactNode
16
+ }
17
+
18
+ export default function ArticleActionButton({
19
+ active = false,
20
+ disabled = false,
21
+ label,
22
+ count,
23
+ onClick,
24
+ variant = 'floating',
25
+ activeClassName = 'is-liked',
26
+ children,
27
+ }: ArticleActionButtonProps) {
28
+ const className = [
29
+ 'rp-action-btn',
30
+ variant === 'inline' ? 'rp-action-btn--inline' : '',
31
+ variant === 'mobile' ? 'rp-action-btn--mobile' : '',
32
+ active ? activeClassName : '',
33
+ disabled ? 'is-disabled' : '',
34
+ ]
35
+ .filter(Boolean)
36
+ .join(' ')
37
+
38
+ if (variant === 'inline') {
39
+ return (
40
+ <button
41
+ type="button"
42
+ aria-label={label}
43
+ aria-pressed={active}
44
+ disabled={disabled}
45
+ onClick={onClick}
46
+ className={className}
47
+ >
48
+ <span className="rp-action-btn__icon" aria-hidden>
49
+ {children}
50
+ </span>
51
+ <span className="rp-action-btn__label">{label}</span>
52
+ {count != null && count > 0 ? <span className="rp-action-btn__count">{count}</span> : null}
53
+ </button>
54
+ )
55
+ }
56
+
57
+ if (variant === 'mobile') {
58
+ return (
59
+ <button
60
+ type="button"
61
+ aria-label={label}
62
+ aria-pressed={active}
63
+ disabled={disabled}
64
+ onClick={onClick}
65
+ className={className}
66
+ >
67
+ <span className="rp-action-btn__icon" aria-hidden>
68
+ {children}
69
+ </span>
70
+ <span className="rp-action-btn__mobile-label">{label}</span>
71
+ {count != null && count > 0 ? (
72
+ <span className="rp-action-btn__mobile-count">{count}</span>
73
+ ) : null}
74
+ </button>
75
+ )
76
+ }
77
+
78
+ return (
79
+ <button
80
+ type="button"
81
+ aria-label={label}
82
+ aria-pressed={active}
83
+ disabled={disabled}
84
+ onClick={onClick}
85
+ className={className}
86
+ >
87
+ <span className="rp-action-btn__icon" aria-hidden>
88
+ {children}
89
+ </span>
90
+ {count != null ? (
91
+ <span className="rp-action-btn__count-below">{count > 0 ? count : ''}</span>
92
+ ) : null}
93
+ </button>
94
+ )
95
+ }
@@ -0,0 +1,61 @@
1
+ 'use client'
2
+
3
+ import ArticleActionButton, {
4
+ type ArticleActionVariant,
5
+ } from '@/components/widgets/ArticleActionButton'
6
+ import { useSiteCatalog } from '@fecommunity/reactpress-toolkit/theme'
7
+ import { useCallback, useEffect, useState } from 'react'
8
+
9
+ const STORAGE_KEY = 'BOOKMARKS'
10
+
11
+ interface BookmarkWidgetProps {
12
+ id: string
13
+ variant?: ArticleActionVariant
14
+ }
15
+
16
+ function readBookmarks(): string[] {
17
+ try {
18
+ return JSON.parse(window.localStorage.getItem(STORAGE_KEY) || '[]') as string[]
19
+ } catch {
20
+ return []
21
+ }
22
+ }
23
+
24
+ export default function BookmarkWidget({ id, variant = 'floating' }: BookmarkWidgetProps) {
25
+ const { locale } = useSiteCatalog()
26
+ const [bookmarked, setBookmarked] = useState(false)
27
+ const label = locale === 'zh' ? '收藏' : 'Bookmark'
28
+
29
+ useEffect(() => {
30
+ setBookmarked(readBookmarks().includes(id))
31
+ }, [id])
32
+
33
+ const toggle = useCallback(() => {
34
+ const stored = readBookmarks()
35
+ const nextStored = bookmarked ? stored.filter((item) => item !== id) : [...stored, id]
36
+ window.localStorage.setItem(STORAGE_KEY, JSON.stringify(nextStored))
37
+ setBookmarked(!bookmarked)
38
+ }, [bookmarked, id])
39
+
40
+ return (
41
+ <ArticleActionButton
42
+ active={bookmarked}
43
+ label={label}
44
+ onClick={toggle}
45
+ variant={variant}
46
+ activeClassName="is-bookmarked"
47
+ >
48
+ <svg
49
+ viewBox="0 0 24 24"
50
+ width="20"
51
+ height="20"
52
+ fill="none"
53
+ stroke="currentColor"
54
+ strokeWidth="1.8"
55
+ aria-hidden
56
+ >
57
+ <path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" />
58
+ </svg>
59
+ </ArticleActionButton>
60
+ )
61
+ }
@@ -0,0 +1,47 @@
1
+ 'use client'
2
+
3
+ import Link from '@/components/shared/Link'
4
+ import { FolderIcon } from '@/lib/utils/icons'
5
+ import { useLocale } from '@fecommunity/reactpress-toolkit/ui'
6
+
7
+ interface Category {
8
+ value: string
9
+ label: string
10
+ articleCount?: number
11
+ }
12
+
13
+ interface CategoriesWidgetProps {
14
+ categories: Category[]
15
+ }
16
+
17
+ export default function CategoriesWidget({ categories = [] }: CategoriesWidgetProps) {
18
+ const { t } = useLocale()
19
+
20
+ return (
21
+ <div className="rp-widget-panel mb-5 overflow-hidden rounded-xl bg-[var(--bg-box)] leading-snug shadow-[var(--box-shadow)] ring-1 ring-black/5 dark:ring-white/5">
22
+ <div className="border-b border-[var(--border-color)] p-4 font-bold text-[var(--main-text-color)]">
23
+ <FolderIcon size={16} className="mr-2 inline-block text-[var(--primary-color)]" />
24
+ <span>{t('categoryTitle')}</span>
25
+ </div>
26
+ <ul className="m-0 list-none p-4">
27
+ {categories.map((category) => (
28
+ <li
29
+ key={category.value}
30
+ className="rp-category-item rounded-lg px-2 py-2 text-[var(--second-text-color)]"
31
+ >
32
+ <Link
33
+ href={`/category/${category.value}`}
34
+ aria-label={category.label}
35
+ className="flex w-full items-center justify-between text-sm no-underline hover:text-inherit"
36
+ >
37
+ <span className="text-[var(--main-text-color)]">{category.label}</span>
38
+ <span>
39
+ {t('total')} {category.articleCount ?? 0} {t('articleCountTemplate')}
40
+ </span>
41
+ </Link>
42
+ </li>
43
+ ))}
44
+ </ul>
45
+ </div>
46
+ )
47
+ }
@@ -0,0 +1,40 @@
1
+ 'use client'
2
+
3
+ import AboutUs from '@/components/widgets/AboutUs'
4
+ import ArticleRecommend from '@/components/article/ArticleRecommend'
5
+ import CategoriesWidget from '@/components/widgets/CategoriesWidget'
6
+ import TagsWidget from '@/components/widgets/TagsWidget'
7
+ import { useSiteCatalog } from '@fecommunity/reactpress-toolkit/theme'
8
+
9
+ interface HomeSidebarProps {
10
+ showTags?: boolean
11
+ showCategories?: boolean
12
+ showRecommend?: boolean
13
+ showAboutUs?: boolean
14
+ deferRecommend?: boolean
15
+ }
16
+
17
+ export default function HomeSidebar({
18
+ showTags = true,
19
+ showCategories = false,
20
+ showRecommend = true,
21
+ showAboutUs = true,
22
+ deferRecommend = true,
23
+ }: HomeSidebarProps) {
24
+ const { tags, categories } = useSiteCatalog()
25
+
26
+ return (
27
+ <div className="rp-sidebar-enter rp-sidebar-sticky sticky mb-4 w-72">
28
+ {showRecommend ? <ArticleRecommend mode="inline" deferFetch={deferRecommend} /> : null}
29
+ {showTags ? (
30
+ <TagsWidget tags={tags as Parameters<typeof TagsWidget>[0]['tags']} animationMode />
31
+ ) : null}
32
+ {showCategories ? (
33
+ <CategoriesWidget
34
+ categories={categories as Parameters<typeof CategoriesWidget>[0]['categories']}
35
+ />
36
+ ) : null}
37
+ {showAboutUs ? <AboutUs variant="sidebar" /> : null}
38
+ </div>
39
+ )
40
+ }