@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,152 @@
|
|
|
1
|
+
export const emojis = {
|
|
2
|
+
grinning: '😀',
|
|
3
|
+
smiley: '😃',
|
|
4
|
+
smile: '😄',
|
|
5
|
+
grin: '😁',
|
|
6
|
+
laughing: '😆',
|
|
7
|
+
satisfied: '😆',
|
|
8
|
+
sweat_smile: '😅',
|
|
9
|
+
joy: '😂',
|
|
10
|
+
wink: '😉',
|
|
11
|
+
blush: '😊',
|
|
12
|
+
innocent: '😇',
|
|
13
|
+
heart_eyes: '😍',
|
|
14
|
+
kissing_heart: '😘',
|
|
15
|
+
kissing: '😗',
|
|
16
|
+
kissing_closed_eyes: '😚',
|
|
17
|
+
kissing_smiling_eyes: '😙',
|
|
18
|
+
yum: '😋',
|
|
19
|
+
stuck_out_tongue: '😛',
|
|
20
|
+
stuck_out_tongue_winking_eye: '😜',
|
|
21
|
+
stuck_out_tongue_closed_eyes: '😝',
|
|
22
|
+
neutral_face: '😐',
|
|
23
|
+
expressionless: '😑',
|
|
24
|
+
no_mouth: '😶',
|
|
25
|
+
smirk: '😏',
|
|
26
|
+
unamused: '😒',
|
|
27
|
+
relieved: '😌',
|
|
28
|
+
pensive: '😔',
|
|
29
|
+
sleepy: '😪',
|
|
30
|
+
sleeping: '😴',
|
|
31
|
+
mask: '😷',
|
|
32
|
+
dizzy_face: '😵',
|
|
33
|
+
sunglasses: '😎',
|
|
34
|
+
confused: '😕',
|
|
35
|
+
worried: '😟',
|
|
36
|
+
open_mouth: '😮',
|
|
37
|
+
hushed: '😯',
|
|
38
|
+
astonished: '😲',
|
|
39
|
+
flushed: '😳',
|
|
40
|
+
frowning: '😦',
|
|
41
|
+
anguished: '😧',
|
|
42
|
+
fearful: '😨',
|
|
43
|
+
cold_sweat: '😰',
|
|
44
|
+
disappointed_relieved: '😥',
|
|
45
|
+
cry: '😢',
|
|
46
|
+
sob: '😭',
|
|
47
|
+
scream: '😱',
|
|
48
|
+
confounded: '😖',
|
|
49
|
+
persevere: '😣',
|
|
50
|
+
disappointed: '😞',
|
|
51
|
+
sweat: '😓',
|
|
52
|
+
weary: '😩',
|
|
53
|
+
tired_face: '😫',
|
|
54
|
+
rage: '😡',
|
|
55
|
+
pout: '😡',
|
|
56
|
+
angry: '😠',
|
|
57
|
+
smiling_imp: '😈',
|
|
58
|
+
smiley_cat: '😺',
|
|
59
|
+
smile_cat: '😸',
|
|
60
|
+
joy_cat: '😹',
|
|
61
|
+
heart_eyes_cat: '😻',
|
|
62
|
+
smirk_cat: '😼',
|
|
63
|
+
kissing_cat: '😽',
|
|
64
|
+
scream_cat: '🙀',
|
|
65
|
+
crying_cat_face: '😿',
|
|
66
|
+
pouting_cat: '😾',
|
|
67
|
+
heart: '❤️',
|
|
68
|
+
hand: '✋',
|
|
69
|
+
raised_hand: '✋',
|
|
70
|
+
v: '✌️',
|
|
71
|
+
point_up: '☝️',
|
|
72
|
+
fist_raised: '✊',
|
|
73
|
+
fist: '✊',
|
|
74
|
+
monkey_face: '🐵',
|
|
75
|
+
cat: '🐱',
|
|
76
|
+
cow: '🐮',
|
|
77
|
+
mouse: '🐭',
|
|
78
|
+
coffee: '☕',
|
|
79
|
+
hotsprings: '♨️',
|
|
80
|
+
anchor: '⚓',
|
|
81
|
+
airplane: '✈️',
|
|
82
|
+
hourglass: '⌛',
|
|
83
|
+
watch: '⌚',
|
|
84
|
+
sunny: '☀️',
|
|
85
|
+
star: '⭐',
|
|
86
|
+
cloud: '☁️',
|
|
87
|
+
umbrella: '☔',
|
|
88
|
+
zap: '⚡',
|
|
89
|
+
snowflake: '❄️',
|
|
90
|
+
sparkles: '✨',
|
|
91
|
+
black_joker: '🃏',
|
|
92
|
+
mahjong: '🀄',
|
|
93
|
+
phone: '☎️',
|
|
94
|
+
telephone: '☎️',
|
|
95
|
+
envelope: '✉️',
|
|
96
|
+
pencil2: '✏️',
|
|
97
|
+
black_nib: '✒️',
|
|
98
|
+
scissors: '✂️',
|
|
99
|
+
wheelchair: '♿',
|
|
100
|
+
warning: '⚠️',
|
|
101
|
+
aries: '♈',
|
|
102
|
+
taurus: '♉',
|
|
103
|
+
gemini: '♊',
|
|
104
|
+
cancer: '♋',
|
|
105
|
+
leo: '♌',
|
|
106
|
+
virgo: '♍',
|
|
107
|
+
libra: '♎',
|
|
108
|
+
scorpius: '♏',
|
|
109
|
+
sagittarius: '♐',
|
|
110
|
+
capricorn: '♑',
|
|
111
|
+
aquarius: '♒',
|
|
112
|
+
pisces: '♓',
|
|
113
|
+
heavy_multiplication_x: '✖️',
|
|
114
|
+
heavy_plus_sign: '➕',
|
|
115
|
+
heavy_minus_sign: '➖',
|
|
116
|
+
heavy_division_sign: '➗',
|
|
117
|
+
bangbang: '‼️',
|
|
118
|
+
interrobang: '⁉️',
|
|
119
|
+
question: '❓',
|
|
120
|
+
grey_question: '❔',
|
|
121
|
+
grey_exclamation: '❕',
|
|
122
|
+
exclamation: '❗',
|
|
123
|
+
heavy_exclamation_mark: '❗',
|
|
124
|
+
wavy_dash: '〰️',
|
|
125
|
+
recycle: '♻️',
|
|
126
|
+
white_check_mark: '✅',
|
|
127
|
+
ballot_box_with_check: '☑️',
|
|
128
|
+
heavy_check_mark: '✔️',
|
|
129
|
+
x: '❌',
|
|
130
|
+
negative_squared_cross_mark: '❎',
|
|
131
|
+
curly_loop: '➰',
|
|
132
|
+
loop: '➿',
|
|
133
|
+
part_alternation_mark: '〽️',
|
|
134
|
+
eight_spoked_asterisk: '✳️',
|
|
135
|
+
eight_pointed_black_star: '✴️',
|
|
136
|
+
sparkle: '❇️',
|
|
137
|
+
copyright: '©️',
|
|
138
|
+
registered: '®️',
|
|
139
|
+
tm: '™️',
|
|
140
|
+
information_source: 'ℹ️',
|
|
141
|
+
m: 'Ⓜ️',
|
|
142
|
+
black_circle: '⚫',
|
|
143
|
+
white_circle: '⚪',
|
|
144
|
+
black_large_square: '⬛',
|
|
145
|
+
white_large_square: '⬜',
|
|
146
|
+
black_medium_square: '◼️',
|
|
147
|
+
white_medium_square: '◻️',
|
|
148
|
+
black_medium_small_square: '◾',
|
|
149
|
+
white_medium_small_square: '◽',
|
|
150
|
+
black_small_square: '▪️',
|
|
151
|
+
white_small_square: '▫️',
|
|
152
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import Link from '@/components/shared/Link'
|
|
4
|
+
import { EyeIcon } from '@/lib/utils/icons'
|
|
5
|
+
import { useLocale } from '@fecommunity/reactpress-toolkit/ui'
|
|
6
|
+
import { Image, LocaleTime } from '@fecommunity/reactpress-toolkit/ui/content'
|
|
7
|
+
import type { IKnowledge } from '@fecommunity/reactpress-toolkit/types'
|
|
8
|
+
|
|
9
|
+
interface KnowledgeListProps {
|
|
10
|
+
knowledges?: Array<IKnowledge & { children?: IKnowledge[] }>
|
|
11
|
+
small?: boolean
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export default function KnowledgeList({ knowledges = [], small = false }: KnowledgeListProps) {
|
|
15
|
+
const { t, locale } = useLocale()
|
|
16
|
+
|
|
17
|
+
if (!knowledges.length) {
|
|
18
|
+
return (
|
|
19
|
+
<div className="px-4 py-6 text-center text-sm text-[var(--second-text-color)]">
|
|
20
|
+
{t('empty')}
|
|
21
|
+
</div>
|
|
22
|
+
)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<div className="p-0">
|
|
27
|
+
{knowledges.map((knowledge) => (
|
|
28
|
+
<div
|
|
29
|
+
key={knowledge.id}
|
|
30
|
+
className={`border-b border-[var(--border-color)] last:border-b-0 ${
|
|
31
|
+
small ? 'px-3 py-3' : 'px-4 py-4'
|
|
32
|
+
}`}
|
|
33
|
+
>
|
|
34
|
+
<Link
|
|
35
|
+
href={`/knowledge/${knowledge.id}`}
|
|
36
|
+
className="block no-underline hover:text-[var(--primary-color)]"
|
|
37
|
+
>
|
|
38
|
+
<header className="mb-2">
|
|
39
|
+
<div className="font-semibold text-[var(--main-text-color)]">{knowledge.title}</div>
|
|
40
|
+
<div className="mt-1 flex items-center gap-2 text-xs text-[var(--second-text-color)]">
|
|
41
|
+
<span className="h-3 w-px bg-[var(--border-color)]" />
|
|
42
|
+
<LocaleTime date={knowledge.publishAt} timeago locale={locale} />
|
|
43
|
+
</div>
|
|
44
|
+
</header>
|
|
45
|
+
{!small ? (
|
|
46
|
+
<main className="flex gap-4">
|
|
47
|
+
<div className="min-w-0 flex-1">
|
|
48
|
+
{knowledge.summary ? (
|
|
49
|
+
<p className="m-0 line-clamp-2 text-sm text-[var(--second-text-color)]">
|
|
50
|
+
{knowledge.summary}
|
|
51
|
+
</p>
|
|
52
|
+
) : null}
|
|
53
|
+
<div className="mt-2 flex items-center gap-1 text-xs text-[var(--second-text-color)]">
|
|
54
|
+
<EyeIcon className="h-3.5 w-3.5" />
|
|
55
|
+
<span>{knowledge.views}</span>
|
|
56
|
+
</div>
|
|
57
|
+
</div>
|
|
58
|
+
{knowledge.cover ? (
|
|
59
|
+
<div className="h-16 w-24 shrink-0 overflow-hidden rounded">
|
|
60
|
+
<Image url={knowledge.cover} size="thumb" alt="cover" loading="lazy" />
|
|
61
|
+
</div>
|
|
62
|
+
) : null}
|
|
63
|
+
</main>
|
|
64
|
+
) : null}
|
|
65
|
+
</Link>
|
|
66
|
+
</div>
|
|
67
|
+
))}
|
|
68
|
+
</div>
|
|
69
|
+
)
|
|
70
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react'
|
|
4
|
+
|
|
5
|
+
export default function BackToTop() {
|
|
6
|
+
const [visible, setVisible] = useState(false)
|
|
7
|
+
|
|
8
|
+
useEffect(() => {
|
|
9
|
+
const onScroll = () => setVisible(window.scrollY > 400)
|
|
10
|
+
window.addEventListener('scroll', onScroll, { passive: true })
|
|
11
|
+
onScroll()
|
|
12
|
+
return () => window.removeEventListener('scroll', onScroll)
|
|
13
|
+
}, [])
|
|
14
|
+
|
|
15
|
+
return (
|
|
16
|
+
<button
|
|
17
|
+
type="button"
|
|
18
|
+
aria-label="Back to top"
|
|
19
|
+
onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}
|
|
20
|
+
className={`rp-back-to-top fixed right-6 bottom-6 z-50 flex h-11 w-11 items-center justify-center rounded-full border border-[var(--border-color)] text-lg text-[var(--second-text-color)] shadow-[var(--box-shadow)] hover:text-[var(--primary-color)] ${
|
|
21
|
+
visible ? 'is-visible' : ''
|
|
22
|
+
}`}
|
|
23
|
+
>
|
|
24
|
+
↑
|
|
25
|
+
</button>
|
|
26
|
+
)
|
|
27
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import SiteFooter from '@/components/layout/SiteFooter'
|
|
4
|
+
import { useHomeFooter } from '@/lib/reactpress/homeFooter'
|
|
5
|
+
import { usePathname } from 'next/navigation'
|
|
6
|
+
|
|
7
|
+
function isFeedLikePage(pathname: string): boolean {
|
|
8
|
+
const path = (pathname || '/').replace(/\/$/, '') || '/'
|
|
9
|
+
if (path === '/') return true
|
|
10
|
+
if (path.startsWith('/category/')) return true
|
|
11
|
+
if (path.startsWith('/tag/')) return true
|
|
12
|
+
if (path === '/search') return true
|
|
13
|
+
if (path === '/archives') return true
|
|
14
|
+
if (path === '/tags') return true
|
|
15
|
+
return false
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function shouldHideSiteFooter(pathname: string, showLayoutFooter: boolean): boolean {
|
|
19
|
+
if (!isFeedLikePage(pathname)) return false
|
|
20
|
+
return !showLayoutFooter
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export default function ConditionalSiteFooter() {
|
|
24
|
+
const pathname = usePathname() ?? '/'
|
|
25
|
+
const { showLayoutFooter } = useHomeFooter()
|
|
26
|
+
|
|
27
|
+
if (shouldHideSiteFooter(pathname, showLayoutFooter)) return null
|
|
28
|
+
return <SiteFooter />
|
|
29
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import ArticleReadingChrome from '@/components/article/ArticleReadingChrome'
|
|
4
|
+
import CommentIconButton from '@/components/comment/CommentIconButton'
|
|
5
|
+
import BookmarkWidget from '@/components/widgets/BookmarkWidget'
|
|
6
|
+
import LikesWidget from '@/components/widgets/LikesWidget'
|
|
7
|
+
import ShareWidget from '@/components/widgets/ShareWidget'
|
|
8
|
+
import SystemNotification from '@/components/layout/SystemNotification'
|
|
9
|
+
import { useSiteCatalog } from '@fecommunity/reactpress-toolkit/theme'
|
|
10
|
+
import { type ReactNode, useCallback, useEffect, useState } from 'react'
|
|
11
|
+
import { createPortal } from 'react-dom'
|
|
12
|
+
|
|
13
|
+
interface LikesProps {
|
|
14
|
+
defaultCount?: number
|
|
15
|
+
id: string
|
|
16
|
+
api: (id: string, type: 'like' | 'dislike') => Promise<number>
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface DoubleColumnLayoutProps {
|
|
20
|
+
leftNode: ReactNode
|
|
21
|
+
rightNode?: ReactNode | null
|
|
22
|
+
topNode?: ReactNode
|
|
23
|
+
likesProps?: LikesProps
|
|
24
|
+
bookmarkId?: string
|
|
25
|
+
showComment?: boolean
|
|
26
|
+
showShare?: boolean
|
|
27
|
+
coverPreloadUrl?: string
|
|
28
|
+
className?: string
|
|
29
|
+
fillMinHeight?: boolean
|
|
30
|
+
sidebarCollapsible?: boolean
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const TOC_COLLAPSED_KEY = 'rp-toc-collapsed'
|
|
34
|
+
|
|
35
|
+
export default function DoubleColumnLayout({
|
|
36
|
+
leftNode,
|
|
37
|
+
rightNode,
|
|
38
|
+
topNode,
|
|
39
|
+
likesProps,
|
|
40
|
+
bookmarkId,
|
|
41
|
+
showComment = false,
|
|
42
|
+
showShare = false,
|
|
43
|
+
coverPreloadUrl,
|
|
44
|
+
className = '',
|
|
45
|
+
fillMinHeight = true,
|
|
46
|
+
sidebarCollapsible = false,
|
|
47
|
+
}: DoubleColumnLayoutProps) {
|
|
48
|
+
const { locale } = useSiteCatalog()
|
|
49
|
+
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
|
|
50
|
+
const [mounted, setMounted] = useState(false)
|
|
51
|
+
|
|
52
|
+
const hasActions = Boolean(likesProps || bookmarkId || showComment || showShare)
|
|
53
|
+
const useFixedSidebar = Boolean(rightNode) && sidebarCollapsible
|
|
54
|
+
const showInlineSidebar = Boolean(rightNode) && !sidebarCollapsible
|
|
55
|
+
const showReadingChrome = hasActions || useFixedSidebar
|
|
56
|
+
|
|
57
|
+
const collapseLabel = locale === 'zh' ? '收起目录' : 'Collapse table of contents'
|
|
58
|
+
const expandLabel = locale === 'zh' ? '展开目录' : 'Expand table of contents'
|
|
59
|
+
const tocLabel = locale === 'zh' ? '目录' : 'TOC'
|
|
60
|
+
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
setMounted(true)
|
|
63
|
+
}, [])
|
|
64
|
+
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
if (!sidebarCollapsible) return
|
|
67
|
+
try {
|
|
68
|
+
setSidebarCollapsed(window.localStorage.getItem(TOC_COLLAPSED_KEY) === '1')
|
|
69
|
+
} catch {
|
|
70
|
+
setSidebarCollapsed(false)
|
|
71
|
+
}
|
|
72
|
+
}, [sidebarCollapsible])
|
|
73
|
+
|
|
74
|
+
const toggleSidebar = useCallback(() => {
|
|
75
|
+
setSidebarCollapsed((prev) => {
|
|
76
|
+
const next = !prev
|
|
77
|
+
try {
|
|
78
|
+
window.localStorage.setItem(TOC_COLLAPSED_KEY, next ? '1' : '0')
|
|
79
|
+
} catch {
|
|
80
|
+
/* ignore */
|
|
81
|
+
}
|
|
82
|
+
return next
|
|
83
|
+
})
|
|
84
|
+
}, [])
|
|
85
|
+
|
|
86
|
+
useEffect(() => {
|
|
87
|
+
if (!coverPreloadUrl) return
|
|
88
|
+
const link = document.createElement('link')
|
|
89
|
+
link.rel = 'preload'
|
|
90
|
+
link.as = 'image'
|
|
91
|
+
link.href = coverPreloadUrl
|
|
92
|
+
document.head.appendChild(link)
|
|
93
|
+
return () => {
|
|
94
|
+
document.head.removeChild(link)
|
|
95
|
+
}
|
|
96
|
+
}, [coverPreloadUrl])
|
|
97
|
+
|
|
98
|
+
const floatingActions = hasActions ? (
|
|
99
|
+
<>
|
|
100
|
+
{likesProps ? <LikesWidget {...likesProps} /> : null}
|
|
101
|
+
{bookmarkId ? <BookmarkWidget id={bookmarkId} /> : null}
|
|
102
|
+
{showComment ? <CommentIconButton /> : null}
|
|
103
|
+
{showShare ? <ShareWidget /> : null}
|
|
104
|
+
</>
|
|
105
|
+
) : null
|
|
106
|
+
|
|
107
|
+
const mobileActions = hasActions ? (
|
|
108
|
+
<>
|
|
109
|
+
{likesProps ? <LikesWidget {...likesProps} variant="mobile" /> : null}
|
|
110
|
+
{bookmarkId ? <BookmarkWidget id={bookmarkId} variant="mobile" /> : null}
|
|
111
|
+
{showComment ? <CommentIconButton variant="mobile" /> : null}
|
|
112
|
+
{showShare ? <ShareWidget variant="mobile" /> : null}
|
|
113
|
+
</>
|
|
114
|
+
) : null
|
|
115
|
+
|
|
116
|
+
const layoutClass = [
|
|
117
|
+
'rp-double-column',
|
|
118
|
+
showReadingChrome ? 'rp-double-column--reading-chrome' : '',
|
|
119
|
+
showInlineSidebar ? 'rp-double-column--sidebar' : '',
|
|
120
|
+
]
|
|
121
|
+
.filter(Boolean)
|
|
122
|
+
.join(' ')
|
|
123
|
+
|
|
124
|
+
const readingChromeProps = {
|
|
125
|
+
actions: floatingActions,
|
|
126
|
+
mobileActions,
|
|
127
|
+
showToc: useFixedSidebar,
|
|
128
|
+
tocExpanded: !sidebarCollapsed,
|
|
129
|
+
onToggleToc: toggleSidebar,
|
|
130
|
+
tocPanel: rightNode,
|
|
131
|
+
collapseLabel,
|
|
132
|
+
expandLabel,
|
|
133
|
+
tocLabel,
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const mobileChromePortal =
|
|
137
|
+
showReadingChrome && mounted
|
|
138
|
+
? createPortal(<ArticleReadingChrome {...readingChromeProps} mode="mobile" />, document.body)
|
|
139
|
+
: null
|
|
140
|
+
|
|
141
|
+
const tocChromePortal =
|
|
142
|
+
useFixedSidebar && mounted
|
|
143
|
+
? createPortal(<ArticleReadingChrome {...readingChromeProps} mode="toc" />, document.body)
|
|
144
|
+
: null
|
|
145
|
+
|
|
146
|
+
const sideChromePortal =
|
|
147
|
+
showReadingChrome && hasActions && mounted
|
|
148
|
+
? createPortal(<ArticleReadingChrome {...readingChromeProps} mode="side" />, document.body)
|
|
149
|
+
: null
|
|
150
|
+
|
|
151
|
+
return (
|
|
152
|
+
<div className={`${fillMinHeight ? 'flex min-h-0 flex-1 flex-col' : ''} ${className}`.trim()}>
|
|
153
|
+
<SystemNotification />
|
|
154
|
+
{topNode}
|
|
155
|
+
<div className={`${layoutClass} relative pt-4 pb-8`}>
|
|
156
|
+
<section className="rp-double-column__main w-full min-w-0 flex-1 pt-0 pb-6 min-[1280px]:pb-8">
|
|
157
|
+
{leftNode}
|
|
158
|
+
</section>
|
|
159
|
+
|
|
160
|
+
{showInlineSidebar ? (
|
|
161
|
+
<aside className="rp-double-column__aside rp-sidebar-slot hidden min-[1280px]:block">
|
|
162
|
+
<div className="rp-sidebar-slot__content">{rightNode}</div>
|
|
163
|
+
</aside>
|
|
164
|
+
) : null}
|
|
165
|
+
</div>
|
|
166
|
+
{sideChromePortal}
|
|
167
|
+
{mobileChromePortal}
|
|
168
|
+
{tocChromePortal}
|
|
169
|
+
</div>
|
|
170
|
+
)
|
|
171
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import Link from '@/components/shared/Link'
|
|
4
|
+
import { SearchIcon, TOOLBAR_ICON_SIZE, useLocale } from '@fecommunity/reactpress-toolkit/ui'
|
|
5
|
+
import { usePathname } from 'next/navigation'
|
|
6
|
+
|
|
7
|
+
export default function HeaderSearch() {
|
|
8
|
+
const { t } = useLocale()
|
|
9
|
+
const pathname = usePathname()
|
|
10
|
+
const isActive = pathname === '/search'
|
|
11
|
+
|
|
12
|
+
return (
|
|
13
|
+
<Link
|
|
14
|
+
href="/search"
|
|
15
|
+
aria-label={t('search')}
|
|
16
|
+
className={`rp-icon-btn inline-flex h-10 w-10 items-center justify-center rounded-full hover:bg-[var(--bg-second)] hover:text-[var(--primary-color)] ${
|
|
17
|
+
isActive ? 'text-[var(--primary-color)]' : 'text-[var(--main-text-color)]'
|
|
18
|
+
}`}
|
|
19
|
+
>
|
|
20
|
+
<SearchIcon size={TOOLBAR_ICON_SIZE} />
|
|
21
|
+
</Link>
|
|
22
|
+
)
|
|
23
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
LocaleToggleButton,
|
|
5
|
+
nextLocale,
|
|
6
|
+
TOOLBAR_ICON_SIZE,
|
|
7
|
+
useLocale,
|
|
8
|
+
} from '@fecommunity/reactpress-toolkit/ui'
|
|
9
|
+
|
|
10
|
+
export default function LocaleSwitcher() {
|
|
11
|
+
const { t, locale, locales = [], setLocale } = useLocale()
|
|
12
|
+
const activeLocale = locale ?? locales[0] ?? 'zh'
|
|
13
|
+
const next = nextLocale(activeLocale, locales)
|
|
14
|
+
|
|
15
|
+
return (
|
|
16
|
+
<LocaleToggleButton
|
|
17
|
+
locale={activeLocale}
|
|
18
|
+
locales={locales}
|
|
19
|
+
onLocaleChange={setLocale}
|
|
20
|
+
size={TOOLBAR_ICON_SIZE}
|
|
21
|
+
aria-label={next === 'en' ? t('locale.switchToEn') : t('locale.switchToZh')}
|
|
22
|
+
/>
|
|
23
|
+
)
|
|
24
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
|
|
3
|
+
interface PageContainerProps {
|
|
4
|
+
children: ReactNode
|
|
5
|
+
className?: string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** Content width aligned with twentytwentyfive `.container` breakpoints. */
|
|
9
|
+
export default function PageContainer({ children, className = '' }: PageContainerProps) {
|
|
10
|
+
return <div className={`rp-container mx-auto w-full px-4 ${className}`}>{children}</div>
|
|
11
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { usePathname, useSearchParams } from 'next/navigation'
|
|
4
|
+
import { Suspense, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
|
5
|
+
import { createPortal } from 'react-dom'
|
|
6
|
+
|
|
7
|
+
import { useHeaderTop } from '@/lib/utils/headerPosition'
|
|
8
|
+
|
|
9
|
+
const MIN_PROGRESS = 0.08
|
|
10
|
+
const TRICKLE_INC = 0.012
|
|
11
|
+
const TRICKLE_MS = 180
|
|
12
|
+
const HIDE_DELAY_MS = 280
|
|
13
|
+
|
|
14
|
+
function clearLoadingMarker() {
|
|
15
|
+
document.documentElement.removeAttribute('data-rp-loading')
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function isSameRoute(pathname: string, href: string) {
|
|
19
|
+
try {
|
|
20
|
+
const url = new URL(href, window.location.href)
|
|
21
|
+
if (url.origin !== window.location.origin) return true
|
|
22
|
+
const current = `${pathname}${window.location.search}`
|
|
23
|
+
const next = `${url.pathname}${url.search}`
|
|
24
|
+
return current === next
|
|
25
|
+
} catch {
|
|
26
|
+
return true
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function PageLoadProgressInner() {
|
|
31
|
+
const headerTop = useHeaderTop()
|
|
32
|
+
const pathname = usePathname()
|
|
33
|
+
const searchParams = useSearchParams()
|
|
34
|
+
const [mounted, setMounted] = useState(false)
|
|
35
|
+
const [visible, setVisible] = useState(false)
|
|
36
|
+
const [progress, setProgress] = useState(0)
|
|
37
|
+
|
|
38
|
+
const progressRef = useRef(0)
|
|
39
|
+
const trickleTimerRef = useRef<ReturnType<typeof setInterval> | undefined>(undefined)
|
|
40
|
+
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
|
|
41
|
+
const initialLoadDoneRef = useRef(false)
|
|
42
|
+
const navigatingRef = useRef(false)
|
|
43
|
+
const controlsRef = useRef<{ start: () => void; done: () => void } | null>(null)
|
|
44
|
+
|
|
45
|
+
controlsRef.current = {
|
|
46
|
+
start() {
|
|
47
|
+
if (hideTimerRef.current) {
|
|
48
|
+
clearTimeout(hideTimerRef.current)
|
|
49
|
+
hideTimerRef.current = undefined
|
|
50
|
+
}
|
|
51
|
+
progressRef.current = MIN_PROGRESS
|
|
52
|
+
setProgress(MIN_PROGRESS)
|
|
53
|
+
setVisible(true)
|
|
54
|
+
|
|
55
|
+
if (trickleTimerRef.current) {
|
|
56
|
+
clearInterval(trickleTimerRef.current)
|
|
57
|
+
}
|
|
58
|
+
trickleTimerRef.current = setInterval(() => {
|
|
59
|
+
const current = progressRef.current
|
|
60
|
+
if (current >= 0.92) return
|
|
61
|
+
const next = current + TRICKLE_INC * (1 - current)
|
|
62
|
+
progressRef.current = next
|
|
63
|
+
setProgress(next)
|
|
64
|
+
}, TRICKLE_MS)
|
|
65
|
+
},
|
|
66
|
+
done() {
|
|
67
|
+
if (trickleTimerRef.current) {
|
|
68
|
+
clearInterval(trickleTimerRef.current)
|
|
69
|
+
trickleTimerRef.current = undefined
|
|
70
|
+
}
|
|
71
|
+
progressRef.current = 1
|
|
72
|
+
setProgress(1)
|
|
73
|
+
clearLoadingMarker()
|
|
74
|
+
hideTimerRef.current = setTimeout(() => {
|
|
75
|
+
setVisible(false)
|
|
76
|
+
progressRef.current = 0
|
|
77
|
+
setProgress(0)
|
|
78
|
+
}, HIDE_DELAY_MS)
|
|
79
|
+
},
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
useLayoutEffect(() => {
|
|
83
|
+
setMounted(true)
|
|
84
|
+
clearLoadingMarker()
|
|
85
|
+
|
|
86
|
+
if (document.readyState === 'complete') {
|
|
87
|
+
initialLoadDoneRef.current = true
|
|
88
|
+
return
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
controlsRef.current?.start()
|
|
92
|
+
const onLoad = () => {
|
|
93
|
+
initialLoadDoneRef.current = true
|
|
94
|
+
controlsRef.current?.done()
|
|
95
|
+
}
|
|
96
|
+
window.addEventListener('load', onLoad)
|
|
97
|
+
return () => window.removeEventListener('load', onLoad)
|
|
98
|
+
}, [])
|
|
99
|
+
|
|
100
|
+
useEffect(() => {
|
|
101
|
+
if (!mounted || !initialLoadDoneRef.current || !navigatingRef.current) return
|
|
102
|
+
navigatingRef.current = false
|
|
103
|
+
controlsRef.current?.done()
|
|
104
|
+
}, [mounted, pathname, searchParams])
|
|
105
|
+
|
|
106
|
+
useEffect(() => {
|
|
107
|
+
const onClick = (event: MouseEvent) => {
|
|
108
|
+
if (event.defaultPrevented) return
|
|
109
|
+
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return
|
|
110
|
+
|
|
111
|
+
const target = event.target
|
|
112
|
+
if (!(target instanceof Element)) return
|
|
113
|
+
|
|
114
|
+
const anchor = target.closest('a')
|
|
115
|
+
if (!anchor) return
|
|
116
|
+
if (anchor.target === '_blank' || anchor.hasAttribute('download')) return
|
|
117
|
+
|
|
118
|
+
const href = anchor.getAttribute('href')
|
|
119
|
+
if (!href || href.startsWith('#') || href.startsWith('mailto:') || href.startsWith('tel:')) {
|
|
120
|
+
return
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (isSameRoute(pathname, href)) return
|
|
124
|
+
|
|
125
|
+
navigatingRef.current = true
|
|
126
|
+
controlsRef.current?.start()
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
document.addEventListener('click', onClick, true)
|
|
130
|
+
return () => document.removeEventListener('click', onClick, true)
|
|
131
|
+
}, [pathname])
|
|
132
|
+
|
|
133
|
+
useEffect(
|
|
134
|
+
() => () => {
|
|
135
|
+
if (trickleTimerRef.current) clearInterval(trickleTimerRef.current)
|
|
136
|
+
if (hideTimerRef.current) clearTimeout(hideTimerRef.current)
|
|
137
|
+
},
|
|
138
|
+
[]
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
if (!mounted || !visible) return null
|
|
142
|
+
|
|
143
|
+
const percent = Math.round(progress * 100)
|
|
144
|
+
|
|
145
|
+
return createPortal(
|
|
146
|
+
<div
|
|
147
|
+
className="rp-page-load-progress"
|
|
148
|
+
style={{ top: headerTop }}
|
|
149
|
+
role="progressbar"
|
|
150
|
+
aria-valuenow={percent}
|
|
151
|
+
aria-valuemin={0}
|
|
152
|
+
aria-valuemax={100}
|
|
153
|
+
aria-label="Page load progress"
|
|
154
|
+
>
|
|
155
|
+
<div className="rp-page-load-progress__track">
|
|
156
|
+
<div className="rp-page-load-progress__bar" style={{ width: `${percent}%` }} />
|
|
157
|
+
</div>
|
|
158
|
+
</div>,
|
|
159
|
+
document.documentElement
|
|
160
|
+
)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export default function PageLoadProgress() {
|
|
164
|
+
return (
|
|
165
|
+
<Suspense fallback={null}>
|
|
166
|
+
<PageLoadProgressInner />
|
|
167
|
+
</Suspense>
|
|
168
|
+
)
|
|
169
|
+
}
|