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