hd-garden 0.3.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/.agents/skills/garden-interactive-chart/SKILL.md +84 -0
- package/.agents/skills/garden-interactive-chart/references/shell.html +49 -0
- package/.agents/skills/garden-interactive-chart/references/style.md +61 -0
- package/.env.example +56 -0
- package/.openai/hosting.json +5 -0
- package/LICENSE +21 -0
- package/Makefile +48 -0
- package/README.md +72 -0
- package/app/about/page.tsx +60 -0
- package/app/admin/page.tsx +117 -0
- package/app/api/admin/distribution/route.ts +120 -0
- package/app/api/admin/posts/pin/route.ts +32 -0
- package/app/api/admin/thoughts/[id]/route.ts +44 -0
- package/app/api/admin/thoughts/route.ts +120 -0
- package/app/api/admin/uploads/route.ts +20 -0
- package/app/api/auth/github/callback/route.ts +136 -0
- package/app/api/auth/github/route.ts +29 -0
- package/app/api/auth/logout/route.ts +13 -0
- package/app/blog/[...path]/page.tsx +135 -0
- package/app/blog/page.tsx +139 -0
- package/app/chatgpt-auth.ts +86 -0
- package/app/columns/[column]/[...path]/page.tsx +160 -0
- package/app/columns/page.tsx +68 -0
- package/app/components/AdminDashboard.tsx +410 -0
- package/app/components/Comments.tsx +55 -0
- package/app/components/ContentEmptyState.tsx +51 -0
- package/app/components/ExternalEmbed.tsx +127 -0
- package/app/components/GardenAdminGuide.tsx +116 -0
- package/app/components/GardenContentGuide.tsx +241 -0
- package/app/components/GardenMediaGuide.tsx +81 -0
- package/app/components/GiscusComments.tsx +71 -0
- package/app/components/GithubLiveOverview.tsx +118 -0
- package/app/components/GithubProfile.tsx +326 -0
- package/app/components/MarkdownArticle.tsx +144 -0
- package/app/components/MermaidDiagram.tsx +117 -0
- package/app/components/PageIntro.tsx +32 -0
- package/app/components/SiteChrome.tsx +18 -0
- package/app/components/SiteFooter.tsx +29 -0
- package/app/components/SiteHeader.tsx +420 -0
- package/app/components/TableOfContents.tsx +433 -0
- package/app/components/ThoughtCard.tsx +242 -0
- package/app/components/Webmentions.tsx +194 -0
- package/app/components/WidgetChart.tsx +222 -0
- package/app/garden/page.tsx +284 -0
- package/app/garden-modules.d.ts +62 -0
- package/app/globals.css +4454 -0
- package/app/layout.tsx +102 -0
- package/app/lib/admin-auth.ts +134 -0
- package/app/lib/content.ts +112 -0
- package/app/lib/fallback-site-config.ts +56 -0
- package/app/lib/generated-content.ts +380 -0
- package/app/lib/github-content.ts +244 -0
- package/app/lib/markdown-plugins.ts +13 -0
- package/app/lib/oss.ts +100 -0
- package/app/lib/public-thoughts.ts +9 -0
- package/app/lib/repository-path.mjs +32 -0
- package/app/lib/site.ts +51 -0
- package/app/page.tsx +119 -0
- package/app/robots.ts +15 -0
- package/app/rss.xml/route.ts +84 -0
- package/app/site.config.ts +35 -0
- package/app/sitemap.ts +46 -0
- package/app/thoughts/[slug]/page.tsx +79 -0
- package/app/thoughts/page.tsx +33 -0
- package/app/yaml.d.ts +4 -0
- package/bin/garden.mjs +348 -0
- package/build/sites-vite-plugin.ts +39 -0
- package/eslint.config.mjs +20 -0
- package/next.config.ts +13 -0
- package/package.json +112 -0
- package/postcss.config.mjs +7 -0
- package/public/favicon.svg +4 -0
- package/public/images/garden/admin-studio.png +0 -0
- package/public/og.png +0 -0
- package/scripts/build-content.mjs +582 -0
- package/scripts/fetch-embed.mjs +189 -0
- package/scripts/garden-paths.mjs +21 -0
- package/scripts/new-post.mjs +73 -0
- package/scripts/prepare-pages-output.mjs +182 -0
- package/scripts/send-webmentions.mjs +27 -0
- package/site.config.yaml +48 -0
- package/templates/site/Makefile +46 -0
- package/templates/site/README.md +13 -0
- package/templates/site/content/columns.yaml +1 -0
- package/templates/site/content/posts/.gitkeep +0 -0
- package/templates/site/content/thoughts/.gitkeep +0 -0
- package/templates/site/package.json +19 -0
- package/templates/site/site.config.yaml +46 -0
- package/tsconfig.json +34 -0
- package/vite.config.ts +130 -0
- package/worker/index.ts +53 -0
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
useCallback,
|
|
5
|
+
useEffect,
|
|
6
|
+
useId,
|
|
7
|
+
useRef,
|
|
8
|
+
useState,
|
|
9
|
+
type MouseEvent,
|
|
10
|
+
type ReactNode,
|
|
11
|
+
} from "react";
|
|
12
|
+
import { List, X } from "@phosphor-icons/react";
|
|
13
|
+
|
|
14
|
+
type Heading = { depth: number; text: string; id: string };
|
|
15
|
+
|
|
16
|
+
type TocNode = Heading & { children: TocNode[] };
|
|
17
|
+
|
|
18
|
+
const nestHeadings = (headings: Heading[]) => {
|
|
19
|
+
const roots: TocNode[] = [];
|
|
20
|
+
const stack: TocNode[] = [];
|
|
21
|
+
|
|
22
|
+
for (const heading of headings) {
|
|
23
|
+
const node: TocNode = { ...heading, children: [] };
|
|
24
|
+
while (stack.at(-1) && stack.at(-1)!.depth >= heading.depth) {
|
|
25
|
+
stack.pop();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const parent = stack.at(-1);
|
|
29
|
+
if (parent) parent.children.push(node);
|
|
30
|
+
else roots.push(node);
|
|
31
|
+
stack.push(node);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return roots;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const getCssScrollPaddingTop = () => {
|
|
38
|
+
const value = window.getComputedStyle(document.documentElement).scrollPaddingTop;
|
|
39
|
+
const offset = Number.parseFloat(value);
|
|
40
|
+
return Number.isFinite(offset) ? offset : 0;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const getScrollPaddingTop = () => {
|
|
44
|
+
const header = document.querySelector(".site-header");
|
|
45
|
+
const headerBottom = header?.getBoundingClientRect().bottom ?? 0;
|
|
46
|
+
if (headerBottom > 8) return headerBottom + 8;
|
|
47
|
+
return Math.min(24, getCssScrollPaddingTop());
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const getReadingProbe = () => {
|
|
51
|
+
const anchorStop = getScrollPaddingTop() + 1;
|
|
52
|
+
return Math.max(
|
|
53
|
+
anchorStop,
|
|
54
|
+
Math.min(window.innerHeight * 0.45, anchorStop + 240),
|
|
55
|
+
);
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const getHashHeadingId = () => {
|
|
59
|
+
const hash = window.location.hash.slice(1);
|
|
60
|
+
try {
|
|
61
|
+
return decodeURIComponent(hash);
|
|
62
|
+
} catch {
|
|
63
|
+
return hash;
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const isLaidOutImage = (image: HTMLImageElement) =>
|
|
68
|
+
image.complete && image.naturalHeight > 0;
|
|
69
|
+
|
|
70
|
+
const layoutRootFor = (target: Element) =>
|
|
71
|
+
target.closest("article.post-article, article.series-article, main") ?? document.body;
|
|
72
|
+
|
|
73
|
+
const precedingImages = (target: Element) =>
|
|
74
|
+
Array.from(layoutRootFor(target).querySelectorAll("img")).filter((image) =>
|
|
75
|
+
Boolean(
|
|
76
|
+
image.compareDocumentPosition(target) & Node.DOCUMENT_POSITION_FOLLOWING,
|
|
77
|
+
),
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
const wakeLazyImage = (image: HTMLImageElement) => {
|
|
81
|
+
if (isLaidOutImage(image)) return;
|
|
82
|
+
image.loading = "eager";
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const prefetchHeadingLayout = (id: string) => {
|
|
86
|
+
const target = document.getElementById(id);
|
|
87
|
+
if (!target) return;
|
|
88
|
+
for (const image of precedingImages(target)) wakeLazyImage(image);
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const scrollToHeading = (id: string) => {
|
|
92
|
+
const target = document.getElementById(id);
|
|
93
|
+
if (!target) return;
|
|
94
|
+
|
|
95
|
+
const top = Math.max(
|
|
96
|
+
0,
|
|
97
|
+
window.scrollY + target.getBoundingClientRect().top - getScrollPaddingTop(),
|
|
98
|
+
);
|
|
99
|
+
if (Math.abs(top - window.scrollY) <= 1) return;
|
|
100
|
+
|
|
101
|
+
// `scrollBy({ behavior })` can still animate or stack with later
|
|
102
|
+
// corrections and send the heading off-screen. Write scrollTop after
|
|
103
|
+
// forcing `scroll-behavior: auto`.
|
|
104
|
+
const html = document.documentElement;
|
|
105
|
+
const previousBehavior = html.style.scrollBehavior;
|
|
106
|
+
html.style.scrollBehavior = "auto";
|
|
107
|
+
html.scrollTop = top;
|
|
108
|
+
html.style.scrollBehavior = previousBehavior;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
export function TableOfContents({
|
|
112
|
+
headings,
|
|
113
|
+
label = "本篇目录",
|
|
114
|
+
}: {
|
|
115
|
+
headings: Heading[];
|
|
116
|
+
label?: string;
|
|
117
|
+
}) {
|
|
118
|
+
const [activeId, setActiveId] = useState(headings[0]?.id);
|
|
119
|
+
const [collapsedSections, setCollapsedSections] = useState<Set<string>>(
|
|
120
|
+
() => new Set(),
|
|
121
|
+
);
|
|
122
|
+
const [mobileOpen, setMobileOpen] = useState(false);
|
|
123
|
+
const panelId = useId();
|
|
124
|
+
const listId = useId();
|
|
125
|
+
const listRef = useRef<HTMLOListElement>(null);
|
|
126
|
+
const mobileTriggerRef = useRef<HTMLButtonElement>(null);
|
|
127
|
+
const mobileCloseRef = useRef<HTMLButtonElement>(null);
|
|
128
|
+
const restoreMobileTriggerFocusRef = useRef(false);
|
|
129
|
+
const anchorCleanupRef = useRef<(() => void) | undefined>(undefined);
|
|
130
|
+
const tree = nestHeadings(headings);
|
|
131
|
+
|
|
132
|
+
const closeMobileToc = useCallback((restoreFocus = false) => {
|
|
133
|
+
restoreMobileTriggerFocusRef.current = restoreFocus;
|
|
134
|
+
setMobileOpen(false);
|
|
135
|
+
}, []);
|
|
136
|
+
|
|
137
|
+
const alignHeadingToAnchor = useCallback((id: string) => {
|
|
138
|
+
scrollToHeading(id);
|
|
139
|
+
}, []);
|
|
140
|
+
|
|
141
|
+
const trackAnchorLayout = useCallback(
|
|
142
|
+
(id: string) => {
|
|
143
|
+
anchorCleanupRef.current?.();
|
|
144
|
+
const target = document.getElementById(id);
|
|
145
|
+
if (!target) return;
|
|
146
|
+
|
|
147
|
+
let frame = 0;
|
|
148
|
+
let timeout = 0;
|
|
149
|
+
const layoutRoot = layoutRootFor(target);
|
|
150
|
+
const pendingImages = precedingImages(target).filter(
|
|
151
|
+
(image) => !isLaidOutImage(image),
|
|
152
|
+
);
|
|
153
|
+
for (const image of pendingImages) wakeLazyImage(image);
|
|
154
|
+
|
|
155
|
+
const scheduleAlignment = () => {
|
|
156
|
+
if (frame) window.cancelAnimationFrame(frame);
|
|
157
|
+
frame = window.requestAnimationFrame(() => {
|
|
158
|
+
frame = 0;
|
|
159
|
+
if (getHashHeadingId() === id) {
|
|
160
|
+
alignHeadingToAnchor(id);
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
};
|
|
164
|
+
function realign() {
|
|
165
|
+
scheduleAlignment();
|
|
166
|
+
}
|
|
167
|
+
const resizeObserver = new ResizeObserver(realign);
|
|
168
|
+
|
|
169
|
+
const cleanup = () => {
|
|
170
|
+
if (frame) window.cancelAnimationFrame(frame);
|
|
171
|
+
if (timeout) window.clearTimeout(timeout);
|
|
172
|
+
resizeObserver.disconnect();
|
|
173
|
+
for (const image of pendingImages) {
|
|
174
|
+
image.removeEventListener("load", realign);
|
|
175
|
+
image.removeEventListener("error", realign);
|
|
176
|
+
}
|
|
177
|
+
window.removeEventListener("wheel", cleanup);
|
|
178
|
+
window.removeEventListener("touchstart", cleanup);
|
|
179
|
+
window.removeEventListener("pointerdown", cleanup);
|
|
180
|
+
window.removeEventListener("keydown", cleanup);
|
|
181
|
+
if (anchorCleanupRef.current === cleanup) {
|
|
182
|
+
anchorCleanupRef.current = undefined;
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
for (const image of pendingImages) {
|
|
187
|
+
image.addEventListener("load", realign);
|
|
188
|
+
image.addEventListener("error", realign);
|
|
189
|
+
}
|
|
190
|
+
window.addEventListener("wheel", cleanup, { passive: true });
|
|
191
|
+
window.addEventListener("touchstart", cleanup, { passive: true });
|
|
192
|
+
window.addEventListener("pointerdown", cleanup, { passive: true });
|
|
193
|
+
window.addEventListener("keydown", cleanup);
|
|
194
|
+
timeout = window.setTimeout(cleanup, 8_000);
|
|
195
|
+
anchorCleanupRef.current = cleanup;
|
|
196
|
+
scheduleAlignment();
|
|
197
|
+
window.requestAnimationFrame(() => {
|
|
198
|
+
if (anchorCleanupRef.current === cleanup) {
|
|
199
|
+
resizeObserver.observe(layoutRoot);
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
},
|
|
203
|
+
[alignHeadingToAnchor],
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
useEffect(() => {
|
|
207
|
+
let frame = 0;
|
|
208
|
+
const updateActiveHeading = () => {
|
|
209
|
+
frame = 0;
|
|
210
|
+
const readingProbe = getReadingProbe();
|
|
211
|
+
const visibleHeadings = headings
|
|
212
|
+
.map((heading) => ({
|
|
213
|
+
id: heading.id,
|
|
214
|
+
top: document.getElementById(heading.id)?.getBoundingClientRect().top,
|
|
215
|
+
}))
|
|
216
|
+
.filter((heading): heading is { id: string; top: number } => heading.top !== undefined);
|
|
217
|
+
const reachedPageEnd =
|
|
218
|
+
window.scrollY + window.innerHeight >=
|
|
219
|
+
document.documentElement.scrollHeight - 2;
|
|
220
|
+
const current = reachedPageEnd
|
|
221
|
+
? visibleHeadings.at(-1)?.id
|
|
222
|
+
: visibleHeadings.filter((heading) => heading.top <= readingProbe).at(-1)
|
|
223
|
+
?.id ?? visibleHeadings[0]?.id;
|
|
224
|
+
|
|
225
|
+
if (current) setActiveId((previous) => (previous === current ? previous : current));
|
|
226
|
+
};
|
|
227
|
+
const scheduleUpdate = () => {
|
|
228
|
+
if (!frame) frame = window.requestAnimationFrame(updateActiveHeading);
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
updateActiveHeading();
|
|
232
|
+
window.addEventListener("scroll", scheduleUpdate, { passive: true });
|
|
233
|
+
window.addEventListener("resize", scheduleUpdate);
|
|
234
|
+
return () => {
|
|
235
|
+
window.removeEventListener("scroll", scheduleUpdate);
|
|
236
|
+
window.removeEventListener("resize", scheduleUpdate);
|
|
237
|
+
if (frame) window.cancelAnimationFrame(frame);
|
|
238
|
+
};
|
|
239
|
+
}, [headings]);
|
|
240
|
+
|
|
241
|
+
useEffect(() => {
|
|
242
|
+
history.scrollRestoration = "manual";
|
|
243
|
+
const syncHashAnchor = () => {
|
|
244
|
+
const id = getHashHeadingId();
|
|
245
|
+
if (!headings.some((heading) => heading.id === id)) return;
|
|
246
|
+
setActiveId(id);
|
|
247
|
+
trackAnchorLayout(id);
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
syncHashAnchor();
|
|
251
|
+
window.addEventListener("hashchange", syncHashAnchor);
|
|
252
|
+
return () => {
|
|
253
|
+
window.removeEventListener("hashchange", syncHashAnchor);
|
|
254
|
+
anchorCleanupRef.current?.();
|
|
255
|
+
};
|
|
256
|
+
}, [headings, trackAnchorLayout]);
|
|
257
|
+
|
|
258
|
+
useEffect(() => {
|
|
259
|
+
if (mobileOpen || !restoreMobileTriggerFocusRef.current) return;
|
|
260
|
+
restoreMobileTriggerFocusRef.current = false;
|
|
261
|
+
mobileTriggerRef.current?.focus({ preventScroll: true });
|
|
262
|
+
}, [mobileOpen]);
|
|
263
|
+
|
|
264
|
+
useEffect(() => {
|
|
265
|
+
if (!mobileOpen) return;
|
|
266
|
+
const mobileQuery = window.matchMedia("(max-width: 900px)");
|
|
267
|
+
if (!mobileQuery.matches) return;
|
|
268
|
+
|
|
269
|
+
const previousOverflow = document.body.style.overflow;
|
|
270
|
+
mobileCloseRef.current?.focus({ preventScroll: true });
|
|
271
|
+
const focusFrame = window.requestAnimationFrame(() => {
|
|
272
|
+
mobileCloseRef.current?.focus({ preventScroll: true });
|
|
273
|
+
});
|
|
274
|
+
const handleKeyDown = (event: KeyboardEvent) => {
|
|
275
|
+
if (event.key === "Escape") closeMobileToc(true);
|
|
276
|
+
};
|
|
277
|
+
const handleViewportChange = () => {
|
|
278
|
+
if (!mobileQuery.matches) setMobileOpen(false);
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
document.body.style.overflow = "hidden";
|
|
282
|
+
window.addEventListener("keydown", handleKeyDown);
|
|
283
|
+
mobileQuery.addEventListener("change", handleViewportChange);
|
|
284
|
+
return () => {
|
|
285
|
+
window.cancelAnimationFrame(focusFrame);
|
|
286
|
+
document.body.style.overflow = previousOverflow;
|
|
287
|
+
window.removeEventListener("keydown", handleKeyDown);
|
|
288
|
+
mobileQuery.removeEventListener("change", handleViewportChange);
|
|
289
|
+
};
|
|
290
|
+
}, [closeMobileToc, mobileOpen]);
|
|
291
|
+
|
|
292
|
+
useEffect(() => {
|
|
293
|
+
if (!activeId) return;
|
|
294
|
+
if (!mobileOpen && window.matchMedia("(max-width: 900px)").matches) return;
|
|
295
|
+
const list = listRef.current;
|
|
296
|
+
const activeItem = list?.querySelector<HTMLElement>(
|
|
297
|
+
'[aria-current="location"]',
|
|
298
|
+
);
|
|
299
|
+
if (!list || !activeItem) return;
|
|
300
|
+
|
|
301
|
+
const edge = 10;
|
|
302
|
+
const listBounds = list.getBoundingClientRect();
|
|
303
|
+
const itemBounds = activeItem.getBoundingClientRect();
|
|
304
|
+
let nextTop: number | undefined;
|
|
305
|
+
|
|
306
|
+
if (itemBounds.top < listBounds.top + edge) {
|
|
307
|
+
nextTop = Math.max(0, list.scrollTop + itemBounds.top - listBounds.top - edge);
|
|
308
|
+
} else if (itemBounds.bottom > listBounds.bottom - edge) {
|
|
309
|
+
nextTop = list.scrollTop + itemBounds.bottom - listBounds.bottom + edge;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (nextTop !== undefined) {
|
|
313
|
+
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
314
|
+
list.scrollTo({ top: nextTop, behavior: reduceMotion ? "auto" : "smooth" });
|
|
315
|
+
}
|
|
316
|
+
}, [activeId, collapsedSections, mobileOpen]);
|
|
317
|
+
|
|
318
|
+
const toggleSection = (id: string) => {
|
|
319
|
+
setCollapsedSections((current) => {
|
|
320
|
+
const next = new Set(current);
|
|
321
|
+
if (next.has(id)) next.delete(id);
|
|
322
|
+
else next.add(id);
|
|
323
|
+
return next;
|
|
324
|
+
});
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
const navigateToHeading = (event: MouseEvent<HTMLAnchorElement>, id: string) => {
|
|
328
|
+
event.preventDefault();
|
|
329
|
+
const hash = encodeURIComponent(id);
|
|
330
|
+
window.history.pushState(
|
|
331
|
+
null,
|
|
332
|
+
"",
|
|
333
|
+
`${window.location.pathname}${window.location.search}#${hash}`,
|
|
334
|
+
);
|
|
335
|
+
setActiveId(id);
|
|
336
|
+
closeMobileToc();
|
|
337
|
+
trackAnchorLayout(id);
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
const renderNode = (node: TocNode, path: number[]): ReactNode => {
|
|
341
|
+
const collapsed = collapsedSections.has(node.id);
|
|
342
|
+
const childListId = `${listId}-${path.join("-")}`;
|
|
343
|
+
|
|
344
|
+
return (
|
|
345
|
+
<li
|
|
346
|
+
key={node.id}
|
|
347
|
+
className={`toc-section toc-depth-${node.depth}${activeId === node.id ? " active" : ""}`}
|
|
348
|
+
>
|
|
349
|
+
<div className="toc-item-row">
|
|
350
|
+
<a
|
|
351
|
+
href={`#${node.id}`}
|
|
352
|
+
onClick={(event) => navigateToHeading(event, node.id)}
|
|
353
|
+
onPointerEnter={() => prefetchHeadingLayout(node.id)}
|
|
354
|
+
onFocus={() => prefetchHeadingLayout(node.id)}
|
|
355
|
+
aria-current={activeId === node.id ? "location" : undefined}
|
|
356
|
+
>
|
|
357
|
+
{node.text}
|
|
358
|
+
</a>
|
|
359
|
+
{node.children.length > 0 ? (
|
|
360
|
+
<button
|
|
361
|
+
className="toc-section-toggle"
|
|
362
|
+
type="button"
|
|
363
|
+
aria-controls={childListId}
|
|
364
|
+
aria-expanded={!collapsed}
|
|
365
|
+
aria-label={`${collapsed ? "展开" : "收起"}「${node.text}」的子目录`}
|
|
366
|
+
onClick={() => toggleSection(node.id)}
|
|
367
|
+
>
|
|
368
|
+
<span aria-hidden="true">{collapsed ? "+" : "−"}</span>
|
|
369
|
+
</button>
|
|
370
|
+
) : null}
|
|
371
|
+
</div>
|
|
372
|
+
{node.children.length > 0 ? (
|
|
373
|
+
<ol id={childListId} className="toc-sublist" hidden={collapsed}>
|
|
374
|
+
{node.children.map((child, index) => renderNode(child, [...path, index]))}
|
|
375
|
+
</ol>
|
|
376
|
+
) : null}
|
|
377
|
+
</li>
|
|
378
|
+
);
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
if (!headings.length) return null;
|
|
382
|
+
|
|
383
|
+
const activeHeading = headings.find((heading) => heading.id === activeId);
|
|
384
|
+
|
|
385
|
+
return (
|
|
386
|
+
<>
|
|
387
|
+
<button
|
|
388
|
+
ref={mobileTriggerRef}
|
|
389
|
+
className="toc-mobile-trigger"
|
|
390
|
+
type="button"
|
|
391
|
+
aria-controls={panelId}
|
|
392
|
+
aria-expanded={mobileOpen}
|
|
393
|
+
aria-label={`打开${label}${activeHeading ? `,当前章节:${activeHeading.text}` : ""}`}
|
|
394
|
+
tabIndex={mobileOpen ? -1 : 0}
|
|
395
|
+
data-mobile-open={mobileOpen}
|
|
396
|
+
onClick={() => setMobileOpen(true)}
|
|
397
|
+
>
|
|
398
|
+
<List size={17} weight="bold" aria-hidden="true" />
|
|
399
|
+
<span>目录</span>
|
|
400
|
+
</button>
|
|
401
|
+
<button
|
|
402
|
+
className="toc-mobile-backdrop"
|
|
403
|
+
type="button"
|
|
404
|
+
aria-label={`关闭${label}`}
|
|
405
|
+
tabIndex={mobileOpen ? 0 : -1}
|
|
406
|
+
data-mobile-open={mobileOpen}
|
|
407
|
+
onClick={() => closeMobileToc(true)}
|
|
408
|
+
/>
|
|
409
|
+
<aside
|
|
410
|
+
className="toc"
|
|
411
|
+
id={panelId}
|
|
412
|
+
aria-label={label}
|
|
413
|
+
data-mobile-open={mobileOpen}
|
|
414
|
+
>
|
|
415
|
+
<div className="toc-header">
|
|
416
|
+
<p>{label}</p>
|
|
417
|
+
<button
|
|
418
|
+
ref={mobileCloseRef}
|
|
419
|
+
className="toc-mobile-close"
|
|
420
|
+
type="button"
|
|
421
|
+
aria-label={`关闭${label}`}
|
|
422
|
+
onClick={() => closeMobileToc(true)}
|
|
423
|
+
>
|
|
424
|
+
<X size={18} aria-hidden="true" />
|
|
425
|
+
</button>
|
|
426
|
+
</div>
|
|
427
|
+
<ol id={listId} ref={listRef} className="toc-list">
|
|
428
|
+
{tree.map((node, index) => renderNode(node, [index]))}
|
|
429
|
+
</ol>
|
|
430
|
+
</aside>
|
|
431
|
+
</>
|
|
432
|
+
);
|
|
433
|
+
}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import Link from "next/link";
|
|
2
|
+
import {
|
|
3
|
+
ArrowUpRight,
|
|
4
|
+
ImageSquare,
|
|
5
|
+
LinkSimple,
|
|
6
|
+
MusicNotes,
|
|
7
|
+
SquaresFour,
|
|
8
|
+
VideoCamera,
|
|
9
|
+
} from "@phosphor-icons/react/ssr";
|
|
10
|
+
import {
|
|
11
|
+
type ContentEntry,
|
|
12
|
+
type MediaItem,
|
|
13
|
+
formatDate,
|
|
14
|
+
} from "../lib/content";
|
|
15
|
+
import { githubUrl, siteConfig } from "../site.config";
|
|
16
|
+
import { MarkdownArticle } from "./MarkdownArticle";
|
|
17
|
+
|
|
18
|
+
const icons = {
|
|
19
|
+
image: ImageSquare,
|
|
20
|
+
audio: MusicNotes,
|
|
21
|
+
video: VideoCamera,
|
|
22
|
+
link: LinkSimple,
|
|
23
|
+
text: LinkSimple,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
type MediaGroup =
|
|
27
|
+
| { type: "images"; items: MediaItem[] }
|
|
28
|
+
| { type: "audio" | "video" | "link"; item: MediaItem };
|
|
29
|
+
|
|
30
|
+
function groupMedia(items: MediaItem[]) {
|
|
31
|
+
return items.reduce<MediaGroup[]>((groups, item) => {
|
|
32
|
+
if (item.type === "image") {
|
|
33
|
+
const previous = groups.at(-1);
|
|
34
|
+
if (previous?.type === "images") {
|
|
35
|
+
previous.items.push(item);
|
|
36
|
+
} else {
|
|
37
|
+
groups.push({ type: "images", items: [item] });
|
|
38
|
+
}
|
|
39
|
+
return groups;
|
|
40
|
+
}
|
|
41
|
+
groups.push({ type: item.type, item });
|
|
42
|
+
return groups;
|
|
43
|
+
}, []);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function videoMime(src: string, explicit?: string) {
|
|
47
|
+
if (explicit) return explicit;
|
|
48
|
+
const extension = new URL(src, "https://local.invalid").pathname
|
|
49
|
+
.split(".")
|
|
50
|
+
.at(-1)
|
|
51
|
+
?.toLowerCase();
|
|
52
|
+
if (extension === "webm") return "video/webm";
|
|
53
|
+
if (extension === "ogg" || extension === "ogv") return "video/ogg";
|
|
54
|
+
return "video/mp4";
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function ThoughtCard({
|
|
58
|
+
thought,
|
|
59
|
+
showPermalink = true,
|
|
60
|
+
}: {
|
|
61
|
+
thought: ContentEntry;
|
|
62
|
+
showPermalink?: boolean;
|
|
63
|
+
}) {
|
|
64
|
+
const media = Array.isArray(thought.media) ? thought.media : [];
|
|
65
|
+
const isMixed = media.length > 1;
|
|
66
|
+
const Icon = isMixed ? SquaresFour : icons[thought.mediaType ?? "text"];
|
|
67
|
+
const mediaGroups = groupMedia(media);
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<article id={thought.slug} className="thought-card h-entry">
|
|
71
|
+
<Link className="u-url sr-only" href={`/thoughts/${thought.slug}`}>
|
|
72
|
+
{thought.title} 永久链接
|
|
73
|
+
</Link>
|
|
74
|
+
<a
|
|
75
|
+
className="p-author h-card sr-only"
|
|
76
|
+
href={githubUrl}
|
|
77
|
+
rel="author"
|
|
78
|
+
>
|
|
79
|
+
{siteConfig.author.name}
|
|
80
|
+
</a>
|
|
81
|
+
<header>
|
|
82
|
+
<span className="thought-kind">
|
|
83
|
+
<Icon size={16} />
|
|
84
|
+
{isMixed
|
|
85
|
+
? `混合记录 · ${media.length} 个附件`
|
|
86
|
+
: thought.mediaType === "image"
|
|
87
|
+
? "一张照片"
|
|
88
|
+
: thought.mediaType === "audio"
|
|
89
|
+
? "一段声音"
|
|
90
|
+
: thought.mediaType === "video"
|
|
91
|
+
? "一段影像"
|
|
92
|
+
: thought.mediaType === "link"
|
|
93
|
+
? "一个链接"
|
|
94
|
+
: "随想"}
|
|
95
|
+
</span>
|
|
96
|
+
<time className="dt-published" dateTime={thought.date}>
|
|
97
|
+
{formatDate(thought.date, true)}
|
|
98
|
+
</time>
|
|
99
|
+
</header>
|
|
100
|
+
|
|
101
|
+
<h2 className="p-name">{thought.title}</h2>
|
|
102
|
+
<div className="e-content">
|
|
103
|
+
<MarkdownArticle content={thought.content} />
|
|
104
|
+
</div>
|
|
105
|
+
|
|
106
|
+
{mediaGroups.length > 0 && (
|
|
107
|
+
<div className="thought-media-stack">
|
|
108
|
+
{mediaGroups.map((group, groupIndex) => {
|
|
109
|
+
if (group.type === "images") {
|
|
110
|
+
return (
|
|
111
|
+
<div
|
|
112
|
+
className={`thought-gallery thought-gallery-${Math.min(group.items.length, 3)}`}
|
|
113
|
+
key={`images-${groupIndex}`}
|
|
114
|
+
aria-label={`${group.items.length} 张照片`}
|
|
115
|
+
>
|
|
116
|
+
{group.items.map((item, index) => (
|
|
117
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
118
|
+
<img
|
|
119
|
+
key={`${item.src}-${index}`}
|
|
120
|
+
className="thought-image u-photo"
|
|
121
|
+
src={item.src}
|
|
122
|
+
alt={item.alt ?? `${thought.title} · 照片 ${index + 1}`}
|
|
123
|
+
loading="lazy"
|
|
124
|
+
/>
|
|
125
|
+
))}
|
|
126
|
+
</div>
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
if (group.type === "audio") {
|
|
130
|
+
return (
|
|
131
|
+
<div className="audio-embed" key={`${group.item.src}-${groupIndex}`}>
|
|
132
|
+
<MusicNotes size={28} />
|
|
133
|
+
<div>
|
|
134
|
+
<strong>{group.item.title ?? `当前播放 · ${thought.title}`}</strong>
|
|
135
|
+
<audio
|
|
136
|
+
className="u-audio"
|
|
137
|
+
controls
|
|
138
|
+
preload="none"
|
|
139
|
+
src={group.item.src}
|
|
140
|
+
>
|
|
141
|
+
你的浏览器不支持音频播放。
|
|
142
|
+
</audio>
|
|
143
|
+
</div>
|
|
144
|
+
</div>
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
if (group.type === "video") {
|
|
148
|
+
return (
|
|
149
|
+
<video
|
|
150
|
+
className="thought-video u-video"
|
|
151
|
+
controls
|
|
152
|
+
preload="metadata"
|
|
153
|
+
poster={group.item.poster}
|
|
154
|
+
key={`${group.item.src}-${groupIndex}`}
|
|
155
|
+
>
|
|
156
|
+
<source
|
|
157
|
+
src={group.item.src}
|
|
158
|
+
type={videoMime(group.item.src, group.item.mime)}
|
|
159
|
+
/>
|
|
160
|
+
</video>
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
return (
|
|
164
|
+
<Link
|
|
165
|
+
className="link-embed u-bookmark-of"
|
|
166
|
+
href={group.item.src}
|
|
167
|
+
target="_blank"
|
|
168
|
+
rel="noopener noreferrer"
|
|
169
|
+
key={`${group.item.src}-${groupIndex}`}
|
|
170
|
+
>
|
|
171
|
+
<span>
|
|
172
|
+
<small>{new URL(group.item.src).hostname}</small>
|
|
173
|
+
<strong>{group.item.title ?? group.item.src}</strong>
|
|
174
|
+
{group.item.description && <p>{group.item.description}</p>}
|
|
175
|
+
</span>
|
|
176
|
+
<ArrowUpRight size={22} />
|
|
177
|
+
</Link>
|
|
178
|
+
);
|
|
179
|
+
})}
|
|
180
|
+
</div>
|
|
181
|
+
)}
|
|
182
|
+
|
|
183
|
+
{!Array.isArray(thought.media) && thought.mediaType === "image" && thought.media && (
|
|
184
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
185
|
+
<img
|
|
186
|
+
className="thought-image u-photo"
|
|
187
|
+
src={thought.media}
|
|
188
|
+
alt={thought.mediaAlt ?? thought.title}
|
|
189
|
+
loading="lazy"
|
|
190
|
+
/>
|
|
191
|
+
)}
|
|
192
|
+
{!Array.isArray(thought.media) && thought.mediaType === "audio" && thought.media && (
|
|
193
|
+
<div className="audio-embed">
|
|
194
|
+
<MusicNotes size={28} />
|
|
195
|
+
<div>
|
|
196
|
+
<strong>当前播放 · {thought.title}</strong>
|
|
197
|
+
<audio
|
|
198
|
+
className="u-audio"
|
|
199
|
+
controls
|
|
200
|
+
preload="none"
|
|
201
|
+
src={thought.media}
|
|
202
|
+
>
|
|
203
|
+
你的浏览器不支持音频播放。
|
|
204
|
+
</audio>
|
|
205
|
+
</div>
|
|
206
|
+
</div>
|
|
207
|
+
)}
|
|
208
|
+
{!Array.isArray(thought.media) && thought.mediaType === "video" && thought.media && (
|
|
209
|
+
<video
|
|
210
|
+
className="thought-video u-video"
|
|
211
|
+
controls
|
|
212
|
+
preload="metadata"
|
|
213
|
+
poster={thought.poster}
|
|
214
|
+
>
|
|
215
|
+
<source src={thought.media} type={videoMime(thought.media)} />
|
|
216
|
+
</video>
|
|
217
|
+
)}
|
|
218
|
+
{!Array.isArray(thought.media) && thought.mediaType === "link" && thought.media && (
|
|
219
|
+
<Link
|
|
220
|
+
className="link-embed u-bookmark-of"
|
|
221
|
+
href={thought.media}
|
|
222
|
+
target="_blank"
|
|
223
|
+
rel="noopener noreferrer"
|
|
224
|
+
>
|
|
225
|
+
<span>
|
|
226
|
+
<small>{new URL(thought.media).hostname}</small>
|
|
227
|
+
<strong>{thought.linkTitle}</strong>
|
|
228
|
+
<p>{thought.linkDescription}</p>
|
|
229
|
+
</span>
|
|
230
|
+
<ArrowUpRight size={22} />
|
|
231
|
+
</Link>
|
|
232
|
+
)}
|
|
233
|
+
|
|
234
|
+
{showPermalink && (
|
|
235
|
+
<Link className="thought-permalink" href={`/thoughts/${thought.slug}`}>
|
|
236
|
+
永久链接
|
|
237
|
+
<ArrowUpRight size={15} />
|
|
238
|
+
</Link>
|
|
239
|
+
)}
|
|
240
|
+
</article>
|
|
241
|
+
);
|
|
242
|
+
}
|