@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,47 @@
|
|
|
1
|
+
import CmsPageClient from '@/components/article/CmsPageClient'
|
|
2
|
+
import { buildCmsPageMetadata, parseSiteSeoContext } from '@/lib/reactpress/contentSeo'
|
|
3
|
+
import { coercePublishedPage } from '@/lib/reactpress/coercePublishedPage'
|
|
4
|
+
import { buildListPageMetadata } from '@/lib/reactpress/siteMetadata'
|
|
5
|
+
import { tServer } from '@/lib/reactpress/serverLocale'
|
|
6
|
+
import {
|
|
7
|
+
fetchCmsPageProps,
|
|
8
|
+
fetchSiteMeta,
|
|
9
|
+
themeApi,
|
|
10
|
+
unwrapSetting,
|
|
11
|
+
withApiRetry,
|
|
12
|
+
} from '@fecommunity/reactpress-toolkit/theme/server'
|
|
13
|
+
import type { Metadata } from 'next'
|
|
14
|
+
import { notFound } from 'next/navigation'
|
|
15
|
+
|
|
16
|
+
export const revalidate = 60
|
|
17
|
+
|
|
18
|
+
export async function generateMetadata(): Promise<Metadata> {
|
|
19
|
+
try {
|
|
20
|
+
const [{ page: rawPage }, siteMeta, settingRow] = await Promise.all([
|
|
21
|
+
withApiRetry(() => fetchCmsPageProps(themeApi, 'suggestions')),
|
|
22
|
+
withApiRetry(() => fetchSiteMeta(themeApi)),
|
|
23
|
+
withApiRetry(() => themeApi.setting.findAll()).then(unwrapSetting),
|
|
24
|
+
])
|
|
25
|
+
const page = coercePublishedPage(rawPage)
|
|
26
|
+
if (page) {
|
|
27
|
+
return buildCmsPageMetadata(page, parseSiteSeoContext(settingRow, siteMeta))
|
|
28
|
+
}
|
|
29
|
+
} catch {
|
|
30
|
+
// fall through
|
|
31
|
+
}
|
|
32
|
+
return buildListPageMetadata(await tServer('suggestions'), undefined, '/suggestions')
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export default async function SuggestionsPage() {
|
|
36
|
+
try {
|
|
37
|
+
const { page: rawPage } = await withApiRetry(() => fetchCmsPageProps(themeApi, 'suggestions'))
|
|
38
|
+
const page = coercePublishedPage(rawPage)
|
|
39
|
+
if (!page) {
|
|
40
|
+
notFound()
|
|
41
|
+
}
|
|
42
|
+
return <CmsPageClient page={page} />
|
|
43
|
+
} catch (error) {
|
|
44
|
+
console.error('[reactpress-theme-starter] suggestions page fetch failed', error)
|
|
45
|
+
notFound()
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import TagArchiveClient from '@/components/views/TagArchiveClient'
|
|
2
|
+
import { buildLocalizedListPageMetadata } from '@/lib/reactpress/siteMetadata'
|
|
3
|
+
import { generateTagStaticParams } from '@/lib/reactpress/staticParams'
|
|
4
|
+
import {
|
|
5
|
+
fetchTagArchivePageProps,
|
|
6
|
+
themeApi,
|
|
7
|
+
withApiRetry,
|
|
8
|
+
} from '@fecommunity/reactpress-toolkit/theme/server'
|
|
9
|
+
import type { Metadata } from 'next'
|
|
10
|
+
import { notFound } from 'next/navigation'
|
|
11
|
+
|
|
12
|
+
export const revalidate = 60
|
|
13
|
+
|
|
14
|
+
export async function generateStaticParams() {
|
|
15
|
+
return generateTagStaticParams()
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface PageProps {
|
|
19
|
+
params: Promise<{ tag: string }>
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
|
23
|
+
const { tag } = await params
|
|
24
|
+
const tagValue = decodeURIComponent(tag)
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
const data = await withApiRetry(() => fetchTagArchivePageProps(themeApi, tagValue))
|
|
28
|
+
const label = data.tag?.label || tagValue
|
|
29
|
+
return buildLocalizedListPageMetadata(
|
|
30
|
+
'pageTitleTag',
|
|
31
|
+
{ label },
|
|
32
|
+
{
|
|
33
|
+
path: `/tag/${encodeURIComponent(tagValue)}`,
|
|
34
|
+
}
|
|
35
|
+
)
|
|
36
|
+
} catch {
|
|
37
|
+
return buildLocalizedListPageMetadata(
|
|
38
|
+
'pageTitleTag',
|
|
39
|
+
{ label: tagValue },
|
|
40
|
+
{
|
|
41
|
+
path: `/tag/${encodeURIComponent(tagValue)}`,
|
|
42
|
+
}
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export default async function TagArchivePage({ params }: PageProps) {
|
|
48
|
+
const { tag } = await params
|
|
49
|
+
const tagValue = decodeURIComponent(tag)
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
const data = await withApiRetry(() => fetchTagArchivePageProps(themeApi, tagValue))
|
|
53
|
+
return <TagArchiveClient initialArticles={data.articles} total={data.total} tag={data.tag} />
|
|
54
|
+
} catch (error) {
|
|
55
|
+
console.error('[reactpress-theme-starter] tag archive fetch failed', error)
|
|
56
|
+
notFound()
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import TagsClient from '@/components/views/TagsClient'
|
|
2
|
+
import { loadAppBootstrap } from '@/lib/reactpress/bootstrap'
|
|
3
|
+
import { buildLocalizedListPageMetadata } from '@/lib/reactpress/siteMetadata'
|
|
4
|
+
import type { ITag } from '@fecommunity/reactpress-toolkit/types'
|
|
5
|
+
import type { Metadata } from 'next'
|
|
6
|
+
|
|
7
|
+
export const revalidate = 60
|
|
8
|
+
|
|
9
|
+
export async function generateMetadata(): Promise<Metadata> {
|
|
10
|
+
return buildLocalizedListPageMetadata('tagTitle', undefined, { path: '/tags' })
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export default async function TagsPage() {
|
|
14
|
+
const bootstrap = await loadAppBootstrap('/tags')
|
|
15
|
+
const tags = (bootstrap.tags ?? []) as ITag[]
|
|
16
|
+
|
|
17
|
+
return <TagsClient tags={tags} />
|
|
18
|
+
}
|
package/app/template.tsx
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import ThemeImage from '@/components/shared/ThemeImage'
|
|
2
|
+
import type { ReactNode } from 'react'
|
|
3
|
+
|
|
4
|
+
interface ArchiveBannerProps {
|
|
5
|
+
title: ReactNode
|
|
6
|
+
subtitle: ReactNode
|
|
7
|
+
imageUrl: string
|
|
8
|
+
isBrandFallback?: boolean
|
|
9
|
+
className?: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export default function ArchiveBanner({
|
|
13
|
+
title,
|
|
14
|
+
subtitle,
|
|
15
|
+
imageUrl,
|
|
16
|
+
isBrandFallback = false,
|
|
17
|
+
className = '',
|
|
18
|
+
}: ArchiveBannerProps) {
|
|
19
|
+
return (
|
|
20
|
+
<div
|
|
21
|
+
className={`rp-archive-banner rp-cover-zoom-host mb-5 h-[200px] w-full overflow-hidden rounded-xl bg-[var(--bg-second)] text-center shadow-[var(--box-shadow)] ring-1 ring-black/5 md:h-[280px] dark:ring-white/5 ${className}`}
|
|
22
|
+
>
|
|
23
|
+
<ThemeImage
|
|
24
|
+
src={imageUrl}
|
|
25
|
+
alt=""
|
|
26
|
+
fill
|
|
27
|
+
sizes="100vw"
|
|
28
|
+
className={`rp-cover-zoom ${isBrandFallback ? 'object-contain' : 'object-cover'} object-center`}
|
|
29
|
+
/>
|
|
30
|
+
<div className="rp-archive-banner__content">
|
|
31
|
+
<p className="m-0 mt-12 text-2xl font-medium text-[var(--font-color-base,#fff)] [text-shadow:0_1px_3px_rgba(0,0,0,0.45)] md:mt-20 md:text-[2rem]">
|
|
32
|
+
{title}
|
|
33
|
+
</p>
|
|
34
|
+
<p className="mt-3 mb-0 text-lg text-white/85 [text-shadow:0_1px_2px_rgba(0,0,0,0.4)] md:text-2xl">
|
|
35
|
+
{subtitle}
|
|
36
|
+
</p>
|
|
37
|
+
</div>
|
|
38
|
+
</div>
|
|
39
|
+
)
|
|
40
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import BookmarkWidget from '@/components/widgets/BookmarkWidget'
|
|
4
|
+
import LikesWidget from '@/components/widgets/LikesWidget'
|
|
5
|
+
import { COMMENT_DOM_ID } from '@/lib/utils/comment'
|
|
6
|
+
import { useLocale } from '@fecommunity/reactpress-toolkit/ui'
|
|
7
|
+
|
|
8
|
+
interface ArticleActionsBarProps {
|
|
9
|
+
articleId: string
|
|
10
|
+
likes: number
|
|
11
|
+
likesApi: (id: string, type: 'like' | 'dislike') => Promise<number>
|
|
12
|
+
showCommentLink?: boolean
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export default function ArticleActionsBar({
|
|
16
|
+
articleId,
|
|
17
|
+
likes,
|
|
18
|
+
likesApi,
|
|
19
|
+
showCommentLink = false,
|
|
20
|
+
}: ArticleActionsBarProps) {
|
|
21
|
+
const { t } = useLocale()
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<div className="rp-article-actions-bar">
|
|
25
|
+
<LikesWidget defaultCount={likes} id={articleId} api={likesApi} variant="inline" />
|
|
26
|
+
<BookmarkWidget id={articleId} variant="inline" />
|
|
27
|
+
{showCommentLink ? (
|
|
28
|
+
<button
|
|
29
|
+
type="button"
|
|
30
|
+
className="rp-action-btn rp-action-btn--inline"
|
|
31
|
+
aria-label={t('comment')}
|
|
32
|
+
onClick={() => {
|
|
33
|
+
document.getElementById(COMMENT_DOM_ID)?.scrollIntoView({ behavior: 'smooth' })
|
|
34
|
+
}}
|
|
35
|
+
>
|
|
36
|
+
<span className="rp-action-btn__icon" aria-hidden>
|
|
37
|
+
<svg
|
|
38
|
+
viewBox="0 0 24 24"
|
|
39
|
+
width="20"
|
|
40
|
+
height="20"
|
|
41
|
+
fill="none"
|
|
42
|
+
stroke="currentColor"
|
|
43
|
+
strokeWidth="1.8"
|
|
44
|
+
>
|
|
45
|
+
<path d="M21 15a4 4 0 0 1-4 4H8l-5 3V7a4 4 0 0 1 4-4h10a4 4 0 0 1 4 4z" />
|
|
46
|
+
</svg>
|
|
47
|
+
</span>
|
|
48
|
+
<span className="rp-action-btn__label">{t('comment')}</span>
|
|
49
|
+
</button>
|
|
50
|
+
) : null}
|
|
51
|
+
</div>
|
|
52
|
+
)
|
|
53
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { resolveImageUrl, type CarouselArticle } from '@fecommunity/reactpress-toolkit/theme'
|
|
4
|
+
import { useLocale } from '@fecommunity/reactpress-toolkit/ui'
|
|
5
|
+
import { LocaleTime } from '@fecommunity/reactpress-toolkit/ui/content'
|
|
6
|
+
import Link from '@/components/shared/Link'
|
|
7
|
+
import ThemeImage from '@/components/shared/ThemeImage'
|
|
8
|
+
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react'
|
|
9
|
+
|
|
10
|
+
const AUTOPLAY_MS = 5000
|
|
11
|
+
|
|
12
|
+
interface ArticleCarouselProps {
|
|
13
|
+
articles?: CarouselArticle[]
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function ChevronIcon({ direction }: { direction: 'left' | 'right' }) {
|
|
17
|
+
return (
|
|
18
|
+
<svg
|
|
19
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
20
|
+
width={16}
|
|
21
|
+
height={16}
|
|
22
|
+
viewBox="0 0 24 24"
|
|
23
|
+
fill="none"
|
|
24
|
+
stroke="currentColor"
|
|
25
|
+
strokeWidth={2.5}
|
|
26
|
+
strokeLinecap="round"
|
|
27
|
+
strokeLinejoin="round"
|
|
28
|
+
aria-hidden
|
|
29
|
+
>
|
|
30
|
+
{direction === 'left' ? <path d="m15 18-6-6 6-6" /> : <path d="m9 18 6-6-6-6" />}
|
|
31
|
+
</svg>
|
|
32
|
+
)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export default function ArticleCarousel({ articles = [] }: ArticleCarouselProps) {
|
|
36
|
+
const { t, locale } = useLocale()
|
|
37
|
+
const slides = useMemo(
|
|
38
|
+
() => (articles || []).filter((article) => article.cover).slice(0, 6),
|
|
39
|
+
[articles]
|
|
40
|
+
)
|
|
41
|
+
const slideIdsKey = useMemo(() => slides.map((article) => article.id).join(','), [slides])
|
|
42
|
+
const [active, setActive] = useState(0)
|
|
43
|
+
const [loadedSlides, setLoadedSlides] = useState<Set<number>>(() => new Set([0]))
|
|
44
|
+
const [paused, setPaused] = useState(false)
|
|
45
|
+
const [progressKey, setProgressKey] = useState(0)
|
|
46
|
+
const timerRef = useRef<number | null>(null)
|
|
47
|
+
|
|
48
|
+
const clearAutoplay = useCallback(() => {
|
|
49
|
+
if (timerRef.current !== null) {
|
|
50
|
+
window.clearInterval(timerRef.current)
|
|
51
|
+
timerRef.current = null
|
|
52
|
+
}
|
|
53
|
+
}, [])
|
|
54
|
+
|
|
55
|
+
const goTo = useCallback(
|
|
56
|
+
(index: number) => {
|
|
57
|
+
if (slides.length <= 1) return
|
|
58
|
+
const normalized = ((index % slides.length) + slides.length) % slides.length
|
|
59
|
+
setActive(normalized)
|
|
60
|
+
setProgressKey((k) => k + 1)
|
|
61
|
+
},
|
|
62
|
+
[slides.length]
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
const goPrev = useCallback(() => goTo(active - 1), [active, goTo])
|
|
66
|
+
const goNext = useCallback(() => goTo(active + 1), [active, goTo])
|
|
67
|
+
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
setLoadedSlides((prev) => {
|
|
70
|
+
if (prev.has(active)) return prev
|
|
71
|
+
const next = new Set(prev)
|
|
72
|
+
next.add(active)
|
|
73
|
+
return next
|
|
74
|
+
})
|
|
75
|
+
}, [active])
|
|
76
|
+
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
setActive(0)
|
|
79
|
+
setLoadedSlides(new Set([0]))
|
|
80
|
+
setProgressKey((k) => k + 1)
|
|
81
|
+
}, [slideIdsKey])
|
|
82
|
+
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
clearAutoplay()
|
|
85
|
+
if (slides.length <= 1 || paused) return undefined
|
|
86
|
+
|
|
87
|
+
timerRef.current = window.setInterval(() => {
|
|
88
|
+
setActive((current) => (current + 1) % slides.length)
|
|
89
|
+
setProgressKey((k) => k + 1)
|
|
90
|
+
}, AUTOPLAY_MS)
|
|
91
|
+
|
|
92
|
+
return clearAutoplay
|
|
93
|
+
}, [clearAutoplay, paused, slideIdsKey, slides.length])
|
|
94
|
+
|
|
95
|
+
if (!slides.length) return null
|
|
96
|
+
|
|
97
|
+
const showControls = slides.length > 1
|
|
98
|
+
const navBtnClass =
|
|
99
|
+
'rp-carousel-nav absolute top-1/2 z-20 flex h-9 w-9 -translate-y-1/2 cursor-pointer items-center justify-center rounded-full p-0 text-white transition-all duration-300 hover:scale-105 hover:bg-white/22 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white/80 md:opacity-0 md:group-hover/carousel:opacity-100 md:group-focus-within/carousel:opacity-100'
|
|
100
|
+
|
|
101
|
+
return (
|
|
102
|
+
<section
|
|
103
|
+
className={`rp-carousel group/carousel relative isolate overflow-hidden rounded-xl bg-[var(--bg-second)] shadow-[var(--box-shadow)] ring-1 ring-black/5 dark:ring-white/5${paused ? 'is-paused' : ''}`}
|
|
104
|
+
aria-roledescription="carousel"
|
|
105
|
+
aria-label={t('recommendToReading')}
|
|
106
|
+
style={{ '--rp-autoplay-ms': `${AUTOPLAY_MS}ms` } as CSSProperties}
|
|
107
|
+
onMouseEnter={() => setPaused(true)}
|
|
108
|
+
onMouseLeave={() => setPaused(false)}
|
|
109
|
+
onFocusCapture={() => setPaused(true)}
|
|
110
|
+
onBlurCapture={(event) => {
|
|
111
|
+
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
|
|
112
|
+
setPaused(false)
|
|
113
|
+
}
|
|
114
|
+
}}
|
|
115
|
+
>
|
|
116
|
+
<div
|
|
117
|
+
className="relative h-[320px] min-[1360px]:h-[480px] sm:h-[340px] md:h-[300px] lg:h-[380px] xl:h-[420px]"
|
|
118
|
+
aria-live="polite"
|
|
119
|
+
>
|
|
120
|
+
{slides.map((article, index) => {
|
|
121
|
+
const coverSrc = resolveImageUrl(article.cover, index === 0 ? 'medium' : 'thumb')
|
|
122
|
+
const isActive = index === active
|
|
123
|
+
const shouldLoadImage = loadedSlides.has(index)
|
|
124
|
+
|
|
125
|
+
return (
|
|
126
|
+
<article
|
|
127
|
+
key={article.id}
|
|
128
|
+
className={`rp-carousel-slide absolute inset-0 overflow-hidden ${isActive ? 'is-active' : ''}`}
|
|
129
|
+
style={{ pointerEvents: isActive ? 'auto' : 'none' }}
|
|
130
|
+
aria-hidden={!isActive}
|
|
131
|
+
>
|
|
132
|
+
{shouldLoadImage ? (
|
|
133
|
+
<ThemeImage
|
|
134
|
+
key={isActive ? `cover-${progressKey}` : article.id}
|
|
135
|
+
src={coverSrc}
|
|
136
|
+
alt=""
|
|
137
|
+
fill
|
|
138
|
+
sizes="(max-width: 768px) 100vw, 1200px"
|
|
139
|
+
className="rp-cover-zoom rp-carousel-cover-motion bg-[var(--bg-second)] object-contain object-center"
|
|
140
|
+
priority={index === 0}
|
|
141
|
+
loading={index === 0 ? 'eager' : 'lazy'}
|
|
142
|
+
/>
|
|
143
|
+
) : (
|
|
144
|
+
<div className="absolute inset-0 bg-[var(--bg-second)]" aria-hidden />
|
|
145
|
+
)}
|
|
146
|
+
|
|
147
|
+
<div
|
|
148
|
+
className="rp-carousel-overlay pointer-events-none absolute inset-0"
|
|
149
|
+
aria-hidden
|
|
150
|
+
/>
|
|
151
|
+
|
|
152
|
+
<Link
|
|
153
|
+
href={`/article/${article.id}`}
|
|
154
|
+
tabIndex={isActive ? 0 : -1}
|
|
155
|
+
className="absolute inset-0 z-10 flex flex-col justify-end no-underline"
|
|
156
|
+
>
|
|
157
|
+
<div
|
|
158
|
+
key={isActive ? `${article.id}-content` : `${article.id}-hidden`}
|
|
159
|
+
className={`rp-carousel-caption px-5 pt-16 pb-8 md:px-7 md:pb-9 lg:px-8 ${
|
|
160
|
+
isActive ? 'rp-carousel-text-enter' : 'opacity-0'
|
|
161
|
+
}`}
|
|
162
|
+
>
|
|
163
|
+
<div className="min-w-0">
|
|
164
|
+
<h2 className="m-0 mb-2 line-clamp-2 max-w-xl text-[1.05rem] leading-snug font-semibold tracking-tight text-white md:max-w-2xl md:text-xl lg:text-[1.3rem]">
|
|
165
|
+
{article.title}
|
|
166
|
+
</h2>
|
|
167
|
+
<p className="m-0 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-white/75 md:text-[0.8125rem]">
|
|
168
|
+
<LocaleTime date={article.publishAt} timeago locale={locale} />
|
|
169
|
+
<span className="text-white/40" aria-hidden>
|
|
170
|
+
·
|
|
171
|
+
</span>
|
|
172
|
+
<span>
|
|
173
|
+
{article.views} {t('readingCountTemplate')}
|
|
174
|
+
</span>
|
|
175
|
+
</p>
|
|
176
|
+
</div>
|
|
177
|
+
</div>
|
|
178
|
+
</Link>
|
|
179
|
+
</article>
|
|
180
|
+
)
|
|
181
|
+
})}
|
|
182
|
+
</div>
|
|
183
|
+
|
|
184
|
+
{showControls ? (
|
|
185
|
+
<>
|
|
186
|
+
<button
|
|
187
|
+
type="button"
|
|
188
|
+
className={`${navBtnClass} left-3 opacity-90 max-md:left-2.5`}
|
|
189
|
+
aria-label={t('carouselPrev')
|
|
190
|
+
.replace('{current}', String(active + 1))
|
|
191
|
+
.replace('{total}', String(slides.length))}
|
|
192
|
+
onClick={goPrev}
|
|
193
|
+
>
|
|
194
|
+
<ChevronIcon direction="left" />
|
|
195
|
+
</button>
|
|
196
|
+
<button
|
|
197
|
+
type="button"
|
|
198
|
+
className={`${navBtnClass} right-3 opacity-90 max-md:right-2.5`}
|
|
199
|
+
aria-label={t('carouselNext')
|
|
200
|
+
.replace('{current}', String(active + 1))
|
|
201
|
+
.replace('{total}', String(slides.length))}
|
|
202
|
+
onClick={goNext}
|
|
203
|
+
>
|
|
204
|
+
<ChevronIcon direction="right" />
|
|
205
|
+
</button>
|
|
206
|
+
|
|
207
|
+
<div
|
|
208
|
+
className="absolute right-5 bottom-3.5 z-20 hidden items-center gap-1 md:bottom-9 md:flex lg:right-8"
|
|
209
|
+
role="tablist"
|
|
210
|
+
aria-label={t('recommendToReading')}
|
|
211
|
+
>
|
|
212
|
+
{slides.map((article, index) => {
|
|
213
|
+
const isDotActive = index === active
|
|
214
|
+
return (
|
|
215
|
+
<button
|
|
216
|
+
key={article.id}
|
|
217
|
+
type="button"
|
|
218
|
+
role="tab"
|
|
219
|
+
aria-selected={isDotActive}
|
|
220
|
+
aria-label={`${index + 1} / ${slides.length}`}
|
|
221
|
+
className="flex min-h-6 min-w-6 cursor-pointer items-center justify-center border-0 bg-transparent p-1"
|
|
222
|
+
onClick={() => goTo(index)}
|
|
223
|
+
>
|
|
224
|
+
<span className={`rp-carousel-dot ${isDotActive ? 'is-active h-1.5' : ''}`} />
|
|
225
|
+
</button>
|
|
226
|
+
)
|
|
227
|
+
})}
|
|
228
|
+
</div>
|
|
229
|
+
|
|
230
|
+
<div className="rp-carousel-progress-track absolute right-0 bottom-0 left-0 z-20">
|
|
231
|
+
<div key={progressKey} className="rp-carousel-progress-meteor" aria-hidden />
|
|
232
|
+
</div>
|
|
233
|
+
</>
|
|
234
|
+
) : null}
|
|
235
|
+
</section>
|
|
236
|
+
)
|
|
237
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import Link from '@/components/shared/Link'
|
|
4
|
+
import ThemeImage from '@/components/shared/ThemeImage'
|
|
5
|
+
import { getRandomColor } from '@/lib/utils/comment'
|
|
6
|
+
import { useSiteCatalog } from '@fecommunity/reactpress-toolkit/theme'
|
|
7
|
+
import { useLocale } from '@fecommunity/reactpress-toolkit/ui'
|
|
8
|
+
import { LocaleTime } from '@fecommunity/reactpress-toolkit/ui/content'
|
|
9
|
+
import { resolveImageUrl, useSiteSetting } from '@fecommunity/reactpress-toolkit/theme'
|
|
10
|
+
|
|
11
|
+
const CC_LICENSE_URL = 'https://creativecommons.org/licenses/by-nc/3.0/cn/deed.zh'
|
|
12
|
+
|
|
13
|
+
interface ArticleCopyrightBarProps {
|
|
14
|
+
publishAt: string
|
|
15
|
+
className?: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function isImageLogo(logo: string) {
|
|
19
|
+
return (
|
|
20
|
+
logo.startsWith('http') ||
|
|
21
|
+
logo.startsWith('/') ||
|
|
22
|
+
/\.(svg|png|jpe?g|gif|webp)(\?.*)?$/i.test(logo)
|
|
23
|
+
)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function AuthorAvatar({ name, logo }: { name: string; logo?: string }) {
|
|
27
|
+
const initial = name.trim().charAt(0).toUpperCase() || 'A'
|
|
28
|
+
|
|
29
|
+
if (logo && logo.includes('<')) {
|
|
30
|
+
return (
|
|
31
|
+
<span
|
|
32
|
+
className="rp-article-author-avatar__brand"
|
|
33
|
+
dangerouslySetInnerHTML={{ __html: logo }}
|
|
34
|
+
/>
|
|
35
|
+
)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (logo && isImageLogo(logo)) {
|
|
39
|
+
return (
|
|
40
|
+
<ThemeImage
|
|
41
|
+
src={resolveImageUrl(logo, 'medium')}
|
|
42
|
+
alt=""
|
|
43
|
+
width={48}
|
|
44
|
+
height={48}
|
|
45
|
+
className="rp-article-author-avatar__img"
|
|
46
|
+
/>
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<span
|
|
52
|
+
className="rp-article-author-avatar__fallback"
|
|
53
|
+
style={{ backgroundColor: getRandomColor(name) }}
|
|
54
|
+
aria-hidden
|
|
55
|
+
>
|
|
56
|
+
{initial}
|
|
57
|
+
</span>
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export default function ArticleCopyrightBar({
|
|
62
|
+
publishAt,
|
|
63
|
+
className = '',
|
|
64
|
+
}: ArticleCopyrightBarProps) {
|
|
65
|
+
const { t } = useLocale()
|
|
66
|
+
const { locale } = useSiteCatalog()
|
|
67
|
+
const setting = useSiteSetting()
|
|
68
|
+
|
|
69
|
+
const authorName = setting.systemTitle?.trim() || 'ReactPress'
|
|
70
|
+
const authorBio = setting.systemSubTitle?.trim()
|
|
71
|
+
const authorLogo = setting.systemLogo?.trim()
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<div className={`rp-article-author-footer ${className}`.trim()}>
|
|
75
|
+
<div className="rp-article-author-panel">
|
|
76
|
+
<div className="rp-article-author-main">
|
|
77
|
+
<Link href="/" className="rp-article-author-avatar" aria-label={authorName}>
|
|
78
|
+
<AuthorAvatar name={authorName} logo={authorLogo} />
|
|
79
|
+
</Link>
|
|
80
|
+
|
|
81
|
+
<div className="rp-article-author-info">
|
|
82
|
+
<span className="rp-article-author-label">{locale === 'zh' ? '作者' : 'Author'}</span>
|
|
83
|
+
<Link href="/" className="rp-article-author-name">
|
|
84
|
+
{authorName}
|
|
85
|
+
</Link>
|
|
86
|
+
{authorBio ? <p className="rp-article-author-bio">{authorBio}</p> : null}
|
|
87
|
+
</div>
|
|
88
|
+
|
|
89
|
+
<Link href="/" className="rp-article-author-cta">
|
|
90
|
+
{t('home')}
|
|
91
|
+
<svg
|
|
92
|
+
viewBox="0 0 24 24"
|
|
93
|
+
width="14"
|
|
94
|
+
height="14"
|
|
95
|
+
fill="none"
|
|
96
|
+
stroke="currentColor"
|
|
97
|
+
strokeWidth="2"
|
|
98
|
+
aria-hidden
|
|
99
|
+
>
|
|
100
|
+
<path d="M5 12h14M13 6l6 6-6 6" strokeLinecap="round" strokeLinejoin="round" />
|
|
101
|
+
</svg>
|
|
102
|
+
</Link>
|
|
103
|
+
</div>
|
|
104
|
+
|
|
105
|
+
<div className="rp-article-author-meta">
|
|
106
|
+
<span>
|
|
107
|
+
{t('publishAt')}
|
|
108
|
+
<LocaleTime date={publishAt} locale={locale} />
|
|
109
|
+
</span>
|
|
110
|
+
<span className="rp-article-author-meta__sep" aria-hidden>
|
|
111
|
+
·
|
|
112
|
+
</span>
|
|
113
|
+
<span>
|
|
114
|
+
{t('copyrightInfo')}
|
|
115
|
+
<a
|
|
116
|
+
href={CC_LICENSE_URL}
|
|
117
|
+
target="_blank"
|
|
118
|
+
rel="noopener noreferrer"
|
|
119
|
+
className="rp-article-author-license"
|
|
120
|
+
>
|
|
121
|
+
{t('copyrightContent')}
|
|
122
|
+
</a>
|
|
123
|
+
</span>
|
|
124
|
+
</div>
|
|
125
|
+
</div>
|
|
126
|
+
</div>
|
|
127
|
+
)
|
|
128
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import BookmarkWidget from '@/components/widgets/BookmarkWidget'
|
|
4
|
+
import LikesWidget from '@/components/widgets/LikesWidget'
|
|
5
|
+
|
|
6
|
+
interface ArticleEndBarProps {
|
|
7
|
+
articleId: string
|
|
8
|
+
likes: number
|
|
9
|
+
likesApi: (id: string, type: 'like' | 'dislike') => Promise<number>
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export default function ArticleEndBar({ articleId, likes, likesApi }: ArticleEndBarProps) {
|
|
13
|
+
return (
|
|
14
|
+
<div className="rp-article-end-bar">
|
|
15
|
+
<LikesWidget defaultCount={likes} id={articleId} api={likesApi} variant="inline" />
|
|
16
|
+
<BookmarkWidget id={articleId} variant="inline" />
|
|
17
|
+
</div>
|
|
18
|
+
)
|
|
19
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import CategoryMenu from '@/components/article/CategoryMenu'
|
|
4
|
+
import LoadMore from '@/components/article/LoadMore'
|
|
5
|
+
import { useLocale } from '@fecommunity/reactpress-toolkit/ui'
|
|
6
|
+
import { useSiteCatalog } from '@fecommunity/reactpress-toolkit/theme'
|
|
7
|
+
import type { ReactNode } from 'react'
|
|
8
|
+
|
|
9
|
+
interface ArticleFeedSectionProps {
|
|
10
|
+
children: ReactNode
|
|
11
|
+
pageStart?: number
|
|
12
|
+
loadMore: (page: number) => void | Promise<void>
|
|
13
|
+
hasMore: boolean
|
|
14
|
+
showCategoryMenu?: boolean
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Shared home/category article list block — matches twentytwentyfive `leftWrap`. */
|
|
18
|
+
export default function ArticleFeedSection({
|
|
19
|
+
children,
|
|
20
|
+
pageStart = 1,
|
|
21
|
+
loadMore,
|
|
22
|
+
hasMore,
|
|
23
|
+
showCategoryMenu = true,
|
|
24
|
+
}: ArticleFeedSectionProps) {
|
|
25
|
+
const { t } = useLocale()
|
|
26
|
+
const { categories } = useSiteCatalog()
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<div className="rp-page-enter-delayed w-full overflow-hidden rounded-xl">
|
|
30
|
+
{showCategoryMenu ? (
|
|
31
|
+
<header className="overflow-hidden rounded-t-xl shadow-[var(--box-shadow)] ring-1 ring-black/5 dark:ring-white/5">
|
|
32
|
+
<CategoryMenu categories={categories as Array<{ value: string; label: string }>} />
|
|
33
|
+
</header>
|
|
34
|
+
) : null}
|
|
35
|
+
<main className="mb-16 w-full pt-4 [&_.rp-article-list]:w-full [&_.rp-article-list]:rounded-none [&_.rp-article-list]:shadow-none">
|
|
36
|
+
<LoadMore
|
|
37
|
+
pageStart={pageStart}
|
|
38
|
+
loadMore={loadMore}
|
|
39
|
+
hasMore={hasMore}
|
|
40
|
+
loader={
|
|
41
|
+
<div
|
|
42
|
+
className="loading flex items-center justify-center gap-2 py-8 text-sm text-[var(--second-text-color)]"
|
|
43
|
+
key={0}
|
|
44
|
+
>
|
|
45
|
+
<span className="rp-load-indicator" aria-hidden>
|
|
46
|
+
<span />
|
|
47
|
+
<span />
|
|
48
|
+
<span />
|
|
49
|
+
</span>
|
|
50
|
+
{t('gettingArticle')}
|
|
51
|
+
</div>
|
|
52
|
+
}
|
|
53
|
+
>
|
|
54
|
+
{children}
|
|
55
|
+
</LoadMore>
|
|
56
|
+
</main>
|
|
57
|
+
</div>
|
|
58
|
+
)
|
|
59
|
+
}
|