@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,75 @@
1
+ 'use client'
2
+
3
+ import PageContainer from '@/components/layout/PageContainer'
4
+ import Link from '@/components/shared/Link'
5
+ import { useLocale } from '@fecommunity/reactpress-toolkit/ui'
6
+ import { getSiteTitle, useSiteSetting } from '@fecommunity/reactpress-toolkit/theme'
7
+
8
+ export default function SiteFooter() {
9
+ const { t } = useLocale()
10
+ const setting = useSiteSetting()
11
+ const siteTitle = getSiteTitle(setting)
12
+
13
+ return (
14
+ <footer
15
+ className="mt-auto w-full shrink-0 border-t border-[var(--border-color)] bg-[var(--bg-box)] py-8"
16
+ role="contentinfo"
17
+ >
18
+ <PageContainer>
19
+ <div className="flex flex-col gap-6 md:flex-row md:items-start md:justify-between">
20
+ <div className="min-w-0 flex-1">
21
+ <div className="mb-2 font-semibold text-[var(--main-text-color)]">{t('aboutUs')}</div>
22
+ {setting?.systemSubTitle ? (
23
+ <p className="m-0 mb-2 text-sm leading-relaxed text-[var(--second-text-color)]">
24
+ {setting.systemSubTitle}
25
+ </p>
26
+ ) : null}
27
+ {setting?.systemFooterInfo ? (
28
+ <div
29
+ className="rp-rich-text text-sm leading-relaxed text-[var(--second-text-color)]"
30
+ dangerouslySetInnerHTML={{ __html: setting.systemFooterInfo }}
31
+ />
32
+ ) : (
33
+ <p className="m-0 text-sm text-[var(--second-text-color)]">
34
+ {siteTitle} — Powered by ReactPress
35
+ </p>
36
+ )}
37
+ </div>
38
+ <div className="flex shrink-0 items-center gap-4">
39
+ <a
40
+ href="/rss"
41
+ target="_blank"
42
+ rel="noopener noreferrer"
43
+ aria-label="rss"
44
+ className="rp-footer-icon text-[var(--main-text-color)] hover:text-[var(--primary-color)]"
45
+ >
46
+ <svg viewBox="0 0 1024 1024" width="24" height="24" fill="currentColor" aria-hidden>
47
+ <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" />
48
+ </svg>
49
+ </a>
50
+ {setting?.aboutUsGithubUrl?.trim() ? (
51
+ <a
52
+ href={setting.aboutUsGithubUrl.trim()}
53
+ target="_blank"
54
+ rel="noopener noreferrer nofollow"
55
+ aria-label="Github"
56
+ className="rp-footer-icon text-[var(--main-text-color)] hover:text-[var(--primary-color)]"
57
+ >
58
+ <svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor" aria-hidden>
59
+ <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" />
60
+ </svg>
61
+ </a>
62
+ ) : null}
63
+ </div>
64
+ </div>
65
+ <div className="mt-6 text-center text-xs text-[var(--second-text-color)]">
66
+ <Link href="/" className="rp-inline-link rp-inline-link--muted">
67
+ {siteTitle}
68
+ </Link>
69
+ {' · '}
70
+ {`© ${new Date().getFullYear()}`}
71
+ </div>
72
+ </PageContainer>
73
+ </footer>
74
+ )
75
+ }
@@ -0,0 +1,312 @@
1
+ 'use client'
2
+
3
+ import PageContainer from '@/components/layout/PageContainer'
4
+ import HeaderSearch from '@/components/layout/HeaderSearch'
5
+ import LocaleSwitcher from '@/components/layout/LocaleSwitcher'
6
+ import ThemeSwitch from '@/components/layout/ThemeSwitch'
7
+ import UserAuth from '@/components/layout/UserAuth'
8
+ import Link from '@/components/shared/Link'
9
+ import ThemeImage from '@/components/shared/ThemeImage'
10
+ import Logo from '@/components/shared/logo.svg'
11
+ import { getDocumentScrollTop } from '@/lib/utils/scroll'
12
+ import { getFirstLevelRoute, getIconByName } from '@/lib/utils/icons'
13
+ import {
14
+ resolveImageUrl,
15
+ useSiteCatalog,
16
+ useSiteSetting,
17
+ useToggle,
18
+ } from '@fecommunity/reactpress-toolkit/theme'
19
+ import { useLocale } from '@fecommunity/reactpress-toolkit/ui'
20
+ import { usePathname } from 'next/navigation'
21
+ import { useEffect, useMemo } from 'react'
22
+
23
+ function isRemovedNavLink(item: { path?: string; locale?: string }) {
24
+ const path = (item.path || '').replace(/\/$/, '') || '/'
25
+ return path === '/nav' || item.locale === 'nav'
26
+ }
27
+
28
+ function HeaderLogo({ systemLogo }: { systemLogo?: string }) {
29
+ const raw = systemLogo?.trim() ?? ''
30
+ if (!raw) {
31
+ return <Logo className="relative z-[1] block h-12 w-auto max-w-[159px]" />
32
+ }
33
+ if (
34
+ raw.startsWith('http') ||
35
+ raw.startsWith('/') ||
36
+ /\.(svg|png|jpe?g|gif|webp)(\?.*)?$/i.test(raw)
37
+ ) {
38
+ return (
39
+ <ThemeImage
40
+ src={resolveImageUrl(raw, 'medium')}
41
+ alt="logo"
42
+ width={159}
43
+ height={48}
44
+ priority
45
+ className="relative z-[1] block h-12 w-auto max-w-[159px] object-contain"
46
+ />
47
+ )
48
+ }
49
+ if (raw.includes('<')) {
50
+ return <span dangerouslySetInnerHTML={{ __html: raw }} />
51
+ }
52
+ return <Logo className="relative z-[1] block h-12 w-auto max-w-[159px]" />
53
+ }
54
+
55
+ export default function SiteHeader() {
56
+ const { t } = useLocale()
57
+ const pathname = usePathname() ?? '/'
58
+ const setting = useSiteSetting()
59
+ const { pages, locales = [], siteConfig } = useSiteCatalog()
60
+ const navLinks = useMemo(() => siteConfig?.header?.navLinks ?? [], [siteConfig?.header?.navLinks])
61
+ const [affix, setAffix] = useToggle(false)
62
+ const [affixVisible, setAffixVisible] = useToggle(false)
63
+ const [visible, setVisible] = useToggle(false)
64
+ const mainPath = getFirstLevelRoute(pathname, locales)
65
+
66
+ useEffect(() => {
67
+ if (!visible) return
68
+ const previousOverflow = document.body.style.overflow
69
+ document.body.style.overflow = 'hidden'
70
+ return () => {
71
+ document.body.style.overflow = previousOverflow
72
+ }
73
+ }, [visible])
74
+
75
+ useEffect(() => {
76
+ let beforeY = 0
77
+ let ticking = false
78
+ const handler = () => {
79
+ if (ticking) return
80
+ ticking = true
81
+ window.requestAnimationFrame(() => {
82
+ const y = getDocumentScrollTop()
83
+ setAffix(y > 0)
84
+ setAffixVisible(beforeY >= y)
85
+ beforeY = y
86
+ ticking = false
87
+ })
88
+ }
89
+ document.addEventListener('scroll', handler, { passive: true })
90
+ return () => document.removeEventListener('scroll', handler)
91
+ }, [setAffix, setAffixVisible])
92
+
93
+ useEffect(() => {
94
+ const offset = affix && affixVisible ? '64px' : '0px'
95
+ document.documentElement.style.setProperty('--rp-header-offset', offset)
96
+ window.postMessage(
97
+ {
98
+ id: 'header-state',
99
+ isFixedVisible: affix && affixVisible,
100
+ height: '64px',
101
+ isFxied: affix,
102
+ },
103
+ location.origin
104
+ )
105
+ return () => {
106
+ document.documentElement.style.removeProperty('--rp-header-offset')
107
+ }
108
+ }, [affix, affixVisible])
109
+
110
+ const navItems = useMemo(() => {
111
+ const navMenu = navLinks
112
+ .filter((nav) => !isRemovedNavLink(nav))
113
+ .map((nav) => {
114
+ const Icon = getIconByName(nav.icon)
115
+ const labelText = nav.label || (nav.locale ? t(nav.locale) : nav.path)
116
+ return {
117
+ key: nav.path,
118
+ href: nav.path,
119
+ label: labelText,
120
+ icon: Icon ? <Icon size={20} /> : null,
121
+ }
122
+ })
123
+
124
+ const pageMenu = pages
125
+ .filter((menu) => !isRemovedNavLink({ path: menu.path, locale: menu.path }))
126
+ .map((menu, index) => {
127
+ const Icon = getIconByName(menu.path)
128
+ const href = menu.path?.startsWith('/') ? menu.path : `/${menu.path}/`
129
+ return {
130
+ key: `${index}-${menu.name}`,
131
+ href,
132
+ label: (menu.path ? t(menu.path) : '') || menu.name,
133
+ icon: Icon ? <Icon size={20} /> : null,
134
+ }
135
+ })
136
+
137
+ return navMenu.concat(pageMenu)
138
+ }, [navLinks, pages, t])
139
+
140
+ useEffect(() => {
141
+ setVisible(false)
142
+ }, [pathname, setVisible])
143
+
144
+ const desktopNavLinkClass = (isActive: boolean) =>
145
+ `rp-nav-link inline-flex h-9 items-center gap-1.5 whitespace-nowrap rounded-lg px-3 text-sm no-underline ${
146
+ isActive
147
+ ? 'bg-[var(--bg-second)] font-medium text-[var(--primary-button-bg)] dark:text-[#ff6659]'
148
+ : 'text-[var(--second-text-color)] hover:bg-[var(--bg-second)] hover:text-[var(--main-text-color)]'
149
+ }`
150
+
151
+ const mobileNavLinkClass = (isActive: boolean) =>
152
+ `rp-nav-link inline-flex items-center gap-2 whitespace-nowrap no-underline ${
153
+ isActive
154
+ ? 'font-medium text-[var(--primary-color)] dark:text-[#ff6659]'
155
+ : 'text-[var(--main-text-color)] hover:text-[var(--primary-color)] dark:hover:text-[#ff6659]'
156
+ }`
157
+
158
+ const renderNavLinks = (mode: 'desktop' | 'mobile', onNavigate?: () => void) => (
159
+ <ul
160
+ className={
161
+ mode === 'desktop'
162
+ ? 'm-0 flex min-w-0 [scrollbar-width:none] list-none items-center justify-start gap-0.5 overflow-x-auto p-0 [-ms-overflow-style:none] lg:gap-1 [&::-webkit-scrollbar]:hidden'
163
+ : 'm-0 flex w-full list-none flex-col items-stretch gap-0 p-0'
164
+ }
165
+ >
166
+ {navItems.map((item, index) => {
167
+ const isActive = mainPath === item.key || pathname === item.href
168
+ const isLast = index === navItems.length - 1
169
+ return (
170
+ <li key={item.key} className={mode === 'mobile' ? 'w-full' : 'shrink-0'}>
171
+ <Link
172
+ href={item.href}
173
+ onClick={onNavigate}
174
+ className={
175
+ mode === 'desktop'
176
+ ? desktopNavLinkClass(isActive)
177
+ : `h-[52px] w-full border-b px-1 ${isLast ? 'border-transparent' : 'border-[var(--border-color)]'} ${mobileNavLinkClass(isActive)}`
178
+ }
179
+ >
180
+ {item.icon}
181
+ <span>{item.label}</span>
182
+ </Link>
183
+ </li>
184
+ )
185
+ })}
186
+ </ul>
187
+ )
188
+
189
+ const renderIconTools = (mode: 'desktop' | 'mobile') => (
190
+ <ul
191
+ className={
192
+ mode === 'desktop'
193
+ ? 'm-0 flex list-none items-center gap-0.5 p-0 xl:gap-1'
194
+ : 'm-0 flex list-none items-center justify-center gap-3 p-0'
195
+ }
196
+ >
197
+ <li className="flex h-10 w-10 items-center justify-center">
198
+ <HeaderSearch />
199
+ </li>
200
+ <li className="flex h-10 w-10 items-center justify-center">
201
+ <ThemeSwitch />
202
+ </li>
203
+ <li className="flex h-10 w-10 items-center justify-center">
204
+ <LocaleSwitcher />
205
+ </li>
206
+ {setting.aboutUsGithubUrl?.trim() ? (
207
+ <li className="flex h-10 w-10 items-center justify-center">
208
+ <a
209
+ href={setting.aboutUsGithubUrl.trim()}
210
+ target="_blank"
211
+ rel="noopener noreferrer nofollow"
212
+ aria-label="Github"
213
+ className="rp-icon-btn inline-flex h-10 w-10 items-center justify-center rounded-full text-[var(--main-text-color)] no-underline hover:bg-[var(--bg-second)] hover:text-[var(--primary-color)]"
214
+ >
215
+ <svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor" aria-hidden>
216
+ <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" />
217
+ </svg>
218
+ </a>
219
+ </li>
220
+ ) : null}
221
+ </ul>
222
+ )
223
+
224
+ const renderMobileToolArea = () => (
225
+ <div className="mt-3 flex w-full flex-col gap-4 border-t border-[var(--border-color)] pt-4">
226
+ {renderIconTools('mobile')}
227
+ <UserAuth layout="drawer" />
228
+ </div>
229
+ )
230
+
231
+ return (
232
+ <header className="relative z-[100] h-16 w-full bg-[var(--bg-box)]">
233
+ <a
234
+ href="#main-content"
235
+ className="absolute top-0 -left-[9999px] z-[200] rounded-lg bg-[var(--primary-color)] px-4 py-2 text-white no-underline focus:top-2 focus:left-4"
236
+ >
237
+ {t('skipToContent')}
238
+ </a>
239
+ <div
240
+ className={`rp-header-bar z-[100] h-16 w-full border-b border-[var(--border-color)] bg-[var(--bg-box)] ${
241
+ affix ? 'is-affix lg:fixed lg:top-0 lg:right-0 lg:left-0 lg:-translate-y-16' : 'relative'
242
+ } ${affix && affixVisible ? 'is-visible lg:!translate-y-0' : ''}`}
243
+ >
244
+ <PageContainer className="relative flex h-16 items-center gap-3 lg:gap-6">
245
+ <div className="rp-header-logo min-w-0 flex-1 shrink-0 text-[var(--main-text-color)] lg:flex-none">
246
+ <Link
247
+ href="/"
248
+ aria-label={setting.systemTitle || 'Home'}
249
+ className="rp-header-logo-link inline-flex h-12 max-h-12 max-w-[159px] items-center overflow-hidden rounded-sm"
250
+ >
251
+ <HeaderLogo systemLogo={setting.systemLogo as string | undefined} />
252
+ </Link>
253
+ </div>
254
+
255
+ <nav
256
+ className="hidden min-w-0 items-center overflow-hidden text-[var(--main-text-color)] lg:flex"
257
+ aria-label={setting.systemTitle || 'Navigation'}
258
+ >
259
+ {renderNavLinks('desktop')}
260
+ </nav>
261
+
262
+ <div className="hidden shrink-0 items-center gap-3 lg:ml-auto lg:flex xl:gap-4">
263
+ {renderIconTools('desktop')}
264
+ <UserAuth layout="header" />
265
+ </div>
266
+
267
+ <button
268
+ type="button"
269
+ className="relative z-[101] flex h-11 w-11 shrink-0 flex-col items-center justify-center border-0 bg-transparent p-0 lg:hidden"
270
+ onClick={() => setVisible(!visible)}
271
+ aria-label="Toggle mobile menu"
272
+ aria-expanded={visible}
273
+ >
274
+ <span
275
+ className={`block h-0.5 w-[22px] rounded bg-[var(--primary-color)] transition-all duration-300 ease-out ${
276
+ visible ? 'translate-y-2 rotate-45' : ''
277
+ }`}
278
+ />
279
+ <span
280
+ className={`my-1.5 block h-0.5 w-[22px] rounded bg-[var(--primary-color)] transition-all duration-300 ease-out ${
281
+ visible ? 'opacity-0' : ''
282
+ }`}
283
+ />
284
+ <span
285
+ className={`block h-0.5 w-[22px] rounded bg-[var(--primary-color)] transition-all duration-300 ease-out ${
286
+ visible ? '-translate-y-2 -rotate-45' : ''
287
+ }`}
288
+ />
289
+ </button>
290
+ </PageContainer>
291
+ </div>
292
+
293
+ {visible ? (
294
+ <>
295
+ <button
296
+ type="button"
297
+ className="rp-mobile-backdrop fixed top-16 right-0 bottom-0 left-0 z-[98] border-0 bg-black/45 lg:hidden"
298
+ aria-label="Close menu"
299
+ onClick={() => setVisible(false)}
300
+ />
301
+ <nav
302
+ className="rp-mobile-drawer fixed top-16 right-0 left-0 z-[99] flex max-h-[calc(100dvh-4rem)] flex-col overflow-y-auto border-b border-[var(--border-color)] bg-[var(--bg-box)] px-4 py-3 text-base text-[var(--main-text-color)] shadow-[var(--box-shadow)] lg:hidden"
303
+ aria-label={setting.systemTitle || 'Navigation'}
304
+ >
305
+ {renderNavLinks('mobile', () => setVisible(false))}
306
+ {renderMobileToolArea()}
307
+ </nav>
308
+ </>
309
+ ) : null}
310
+ </header>
311
+ )
312
+ }
@@ -0,0 +1,176 @@
1
+ 'use client'
2
+
3
+ import { siteNoticeDisplayLines, useSiteSetting } from '@fecommunity/reactpress-toolkit/theme'
4
+ import { useEffect, useRef, useState } from 'react'
5
+
6
+ import { BellIcon } from '@/lib/utils/icons'
7
+
8
+ const NOTICE_INTERVAL_MS = 4500
9
+
10
+ function noticePlainText(html: string) {
11
+ return html
12
+ .replace(/<[^>]+>/g, ' ')
13
+ .replace(/\s+/g, ' ')
14
+ .trim()
15
+ }
16
+
17
+ function NoticeContent({ html }: { html: string }) {
18
+ const plain = noticePlainText(html)
19
+
20
+ return (
21
+ <div
22
+ className="rp-notification-text"
23
+ title={plain || undefined}
24
+ dangerouslySetInnerHTML={{ __html: html }}
25
+ />
26
+ )
27
+ }
28
+
29
+ function NoticeTicker({
30
+ notices,
31
+ activeIndex,
32
+ canRotate,
33
+ canAnimate,
34
+ }: {
35
+ notices: string[]
36
+ activeIndex: number
37
+ canRotate: boolean
38
+ canAnimate: boolean
39
+ }) {
40
+ const [leavingIndex, setLeavingIndex] = useState<number | null>(null)
41
+ const prevIndexRef = useRef(activeIndex)
42
+
43
+ useEffect(() => {
44
+ if (!canAnimate || prevIndexRef.current === activeIndex) return undefined
45
+
46
+ setLeavingIndex(prevIndexRef.current)
47
+ prevIndexRef.current = activeIndex
48
+
49
+ const timer = window.setTimeout(() => setLeavingIndex(null), 540)
50
+ return () => window.clearTimeout(timer)
51
+ }, [activeIndex, canAnimate])
52
+
53
+ useEffect(() => {
54
+ prevIndexRef.current = 0
55
+ setLeavingIndex(null)
56
+ }, [notices])
57
+
58
+ const content = notices[activeIndex] ?? notices[0]
59
+
60
+ if (!canRotate) {
61
+ return <NoticeContent html={content} />
62
+ }
63
+
64
+ if (!canAnimate) {
65
+ return <NoticeContent html={content} />
66
+ }
67
+
68
+ return (
69
+ <div className="rp-notification-ticker-viewport">
70
+ {leavingIndex !== null ? (
71
+ <div
72
+ key={`leave-${leavingIndex}-${activeIndex}`}
73
+ className="rp-notification-ticker-item is-leaving"
74
+ aria-hidden
75
+ >
76
+ <NoticeContent html={notices[leavingIndex]} />
77
+ </div>
78
+ ) : null}
79
+ <div key={`enter-${activeIndex}`} className="rp-notification-ticker-item is-entering">
80
+ <NoticeContent html={content} />
81
+ </div>
82
+ </div>
83
+ )
84
+ }
85
+
86
+ export default function SystemNotification() {
87
+ const setting = useSiteSetting()
88
+ const notices = siteNoticeDisplayLines(setting?.systemNoticeInfo)
89
+ const [activeIndex, setActiveIndex] = useState(0)
90
+ const [paused, setPaused] = useState(false)
91
+ const [bellPulse, setBellPulse] = useState(false)
92
+ const [phase, setPhase] = useState<'visible' | 'dismissing' | 'hidden'>('visible')
93
+ const [reduceMotion, setReduceMotion] = useState(false)
94
+
95
+ const canRotate = notices.length > 1
96
+ const canAnimate = canRotate && !reduceMotion
97
+
98
+ useEffect(() => {
99
+ const mq = window.matchMedia('(prefers-reduced-motion: reduce)')
100
+ const sync = () => setReduceMotion(mq.matches)
101
+ sync()
102
+ mq.addEventListener('change', sync)
103
+ return () => mq.removeEventListener('change', sync)
104
+ }, [])
105
+
106
+ useEffect(() => {
107
+ setActiveIndex(0)
108
+ }, [notices])
109
+
110
+ useEffect(() => {
111
+ if (!canRotate || paused || phase !== 'visible') return undefined
112
+
113
+ const timer = window.setInterval(() => {
114
+ setActiveIndex((current) => (current + 1) % notices.length)
115
+ setBellPulse(true)
116
+ }, NOTICE_INTERVAL_MS)
117
+
118
+ return () => window.clearInterval(timer)
119
+ }, [canRotate, paused, phase, notices.length])
120
+
121
+ useEffect(() => {
122
+ if (!bellPulse) return undefined
123
+ const timer = window.setTimeout(() => setBellPulse(false), 560)
124
+ return () => window.clearTimeout(timer)
125
+ }, [bellPulse])
126
+
127
+ const handleDismiss = () => {
128
+ setPhase('dismissing')
129
+ window.setTimeout(() => setPhase('hidden'), 320)
130
+ }
131
+
132
+ if (!notices.length || phase === 'hidden') return null
133
+
134
+ return (
135
+ <div
136
+ className={`rp-notification-bar mt-4 flex items-center gap-3 overflow-hidden rounded-xl border border-[color-mix(in_srgb,var(--border-color)_70%,transparent)] bg-[color-mix(in_srgb,var(--bg-box)_92%,transparent)] px-4 py-3 text-sm text-[var(--main-text-color)] shadow-[var(--box-shadow)] backdrop-blur-sm ${
137
+ phase === 'dismissing' ? 'is-dismissing' : ''
138
+ }`}
139
+ onMouseEnter={() => setPaused(true)}
140
+ onMouseLeave={() => setPaused(false)}
141
+ onFocusCapture={() => setPaused(true)}
142
+ onBlurCapture={(event) => {
143
+ if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
144
+ setPaused(false)
145
+ }
146
+ }}
147
+ role="status"
148
+ aria-live="polite"
149
+ >
150
+ <span
151
+ className={`rp-notification-icon shrink-0 text-[var(--primary-color)] ${bellPulse ? 'is-active' : ''}`}
152
+ aria-hidden
153
+ >
154
+ <BellIcon size={16} />
155
+ </span>
156
+
157
+ <div className="min-w-0 flex-1 [&_a]:text-inherit [&_a]:no-underline [&_a:hover]:text-[var(--primary-color)]">
158
+ <NoticeTicker
159
+ notices={notices}
160
+ activeIndex={activeIndex}
161
+ canRotate={canRotate}
162
+ canAnimate={canAnimate}
163
+ />
164
+ </div>
165
+
166
+ <button
167
+ type="button"
168
+ aria-label="Close notification"
169
+ className="rp-notification-close shrink-0 text-[var(--second-text-color)] hover:text-[var(--primary-color)]"
170
+ onClick={handleDismiss}
171
+ >
172
+ ×
173
+ </button>
174
+ </div>
175
+ )
176
+ }
@@ -0,0 +1,19 @@
1
+ 'use client'
2
+
3
+ import { ThemeToggleButton, TOOLBAR_ICON_SIZE, useLocale } from '@fecommunity/reactpress-toolkit/ui'
4
+ import { useColorMode } from '@fecommunity/reactpress-toolkit/theme'
5
+
6
+ export default function ThemeSwitch() {
7
+ const { t } = useLocale()
8
+ const { colorMode: theme = 'light', changeColorMode: changeTheme } = useColorMode()
9
+ const dark = theme === 'dark'
10
+
11
+ return (
12
+ <ThemeToggleButton
13
+ isDark={dark}
14
+ onToggle={() => changeTheme?.(dark ? 'light' : 'dark')}
15
+ size={TOOLBAR_ICON_SIZE}
16
+ aria-label={t('theme.toggle')}
17
+ />
18
+ )
19
+ }