hd-garden 0.7.0 → 0.8.1
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/.agents/skills/garden-ui/SKILL.md +26 -19
- package/.env.en.example +67 -0
- package/README.en.md +57 -0
- package/README.md +31 -46
- package/app/a2/[slug]/page.tsx +4 -3
- package/app/a2/page.tsx +4 -3
- package/app/about/page.tsx +8 -9
- package/app/admin/page.tsx +7 -6
- package/app/admin/preview/[...path]/page.tsx +2 -1
- package/app/admin/ui/blocks/page.tsx +2 -1
- package/app/admin/ui/catalog/page.tsx +2 -1
- package/app/admin/ui/evals/page.tsx +2 -1
- package/app/admin/ui/page.tsx +2 -1
- package/app/blog/[...path]/page.tsx +14 -5
- package/app/blog/page.tsx +4 -2
- package/app/columns/[column]/[...path]/page.tsx +17 -18
- package/app/columns/page.tsx +12 -8
- package/app/components/AdminUiShell.tsx +4 -3
- package/app/components/ArticlePager.tsx +32 -0
- package/app/components/ArticleReferences.tsx +4 -1
- package/app/components/ArticleTags.tsx +3 -2
- package/app/components/BlogArchive.tsx +20 -16
- package/app/components/Comments.tsx +17 -10
- package/app/components/ContentEmptyState.tsx +29 -28
- package/app/components/ExternalEmbed.tsx +15 -14
- package/app/components/FeedMore.tsx +70 -0
- package/app/components/GardenContentGuide.tsx +10 -2
- package/app/components/GardenDocsNav.tsx +29 -22
- package/app/components/GiscusComments.tsx +2 -1
- package/app/components/GithubLiveOverview.tsx +10 -8
- package/app/components/GithubProfile.tsx +31 -21
- package/app/components/HandbookLangRedirect.tsx +30 -0
- package/app/components/HandbookLangSwitch.tsx +53 -0
- package/app/components/HandbookView.tsx +270 -0
- package/app/components/MarkdownArticle.tsx +39 -9
- package/app/components/MarkdownImage.tsx +6 -4
- package/app/components/SiteChrome.tsx +11 -2
- package/app/components/SiteFooter.tsx +6 -4
- package/app/components/SiteHeader.tsx +38 -25
- package/app/components/TableOfContents.tsx +15 -9
- package/app/components/ThoughtCard.tsx +16 -14
- package/app/components/ThoughtFeed.tsx +41 -0
- package/app/components/UiBlock.tsx +3 -2
- package/app/components/UiLang.tsx +18 -0
- package/app/components/Webmentions.tsx +13 -12
- package/app/components/WidgetChart.tsx +8 -6
- package/app/components/garden/FilePreview.tsx +14 -12
- package/app/components/garden/GardenBlock.tsx +10 -1
- package/app/components/garden/GardenFile.tsx +10 -8
- package/app/components/garden/RefDemo.tsx +62 -0
- package/app/components/garden/blocks.tsx +90 -32
- package/app/components/garden/chrome.tsx +20 -13
- package/app/components/garden/patterns.tsx +497 -0
- package/app/garden/[...slug]/page.tsx +11 -130
- package/app/garden/en/[...slug]/page.tsx +28 -0
- package/app/garden/en/page.tsx +16 -0
- package/app/garden/page.tsx +8 -74
- package/app/garden-modules.d.ts +3 -0
- package/app/globals.css +10 -0
- package/app/layout.tsx +2 -4
- package/app/lib/a2-meta.ts +26 -3
- package/app/lib/content.ts +13 -3
- package/app/lib/garden-docs-meta.ts +84 -5
- package/app/lib/github-id.mjs +8 -0
- package/app/lib/handbook-path.ts +11 -0
- package/app/lib/handbook-switch.mjs +12 -0
- package/app/lib/i18n.ts +621 -0
- package/app/lib/page-alternates.ts +2 -2
- package/app/lib/rehype-field-tables.mjs +47 -0
- package/app/lib/remark-a2.mjs +18 -8
- package/app/lib/remark-link-card.mjs +22 -0
- package/app/lib/remark-ref-demos.mjs +42 -0
- package/app/lib/tag-filter.ts +9 -2
- package/app/not-found.tsx +34 -4
- package/app/page.tsx +17 -14
- package/app/rss.xml/route.ts +2 -1
- package/app/site.config.ts +1 -1
- package/app/styles/a2.css +439 -32
- package/app/styles/shell.css +21 -2
- package/app/tags/page.tsx +7 -5
- package/app/thoughts/[slug]/page.tsx +2 -1
- package/app/thoughts/page/[n]/page.tsx +44 -0
- package/app/thoughts/page.tsx +4 -7
- package/app/ui-parity/page.tsx +38 -22
- package/bin/garden.mjs +133 -19
- package/docs/handbook/ai-writing.md +99 -0
- package/docs/handbook/assets/reading-layout-en.svg +26 -0
- package/docs/handbook/components/card.md +43 -20
- package/docs/handbook/components/chart.md +10 -24
- package/docs/handbook/components/choice.md +42 -0
- package/docs/handbook/components/compare.md +34 -21
- package/docs/handbook/components/figure.md +33 -44
- package/docs/handbook/components/file.md +10 -13
- package/docs/handbook/components/html.md +10 -28
- package/docs/handbook/components/interactive.md +9 -33
- package/docs/handbook/components/list.md +65 -0
- package/docs/handbook/components/relations.md +95 -0
- package/docs/handbook/components/stat.md +10 -26
- package/docs/handbook/components/timeline.md +13 -26
- package/docs/handbook/components.md +210 -19
- package/docs/handbook/config.md +7 -5
- package/docs/handbook/deploy.md +2 -2
- package/docs/handbook/design.md +23 -15
- package/docs/handbook/en/ai-writing.md +99 -0
- package/docs/handbook/en/components/card.md +67 -0
- package/docs/handbook/en/components/chart.md +38 -0
- package/docs/handbook/en/components/choice.md +42 -0
- package/docs/handbook/en/components/compare.md +53 -0
- package/docs/handbook/en/components/figure.md +59 -0
- package/docs/handbook/en/components/file/checklist.md +17 -0
- package/docs/handbook/en/components/file.md +29 -0
- package/docs/handbook/en/components/html.md +49 -0
- package/docs/handbook/en/components/interactive.md +41 -0
- package/docs/handbook/en/components/list.md +65 -0
- package/docs/handbook/en/components/relations.md +95 -0
- package/docs/handbook/en/components/stat.md +39 -0
- package/docs/handbook/en/components/timeline.md +37 -0
- package/docs/handbook/en/components.md +222 -0
- package/docs/handbook/en/config.md +127 -0
- package/docs/handbook/en/deploy.md +273 -0
- package/docs/handbook/en/design.md +88 -0
- package/docs/handbook/en/index.md +192 -0
- package/docs/handbook/en/reference/card.md +85 -0
- package/docs/handbook/en/reference/chart.md +68 -0
- package/docs/handbook/en/reference/choice.md +69 -0
- package/docs/handbook/en/reference/compare.md +59 -0
- package/docs/handbook/en/reference/figure.md +84 -0
- package/docs/handbook/en/reference/file/checklist.md +17 -0
- package/docs/handbook/en/reference/file.md +50 -0
- package/docs/handbook/en/reference/html.md +62 -0
- package/docs/handbook/en/reference/interactive.md +71 -0
- package/docs/handbook/en/reference/list.md +72 -0
- package/docs/handbook/en/reference/relations.md +113 -0
- package/docs/handbook/en/reference/stat.md +60 -0
- package/docs/handbook/en/reference/timeline.md +56 -0
- package/docs/handbook/en/reference.md +26 -0
- package/docs/handbook/en/start.md +92 -0
- package/docs/handbook/en/workflow.md +82 -0
- package/docs/handbook/en/writing.md +157 -0
- package/docs/handbook/index.md +99 -4
- package/docs/handbook/reference/card.md +85 -0
- package/docs/handbook/reference/chart.md +68 -0
- package/docs/handbook/reference/choice.md +69 -0
- package/docs/handbook/reference/compare.md +59 -0
- package/docs/handbook/reference/figure.md +84 -0
- package/docs/handbook/reference/file/checklist.md +17 -0
- package/docs/handbook/reference/file.md +50 -0
- package/docs/handbook/reference/html.md +62 -0
- package/docs/handbook/reference/interactive.md +71 -0
- package/docs/handbook/reference/list.md +72 -0
- package/docs/handbook/reference/relations.md +113 -0
- package/docs/handbook/reference/stat.md +60 -0
- package/docs/handbook/reference/timeline.md +56 -0
- package/docs/handbook/reference.md +26 -0
- package/docs/handbook/start.md +1 -1
- package/docs/handbook/workflow.md +6 -5
- package/docs/handbook/writing.md +21 -12
- package/next.config.ts +6 -0
- package/package.json +2 -1
- package/public/images/garden/ref-card-book.svg +1 -0
- package/public/images/garden/ref-card-lamp.svg +1 -0
- package/scripts/build-content.mjs +57 -16
- package/scripts/favicon-ico.mjs +9 -6
- package/scripts/file-preview.mjs +10 -7
- package/scripts/handbook-allowlist.mjs +18 -0
- package/scripts/handbook-facts.mjs +339 -11
- package/scripts/html-lang.mjs +36 -0
- package/scripts/link-assets.mjs +7 -0
- package/scripts/new-post.mjs +18 -9
- package/scripts/prepare-pages-output.mjs +30 -18
- package/scripts/redirects.mjs +17 -6
- package/scripts/static-404.mjs +25 -0
- package/scripts/ui-cli.mjs +14 -8
- package/templates/garden-blocks.md +67 -0
- package/templates/garden-snippets.plist +61 -0
- package/templates/site/Makefile.en +45 -0
- package/templates/site/README.en.md +15 -0
- package/templates/site/site.config.en.yaml +55 -0
- package/templates/site/site.config.yaml +2 -0
- package/templates/skills-en/garden-interactive-chart/SKILL.md +84 -0
- package/templates/skills-en/garden-interactive-chart/references/shell.html +49 -0
- package/templates/skills-en/garden-interactive-chart/references/style.md +61 -0
- package/templates/skills-en/garden-post-review/SKILL.md +63 -0
- package/templates/skills-en/garden-ui/SKILL.md +118 -0
- package/templates/workflows/pages.en.yml +195 -0
- package/templates/workflows/pages.yml +195 -0
- package/ui/core/apply.mjs +22 -4
- package/ui/core/cli-locale.mjs +67 -0
- package/ui/core/fence.mjs +18 -3
- package/ui/core/fix-prompt.mjs +18 -15
- package/ui/core/garden-fence.mjs +64 -0
- package/ui/core/scan.mjs +19 -3
- package/ui/garden/adjacent.mjs +21 -0
- package/ui/garden/catalog.mjs +205 -4
- package/ui/garden/check.mjs +12 -0
- package/ui/garden/file-block.mjs +8 -7
- package/ui/garden/html-lang.mjs +88 -0
- package/ui/garden/markdown-links.mjs +7 -4
- package/ui/garden/paging.mjs +30 -0
- package/ui/garden/parse.mjs +45 -0
- package/ui/garden/relations.mjs +252 -0
- package/ui/garden/search-index.mjs +6 -4
- package/ui/garden/source-icon.mjs +54 -0
- package/ui/parity-fixtures.mjs +77 -0
- package/ui/preview/dist/garden-ui.css +9 -9
- package/ui/preview/dist/garden-ui.esm.js +206 -169
- package/ui/preview/dist/garden-ui.iife.js +206 -169
- package/ui/prompt/rules.md +28 -1
- package/ui/react/components/chart.tsx +8 -5
- package/ui/react/components/display.tsx +7 -4
- package/ui/react/components/inputs.tsx +6 -3
- package/ui/react/components/layout.tsx +6 -4
- package/ui/react/components/media.tsx +7 -4
- package/ui/react/components/sandbox.tsx +3 -1
- package/ui/react/index.tsx +11 -7
- package/ui/react/lang.tsx +89 -0
- package/ui/react/parity.tsx +8 -6
- package/ui/react/runtime.tsx +3 -1
- package/ui/react/ui.css +9 -9
- package/worker/index.ts +14 -1
package/app/blog/page.tsx
CHANGED
|
@@ -4,6 +4,7 @@ import { BlogArchive, BlogArchiveFallback } from "../components/BlogArchive";
|
|
|
4
4
|
import { posts } from "../lib/content";
|
|
5
5
|
import { archivePosts, archiveSeries } from "../lib/archive";
|
|
6
6
|
import { pageAlternates } from "../lib/page-alternates";
|
|
7
|
+
import { siteLang, ui } from "../lib/i18n";
|
|
7
8
|
|
|
8
9
|
export const metadata = {
|
|
9
10
|
title: siteDocumentTitle,
|
|
@@ -12,13 +13,14 @@ export const metadata = {
|
|
|
12
13
|
};
|
|
13
14
|
|
|
14
15
|
export default function BlogIndex() {
|
|
16
|
+
const t = ui();
|
|
15
17
|
const list = archivePosts();
|
|
16
18
|
const series = archiveSeries();
|
|
17
19
|
return (
|
|
18
20
|
<main className="sh-main" id="content">
|
|
19
21
|
<div className="sh-head">
|
|
20
|
-
<h1 id="sh-title"
|
|
21
|
-
<p className="sh-deck"
|
|
22
|
+
<h1 id="sh-title">{t.blogHeading}</h1>
|
|
23
|
+
<p className="sh-deck">{siteLang === "en" ? t.blogDeck(posts.length) : <>阶段性的完整沉淀,关于工程、架构与技术思考。共 {posts.length} 篇,按年份归档。</>}</p>
|
|
22
24
|
</div>
|
|
23
25
|
<Suspense fallback={<BlogArchiveFallback posts={list} series={series} />}>
|
|
24
26
|
<BlogArchive posts={list} series={series} />
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import Link from "next/link";
|
|
2
|
+
import { ArticlePager } from "../../../components/ArticlePager";
|
|
2
3
|
import { CoverImage } from "../../../components/CoverImage";
|
|
3
4
|
import type { Metadata } from "next";
|
|
4
5
|
import { FEED_ALTERNATES } from "../../../lib/page-alternates";
|
|
5
6
|
import { notFound } from "next/navigation";
|
|
6
|
-
import { ArrowLeft
|
|
7
|
+
import { ArrowLeft } from "@phosphor-icons/react/ssr";
|
|
7
8
|
import {
|
|
8
9
|
columns,
|
|
9
10
|
extractHeadings,
|
|
@@ -28,6 +29,7 @@ import {
|
|
|
28
29
|
import { githubUrl, siteConfig } from "../../../site.config";
|
|
29
30
|
import { siteHref } from "../../../lib/site-href";
|
|
30
31
|
import { isStaticPlaceholder, STATIC_PLACEHOLDER, staticParams } from "../../../../ui/garden/static-params.mjs";
|
|
32
|
+
import { columnStatusLabel, siteLang, ui } from "../../../lib/i18n";
|
|
31
33
|
|
|
32
34
|
export function generateStaticParams() {
|
|
33
35
|
return staticParams(
|
|
@@ -88,20 +90,22 @@ export default async function ColumnEntryPage({
|
|
|
88
90
|
const series = columns.filter((item) => item.column === column);
|
|
89
91
|
const listed = [...series].sort((left, right) => Date.parse(right.date) - Date.parse(left.date));
|
|
90
92
|
const currentIndex = series.findIndex((item) => item.path === entry.path);
|
|
91
|
-
const
|
|
93
|
+
const prev = series[currentIndex - 1] ?? null;
|
|
94
|
+
const next = series[currentIndex + 1] ?? null;
|
|
92
95
|
const headings = extractHeadings(entry.content, { numbering: entry.numbering });
|
|
93
96
|
const baseUrl = await getSiteUrl();
|
|
94
97
|
const pathname = columnHref(entry);
|
|
95
98
|
const canonicalUrl = absoluteUrl(pathname, baseUrl);
|
|
99
|
+
const t = ui();
|
|
96
100
|
|
|
97
101
|
return (
|
|
98
102
|
<main className="column-reading-page">
|
|
99
103
|
<aside className="series-nav">
|
|
100
104
|
<Link href={siteHref("/columns")} className="back-link">
|
|
101
105
|
<ArrowLeft size={15} />
|
|
102
|
-
|
|
106
|
+
{t.allColumnsBack}
|
|
103
107
|
</Link>
|
|
104
|
-
<p
|
|
108
|
+
<p>{t.seriesEyebrow}</p>
|
|
105
109
|
<h2>{entry.columnTitle}</h2>
|
|
106
110
|
<ol>
|
|
107
111
|
{listed.map((item) => (
|
|
@@ -110,12 +114,12 @@ export default async function ColumnEntryPage({
|
|
|
110
114
|
</li>
|
|
111
115
|
))}
|
|
112
116
|
</ol>
|
|
113
|
-
<small>{series.length}
|
|
117
|
+
<small>{ui().columnSideCount(series.length, columnStatusLabel(entry.columnStatus))}</small>
|
|
114
118
|
</aside>
|
|
115
119
|
|
|
116
120
|
<article className="article-column series-article h-entry">
|
|
117
121
|
<Link className="u-url sr-only" href={pathname}>
|
|
118
|
-
{entry.title}
|
|
122
|
+
{siteLang === "en" ? t.permalinkLabel(entry.title) : <>{entry.title} 永久链接</>}
|
|
119
123
|
</Link>
|
|
120
124
|
<a
|
|
121
125
|
className="p-author h-card sr-only"
|
|
@@ -134,13 +138,13 @@ export default async function ColumnEntryPage({
|
|
|
134
138
|
</time>
|
|
135
139
|
</li>
|
|
136
140
|
{originalUrl(entry) ? (
|
|
137
|
-
<li><a className="orig ext" href={originalUrl(entry)} target="_blank" rel="noopener noreferrer"
|
|
141
|
+
<li><a className="orig ext" href={originalUrl(entry)} target="_blank" rel="noopener noreferrer">{t.originalLink}</a></li>
|
|
138
142
|
) : null}
|
|
139
143
|
</ul>
|
|
140
144
|
<ArticleTags tags={entry.tags} />
|
|
141
145
|
{entry.cover && (
|
|
142
146
|
<figure className="article-hero article-cover cover-img">
|
|
143
|
-
<CoverImage className="u-photo" src={entry.cover} image={entry.coverImage} alt={`${entry.title} 封面`} sizes="(max-width: 852px) calc(100vw - 40px), 812px" priority />
|
|
147
|
+
<CoverImage className="u-photo" src={entry.cover} image={entry.coverImage} alt={siteLang === "en" ? t.coverLabel(entry.title) : `${entry.title} 封面`} sizes="(max-width: 852px) calc(100vw - 40px), 812px" priority />
|
|
144
148
|
</figure>
|
|
145
149
|
)}
|
|
146
150
|
</header>
|
|
@@ -150,20 +154,15 @@ export default async function ColumnEntryPage({
|
|
|
150
154
|
<MarkdownArticle content={entry.content} numbering={entry.numbering} />
|
|
151
155
|
</div>
|
|
152
156
|
<ArticleReferences references={entry.references} />
|
|
153
|
-
{next && (
|
|
154
|
-
<Link
|
|
155
|
-
href={columnHref(next)}
|
|
156
|
-
className="next-entry"
|
|
157
|
-
>
|
|
158
|
-
<span>下一篇</span>
|
|
159
|
-
<strong>{next.title}</strong>
|
|
160
|
-
<ArrowRight size={20} />
|
|
161
|
-
</Link>
|
|
162
|
-
)}
|
|
163
157
|
<Webmentions
|
|
164
158
|
target={canonicalUrl}
|
|
165
159
|
endpoint={webmentionApiEndpoint()}
|
|
166
160
|
/>
|
|
161
|
+
<ArticlePager
|
|
162
|
+
label={ui().columnPagerLabel}
|
|
163
|
+
prev={prev ? { href: columnHref(prev), title: prev.title } : null}
|
|
164
|
+
next={next ? { href: columnHref(next), title: next.title } : null}
|
|
165
|
+
/>
|
|
167
166
|
<Comments slug={`${column}:${entry.path}`} />
|
|
168
167
|
</article>
|
|
169
168
|
|
package/app/columns/page.tsx
CHANGED
|
@@ -4,6 +4,7 @@ import { columnHref, columns } from "../lib/content";
|
|
|
4
4
|
import { ContentEmptyState } from "../components/ContentEmptyState";
|
|
5
5
|
import { siteConfig, siteDocumentTitle } from "../site.config";
|
|
6
6
|
import { pageAlternates } from "../lib/page-alternates";
|
|
7
|
+
import { columnStatusLabel, ui } from "../lib/i18n";
|
|
7
8
|
|
|
8
9
|
export const metadata = {
|
|
9
10
|
title: siteDocumentTitle,
|
|
@@ -23,6 +24,7 @@ function day(value: string) {
|
|
|
23
24
|
}
|
|
24
25
|
|
|
25
26
|
export default function ColumnsPage() {
|
|
27
|
+
const t = ui();
|
|
26
28
|
const grouped = Array.from(
|
|
27
29
|
columns.reduce((result, entry) => {
|
|
28
30
|
const existing = result.get(entry.column ?? "");
|
|
@@ -39,19 +41,21 @@ export default function ColumnsPage() {
|
|
|
39
41
|
return (
|
|
40
42
|
<main className="sh-main" id="content">
|
|
41
43
|
<div className="sh-head">
|
|
42
|
-
<h1>{siteConfig.pages.columns.title ||
|
|
44
|
+
<h1>{siteConfig.pages.columns.title || t.homeColumns}</h1>
|
|
43
45
|
{siteConfig.pages.columns.subtitle ? <p className="sh-deck">{siteConfig.pages.columns.subtitle}</p> : null}
|
|
44
46
|
</div>
|
|
45
47
|
{grouped.length ? grouped.map(({ slug, first, entries }) => (
|
|
46
|
-
<section className="sh-series" aria-labelledby={slug} key={slug}>
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
48
|
+
<section className={first.columnCover ? "sh-series" : "sh-series no-cover"} aria-labelledby={slug} key={slug}>
|
|
49
|
+
{first.columnCover ? (
|
|
50
|
+
<div className="cover" style={{ background: "var(--well)" }}>
|
|
51
|
+
<img className="column-feature-cover" src={first.columnCover} alt={first.columnCoverAlt ?? t.columnCoverAlt(first.columnTitle)} />
|
|
52
|
+
</div>
|
|
53
|
+
) : null}
|
|
50
54
|
<div className="body">
|
|
51
55
|
<h2 id={slug}>{first.columnTitle}</h2>
|
|
52
|
-
<p className="count"
|
|
53
|
-
<p className="desc">{first.columnDescription ?? first.description ??
|
|
54
|
-
<ol aria-label={
|
|
56
|
+
<p className="count">{t.columnReadingList(entries.length, columnStatusLabel(first.columnStatus))}</p>
|
|
57
|
+
<p className="desc">{first.columnDescription ?? first.description ?? t.columnFallbackDesc}</p>
|
|
58
|
+
<ol aria-label={t.columnNewestFirst(first.columnTitle)}>
|
|
55
59
|
{entries.map((entry) => (
|
|
56
60
|
<li key={entry.path}>
|
|
57
61
|
<Link href={columnHref(entry)}>{entry.title}</Link>
|
|
@@ -2,6 +2,7 @@ import Link from "next/link";
|
|
|
2
2
|
import { redirect } from "next/navigation";
|
|
3
3
|
import type { ReactNode } from "react";
|
|
4
4
|
import { getAdminUser } from "../lib/admin-auth";
|
|
5
|
+
import { ui } from "../lib/i18n";
|
|
5
6
|
|
|
6
7
|
const SECTIONS = [
|
|
7
8
|
{ href: "/admin", label: "总览" },
|
|
@@ -23,9 +24,9 @@ export function StaticAdminNotice() {
|
|
|
23
24
|
return (
|
|
24
25
|
<main className="admin-access-page">
|
|
25
26
|
<div className="admin-access-card">
|
|
26
|
-
<h1
|
|
27
|
-
<p
|
|
28
|
-
<Link href="/"
|
|
27
|
+
<h1>{ui().staticAdminHeading}</h1>
|
|
28
|
+
<p>{ui().staticAdminBody}</p>
|
|
29
|
+
<Link href="/">{ui().staticAdminBack}</Link>
|
|
29
30
|
</div>
|
|
30
31
|
</main>
|
|
31
32
|
);
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import Link from "next/link";
|
|
2
|
+
import { ArrowLeft, ArrowRight } from "@phosphor-icons/react/ssr";
|
|
3
|
+
import { ui } from "../lib/i18n";
|
|
4
|
+
|
|
5
|
+
export type PagerTarget = { href: string; title: string } | null;
|
|
6
|
+
|
|
7
|
+
/** 上一篇 / 下一篇, the same cards as the handbook pager. A missing side stays empty. */
|
|
8
|
+
export function ArticlePager({ prev, next, label = ui().pagerLabel }: { prev: PagerTarget; next: PagerTarget; label?: string }) {
|
|
9
|
+
if (!prev && !next) return null;
|
|
10
|
+
return (
|
|
11
|
+
<nav className="gdoc-pager article-pager" aria-label={label}>
|
|
12
|
+
{prev ? (
|
|
13
|
+
<Link href={prev.href} className="gdoc-pager-card is-prev">
|
|
14
|
+
<span className="gdoc-pager-dir">
|
|
15
|
+
<ArrowLeft size={14} aria-hidden="true" />
|
|
16
|
+
{ui().prev}
|
|
17
|
+
</span>
|
|
18
|
+
<strong>{prev.title}</strong>
|
|
19
|
+
</Link>
|
|
20
|
+
) : null}
|
|
21
|
+
{next ? (
|
|
22
|
+
<Link href={next.href} className="gdoc-pager-card is-next">
|
|
23
|
+
<span className="gdoc-pager-dir">
|
|
24
|
+
{ui().next}
|
|
25
|
+
<ArrowRight size={14} aria-hidden="true" />
|
|
26
|
+
</span>
|
|
27
|
+
<strong>{next.title}</strong>
|
|
28
|
+
</Link>
|
|
29
|
+
) : null}
|
|
30
|
+
</nav>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { referenceColumns, referenceDomain } from "../../ui/garden/references.mjs";
|
|
2
|
+
import { ui, type UiLang } from "../lib/i18n";
|
|
2
3
|
|
|
3
4
|
type ReferenceItem = {
|
|
4
5
|
title: string;
|
|
@@ -8,8 +9,10 @@ type ReferenceItem = {
|
|
|
8
9
|
|
|
9
10
|
export function ArticleReferences({
|
|
10
11
|
references,
|
|
12
|
+
lang,
|
|
11
13
|
}: {
|
|
12
14
|
references?: ReferenceItem[];
|
|
15
|
+
lang?: UiLang;
|
|
13
16
|
}) {
|
|
14
17
|
if (!references?.length) return null;
|
|
15
18
|
const split = referenceColumns(references.length) === 2;
|
|
@@ -19,7 +22,7 @@ export function ArticleReferences({
|
|
|
19
22
|
className={split ? "article-references is-split" : "article-references"}
|
|
20
23
|
aria-labelledby="article-references-heading"
|
|
21
24
|
>
|
|
22
|
-
<h2 id="article-references-heading"
|
|
25
|
+
<h2 id="article-references-heading">{ui(lang).referencesHeading}</h2>
|
|
23
26
|
<ol>
|
|
24
27
|
{references.map((item, index) => {
|
|
25
28
|
const domain = item.domain ?? referenceDomain(item.url);
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import Link from "next/link";
|
|
2
2
|
import { articleTagHref } from "../lib/tag-filter";
|
|
3
3
|
import { siteHref } from "../lib/site-href";
|
|
4
|
+
import { ui, type UiLang } from "../lib/i18n";
|
|
4
5
|
|
|
5
6
|
/** Tags under an article title. Link, so following one is a client navigation like the rest of the site. */
|
|
6
|
-
export function ArticleTags({ tags }: { tags?: string[] }) {
|
|
7
|
+
export function ArticleTags({ tags, lang }: { tags?: string[]; lang?: UiLang }) {
|
|
7
8
|
const list = (tags ?? []).filter(Boolean);
|
|
8
9
|
if (!list.length) return null;
|
|
9
10
|
return (
|
|
10
|
-
<ul className="article-tagrow" aria-label=
|
|
11
|
+
<ul className="article-tagrow" aria-label={ui(lang).articleTagsLabel}>
|
|
11
12
|
{list.map((tag) => (
|
|
12
13
|
<li key={tag}>
|
|
13
14
|
<Link className="tag-chip p-category" href={siteHref(articleTagHref(tag))}>
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
} from "../lib/tag-filter";
|
|
18
18
|
import { ContentEmptyState } from "./ContentEmptyState";
|
|
19
19
|
import { siteHref } from "../lib/site-href";
|
|
20
|
+
import { siteLang, ui } from "../lib/i18n";
|
|
20
21
|
|
|
21
22
|
function day(value: string) {
|
|
22
23
|
const date = new Date(value);
|
|
@@ -72,6 +73,7 @@ export function BlogArchiveFallback({ posts, series }: ArchiveData) {
|
|
|
72
73
|
}
|
|
73
74
|
|
|
74
75
|
function ArchiveView({ posts, series, selected, go }: ArchiveData & { selected: string[]; go: (next: string[]) => void }) {
|
|
76
|
+
const t = ui();
|
|
75
77
|
const [compact, setCompact] = useState(false);
|
|
76
78
|
const [expanded, setExpanded] = useState(false);
|
|
77
79
|
const options = useMemo(() => tagOptions(posts, selected), [posts, selected]);
|
|
@@ -81,7 +83,7 @@ function ArchiveView({ posts, series, selected, go }: ArchiveData & { selected:
|
|
|
81
83
|
.sort((left, right) => Date.parse(right.date) - Date.parse(left.date));
|
|
82
84
|
const years = new Map<string, typeof filtered>();
|
|
83
85
|
for (const post of filtered) {
|
|
84
|
-
const year = day(post.date).slice(0, 4) ||
|
|
86
|
+
const year = day(post.date).slice(0, 4) || t.unknownYear;
|
|
85
87
|
const list = years.get(year);
|
|
86
88
|
if (list) list.push(post);
|
|
87
89
|
else years.set(year, [post]);
|
|
@@ -92,7 +94,7 @@ function ArchiveView({ posts, series, selected, go }: ArchiveData & { selected:
|
|
|
92
94
|
<div>
|
|
93
95
|
{options.length ? (
|
|
94
96
|
<div className="sh-tagfilter">
|
|
95
|
-
<ul className="sh-tagchips" aria-label=
|
|
97
|
+
<ul className="sh-tagchips" aria-label={t.tagFilterLabel}>
|
|
96
98
|
{shown.map((option) => (
|
|
97
99
|
<li key={option.tag}>
|
|
98
100
|
<button
|
|
@@ -111,7 +113,9 @@ function ArchiveView({ posts, series, selected, go }: ArchiveData & { selected:
|
|
|
111
113
|
{hidden > 0 || expanded ? (
|
|
112
114
|
<li>
|
|
113
115
|
<button type="button" className="sh-tagmore" aria-expanded={expanded} onClick={() => setExpanded(!expanded)}>
|
|
114
|
-
{
|
|
116
|
+
{siteLang === "en"
|
|
117
|
+
? (expanded ? t.tagCollapse : t.tagExpandAll(options.length))
|
|
118
|
+
: (expanded ? "收起" : `展开全部 ${options.length} 个标签`)}
|
|
115
119
|
</button>
|
|
116
120
|
</li>
|
|
117
121
|
) : null}
|
|
@@ -119,17 +123,17 @@ function ArchiveView({ posts, series, selected, go }: ArchiveData & { selected:
|
|
|
119
123
|
</div>
|
|
120
124
|
) : null}
|
|
121
125
|
<div className="sh-status">
|
|
122
|
-
<span aria-live="polite">{tagSummary(selected.length, filtered.length)}</span>
|
|
123
|
-
{selected.length ? <button type="button" onClick={() => go([])}
|
|
124
|
-
<div className="sh-view" role="group" aria-label=
|
|
125
|
-
<button type="button" aria-pressed={!compact} onClick={() => setCompact(false)}
|
|
126
|
-
<button type="button" aria-pressed={compact} onClick={() => setCompact(true)}
|
|
126
|
+
<span aria-live="polite">{tagSummary(selected.length, filtered.length, siteLang)}</span>
|
|
127
|
+
{selected.length ? <button type="button" onClick={() => go([])}>{siteLang === "en" ? t.tagClear : "清除"}</button> : null}
|
|
128
|
+
<div className="sh-view" role="group" aria-label={t.densityLabel}>
|
|
129
|
+
<button type="button" aria-pressed={!compact} onClick={() => setCompact(false)}>{t.densityDetailed}</button>
|
|
130
|
+
<button type="button" aria-pressed={compact} onClick={() => setCompact(true)}>{t.densityCompact}</button>
|
|
127
131
|
</div>
|
|
128
132
|
</div>
|
|
129
133
|
<div id="sh-list" className={compact ? "is-compact" : undefined}>
|
|
130
134
|
{filtered.length ? [...years.entries()].map(([year, entries]) => (
|
|
131
135
|
<section className="sh-year" id={`y${year}`} key={year} aria-labelledby={`yh${year}`}>
|
|
132
|
-
<h2 id={`yh${year}`}>{year}<span className="n">{entries.length}
|
|
136
|
+
<h2 id={`yh${year}`}>{year}<span className="n">{siteLang === "en" ? t.yearPostCount(entries.length) : <>{entries.length} 篇</>}</span></h2>
|
|
133
137
|
<ol className="sh-rows">
|
|
134
138
|
{entries.map((post) => (
|
|
135
139
|
<li className="sh-row" key={post.path}>
|
|
@@ -137,7 +141,7 @@ function ArchiveView({ posts, series, selected, go }: ArchiveData & { selected:
|
|
|
137
141
|
<div className="archive-copy">
|
|
138
142
|
<h3>
|
|
139
143
|
<Link href={postHref(post)}>{post.title}</Link>
|
|
140
|
-
{post.pinned ? <span className="pin"
|
|
144
|
+
{post.pinned ? <span className="pin">{t.pinnedBadge}</span> : null}
|
|
141
145
|
</h3>
|
|
142
146
|
{post.description ? <p>{post.description}</p> : null}
|
|
143
147
|
</div>
|
|
@@ -148,19 +152,19 @@ function ArchiveView({ posts, series, selected, go }: ArchiveData & { selected:
|
|
|
148
152
|
</section>
|
|
149
153
|
)) : selected.length ? (
|
|
150
154
|
<div className="sh-tagempty">
|
|
151
|
-
<p
|
|
155
|
+
<p>{siteLang === "en" ? t.tagEmptyMatch : "没有同时带这些标签的文章。"}</p>
|
|
152
156
|
<div className="acts">
|
|
153
157
|
{selected.length > 1 ? (
|
|
154
|
-
<button type="button" onClick={() => go(selected.slice(0, -1))}
|
|
158
|
+
<button type="button" onClick={() => go(selected.slice(0, -1))}>{t.tagDropLast(selected[selected.length - 1])}</button>
|
|
155
159
|
) : null}
|
|
156
|
-
<button type="button" onClick={() => go([])}
|
|
160
|
+
<button type="button" onClick={() => go([])}>{t.browseAllPosts}</button>
|
|
157
161
|
</div>
|
|
158
162
|
</div>
|
|
159
163
|
) : <ContentEmptyState kind="posts" />}
|
|
160
164
|
</div>
|
|
161
165
|
</div>
|
|
162
|
-
<aside className="sh-rail" aria-label=
|
|
163
|
-
{years.size ? <h2
|
|
166
|
+
<aside className="sh-rail" aria-label={t.railJumpByYear}>
|
|
167
|
+
{years.size ? <h2>{t.railByYear}</h2> : null}
|
|
164
168
|
<ol>
|
|
165
169
|
{[...years.keys()].map((year) => (
|
|
166
170
|
<li key={year}>
|
|
@@ -170,7 +174,7 @@ function ArchiveView({ posts, series, selected, go }: ArchiveData & { selected:
|
|
|
170
174
|
</ol>
|
|
171
175
|
{series.length ? (
|
|
172
176
|
<>
|
|
173
|
-
<h2
|
|
177
|
+
<h2>{t.railColumns}</h2>
|
|
174
178
|
<ol>
|
|
175
179
|
{series.map((column) => (
|
|
176
180
|
<li key={column.slug}>
|
|
@@ -1,20 +1,20 @@
|
|
|
1
1
|
import { ChatCircle, GithubLogo } from "@phosphor-icons/react/ssr";
|
|
2
2
|
import { readGiscusConfig } from "../lib/giscus-config";
|
|
3
3
|
import { GiscusComments } from "./GiscusComments";
|
|
4
|
+
import { ui } from "../lib/i18n";
|
|
4
5
|
|
|
5
6
|
export function Comments({ slug }: { slug: string }) {
|
|
6
7
|
const config = readGiscusConfig();
|
|
8
|
+
const t = ui();
|
|
7
9
|
|
|
8
10
|
return (
|
|
9
11
|
<section className="comments" aria-labelledby={`comments-heading-${slug}`}>
|
|
10
12
|
<div className="comments-heading">
|
|
11
13
|
<ChatCircle size={23} />
|
|
12
14
|
<div>
|
|
13
|
-
<h2 id={`comments-heading-${slug}`}
|
|
15
|
+
<h2 id={`comments-heading-${slug}`}>{t.comments}</h2>
|
|
14
16
|
<p>
|
|
15
|
-
{config
|
|
16
|
-
? "评论由 GitHub Discussions 保存和管理。"
|
|
17
|
-
: "评论还没开放。配好下面四项后,这里才会出现评论框。"}
|
|
17
|
+
{config ? t.commentsSummaryReady : t.commentsSummaryUnconfigured}
|
|
18
18
|
</p>
|
|
19
19
|
</div>
|
|
20
20
|
</div>
|
|
@@ -25,14 +25,21 @@ export function Comments({ slug }: { slug: string }) {
|
|
|
25
25
|
<div className="integration-notice">
|
|
26
26
|
<GithubLogo size={20} weight="fill" />
|
|
27
27
|
<p>
|
|
28
|
-
|
|
29
|
-
<a href=
|
|
28
|
+
{t.commentsSetupBefore}{" "}
|
|
29
|
+
<a href={t.commentsGiscusGuideUrl} target="_blank" rel="noreferrer">
|
|
30
30
|
Giscus
|
|
31
31
|
</a>
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
<code>
|
|
32
|
+
{t.commentsSetupMiddle}
|
|
33
|
+
<code>.env.local</code>
|
|
34
|
+
{t.commentsSetupAnd}
|
|
35
|
+
<code>GISCUS_REPO</code>
|
|
36
|
+
{t.commentsSetupJoin}
|
|
37
|
+
<code>GISCUS_REPO_ID</code>
|
|
38
|
+
{t.commentsSetupJoin}
|
|
39
|
+
<code>GISCUS_CATEGORY</code>
|
|
40
|
+
{t.commentsSetupJoin}
|
|
41
|
+
<code>GISCUS_CATEGORY_ID</code>
|
|
42
|
+
{t.commentsSetupEnd}
|
|
36
43
|
</p>
|
|
37
44
|
</div>
|
|
38
45
|
)}
|
|
@@ -3,35 +3,36 @@
|
|
|
3
3
|
import Link from "next/link";
|
|
4
4
|
import { ArrowRight, Article, Books, Quotes } from "@phosphor-icons/react/ssr";
|
|
5
5
|
import { siteHref } from "../lib/site-href";
|
|
6
|
+
import { ui } from "../lib/i18n";
|
|
6
7
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
8
|
+
export function ContentEmptyState({ kind }: { kind: "posts" | "thoughts" | "columns" }) {
|
|
9
|
+
const s = ui();
|
|
10
|
+
const emptyContent = {
|
|
11
|
+
posts: {
|
|
12
|
+
icon: Article,
|
|
13
|
+
eyebrow: s.emptyPostsEyebrow,
|
|
14
|
+
title: s.emptyPostsTitle,
|
|
15
|
+
description: s.emptyPostsDesc,
|
|
16
|
+
actionHref: "/",
|
|
17
|
+
actionLabel: s.emptyPostsCta,
|
|
18
|
+
},
|
|
19
|
+
thoughts: {
|
|
20
|
+
icon: Quotes,
|
|
21
|
+
eyebrow: s.emptyThoughtsEyebrow,
|
|
22
|
+
title: s.emptyThoughtsTitle,
|
|
23
|
+
description: s.emptyThoughtsDesc,
|
|
24
|
+
actionHref: "/blog",
|
|
25
|
+
actionLabel: s.emptyThoughtsCta,
|
|
26
|
+
},
|
|
27
|
+
columns: {
|
|
28
|
+
icon: Books,
|
|
29
|
+
eyebrow: s.emptyColumnsEyebrow,
|
|
30
|
+
title: s.emptyColumnsTitle,
|
|
31
|
+
description: s.emptyColumnsDesc,
|
|
32
|
+
actionHref: "/blog",
|
|
33
|
+
actionLabel: s.emptyColumnsCta,
|
|
34
|
+
},
|
|
35
|
+
} as const;
|
|
35
36
|
const content = emptyContent[kind];
|
|
36
37
|
const Icon = content.icon;
|
|
37
38
|
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
YoutubeLogo,
|
|
8
8
|
} from "@phosphor-icons/react/ssr";
|
|
9
9
|
import { parse } from "yaml";
|
|
10
|
+
import { ui, type UiLang } from "../lib/i18n";
|
|
10
11
|
|
|
11
12
|
type EmbedData = {
|
|
12
13
|
url: string;
|
|
@@ -70,23 +71,23 @@ function playableUrl(provider: EmbedProvider, url: URL) {
|
|
|
70
71
|
return null;
|
|
71
72
|
}
|
|
72
73
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
bilibili: { label: "Bilibili", Icon: VideoCamera },
|
|
76
|
-
x: { label: "X", Icon: XLogo },
|
|
77
|
-
wechat: { label: "微信公众号", Icon: WechatLogo },
|
|
78
|
-
website: { label: "网页摘要", Icon: LinkSimple },
|
|
79
|
-
};
|
|
80
|
-
|
|
81
|
-
export function ExternalEmbed({ source }: { source: string }) {
|
|
74
|
+
export function ExternalEmbed({ source, lang }: { source: string; lang?: UiLang }) {
|
|
75
|
+
const s = ui(lang);
|
|
82
76
|
const data = readEmbedData(source);
|
|
83
77
|
if (!data) {
|
|
84
|
-
return <p className="external-embed-error"
|
|
78
|
+
return <p className="external-embed-error">{s.embedParseError}</p>;
|
|
85
79
|
}
|
|
86
80
|
|
|
87
81
|
const url = new URL(data.url);
|
|
88
82
|
const provider = providerFor(url);
|
|
89
83
|
const playerUrl = playableUrl(provider, url);
|
|
84
|
+
const providerCopy = {
|
|
85
|
+
youtube: { label: "YouTube", Icon: YoutubeLogo },
|
|
86
|
+
bilibili: { label: "Bilibili", Icon: VideoCamera },
|
|
87
|
+
x: { label: "X", Icon: XLogo },
|
|
88
|
+
wechat: { label: s.embedProviderWechat, Icon: WechatLogo },
|
|
89
|
+
website: { label: s.embedProviderWeb, Icon: LinkSimple },
|
|
90
|
+
};
|
|
90
91
|
const { Icon, label } = providerCopy[provider];
|
|
91
92
|
|
|
92
93
|
if (playerUrl) {
|
|
@@ -95,7 +96,7 @@ export function ExternalEmbed({ source }: { source: string }) {
|
|
|
95
96
|
<div className="external-embed-frame">
|
|
96
97
|
<iframe
|
|
97
98
|
src={playerUrl}
|
|
98
|
-
title={data.title ??
|
|
99
|
+
title={data.title ?? s.embedFrameTitle(label)}
|
|
99
100
|
loading="lazy"
|
|
100
101
|
allow="accelerometer; autoplay; encrypted-media; fullscreen; picture-in-picture"
|
|
101
102
|
referrerPolicy="strict-origin-when-cross-origin"
|
|
@@ -104,7 +105,7 @@ export function ExternalEmbed({ source }: { source: string }) {
|
|
|
104
105
|
</div>
|
|
105
106
|
<figcaption>
|
|
106
107
|
<span><Icon size={17} />{label}</span>
|
|
107
|
-
<a href={data.url} target="_blank" rel="noopener noreferrer"
|
|
108
|
+
<a href={data.url} target="_blank" rel="noopener noreferrer">{s.embedOpenOriginal}<ArrowUpRight size={14} /></a>
|
|
108
109
|
</figcaption>
|
|
109
110
|
</figure>
|
|
110
111
|
);
|
|
@@ -118,8 +119,8 @@ export function ExternalEmbed({ source }: { source: string }) {
|
|
|
118
119
|
)}
|
|
119
120
|
<span className="external-embed-copy">
|
|
120
121
|
<small><Icon size={16} />{label} · {url.hostname}</small>
|
|
121
|
-
<strong>{data.title ??
|
|
122
|
-
<p>{data.description ?? (provider === "x" || provider === "wechat" ?
|
|
122
|
+
<strong>{data.title ?? s.embedFallbackTitle}</strong>
|
|
123
|
+
<p>{data.description ?? (provider === "x" || provider === "wechat" ? s.embedFallbackNoScript : s.embedFallbackWeb)}</p>
|
|
123
124
|
</span>
|
|
124
125
|
<ArrowUpRight size={21} />
|
|
125
126
|
</a>
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
4
|
+
|
|
5
|
+
type Labels = { nextPage: string; loadingMore: string; loadFailed: string; feedEnd: string };
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The end of a feed page. Without JavaScript it is a plain 下一页 link to the
|
|
9
|
+
* next pre-rendered page. With JavaScript, when it scrolls near the viewport it
|
|
10
|
+
* fetches that page's HTML, appends its items to the list above and moves on.
|
|
11
|
+
*/
|
|
12
|
+
export function FeedMore({ next, streamSelector, labels }: { next: string | null; streamSelector: string; labels: Labels }) {
|
|
13
|
+
// Page links are written without the basePath; the pages fetched later carry it already.
|
|
14
|
+
const base = (process.env.NEXT_PUBLIC_BASE_PATH ?? "").replace(/\/+$/u, "");
|
|
15
|
+
const [href, setHref] = useState(next ? `${base}${next}` : null);
|
|
16
|
+
const [state, setState] = useState<"idle" | "loading" | "error">("idle");
|
|
17
|
+
const navRef = useRef<HTMLElement>(null);
|
|
18
|
+
const busy = useRef(false);
|
|
19
|
+
|
|
20
|
+
const load = useCallback(async () => {
|
|
21
|
+
if (!href || busy.current) return;
|
|
22
|
+
busy.current = true;
|
|
23
|
+
setState("loading");
|
|
24
|
+
try {
|
|
25
|
+
const response = await fetch(href, { headers: { Accept: "text/html" } });
|
|
26
|
+
if (!response.ok) throw new Error(String(response.status));
|
|
27
|
+
const page = new DOMParser().parseFromString(await response.text(), "text/html");
|
|
28
|
+
const incoming = page.querySelector(streamSelector);
|
|
29
|
+
const stream = navRef.current?.parentElement?.querySelector(streamSelector);
|
|
30
|
+
if (!incoming || !stream) throw new Error("feed markup");
|
|
31
|
+
stream.append(...Array.from(incoming.children).map((node) => document.importNode(node, true)));
|
|
32
|
+
setHref(page.querySelector<HTMLAnchorElement>("[data-feed-next]")?.getAttribute("href") ?? null);
|
|
33
|
+
setState("idle");
|
|
34
|
+
} catch {
|
|
35
|
+
setState("error");
|
|
36
|
+
} finally {
|
|
37
|
+
busy.current = false;
|
|
38
|
+
}
|
|
39
|
+
}, [href, streamSelector]);
|
|
40
|
+
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
const node = navRef.current;
|
|
43
|
+
if (!node || !href || state !== "idle" || typeof IntersectionObserver === "undefined") return;
|
|
44
|
+
const observer = new IntersectionObserver((entries) => {
|
|
45
|
+
if (entries.some((entry) => entry.isIntersecting)) void load();
|
|
46
|
+
}, { rootMargin: "600px 0px" });
|
|
47
|
+
observer.observe(node);
|
|
48
|
+
return () => observer.disconnect();
|
|
49
|
+
}, [href, state, load]);
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<nav ref={navRef} className="feed-more" aria-live="polite">
|
|
53
|
+
{href ? (
|
|
54
|
+
<a
|
|
55
|
+
href={href}
|
|
56
|
+
rel="next"
|
|
57
|
+
data-feed-next=""
|
|
58
|
+
onClick={(event) => {
|
|
59
|
+
event.preventDefault();
|
|
60
|
+
void load();
|
|
61
|
+
}}
|
|
62
|
+
>
|
|
63
|
+
{state === "loading" ? labels.loadingMore : state === "error" ? labels.loadFailed : labels.nextPage}
|
|
64
|
+
</a>
|
|
65
|
+
) : (
|
|
66
|
+
<span className="feed-end">{labels.feedEnd}</span>
|
|
67
|
+
)}
|
|
68
|
+
</nav>
|
|
69
|
+
);
|
|
70
|
+
}
|