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.
Files changed (91) hide show
  1. package/.agents/skills/garden-interactive-chart/SKILL.md +84 -0
  2. package/.agents/skills/garden-interactive-chart/references/shell.html +49 -0
  3. package/.agents/skills/garden-interactive-chart/references/style.md +61 -0
  4. package/.env.example +56 -0
  5. package/.openai/hosting.json +5 -0
  6. package/LICENSE +21 -0
  7. package/Makefile +48 -0
  8. package/README.md +72 -0
  9. package/app/about/page.tsx +60 -0
  10. package/app/admin/page.tsx +117 -0
  11. package/app/api/admin/distribution/route.ts +120 -0
  12. package/app/api/admin/posts/pin/route.ts +32 -0
  13. package/app/api/admin/thoughts/[id]/route.ts +44 -0
  14. package/app/api/admin/thoughts/route.ts +120 -0
  15. package/app/api/admin/uploads/route.ts +20 -0
  16. package/app/api/auth/github/callback/route.ts +136 -0
  17. package/app/api/auth/github/route.ts +29 -0
  18. package/app/api/auth/logout/route.ts +13 -0
  19. package/app/blog/[...path]/page.tsx +135 -0
  20. package/app/blog/page.tsx +139 -0
  21. package/app/chatgpt-auth.ts +86 -0
  22. package/app/columns/[column]/[...path]/page.tsx +160 -0
  23. package/app/columns/page.tsx +68 -0
  24. package/app/components/AdminDashboard.tsx +410 -0
  25. package/app/components/Comments.tsx +55 -0
  26. package/app/components/ContentEmptyState.tsx +51 -0
  27. package/app/components/ExternalEmbed.tsx +127 -0
  28. package/app/components/GardenAdminGuide.tsx +116 -0
  29. package/app/components/GardenContentGuide.tsx +241 -0
  30. package/app/components/GardenMediaGuide.tsx +81 -0
  31. package/app/components/GiscusComments.tsx +71 -0
  32. package/app/components/GithubLiveOverview.tsx +118 -0
  33. package/app/components/GithubProfile.tsx +326 -0
  34. package/app/components/MarkdownArticle.tsx +144 -0
  35. package/app/components/MermaidDiagram.tsx +117 -0
  36. package/app/components/PageIntro.tsx +32 -0
  37. package/app/components/SiteChrome.tsx +18 -0
  38. package/app/components/SiteFooter.tsx +29 -0
  39. package/app/components/SiteHeader.tsx +420 -0
  40. package/app/components/TableOfContents.tsx +433 -0
  41. package/app/components/ThoughtCard.tsx +242 -0
  42. package/app/components/Webmentions.tsx +194 -0
  43. package/app/components/WidgetChart.tsx +222 -0
  44. package/app/garden/page.tsx +284 -0
  45. package/app/garden-modules.d.ts +62 -0
  46. package/app/globals.css +4454 -0
  47. package/app/layout.tsx +102 -0
  48. package/app/lib/admin-auth.ts +134 -0
  49. package/app/lib/content.ts +112 -0
  50. package/app/lib/fallback-site-config.ts +56 -0
  51. package/app/lib/generated-content.ts +380 -0
  52. package/app/lib/github-content.ts +244 -0
  53. package/app/lib/markdown-plugins.ts +13 -0
  54. package/app/lib/oss.ts +100 -0
  55. package/app/lib/public-thoughts.ts +9 -0
  56. package/app/lib/repository-path.mjs +32 -0
  57. package/app/lib/site.ts +51 -0
  58. package/app/page.tsx +119 -0
  59. package/app/robots.ts +15 -0
  60. package/app/rss.xml/route.ts +84 -0
  61. package/app/site.config.ts +35 -0
  62. package/app/sitemap.ts +46 -0
  63. package/app/thoughts/[slug]/page.tsx +79 -0
  64. package/app/thoughts/page.tsx +33 -0
  65. package/app/yaml.d.ts +4 -0
  66. package/bin/garden.mjs +348 -0
  67. package/build/sites-vite-plugin.ts +39 -0
  68. package/eslint.config.mjs +20 -0
  69. package/next.config.ts +13 -0
  70. package/package.json +112 -0
  71. package/postcss.config.mjs +7 -0
  72. package/public/favicon.svg +4 -0
  73. package/public/images/garden/admin-studio.png +0 -0
  74. package/public/og.png +0 -0
  75. package/scripts/build-content.mjs +582 -0
  76. package/scripts/fetch-embed.mjs +189 -0
  77. package/scripts/garden-paths.mjs +21 -0
  78. package/scripts/new-post.mjs +73 -0
  79. package/scripts/prepare-pages-output.mjs +182 -0
  80. package/scripts/send-webmentions.mjs +27 -0
  81. package/site.config.yaml +48 -0
  82. package/templates/site/Makefile +46 -0
  83. package/templates/site/README.md +13 -0
  84. package/templates/site/content/columns.yaml +1 -0
  85. package/templates/site/content/posts/.gitkeep +0 -0
  86. package/templates/site/content/thoughts/.gitkeep +0 -0
  87. package/templates/site/package.json +19 -0
  88. package/templates/site/site.config.yaml +46 -0
  89. package/tsconfig.json +34 -0
  90. package/vite.config.ts +130 -0
  91. 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
+ }