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