@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,170 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import Link from '@/components/shared/Link'
|
|
4
|
+
import Logo from '@/components/shared/logo.svg'
|
|
5
|
+
import { getColorFromNumber, getTagStyle } from '@/lib/utils/colors'
|
|
6
|
+
import { ClockIcon, EyeIcon, FolderIcon, HeartIcon } from '@/lib/utils/icons'
|
|
7
|
+
import { useLocale } from '@fecommunity/reactpress-toolkit/ui'
|
|
8
|
+
import { Image, LocaleTime } from '@fecommunity/reactpress-toolkit/ui/content'
|
|
9
|
+
import { useSiteCatalog } from '@fecommunity/reactpress-toolkit/theme'
|
|
10
|
+
import { useEffect, useMemo, useRef } from 'react'
|
|
11
|
+
|
|
12
|
+
interface Article {
|
|
13
|
+
id: string
|
|
14
|
+
title: string
|
|
15
|
+
cover?: string
|
|
16
|
+
summary: string
|
|
17
|
+
category?: { value: string; label: string }
|
|
18
|
+
likes: number
|
|
19
|
+
views: number
|
|
20
|
+
publishAt: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface ArticleListProps {
|
|
24
|
+
articles: Article[]
|
|
25
|
+
/** Tighter spacing for recommend blocks. */
|
|
26
|
+
density?: 'feed' | 'compact'
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const COVER_WIDTH = 200
|
|
30
|
+
const COVER_HEIGHT = 114
|
|
31
|
+
|
|
32
|
+
function ArticleCard({
|
|
33
|
+
article,
|
|
34
|
+
categoryIndex,
|
|
35
|
+
index,
|
|
36
|
+
animate = false,
|
|
37
|
+
compact = false,
|
|
38
|
+
}: {
|
|
39
|
+
article: Article
|
|
40
|
+
categoryIndex: number
|
|
41
|
+
index: number
|
|
42
|
+
animate?: boolean
|
|
43
|
+
compact?: boolean
|
|
44
|
+
}) {
|
|
45
|
+
const { locale } = useLocale()
|
|
46
|
+
const eager = index < 3
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<div
|
|
50
|
+
className={`rp-article-card group/card rp-surface relative flex w-full justify-between overflow-hidden rounded-xl border border-transparent ring-1 ring-black/5 transition-[box-shadow,border-color] duration-300 ease-out hover:border-[color-mix(in_srgb,var(--border-color)_70%,transparent)] hover:shadow-[0_8px_24px_color-mix(in_srgb,var(--main-text-color)_10%,transparent),var(--box-shadow)] dark:ring-white/5 hover:[&_header_.title]:text-[var(--primary-color)] ${
|
|
51
|
+
compact ? 'p-3' : 'p-4'
|
|
52
|
+
} ${animate ? 'rp-home-card-enter' : ''}`}
|
|
53
|
+
>
|
|
54
|
+
<span
|
|
55
|
+
className="absolute top-5 left-0 h-6 w-1 rounded-r bg-[var(--primary-color)] opacity-90 shadow-sm transition-opacity duration-300 group-hover/card:opacity-100"
|
|
56
|
+
aria-hidden
|
|
57
|
+
/>
|
|
58
|
+
<div className="rp-cover-zoom-host mr-2.5 h-[114px] w-[200px] shrink-0 overflow-hidden rounded-lg bg-[var(--bg-second)] max-md:h-20 max-md:w-[140px]">
|
|
59
|
+
{article.cover ? (
|
|
60
|
+
<Link href={`/article/${article.id}`} className="block h-full w-full overflow-hidden">
|
|
61
|
+
<Image
|
|
62
|
+
url={article.cover}
|
|
63
|
+
size="thumb"
|
|
64
|
+
alt={`${article.title} cover`}
|
|
65
|
+
width={COVER_WIDTH}
|
|
66
|
+
height={COVER_HEIGHT}
|
|
67
|
+
loading={eager ? 'eager' : 'lazy'}
|
|
68
|
+
fetchPriority={index === 0 ? 'high' : undefined}
|
|
69
|
+
decoding={index === 0 ? 'sync' : 'async'}
|
|
70
|
+
className="rp-article-cover-img rp-cover-zoom h-full w-full object-contain object-center"
|
|
71
|
+
/>
|
|
72
|
+
</Link>
|
|
73
|
+
) : (
|
|
74
|
+
<div className="flex h-full w-full items-center justify-center bg-[var(--bg-second)]">
|
|
75
|
+
<Logo className="h-12 w-auto max-w-[155px] object-contain" aria-label="ReactPress" />
|
|
76
|
+
</div>
|
|
77
|
+
)}
|
|
78
|
+
</div>
|
|
79
|
+
<div className="min-w-0 flex-1">
|
|
80
|
+
<header className="flex items-start gap-1">
|
|
81
|
+
<Link
|
|
82
|
+
href={`/article/${article.id}`}
|
|
83
|
+
className="title line-clamp-2 min-w-0 flex-1 text-base leading-snug font-semibold text-[var(--main-text-color)] no-underline transition-colors duration-300"
|
|
84
|
+
>
|
|
85
|
+
{article.title}
|
|
86
|
+
</Link>
|
|
87
|
+
{article.category && categoryIndex >= 0 ? (
|
|
88
|
+
<Link
|
|
89
|
+
href={`/category/${article.category.value}`}
|
|
90
|
+
className="rp-category-tag ml-1 inline-flex shrink-0 items-center gap-1 rounded px-2 py-0.5 text-xs no-underline transition-opacity duration-200 hover:opacity-80"
|
|
91
|
+
style={getTagStyle(getColorFromNumber(categoryIndex))}
|
|
92
|
+
>
|
|
93
|
+
<FolderIcon size={12} className="opacity-90" />
|
|
94
|
+
<span>{article.category.label}</span>
|
|
95
|
+
</Link>
|
|
96
|
+
) : null}
|
|
97
|
+
</header>
|
|
98
|
+
<Link href={`/article/${article.id}`} className="mt-2 block no-underline">
|
|
99
|
+
<main className="flex flex-col justify-between">
|
|
100
|
+
<div
|
|
101
|
+
className="rp-rich-text line-clamp-2 text-sm text-[var(--second-text-color)] max-md:line-clamp-1"
|
|
102
|
+
dangerouslySetInnerHTML={{ __html: article.summary }}
|
|
103
|
+
/>
|
|
104
|
+
<div className="mt-3 flex items-center justify-between text-sm whitespace-nowrap text-[#8590a6]">
|
|
105
|
+
<div className="flex items-center gap-2">
|
|
106
|
+
<span className="inline-flex items-center text-[var(--second-text-color)]">
|
|
107
|
+
<HeartIcon size={14} />
|
|
108
|
+
<span className="ml-1.5">{article.likes}</span>
|
|
109
|
+
</span>
|
|
110
|
+
<span>·</span>
|
|
111
|
+
<span className="inline-flex items-center text-[var(--second-text-color)]">
|
|
112
|
+
<EyeIcon size={14} />
|
|
113
|
+
<span className="ml-1.5">{article.views}</span>
|
|
114
|
+
</span>
|
|
115
|
+
</div>
|
|
116
|
+
<span className="inline-flex items-center max-md:hidden">
|
|
117
|
+
<ClockIcon size={14} />
|
|
118
|
+
<span className="ml-1.5">
|
|
119
|
+
<LocaleTime date={article.publishAt} format="yyyy-MM-dd" locale={locale} />
|
|
120
|
+
</span>
|
|
121
|
+
</span>
|
|
122
|
+
</div>
|
|
123
|
+
</main>
|
|
124
|
+
</Link>
|
|
125
|
+
</div>
|
|
126
|
+
</div>
|
|
127
|
+
)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export default function ArticleList({ articles = [], density = 'feed' }: ArticleListProps) {
|
|
131
|
+
const { t } = useLocale()
|
|
132
|
+
const { categories } = useSiteCatalog()
|
|
133
|
+
const compact = density === 'compact'
|
|
134
|
+
const animatedCountRef = useRef(0)
|
|
135
|
+
|
|
136
|
+
useEffect(() => {
|
|
137
|
+
animatedCountRef.current = articles.length
|
|
138
|
+
}, [articles])
|
|
139
|
+
|
|
140
|
+
const categoryIndices = useMemo(
|
|
141
|
+
() =>
|
|
142
|
+
articles.map((article) =>
|
|
143
|
+
categories?.findIndex((category) => category?.value === article?.category?.value)
|
|
144
|
+
),
|
|
145
|
+
[articles, categories]
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
const animateFromIndex = animatedCountRef.current
|
|
149
|
+
|
|
150
|
+
return (
|
|
151
|
+
<div className="rp-article-list rp-home-stagger flex w-full flex-col gap-4">
|
|
152
|
+
{articles.length ? (
|
|
153
|
+
articles.map((article, index) => (
|
|
154
|
+
<ArticleCard
|
|
155
|
+
key={article.id}
|
|
156
|
+
article={article}
|
|
157
|
+
categoryIndex={categoryIndices[index]}
|
|
158
|
+
index={index}
|
|
159
|
+
animate={index >= animateFromIndex}
|
|
160
|
+
compact={compact}
|
|
161
|
+
/>
|
|
162
|
+
))
|
|
163
|
+
) : (
|
|
164
|
+
<div className="rounded-xl bg-[var(--bg-box)] p-8 text-center text-[var(--second-text-color)]">
|
|
165
|
+
{t('empty')}
|
|
166
|
+
</div>
|
|
167
|
+
)}
|
|
168
|
+
</div>
|
|
169
|
+
)
|
|
170
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import type { ReactNode } from 'react'
|
|
4
|
+
|
|
5
|
+
type ReadingChromeMode = 'side' | 'mobile' | 'toc' | 'all'
|
|
6
|
+
|
|
7
|
+
interface ArticleReadingChromeProps {
|
|
8
|
+
actions: ReactNode
|
|
9
|
+
mobileActions: ReactNode
|
|
10
|
+
showToc: boolean
|
|
11
|
+
tocExpanded: boolean
|
|
12
|
+
onToggleToc: () => void
|
|
13
|
+
tocPanel: ReactNode
|
|
14
|
+
collapseLabel: string
|
|
15
|
+
expandLabel: string
|
|
16
|
+
tocLabel: string
|
|
17
|
+
mode?: ReadingChromeMode
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function TocIcon() {
|
|
21
|
+
return (
|
|
22
|
+
<svg
|
|
23
|
+
viewBox="0 0 24 24"
|
|
24
|
+
width="20"
|
|
25
|
+
height="20"
|
|
26
|
+
fill="none"
|
|
27
|
+
stroke="currentColor"
|
|
28
|
+
strokeWidth="1.8"
|
|
29
|
+
aria-hidden
|
|
30
|
+
>
|
|
31
|
+
<path d="M4 6h16M4 12h10M4 18h14" strokeLinecap="round" />
|
|
32
|
+
</svg>
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export default function ArticleReadingChrome({
|
|
37
|
+
actions,
|
|
38
|
+
mobileActions,
|
|
39
|
+
showToc,
|
|
40
|
+
tocExpanded,
|
|
41
|
+
onToggleToc,
|
|
42
|
+
tocPanel,
|
|
43
|
+
collapseLabel,
|
|
44
|
+
expandLabel,
|
|
45
|
+
tocLabel,
|
|
46
|
+
mode = 'all',
|
|
47
|
+
}: ArticleReadingChromeProps) {
|
|
48
|
+
const showSide = mode === 'side' || mode === 'all'
|
|
49
|
+
const showMobile = mode === 'mobile' || mode === 'all'
|
|
50
|
+
const showTocDrawer = mode === 'toc' || mode === 'all'
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<>
|
|
54
|
+
{showToc && showTocDrawer ? (
|
|
55
|
+
<>
|
|
56
|
+
<button
|
|
57
|
+
type="button"
|
|
58
|
+
className={`rp-toc-toggle${tocExpanded ? 'is-open' : ''}`}
|
|
59
|
+
aria-label={tocExpanded ? collapseLabel : expandLabel}
|
|
60
|
+
aria-expanded={tocExpanded}
|
|
61
|
+
aria-controls="rp-toc-fixed-drawer"
|
|
62
|
+
onClick={onToggleToc}
|
|
63
|
+
>
|
|
64
|
+
<TocIcon />
|
|
65
|
+
</button>
|
|
66
|
+
<aside
|
|
67
|
+
id="rp-toc-fixed-drawer"
|
|
68
|
+
className={`rp-toc-fixed-drawer${tocExpanded ? '' : 'is-collapsed'}`}
|
|
69
|
+
aria-hidden={!tocExpanded}
|
|
70
|
+
>
|
|
71
|
+
<div className="rp-toc-fixed-drawer__inner">{tocPanel}</div>
|
|
72
|
+
</aside>
|
|
73
|
+
</>
|
|
74
|
+
) : null}
|
|
75
|
+
|
|
76
|
+
{showSide && actions ? (
|
|
77
|
+
<nav className="rp-reading-dock rp-reading-dock--side" aria-label="Reading actions">
|
|
78
|
+
<div className="rp-reading-dock__actions">{actions}</div>
|
|
79
|
+
</nav>
|
|
80
|
+
) : null}
|
|
81
|
+
|
|
82
|
+
{showMobile ? (
|
|
83
|
+
<nav className="rp-reading-dock rp-reading-dock--bottom" aria-label="Reading actions">
|
|
84
|
+
<div className="rp-reading-dock__inner rp-reading-dock__inner--bottom">
|
|
85
|
+
{showToc ? (
|
|
86
|
+
<button
|
|
87
|
+
type="button"
|
|
88
|
+
className={`rp-reading-dock__toc rp-reading-dock__toc--mobile${tocExpanded ? 'is-open' : ''}`}
|
|
89
|
+
aria-label={tocExpanded ? collapseLabel : expandLabel}
|
|
90
|
+
aria-expanded={tocExpanded}
|
|
91
|
+
aria-controls="rp-toc-fixed-drawer"
|
|
92
|
+
onClick={onToggleToc}
|
|
93
|
+
>
|
|
94
|
+
<TocIcon />
|
|
95
|
+
<span className="rp-reading-dock__toc-label">{tocLabel}</span>
|
|
96
|
+
</button>
|
|
97
|
+
) : null}
|
|
98
|
+
{mobileActions}
|
|
99
|
+
</div>
|
|
100
|
+
</nav>
|
|
101
|
+
) : null}
|
|
102
|
+
</>
|
|
103
|
+
)
|
|
104
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useHeaderTop } from '@/lib/utils/headerPosition'
|
|
4
|
+
import { useEffect, useState } from 'react'
|
|
5
|
+
import { createPortal } from 'react-dom'
|
|
6
|
+
|
|
7
|
+
const ARTICLE_SELECTOR = '#js-article-wrapper'
|
|
8
|
+
|
|
9
|
+
function measureProgress(): number {
|
|
10
|
+
const article = document.querySelector(ARTICLE_SELECTOR)
|
|
11
|
+
if (!article) return 0
|
|
12
|
+
|
|
13
|
+
const rect = article.getBoundingClientRect()
|
|
14
|
+
const articleHeight = rect.height
|
|
15
|
+
const viewportHeight = window.innerHeight
|
|
16
|
+
const scrollable = articleHeight - viewportHeight
|
|
17
|
+
|
|
18
|
+
if (scrollable <= 0) {
|
|
19
|
+
return rect.top <= 0 ? 100 : 0
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const scrolled = -rect.top
|
|
23
|
+
return Math.min(100, Math.max(0, (scrolled / scrollable) * 100))
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export default function ArticleReadingProgress() {
|
|
27
|
+
const headerTop = useHeaderTop()
|
|
28
|
+
const [progress, setProgress] = useState(0)
|
|
29
|
+
const [mounted, setMounted] = useState(false)
|
|
30
|
+
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
setMounted(true)
|
|
33
|
+
}, [])
|
|
34
|
+
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
let frame = 0
|
|
37
|
+
|
|
38
|
+
const update = () => {
|
|
39
|
+
frame = 0
|
|
40
|
+
setProgress(measureProgress())
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const onScroll = () => {
|
|
44
|
+
if (frame) return
|
|
45
|
+
frame = window.requestAnimationFrame(update)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
update()
|
|
49
|
+
window.addEventListener('scroll', onScroll, { passive: true })
|
|
50
|
+
window.addEventListener('resize', onScroll, { passive: true })
|
|
51
|
+
|
|
52
|
+
return () => {
|
|
53
|
+
if (frame) window.cancelAnimationFrame(frame)
|
|
54
|
+
window.removeEventListener('scroll', onScroll)
|
|
55
|
+
window.removeEventListener('resize', onScroll)
|
|
56
|
+
}
|
|
57
|
+
}, [])
|
|
58
|
+
|
|
59
|
+
if (!mounted) return null
|
|
60
|
+
|
|
61
|
+
return createPortal(
|
|
62
|
+
<div
|
|
63
|
+
className="rp-reading-progress"
|
|
64
|
+
style={{ top: headerTop }}
|
|
65
|
+
role="progressbar"
|
|
66
|
+
aria-valuenow={Math.round(progress)}
|
|
67
|
+
aria-valuemin={0}
|
|
68
|
+
aria-valuemax={100}
|
|
69
|
+
aria-label="Reading progress"
|
|
70
|
+
>
|
|
71
|
+
<div className="rp-reading-progress__bar" style={{ width: `${progress}%` }} />
|
|
72
|
+
</div>,
|
|
73
|
+
document.body
|
|
74
|
+
)
|
|
75
|
+
}
|