@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,77 @@
|
|
|
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
|
+
interface LikesWidgetProps {
|
|
10
|
+
defaultCount?: number
|
|
11
|
+
id: string
|
|
12
|
+
api: (id: string, type: 'like' | 'dislike') => Promise<number>
|
|
13
|
+
variant?: ArticleActionVariant
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export default function LikesWidget({
|
|
17
|
+
defaultCount = 0,
|
|
18
|
+
id,
|
|
19
|
+
api,
|
|
20
|
+
variant = 'floating',
|
|
21
|
+
}: LikesWidgetProps) {
|
|
22
|
+
const { locale } = useSiteCatalog()
|
|
23
|
+
const [count, setCount] = useState(defaultCount)
|
|
24
|
+
const [liked, setLiked] = useState(false)
|
|
25
|
+
const label = locale === 'zh' ? '点赞' : 'Like'
|
|
26
|
+
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
setCount(defaultCount)
|
|
29
|
+
}, [defaultCount])
|
|
30
|
+
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
try {
|
|
33
|
+
const stored = JSON.parse(window.localStorage.getItem('LIKES') || '[]') as string[]
|
|
34
|
+
setLiked(stored.includes(id))
|
|
35
|
+
} catch {
|
|
36
|
+
setLiked(false)
|
|
37
|
+
}
|
|
38
|
+
}, [id])
|
|
39
|
+
|
|
40
|
+
const toggle = useCallback(() => {
|
|
41
|
+
const type = liked ? 'dislike' : 'like'
|
|
42
|
+
api(id, type).then((next) => {
|
|
43
|
+
setCount(next)
|
|
44
|
+
try {
|
|
45
|
+
const stored = JSON.parse(window.localStorage.getItem('LIKES') || '[]') as string[]
|
|
46
|
+
const nextStored = liked ? stored.filter((item) => item !== id) : [...stored, id]
|
|
47
|
+
window.localStorage.setItem('LIKES', JSON.stringify(nextStored))
|
|
48
|
+
setLiked(!liked)
|
|
49
|
+
} catch {
|
|
50
|
+
setLiked(!liked)
|
|
51
|
+
}
|
|
52
|
+
})
|
|
53
|
+
}, [api, id, liked])
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<ArticleActionButton
|
|
57
|
+
active={liked}
|
|
58
|
+
label={label}
|
|
59
|
+
count={count}
|
|
60
|
+
onClick={toggle}
|
|
61
|
+
variant={variant}
|
|
62
|
+
activeClassName="is-liked"
|
|
63
|
+
>
|
|
64
|
+
<svg
|
|
65
|
+
viewBox="0 0 24 24"
|
|
66
|
+
width="22"
|
|
67
|
+
height="22"
|
|
68
|
+
fill="none"
|
|
69
|
+
stroke="currentColor"
|
|
70
|
+
strokeWidth="1.75"
|
|
71
|
+
aria-hidden
|
|
72
|
+
>
|
|
73
|
+
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" />
|
|
74
|
+
</svg>
|
|
75
|
+
</ArticleActionButton>
|
|
76
|
+
)
|
|
77
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import ArticleActionButton, {
|
|
4
|
+
type ArticleActionVariant,
|
|
5
|
+
} from '@/components/widgets/ArticleActionButton'
|
|
6
|
+
import { useLocale } from '@fecommunity/reactpress-toolkit/ui'
|
|
7
|
+
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
8
|
+
import { createPortal } from 'react-dom'
|
|
9
|
+
|
|
10
|
+
interface ShareWidgetProps {
|
|
11
|
+
variant?: ArticleActionVariant
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async function copyToClipboard(text: string): Promise<boolean> {
|
|
15
|
+
try {
|
|
16
|
+
await navigator.clipboard.writeText(text)
|
|
17
|
+
return true
|
|
18
|
+
} catch {
|
|
19
|
+
try {
|
|
20
|
+
const textarea = document.createElement('textarea')
|
|
21
|
+
textarea.value = text
|
|
22
|
+
textarea.style.position = 'fixed'
|
|
23
|
+
textarea.style.opacity = '0'
|
|
24
|
+
document.body.appendChild(textarea)
|
|
25
|
+
textarea.select()
|
|
26
|
+
const ok = document.execCommand('copy')
|
|
27
|
+
document.body.removeChild(textarea)
|
|
28
|
+
return ok
|
|
29
|
+
} catch {
|
|
30
|
+
return false
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export default function ShareWidget({ variant = 'floating' }: ShareWidgetProps) {
|
|
36
|
+
const { t } = useLocale()
|
|
37
|
+
const [toast, setToast] = useState('')
|
|
38
|
+
const [copied, setCopied] = useState(false)
|
|
39
|
+
const [busy, setBusy] = useState(false)
|
|
40
|
+
const [mounted, setMounted] = useState(false)
|
|
41
|
+
const timerRef = useRef<number | null>(null)
|
|
42
|
+
const lockingRef = useRef(false)
|
|
43
|
+
|
|
44
|
+
const label = t('share')
|
|
45
|
+
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
setMounted(true)
|
|
48
|
+
}, [])
|
|
49
|
+
|
|
50
|
+
const clearTimer = useCallback(() => {
|
|
51
|
+
if (timerRef.current !== null) {
|
|
52
|
+
window.clearTimeout(timerRef.current)
|
|
53
|
+
timerRef.current = null
|
|
54
|
+
}
|
|
55
|
+
}, [])
|
|
56
|
+
|
|
57
|
+
useEffect(() => clearTimer, [clearTimer])
|
|
58
|
+
|
|
59
|
+
const handleShare = useCallback(async () => {
|
|
60
|
+
if (lockingRef.current) return
|
|
61
|
+
|
|
62
|
+
lockingRef.current = true
|
|
63
|
+
setBusy(true)
|
|
64
|
+
|
|
65
|
+
const ok = await copyToClipboard(window.location.href)
|
|
66
|
+
if (!ok) {
|
|
67
|
+
lockingRef.current = false
|
|
68
|
+
setBusy(false)
|
|
69
|
+
return
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
setCopied(true)
|
|
73
|
+
setToast(t('shareLinkCopied'))
|
|
74
|
+
clearTimer()
|
|
75
|
+
timerRef.current = window.setTimeout(() => {
|
|
76
|
+
setToast('')
|
|
77
|
+
setCopied(false)
|
|
78
|
+
setBusy(false)
|
|
79
|
+
lockingRef.current = false
|
|
80
|
+
timerRef.current = null
|
|
81
|
+
}, 2000)
|
|
82
|
+
}, [clearTimer, t])
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<>
|
|
86
|
+
<ArticleActionButton
|
|
87
|
+
active={copied}
|
|
88
|
+
disabled={busy}
|
|
89
|
+
label={label}
|
|
90
|
+
onClick={handleShare}
|
|
91
|
+
variant={variant}
|
|
92
|
+
activeClassName="is-shared"
|
|
93
|
+
>
|
|
94
|
+
<svg
|
|
95
|
+
viewBox="0 0 24 24"
|
|
96
|
+
width="22"
|
|
97
|
+
height="22"
|
|
98
|
+
fill="none"
|
|
99
|
+
stroke="currentColor"
|
|
100
|
+
strokeWidth="1.75"
|
|
101
|
+
aria-hidden
|
|
102
|
+
>
|
|
103
|
+
<circle cx="18" cy="5" r="3" />
|
|
104
|
+
<circle cx="6" cy="12" r="3" />
|
|
105
|
+
<circle cx="18" cy="19" r="3" />
|
|
106
|
+
<path d="m8.59 13.51 6.83 3.98" strokeLinecap="round" strokeLinejoin="round" />
|
|
107
|
+
<path d="M15.41 6.51l-6.82 3.98" strokeLinecap="round" strokeLinejoin="round" />
|
|
108
|
+
</svg>
|
|
109
|
+
</ArticleActionButton>
|
|
110
|
+
{toast && mounted
|
|
111
|
+
? createPortal(
|
|
112
|
+
<div className="rp-share-toast" role="status" aria-live="polite">
|
|
113
|
+
{toast}
|
|
114
|
+
</div>,
|
|
115
|
+
document.body
|
|
116
|
+
)
|
|
117
|
+
: null}
|
|
118
|
+
</>
|
|
119
|
+
)
|
|
120
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import TagCloudEngine from '@/lib/utils/tagCloud'
|
|
4
|
+
import { type ReactNode, useLayoutEffect, useRef } from 'react'
|
|
5
|
+
|
|
6
|
+
interface TagCloudProps {
|
|
7
|
+
children: ReactNode
|
|
8
|
+
className?: string
|
|
9
|
+
onReadyChange?: (ready: boolean) => void
|
|
10
|
+
'aria-hidden'?: boolean
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function canInitialize(el: HTMLDivElement) {
|
|
14
|
+
const anchors = el.getElementsByTagName('a')
|
|
15
|
+
if (!anchors.length || el.offsetWidth === 0 || el.offsetHeight === 0) return false
|
|
16
|
+
|
|
17
|
+
for (let i = 0; i < anchors.length; i++) {
|
|
18
|
+
if (anchors[i].offsetWidth > 0) return true
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return false
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export default function TagCloud({
|
|
25
|
+
children,
|
|
26
|
+
className = '',
|
|
27
|
+
onReadyChange,
|
|
28
|
+
...rest
|
|
29
|
+
}: TagCloudProps) {
|
|
30
|
+
const ref = useRef<HTMLDivElement>(null)
|
|
31
|
+
const engineRef = useRef<TagCloudEngine | null>(null)
|
|
32
|
+
|
|
33
|
+
useLayoutEffect(() => {
|
|
34
|
+
const el = ref.current
|
|
35
|
+
if (!el) return undefined
|
|
36
|
+
|
|
37
|
+
if (!engineRef.current) {
|
|
38
|
+
engineRef.current = new TagCloudEngine()
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const engine = engineRef.current
|
|
42
|
+
let cancelled = false
|
|
43
|
+
let observer: ResizeObserver | null = null
|
|
44
|
+
|
|
45
|
+
const markReady = (ready: boolean) => {
|
|
46
|
+
onReadyChange?.(ready)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const tryInit = () => {
|
|
50
|
+
if (cancelled || !ref.current) return true
|
|
51
|
+
if (!canInitialize(ref.current)) return false
|
|
52
|
+
|
|
53
|
+
ref.current.classList.remove('is-ready')
|
|
54
|
+
markReady(false)
|
|
55
|
+
engine.init(ref.current)
|
|
56
|
+
ref.current.classList.add('is-ready')
|
|
57
|
+
markReady(true)
|
|
58
|
+
return true
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
el.classList.remove('is-ready')
|
|
62
|
+
markReady(false)
|
|
63
|
+
|
|
64
|
+
if (!tryInit()) {
|
|
65
|
+
observer = new ResizeObserver(() => {
|
|
66
|
+
if (tryInit() && observer) {
|
|
67
|
+
observer.disconnect()
|
|
68
|
+
observer = null
|
|
69
|
+
}
|
|
70
|
+
})
|
|
71
|
+
observer.observe(el)
|
|
72
|
+
|
|
73
|
+
if (typeof document !== 'undefined' && document.fonts?.ready) {
|
|
74
|
+
void document.fonts.ready.then(() => {
|
|
75
|
+
if (!cancelled) tryInit()
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return () => {
|
|
81
|
+
cancelled = true
|
|
82
|
+
observer?.disconnect()
|
|
83
|
+
engine.destroy()
|
|
84
|
+
el.classList.remove('is-ready')
|
|
85
|
+
markReady(false)
|
|
86
|
+
}
|
|
87
|
+
}, [children, onReadyChange])
|
|
88
|
+
|
|
89
|
+
return (
|
|
90
|
+
<div ref={ref} className={`rp-tag-cloud ${className}`.trim()} {...rest}>
|
|
91
|
+
{children}
|
|
92
|
+
</div>
|
|
93
|
+
)
|
|
94
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import Link from '@/components/shared/Link'
|
|
4
|
+
import TagCloud from '@/components/widgets/TagCloud'
|
|
5
|
+
import { getColorFromNumber, getTagStyle } from '@/lib/utils/colors'
|
|
6
|
+
import { TagIcon } from '@/lib/utils/icons'
|
|
7
|
+
import { useLocale } from '@fecommunity/reactpress-toolkit/ui'
|
|
8
|
+
import { useCallback, useEffect, useState } from 'react'
|
|
9
|
+
|
|
10
|
+
interface Tag {
|
|
11
|
+
id?: string | number
|
|
12
|
+
value: string
|
|
13
|
+
label: string
|
|
14
|
+
articleCount?: number
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function tagKey(tag: Tag, index: number): string {
|
|
18
|
+
return String(tag.id ?? tag.value ?? index)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface TagsWidgetProps {
|
|
22
|
+
tags: Tag[]
|
|
23
|
+
needTitle?: boolean
|
|
24
|
+
animationMode?: boolean
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const TAG_CLOUD_SKELETON_ITEMS = [
|
|
28
|
+
{ top: '16%', left: '18%', width: 68 },
|
|
29
|
+
{ top: '28%', left: '56%', width: 54 },
|
|
30
|
+
{ top: '42%', left: '8%', width: 76 },
|
|
31
|
+
{ top: '50%', left: '42%', width: 48 },
|
|
32
|
+
{ top: '64%', left: '24%', width: 62 },
|
|
33
|
+
{ top: '70%', left: '58%', width: 52 },
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
function TagCloudSkeleton() {
|
|
37
|
+
return (
|
|
38
|
+
<div className="rp-tag-cloud-skeleton mx-auto my-2" aria-hidden>
|
|
39
|
+
{TAG_CLOUD_SKELETON_ITEMS.map((item, index) => (
|
|
40
|
+
<span
|
|
41
|
+
key={index}
|
|
42
|
+
className="rp-tag-cloud-skeleton-item"
|
|
43
|
+
style={{
|
|
44
|
+
top: item.top,
|
|
45
|
+
left: item.left,
|
|
46
|
+
width: item.width,
|
|
47
|
+
animationDelay: `${index * 80}ms`,
|
|
48
|
+
}}
|
|
49
|
+
/>
|
|
50
|
+
))}
|
|
51
|
+
</div>
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export default function TagsWidget({
|
|
56
|
+
tags = [],
|
|
57
|
+
needTitle = true,
|
|
58
|
+
animationMode = false,
|
|
59
|
+
}: TagsWidgetProps) {
|
|
60
|
+
const { t } = useLocale()
|
|
61
|
+
const [cloudReady, setCloudReady] = useState(false)
|
|
62
|
+
const [hydrated, setHydrated] = useState(false)
|
|
63
|
+
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
setHydrated(true)
|
|
66
|
+
}, [])
|
|
67
|
+
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
setCloudReady(false)
|
|
70
|
+
}, [tags])
|
|
71
|
+
|
|
72
|
+
const handleCloudReadyChange = useCallback((ready: boolean) => {
|
|
73
|
+
setCloudReady(ready)
|
|
74
|
+
}, [])
|
|
75
|
+
|
|
76
|
+
const useTagCloud = animationMode && tags.length > 4
|
|
77
|
+
const showCloudSkeleton = useTagCloud && tags.length > 0 && (!hydrated || !cloudReady)
|
|
78
|
+
|
|
79
|
+
const compactTagList =
|
|
80
|
+
tags.length > 0 ? (
|
|
81
|
+
<ul className="rp-sidebar-widget__tags-list">
|
|
82
|
+
{tags.map((tag, index) => (
|
|
83
|
+
<li key={tagKey(tag, index)} className="m-0 p-0">
|
|
84
|
+
<Link
|
|
85
|
+
href={`/tag/${tag.value}`}
|
|
86
|
+
className="inline-block rounded px-2 py-1 text-sm no-underline transition-all duration-200 hover:scale-105 hover:opacity-90 hover:shadow-sm"
|
|
87
|
+
style={getTagStyle(getColorFromNumber(index))}
|
|
88
|
+
>
|
|
89
|
+
{tag.label}
|
|
90
|
+
{typeof tag.articleCount === 'number' ? ` [${tag.articleCount}]` : ''}
|
|
91
|
+
</Link>
|
|
92
|
+
</li>
|
|
93
|
+
))}
|
|
94
|
+
</ul>
|
|
95
|
+
) : (
|
|
96
|
+
<div className="flex h-full items-center justify-center px-4 text-sm text-[var(--second-text-color)]">
|
|
97
|
+
{t('empty')}
|
|
98
|
+
</div>
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
return (
|
|
102
|
+
<div
|
|
103
|
+
className={`rp-widget-panel rp-sidebar-widget 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${useTagCloud ? 'rp-sidebar-widget--cloud' : 'rp-sidebar-widget--compact'}`}
|
|
104
|
+
>
|
|
105
|
+
{needTitle ? (
|
|
106
|
+
<div className="rp-sidebar-widget__header">
|
|
107
|
+
<TagIcon size={16} className="mr-2 inline-block text-[var(--primary-color)]" />
|
|
108
|
+
<span>{t('tagTitle')}</span>
|
|
109
|
+
</div>
|
|
110
|
+
) : null}
|
|
111
|
+
|
|
112
|
+
<div className="rp-sidebar-widget__body">
|
|
113
|
+
{useTagCloud ? (
|
|
114
|
+
<>
|
|
115
|
+
<div className="rp-sidebar-widget__tag-host">
|
|
116
|
+
{showCloudSkeleton ? <TagCloudSkeleton /> : null}
|
|
117
|
+
{tags.length > 0 ? (
|
|
118
|
+
<TagCloud
|
|
119
|
+
className={`transition-opacity duration-300 ${cloudReady ? 'opacity-100' : 'opacity-0'}`}
|
|
120
|
+
onReadyChange={handleCloudReadyChange}
|
|
121
|
+
aria-hidden
|
|
122
|
+
>
|
|
123
|
+
{tags.map((tag, index) => (
|
|
124
|
+
<a
|
|
125
|
+
key={tagKey(tag, index)}
|
|
126
|
+
href={`/tag/${tag.value}`}
|
|
127
|
+
tabIndex={-1}
|
|
128
|
+
aria-hidden
|
|
129
|
+
style={getTagStyle(getColorFromNumber(index))}
|
|
130
|
+
>
|
|
131
|
+
{tag.label}
|
|
132
|
+
</a>
|
|
133
|
+
))}
|
|
134
|
+
</TagCloud>
|
|
135
|
+
) : (
|
|
136
|
+
<div className="flex h-full items-center justify-center px-4 text-sm text-[var(--second-text-color)]">
|
|
137
|
+
{t('empty')}
|
|
138
|
+
</div>
|
|
139
|
+
)}
|
|
140
|
+
</div>
|
|
141
|
+
<nav aria-label={t('tagTitle')} className="sr-only">
|
|
142
|
+
<ul>
|
|
143
|
+
{tags.map((tag, index) => (
|
|
144
|
+
<li key={tagKey(tag, index)}>
|
|
145
|
+
<Link href={`/tag/${tag.value}`}>
|
|
146
|
+
{tag.label}
|
|
147
|
+
{typeof tag.articleCount === 'number' ? ` [${tag.articleCount}]` : ''}
|
|
148
|
+
</Link>
|
|
149
|
+
</li>
|
|
150
|
+
))}
|
|
151
|
+
</ul>
|
|
152
|
+
</nav>
|
|
153
|
+
</>
|
|
154
|
+
) : (
|
|
155
|
+
compactTagList
|
|
156
|
+
)}
|
|
157
|
+
</div>
|
|
158
|
+
</div>
|
|
159
|
+
)
|
|
160
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import typescriptEslint from '@typescript-eslint/eslint-plugin'
|
|
2
|
+
import globals from 'globals'
|
|
3
|
+
import tsParser from '@typescript-eslint/parser'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import { fileURLToPath } from 'node:url'
|
|
6
|
+
import js from '@eslint/js'
|
|
7
|
+
import { FlatCompat } from '@eslint/eslintrc'
|
|
8
|
+
|
|
9
|
+
const __filename = fileURLToPath(import.meta.url)
|
|
10
|
+
const __dirname = path.dirname(__filename)
|
|
11
|
+
const compat = new FlatCompat({
|
|
12
|
+
baseDirectory: __dirname,
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
const eslintConfig = [
|
|
16
|
+
{
|
|
17
|
+
ignores: [
|
|
18
|
+
'next-env.d.ts',
|
|
19
|
+
'next.config.js',
|
|
20
|
+
'.next/**',
|
|
21
|
+
'.next-dev/**',
|
|
22
|
+
'node_modules/**',
|
|
23
|
+
'scripts/**',
|
|
24
|
+
],
|
|
25
|
+
},
|
|
26
|
+
js.configs.recommended,
|
|
27
|
+
...compat.extends(
|
|
28
|
+
'plugin:@typescript-eslint/eslint-recommended',
|
|
29
|
+
'plugin:@typescript-eslint/recommended',
|
|
30
|
+
'plugin:jsx-a11y/recommended',
|
|
31
|
+
'plugin:prettier/recommended',
|
|
32
|
+
'next',
|
|
33
|
+
'next/core-web-vitals'
|
|
34
|
+
),
|
|
35
|
+
{
|
|
36
|
+
plugins: {
|
|
37
|
+
'@typescript-eslint': typescriptEslint,
|
|
38
|
+
},
|
|
39
|
+
|
|
40
|
+
languageOptions: {
|
|
41
|
+
globals: {
|
|
42
|
+
...globals.browser,
|
|
43
|
+
...globals.node,
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
parser: tsParser,
|
|
47
|
+
ecmaVersion: 'latest',
|
|
48
|
+
sourceType: 'module',
|
|
49
|
+
|
|
50
|
+
parserOptions: {
|
|
51
|
+
project: './tsconfig.json',
|
|
52
|
+
tsconfigRootDir: __dirname,
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
|
|
56
|
+
rules: {
|
|
57
|
+
'prettier/prettier': 'error',
|
|
58
|
+
'react/react-in-jsx-scope': 'off',
|
|
59
|
+
'jsx-a11y/anchor-is-valid': [
|
|
60
|
+
'error',
|
|
61
|
+
{
|
|
62
|
+
components: ['Link'],
|
|
63
|
+
specialLink: ['hrefLeft', 'hrefRight'],
|
|
64
|
+
aspects: ['invalidHref', 'preferButton'],
|
|
65
|
+
},
|
|
66
|
+
],
|
|
67
|
+
'react/prop-types': 'off',
|
|
68
|
+
'@typescript-eslint/no-unused-vars': [
|
|
69
|
+
'warn',
|
|
70
|
+
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
|
|
71
|
+
],
|
|
72
|
+
'react/no-unescaped-entities': 'off',
|
|
73
|
+
'@typescript-eslint/explicit-module-boundary-types': 'off',
|
|
74
|
+
'@typescript-eslint/no-var-requires': 'off',
|
|
75
|
+
'@typescript-eslint/ban-ts-comment': 'off',
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
export default eslintConfig
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
const BASE_PATH = (process.env.BASE_PATH || '').replace(/\/$/, '')
|
|
2
|
+
|
|
3
|
+
export function resolveGithubCallbackUrl(from = '/'): string {
|
|
4
|
+
if (typeof window === 'undefined') {
|
|
5
|
+
const site = (process.env.CLIENT_SITE_URL || 'http://localhost:3001').replace(/\/$/, '')
|
|
6
|
+
const safeFrom = from.startsWith('/') ? from : '/'
|
|
7
|
+
return `${site}${BASE_PATH}/login/?from=${encodeURIComponent(safeFrom)}`
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const origin = window.location.origin
|
|
11
|
+
const safeFrom = from.startsWith('/') ? from : '/'
|
|
12
|
+
return `${origin}${BASE_PATH}/login/?from=${encodeURIComponent(safeFrom)}`
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function resolveGithubAuthorizeUrl(from = '/'): string | null {
|
|
16
|
+
const clientId = process.env.NEXT_PUBLIC_GITHUB_CLIENT_ID?.trim()
|
|
17
|
+
if (!clientId) return null
|
|
18
|
+
|
|
19
|
+
const params = new URLSearchParams({
|
|
20
|
+
client_id: clientId,
|
|
21
|
+
redirect_uri: resolveGithubCallbackUrl(from),
|
|
22
|
+
scope: 'user:email',
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
return `https://github.com/login/oauth/authorize?${params.toString()}`
|
|
26
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { ThemeSessionUser } from '@fecommunity/reactpress-toolkit/theme'
|
|
2
|
+
|
|
3
|
+
function readToken(record: Record<string, unknown>): string {
|
|
4
|
+
if (typeof record.token === 'string' && record.token.trim()) {
|
|
5
|
+
return record.token.trim()
|
|
6
|
+
}
|
|
7
|
+
if (typeof record.accessToken === 'string' && record.accessToken.trim()) {
|
|
8
|
+
return record.accessToken.trim()
|
|
9
|
+
}
|
|
10
|
+
const tokens = record.tokens
|
|
11
|
+
if (tokens && typeof tokens === 'object') {
|
|
12
|
+
const accessToken = (tokens as Record<string, unknown>).accessToken
|
|
13
|
+
if (typeof accessToken === 'string' && accessToken.trim()) {
|
|
14
|
+
return accessToken.trim()
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return ''
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Map `/auth/login` and `/auth/github` payloads to theme session storage shape. */
|
|
21
|
+
export function normalizeSessionUser(data: unknown): ThemeSessionUser | null {
|
|
22
|
+
if (!data || typeof data !== 'object') return null
|
|
23
|
+
|
|
24
|
+
const record = data as Record<string, unknown>
|
|
25
|
+
const nested =
|
|
26
|
+
record.user && typeof record.user === 'object'
|
|
27
|
+
? (record.user as Record<string, unknown>)
|
|
28
|
+
: record
|
|
29
|
+
|
|
30
|
+
const name = typeof nested.name === 'string' ? nested.name.trim() : ''
|
|
31
|
+
const token = readToken(record) || readToken(nested)
|
|
32
|
+
if (!name) return null
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
name,
|
|
36
|
+
email: typeof nested.email === 'string' ? nested.email : '',
|
|
37
|
+
avatar: typeof nested.avatar === 'string' ? nested.avatar : '',
|
|
38
|
+
token,
|
|
39
|
+
role: typeof nested.role === 'string' ? nested.role : undefined,
|
|
40
|
+
}
|
|
41
|
+
}
|