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,410 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import Link from "next/link";
|
|
4
|
+
import { useMemo, useState } from "react";
|
|
5
|
+
import {
|
|
6
|
+
ArrowSquareOut,
|
|
7
|
+
ArrowUpRight,
|
|
8
|
+
CheckCircle,
|
|
9
|
+
Copy,
|
|
10
|
+
DotsThree,
|
|
11
|
+
FileText,
|
|
12
|
+
LinkSimple,
|
|
13
|
+
PaperPlaneTilt,
|
|
14
|
+
Plus,
|
|
15
|
+
Trash,
|
|
16
|
+
} from "@phosphor-icons/react";
|
|
17
|
+
import type { ContentEntry, MediaItem } from "../lib/content";
|
|
18
|
+
|
|
19
|
+
type ManagedThought = ContentEntry & {
|
|
20
|
+
id: string;
|
|
21
|
+
status: "draft" | "published";
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
type CatalogEntry = {
|
|
25
|
+
kind: "post" | "column";
|
|
26
|
+
column?: string;
|
|
27
|
+
slug: string;
|
|
28
|
+
path: string;
|
|
29
|
+
title: string;
|
|
30
|
+
topic: string;
|
|
31
|
+
pinned?: boolean;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
type AttachmentDraft = Partial<MediaItem> & {
|
|
35
|
+
type: MediaItem["type"];
|
|
36
|
+
src: string;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const emptyAttachment = (): AttachmentDraft => ({ type: "image", src: "" });
|
|
40
|
+
|
|
41
|
+
function MediaFields({
|
|
42
|
+
attachments,
|
|
43
|
+
onChange,
|
|
44
|
+
onUpload,
|
|
45
|
+
uploading,
|
|
46
|
+
}: {
|
|
47
|
+
attachments: AttachmentDraft[];
|
|
48
|
+
onChange: (next: AttachmentDraft[]) => void;
|
|
49
|
+
onUpload: (index: number, file: File) => void;
|
|
50
|
+
uploading: boolean;
|
|
51
|
+
}) {
|
|
52
|
+
return (
|
|
53
|
+
<section className="admin-attachments">
|
|
54
|
+
<div className="admin-field-heading">
|
|
55
|
+
<div>
|
|
56
|
+
<span>附件</span>
|
|
57
|
+
<small>支持一条随想中混合多个图片、音频、视频与链接。</small>
|
|
58
|
+
</div>
|
|
59
|
+
<button type="button" className="admin-quiet-button" onClick={() => onChange([...attachments, emptyAttachment()])}>
|
|
60
|
+
<Plus size={14} /> 添加附件
|
|
61
|
+
</button>
|
|
62
|
+
</div>
|
|
63
|
+
{attachments.map((item, index) => (
|
|
64
|
+
<div className="admin-attachment-row" key={index}>
|
|
65
|
+
<select
|
|
66
|
+
aria-label={`附件 ${index + 1} 类型`}
|
|
67
|
+
value={item.type}
|
|
68
|
+
onChange={(event) => {
|
|
69
|
+
const next = [...attachments];
|
|
70
|
+
next[index] = { ...next[index], type: event.target.value as MediaItem["type"] };
|
|
71
|
+
onChange(next);
|
|
72
|
+
}}
|
|
73
|
+
>
|
|
74
|
+
<option value="image">图片</option>
|
|
75
|
+
<option value="audio">音频</option>
|
|
76
|
+
<option value="video">视频</option>
|
|
77
|
+
<option value="link">链接</option>
|
|
78
|
+
</select>
|
|
79
|
+
<input
|
|
80
|
+
value={item.src}
|
|
81
|
+
onChange={(event) => {
|
|
82
|
+
const next = [...attachments];
|
|
83
|
+
next[index] = { ...next[index], src: event.target.value };
|
|
84
|
+
onChange(next);
|
|
85
|
+
}}
|
|
86
|
+
placeholder={item.type === "link" ? "https://…" : "https://… 或上传到 OSS"}
|
|
87
|
+
aria-label={`附件 ${index + 1} 地址`}
|
|
88
|
+
/>
|
|
89
|
+
{item.type !== "link" && (
|
|
90
|
+
<input
|
|
91
|
+
type="file"
|
|
92
|
+
accept={item.type === "image" ? "image/*" : item.type === "audio" ? "audio/*" : "video/*"}
|
|
93
|
+
aria-label={`上传附件 ${index + 1}`}
|
|
94
|
+
disabled={uploading}
|
|
95
|
+
onChange={(event) => {
|
|
96
|
+
const file = event.target.files?.[0];
|
|
97
|
+
if (file) onUpload(index, file);
|
|
98
|
+
event.currentTarget.value = "";
|
|
99
|
+
}}
|
|
100
|
+
/>
|
|
101
|
+
)}
|
|
102
|
+
<input
|
|
103
|
+
value={item.title ?? ""}
|
|
104
|
+
onChange={(event) => {
|
|
105
|
+
const next = [...attachments];
|
|
106
|
+
next[index] = { ...next[index], title: event.target.value };
|
|
107
|
+
onChange(next);
|
|
108
|
+
}}
|
|
109
|
+
placeholder="标题 / 说明(可选)"
|
|
110
|
+
aria-label={`附件 ${index + 1} 标题`}
|
|
111
|
+
/>
|
|
112
|
+
<button
|
|
113
|
+
type="button"
|
|
114
|
+
className="admin-icon-button"
|
|
115
|
+
aria-label={`删除附件 ${index + 1}`}
|
|
116
|
+
onClick={() => onChange(attachments.filter((_, attachmentIndex) => attachmentIndex !== index))}
|
|
117
|
+
>
|
|
118
|
+
<Trash size={16} />
|
|
119
|
+
</button>
|
|
120
|
+
</div>
|
|
121
|
+
))}
|
|
122
|
+
</section>
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function AdminDashboard({
|
|
127
|
+
userName,
|
|
128
|
+
initialThoughts,
|
|
129
|
+
staticThoughtCount,
|
|
130
|
+
catalog,
|
|
131
|
+
publicSiteUrl,
|
|
132
|
+
contentWarning,
|
|
133
|
+
}: {
|
|
134
|
+
userName: string;
|
|
135
|
+
initialThoughts: ManagedThought[];
|
|
136
|
+
staticThoughtCount: number;
|
|
137
|
+
catalog: CatalogEntry[];
|
|
138
|
+
publicSiteUrl: string;
|
|
139
|
+
contentWarning: string;
|
|
140
|
+
}) {
|
|
141
|
+
const [thoughts, setThoughts] = useState(initialThoughts);
|
|
142
|
+
const [title, setTitle] = useState("");
|
|
143
|
+
const [content, setContent] = useState("");
|
|
144
|
+
const [tags, setTags] = useState("");
|
|
145
|
+
const [attachments, setAttachments] = useState<AttachmentDraft[]>([]);
|
|
146
|
+
const [notice, setNotice] = useState("");
|
|
147
|
+
const [isSaving, setIsSaving] = useState(false);
|
|
148
|
+
const [uploadingCount, setUploadingCount] = useState(0);
|
|
149
|
+
const [distributionStatus, setDistributionStatus] = useState<Record<string, string>>({});
|
|
150
|
+
const [catalogEntries, setCatalogEntries] = useState(catalog);
|
|
151
|
+
const [pinningPath, setPinningPath] = useState<string | null>(null);
|
|
152
|
+
const publishedCount = useMemo(
|
|
153
|
+
() => thoughts.filter((thought) => thought.status === "published").length,
|
|
154
|
+
[thoughts],
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
async function uploadMedia(index: number, file: File) {
|
|
158
|
+
setNotice("");
|
|
159
|
+
setUploadingCount((count) => count + 1);
|
|
160
|
+
try {
|
|
161
|
+
const policyResponse = await fetch("/api/admin/uploads", {
|
|
162
|
+
method: "POST",
|
|
163
|
+
headers: { "Content-Type": "application/json" },
|
|
164
|
+
body: JSON.stringify({ name: file.name, type: file.type, size: file.size }),
|
|
165
|
+
});
|
|
166
|
+
const policy = (await policyResponse.json()) as {
|
|
167
|
+
endpoint?: string; key?: string; policy?: string; signature?: string; accessKeyId?: string; publicUrl?: string; error?: string;
|
|
168
|
+
};
|
|
169
|
+
if (!policyResponse.ok || !policy.endpoint || !policy.key || !policy.policy || !policy.signature || !policy.accessKeyId || !policy.publicUrl) {
|
|
170
|
+
throw new Error(policy.error ?? "无法获取 OSS 上传许可。");
|
|
171
|
+
}
|
|
172
|
+
const form = new FormData();
|
|
173
|
+
form.set("key", policy.key);
|
|
174
|
+
form.set("OSSAccessKeyId", policy.accessKeyId);
|
|
175
|
+
form.set("policy", policy.policy);
|
|
176
|
+
form.set("Signature", policy.signature);
|
|
177
|
+
form.set("success_action_status", "201");
|
|
178
|
+
form.set("Content-Type", file.type);
|
|
179
|
+
form.set("file", file);
|
|
180
|
+
const uploadResponse = await fetch(policy.endpoint, { method: "POST", body: form });
|
|
181
|
+
if (!uploadResponse.ok) throw new Error("OSS 未接受附件上传,请检查 Bucket CORS 和写入策略。");
|
|
182
|
+
setAttachments((current) => current.map((item, itemIndex) =>
|
|
183
|
+
itemIndex === index ? { ...item, src: policy.publicUrl!, mime: file.type } : item,
|
|
184
|
+
));
|
|
185
|
+
setNotice("附件已上传到阿里云 OSS;发布随想后会使用该公开媒体地址。");
|
|
186
|
+
} catch (error) {
|
|
187
|
+
setNotice(error instanceof Error ? error.message : "上传失败,请稍后重试。");
|
|
188
|
+
} finally {
|
|
189
|
+
setUploadingCount((count) => Math.max(0, count - 1));
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function publishThought(status: "draft" | "published") {
|
|
194
|
+
setNotice("");
|
|
195
|
+
setIsSaving(true);
|
|
196
|
+
const media = attachments.filter((item) => item.src.trim()).map((item) => ({
|
|
197
|
+
...item,
|
|
198
|
+
src: item.src.trim(),
|
|
199
|
+
...(item.title?.trim() ? { title: item.title.trim() } : {}),
|
|
200
|
+
}));
|
|
201
|
+
try {
|
|
202
|
+
const response = await fetch("/api/admin/thoughts", {
|
|
203
|
+
method: "POST",
|
|
204
|
+
headers: { "Content-Type": "application/json" },
|
|
205
|
+
body: JSON.stringify({
|
|
206
|
+
title,
|
|
207
|
+
content,
|
|
208
|
+
tags: tags.split(",").map((tag) => tag.trim()).filter(Boolean),
|
|
209
|
+
media,
|
|
210
|
+
status,
|
|
211
|
+
}),
|
|
212
|
+
});
|
|
213
|
+
const result = (await response.json()) as { thought?: ManagedThought; error?: string };
|
|
214
|
+
if (!response.ok || !result.thought) throw new Error(result.error ?? "保存失败。");
|
|
215
|
+
setThoughts((current) => [result.thought!, ...current]);
|
|
216
|
+
setTitle("");
|
|
217
|
+
setContent("");
|
|
218
|
+
setTags("");
|
|
219
|
+
setAttachments([]);
|
|
220
|
+
setNotice(status === "published" ? "随想已提交到 GitHub,Pages 将自动重新发布。" : "草稿已提交到 GitHub,仅后台可见。"
|
|
221
|
+
);
|
|
222
|
+
} catch (error) {
|
|
223
|
+
setNotice(error instanceof Error ? error.message : "保存失败,请稍后重试。");
|
|
224
|
+
} finally {
|
|
225
|
+
setIsSaving(false);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function updateThought(id: string, status: "draft" | "published") {
|
|
230
|
+
const response = await fetch(`/api/admin/thoughts/${id}`, {
|
|
231
|
+
method: "PATCH",
|
|
232
|
+
headers: { "Content-Type": "application/json" },
|
|
233
|
+
body: JSON.stringify({ status }),
|
|
234
|
+
});
|
|
235
|
+
if (!response.ok) {
|
|
236
|
+
setNotice("状态更新失败,请稍后重试。");
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
setThoughts((current) =>
|
|
240
|
+
current.map((thought) =>
|
|
241
|
+
thought.id === id
|
|
242
|
+
? { ...thought, status, date: status === "published" ? new Date().toISOString() : thought.date }
|
|
243
|
+
: thought,
|
|
244
|
+
),
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async function deleteThought(id: string) {
|
|
249
|
+
if (!window.confirm("确定删除这条后台随想吗?此操作无法恢复。")) return;
|
|
250
|
+
const response = await fetch(`/api/admin/thoughts/${id}`, { method: "DELETE" });
|
|
251
|
+
if (!response.ok) {
|
|
252
|
+
setNotice("删除失败,请稍后重试。");
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
setThoughts((current) => current.filter((thought) => thought.id !== id));
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async function distribute(entry: CatalogEntry, platform: "x" | "csdn" | "zhihu" | "juejin") {
|
|
259
|
+
const key = `${entry.kind}:${entry.column ?? ""}:${entry.path}:${platform}`;
|
|
260
|
+
setDistributionStatus((current) => ({ ...current, [key]: "正在发送…" }));
|
|
261
|
+
try {
|
|
262
|
+
const response = await fetch("/api/admin/distribution", {
|
|
263
|
+
method: "POST",
|
|
264
|
+
headers: { "Content-Type": "application/json" },
|
|
265
|
+
body: JSON.stringify({ kind: entry.kind, column: entry.column, path: entry.path, platform }),
|
|
266
|
+
});
|
|
267
|
+
const result = (await response.json()) as { job?: { status: string; externalUrl?: string | null; error?: string | null }; error?: string };
|
|
268
|
+
if (!response.ok || !result.job) throw new Error(result.error ?? "分发失败。");
|
|
269
|
+
const text = result.job.status === "published"
|
|
270
|
+
? "已发布"
|
|
271
|
+
: result.job.status === "needs_credentials"
|
|
272
|
+
? `等待凭据:${result.job.error}`
|
|
273
|
+
: result.job.error ?? "分发失败";
|
|
274
|
+
setDistributionStatus((current) => ({ ...current, [key]: text }));
|
|
275
|
+
} catch (error) {
|
|
276
|
+
setDistributionStatus((current) => ({
|
|
277
|
+
...current,
|
|
278
|
+
[key]: error instanceof Error ? error.message : "分发失败。",
|
|
279
|
+
}));
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async function copyPackage(entry: CatalogEntry) {
|
|
284
|
+
const path = entry.kind === "post" ? `/blog/${entry.path}` : `/columns/${entry.column}/${entry.path}`;
|
|
285
|
+
await navigator.clipboard.writeText(`${entry.title}\n${entry.topic}\n${publicSiteUrl}${path}`);
|
|
286
|
+
setNotice("标题、栏目与公开链接已复制,可直接粘贴到任意发布平台。"
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async function updatePinned(entry: CatalogEntry) {
|
|
291
|
+
if (entry.kind !== "post") return;
|
|
292
|
+
setNotice("");
|
|
293
|
+
setPinningPath(entry.path);
|
|
294
|
+
try {
|
|
295
|
+
const response = await fetch("/api/admin/posts/pin", {
|
|
296
|
+
method: "PATCH",
|
|
297
|
+
headers: { "Content-Type": "application/json" },
|
|
298
|
+
body: JSON.stringify({ path: entry.path, pinned: !entry.pinned }),
|
|
299
|
+
});
|
|
300
|
+
const result = (await response.json()) as { post?: { pinned: boolean }; error?: string };
|
|
301
|
+
if (!response.ok || !result.post) throw new Error(result.error ?? "置顶状态更新失败。");
|
|
302
|
+
setCatalogEntries((current) => current.map((item) =>
|
|
303
|
+
item.kind === "post" && item.path === entry.path ? { ...item, pinned: result.post!.pinned } : item,
|
|
304
|
+
));
|
|
305
|
+
setNotice(result.post.pinned
|
|
306
|
+
? "博文已置顶,GitHub Pages 将自动重新发布首页。"
|
|
307
|
+
: "已取消博文置顶,GitHub Pages 将自动重新发布首页。",
|
|
308
|
+
);
|
|
309
|
+
} catch (error) {
|
|
310
|
+
setNotice(error instanceof Error ? error.message : "置顶状态更新失败。");
|
|
311
|
+
} finally {
|
|
312
|
+
setPinningPath(null);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
return (
|
|
317
|
+
<main className="admin-shell">
|
|
318
|
+
<header className="admin-topbar">
|
|
319
|
+
<Link className="admin-brand" href="/admin">HEDON <span>STUDIO</span></Link>
|
|
320
|
+
<div>
|
|
321
|
+
<span>{userName}</span>
|
|
322
|
+
<form action="/api/auth/logout" method="post">
|
|
323
|
+
<button type="submit" className="admin-signout">退出</button>
|
|
324
|
+
</form>
|
|
325
|
+
<a href={publicSiteUrl} target="_blank" rel="noreferrer">
|
|
326
|
+
查看公开博客 <ArrowSquareOut size={15} />
|
|
327
|
+
</a>
|
|
328
|
+
</div>
|
|
329
|
+
</header>
|
|
330
|
+
|
|
331
|
+
<section className="admin-hero">
|
|
332
|
+
<div>
|
|
333
|
+
<p className="eyebrow">CONTENT OPERATIONS</p>
|
|
334
|
+
<h1>写作留在本地,发布与分发留在这里。</h1>
|
|
335
|
+
<p>博文和专栏仍由 Typora 与 Git 管理;这里负责随想、发布状态和跨平台分发。</p>
|
|
336
|
+
</div>
|
|
337
|
+
<dl className="admin-metrics">
|
|
338
|
+
<div><dt>公开随想</dt><dd>{publishedCount + staticThoughtCount}</dd></div>
|
|
339
|
+
<div><dt>本地博文</dt><dd>{catalogEntries.filter((entry) => entry.kind === "post").length}</dd></div>
|
|
340
|
+
<div><dt>专栏篇目</dt><dd>{catalogEntries.filter((entry) => entry.kind === "column").length}</dd></div>
|
|
341
|
+
</dl>
|
|
342
|
+
</section>
|
|
343
|
+
|
|
344
|
+
{notice && <p className="admin-notice"><CheckCircle size={16} /> {notice}</p>}
|
|
345
|
+
{contentWarning && <p className="admin-notice"><CheckCircle size={16} /> {contentWarning}</p>}
|
|
346
|
+
|
|
347
|
+
<section className="admin-grid">
|
|
348
|
+
<div className="admin-panel admin-compose-panel">
|
|
349
|
+
<div className="admin-panel-heading">
|
|
350
|
+
<div><span>发布随想</span><h2>一条记录,可以有多种媒介。</h2></div>
|
|
351
|
+
<PaperPlaneTilt size={22} />
|
|
352
|
+
</div>
|
|
353
|
+
<label>标题<input value={title} onChange={(event) => setTitle(event.target.value)} placeholder="给这条记录一个标题" /></label>
|
|
354
|
+
<label>正文(Markdown)<textarea value={content} onChange={(event) => setContent(event.target.value)} placeholder="写下此刻想留下的文字…" rows={8} /></label>
|
|
355
|
+
<label>标签(用逗号分隔)<input value={tags} onChange={(event) => setTags(event.target.value)} placeholder="例如:产品, 夜晚, 摄影" /></label>
|
|
356
|
+
<MediaFields attachments={attachments} onChange={setAttachments} onUpload={uploadMedia} uploading={uploadingCount > 0} />
|
|
357
|
+
<div className="admin-actions">
|
|
358
|
+
<button type="button" className="admin-secondary-button" disabled={isSaving || uploadingCount > 0} onClick={() => publishThought("draft")}>保存草稿</button>
|
|
359
|
+
<button type="button" className="admin-primary-button" disabled={isSaving || uploadingCount > 0} onClick={() => publishThought("published")}><PaperPlaneTilt size={16} /> {uploadingCount > 0 ? "正在上传附件" : isSaving ? "正在保存" : "发布到博客"}</button>
|
|
360
|
+
</div>
|
|
361
|
+
</div>
|
|
362
|
+
|
|
363
|
+
<div className="admin-panel admin-thoughts-panel">
|
|
364
|
+
<div className="admin-panel-heading">
|
|
365
|
+
<div><span>后台随想</span><h2>已发布与草稿</h2></div>
|
|
366
|
+
<DotsThree size={23} />
|
|
367
|
+
</div>
|
|
368
|
+
<div className="admin-thought-list">
|
|
369
|
+
{thoughts.length === 0 ? <p className="admin-empty">还没有从后台创建的随想。</p> : thoughts.map((thought) => (
|
|
370
|
+
<article key={thought.id}>
|
|
371
|
+
<div><span className={`admin-status ${thought.status}`}>{thought.status === "published" ? "已发布" : "草稿"}</span><h3>{thought.title}</h3><p>{thought.content.slice(0, 76)}{thought.content.length > 76 ? "…" : ""}</p></div>
|
|
372
|
+
<div className="admin-item-actions">
|
|
373
|
+
{thought.status === "published" && <a href={`${publicSiteUrl}/thoughts/${thought.slug}`} target="_blank" rel="noreferrer" aria-label={`查看 ${thought.title}`}><ArrowUpRight size={17} /></a>}
|
|
374
|
+
<button type="button" onClick={() => updateThought(thought.id, thought.status === "published" ? "draft" : "published")}>{thought.status === "published" ? "撤回" : "发布"}</button>
|
|
375
|
+
<button type="button" aria-label={`删除 ${thought.title}`} onClick={() => deleteThought(thought.id)}><Trash size={16} /></button>
|
|
376
|
+
</div>
|
|
377
|
+
</article>
|
|
378
|
+
))}</div>
|
|
379
|
+
</div>
|
|
380
|
+
</section>
|
|
381
|
+
|
|
382
|
+
<section className="admin-panel admin-distribution-panel">
|
|
383
|
+
<div className="admin-panel-heading">
|
|
384
|
+
<div><span>博文与专栏分发</span><h2>后台不改正文,只生成发布动作。</h2></div>
|
|
385
|
+
<FileText size={22} />
|
|
386
|
+
</div>
|
|
387
|
+
<p className="admin-distribution-note"><LinkSimple size={16} /> CSDN、知乎、掘金采用你配置的同步服务;X 在填写用户访问令牌后可直接发布。没有配置时仍可一键复制分发包。</p>
|
|
388
|
+
<div className="admin-catalog">
|
|
389
|
+
{catalogEntries.map((entry) => (
|
|
390
|
+
<article key={`${entry.kind}-${entry.column ?? ""}-${entry.path}`}>
|
|
391
|
+
<div><span>{entry.kind === "post" ? `博文${entry.pinned ? " · 已置顶" : ""}` : "专栏"} · {entry.topic}</span><h3>{entry.title}</h3></div>
|
|
392
|
+
<div className="admin-distribution-actions">
|
|
393
|
+
{entry.kind === "post" && (
|
|
394
|
+
<button type="button" disabled={pinningPath === entry.path} onClick={() => updatePinned(entry)}>
|
|
395
|
+
{pinningPath === entry.path ? "正在更新" : entry.pinned ? "取消置顶" : "置顶首页"}
|
|
396
|
+
</button>
|
|
397
|
+
)}
|
|
398
|
+
<button type="button" onClick={() => copyPackage(entry)}><Copy size={14} /> 复制</button>
|
|
399
|
+
{(["x", "csdn", "zhihu", "juejin"] as const).map((platform) => {
|
|
400
|
+
const key = `${entry.kind}:${entry.column ?? ""}:${entry.path}:${platform}`;
|
|
401
|
+
return <button type="button" key={platform} onClick={() => distribute(entry, platform)}>{platform === "x" ? "X" : platform === "csdn" ? "CSDN" : platform === "zhihu" ? "知乎" : "掘金"}{distributionStatus[key] && <small>{distributionStatus[key]}</small>}</button>;
|
|
402
|
+
})}
|
|
403
|
+
</div>
|
|
404
|
+
</article>
|
|
405
|
+
))}
|
|
406
|
+
</div>
|
|
407
|
+
</section>
|
|
408
|
+
</main>
|
|
409
|
+
);
|
|
410
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { ChatCircle, GithubLogo } from "@phosphor-icons/react/ssr";
|
|
2
|
+
import {
|
|
3
|
+
GiscusComments,
|
|
4
|
+
type GiscusConfig,
|
|
5
|
+
} from "./GiscusComments";
|
|
6
|
+
|
|
7
|
+
function giscusConfig(): GiscusConfig | undefined {
|
|
8
|
+
const config = {
|
|
9
|
+
repo:
|
|
10
|
+
process.env.GISCUS_REPO ?? process.env.NEXT_PUBLIC_GISCUS_REPO,
|
|
11
|
+
repoId:
|
|
12
|
+
process.env.GISCUS_REPO_ID ?? process.env.NEXT_PUBLIC_GISCUS_REPO_ID,
|
|
13
|
+
category:
|
|
14
|
+
process.env.GISCUS_CATEGORY ??
|
|
15
|
+
process.env.NEXT_PUBLIC_GISCUS_CATEGORY,
|
|
16
|
+
categoryId:
|
|
17
|
+
process.env.GISCUS_CATEGORY_ID ??
|
|
18
|
+
process.env.NEXT_PUBLIC_GISCUS_CATEGORY_ID,
|
|
19
|
+
};
|
|
20
|
+
return Object.values(config).every(Boolean)
|
|
21
|
+
? (config as GiscusConfig)
|
|
22
|
+
: undefined;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function Comments({ slug }: { slug: string }) {
|
|
26
|
+
const config = giscusConfig();
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<section className="comments" aria-labelledby={`comments-heading-${slug}`}>
|
|
30
|
+
<div className="comments-heading">
|
|
31
|
+
<ChatCircle size={23} />
|
|
32
|
+
<div>
|
|
33
|
+
<h2 id={`comments-heading-${slug}`}>评论</h2>
|
|
34
|
+
<p>
|
|
35
|
+
{config
|
|
36
|
+
? "评论由 GitHub Discussions 保存和管理。"
|
|
37
|
+
: "评论接入已准备好,补充 GitHub Discussions 配置后即可开放。"}
|
|
38
|
+
</p>
|
|
39
|
+
</div>
|
|
40
|
+
</div>
|
|
41
|
+
|
|
42
|
+
{config ? (
|
|
43
|
+
<GiscusComments slug={slug} config={config} />
|
|
44
|
+
) : (
|
|
45
|
+
<div className="integration-notice">
|
|
46
|
+
<GithubLogo size={20} weight="fill" />
|
|
47
|
+
<p>
|
|
48
|
+
需要一个公开 GitHub 仓库,并为它启用 Discussions、安装 Giscus
|
|
49
|
+
App、选择评论分类。
|
|
50
|
+
</p>
|
|
51
|
+
</div>
|
|
52
|
+
)}
|
|
53
|
+
</section>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import Link from "next/link";
|
|
2
|
+
import { ArrowRight, Article, Books, Quotes } from "@phosphor-icons/react/ssr";
|
|
3
|
+
|
|
4
|
+
const emptyContent = {
|
|
5
|
+
posts: {
|
|
6
|
+
icon: Article,
|
|
7
|
+
eyebrow: "NO POSTS YET / 暂无博文",
|
|
8
|
+
title: "这里还没有博文。",
|
|
9
|
+
description: "第一篇长文发布后,会从这里开始形成可检索、可归档的写作记录。",
|
|
10
|
+
actionHref: "/",
|
|
11
|
+
actionLabel: "返回首页",
|
|
12
|
+
},
|
|
13
|
+
thoughts: {
|
|
14
|
+
icon: Quotes,
|
|
15
|
+
eyebrow: "NO THOUGHTS YET / 暂无随想",
|
|
16
|
+
title: "随想还没有开始。",
|
|
17
|
+
description: "短句、照片、声音和临时冒出的念头,之后都会沿着时间线落在这里。",
|
|
18
|
+
actionHref: "/blog",
|
|
19
|
+
actionLabel: "先读博文",
|
|
20
|
+
},
|
|
21
|
+
columns: {
|
|
22
|
+
icon: Books,
|
|
23
|
+
eyebrow: "NO COLUMNS YET / 暂无专栏",
|
|
24
|
+
title: "专栏正在形成。",
|
|
25
|
+
description: "当同一主题积累出清晰的阅读顺序,这里会出现第一条连续的阅读路径。",
|
|
26
|
+
actionHref: "/blog",
|
|
27
|
+
actionLabel: "浏览全部博文",
|
|
28
|
+
},
|
|
29
|
+
} as const;
|
|
30
|
+
|
|
31
|
+
export function ContentEmptyState({ kind }: { kind: keyof typeof emptyContent }) {
|
|
32
|
+
const content = emptyContent[kind];
|
|
33
|
+
const Icon = content.icon;
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<section className="content-empty-state" aria-labelledby={`${kind}-empty-title`}>
|
|
37
|
+
<div className="content-empty-mark" aria-hidden="true">
|
|
38
|
+
<Icon size={30} weight="light" />
|
|
39
|
+
</div>
|
|
40
|
+
<div>
|
|
41
|
+
<p className="eyebrow">{content.eyebrow}</p>
|
|
42
|
+
<h2 id={`${kind}-empty-title`}>{content.title}</h2>
|
|
43
|
+
<p>{content.description}</p>
|
|
44
|
+
</div>
|
|
45
|
+
<Link href={content.actionHref}>
|
|
46
|
+
{content.actionLabel}
|
|
47
|
+
<ArrowRight size={17} />
|
|
48
|
+
</Link>
|
|
49
|
+
</section>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ArrowUpRight,
|
|
3
|
+
LinkSimple,
|
|
4
|
+
VideoCamera,
|
|
5
|
+
WechatLogo,
|
|
6
|
+
XLogo,
|
|
7
|
+
YoutubeLogo,
|
|
8
|
+
} from "@phosphor-icons/react/ssr";
|
|
9
|
+
import { parse } from "yaml";
|
|
10
|
+
|
|
11
|
+
type EmbedData = {
|
|
12
|
+
url: string;
|
|
13
|
+
title?: string;
|
|
14
|
+
description?: string;
|
|
15
|
+
image?: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
type EmbedProvider = "youtube" | "bilibili" | "x" | "wechat" | "website";
|
|
19
|
+
|
|
20
|
+
function readEmbedData(source: string): EmbedData | null {
|
|
21
|
+
try {
|
|
22
|
+
const value = parse(source.trim()) as unknown;
|
|
23
|
+
const input = typeof value === "string" ? { url: value } : value;
|
|
24
|
+
if (!input || typeof input !== "object" || !("url" in input) || typeof input.url !== "string") return null;
|
|
25
|
+
const url = new URL(input.url);
|
|
26
|
+
if (!['http:', 'https:'].includes(url.protocol)) return null;
|
|
27
|
+
return {
|
|
28
|
+
url: url.toString(),
|
|
29
|
+
title: "title" in input && typeof input.title === "string" ? input.title : undefined,
|
|
30
|
+
description: "description" in input && typeof input.description === "string" ? input.description : undefined,
|
|
31
|
+
image: "image" in input && typeof input.image === "string" && /^https?:\/\//u.test(input.image) ? input.image : undefined,
|
|
32
|
+
};
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function providerFor(url: URL): EmbedProvider {
|
|
39
|
+
const hostname = url.hostname.replace(/^www\./u, "");
|
|
40
|
+
if (["youtube.com", "m.youtube.com", "youtu.be", "youtube-nocookie.com"].includes(hostname)) return "youtube";
|
|
41
|
+
if (hostname === "bilibili.com" || hostname.endsWith(".bilibili.com")) return "bilibili";
|
|
42
|
+
if (["x.com", "twitter.com", "mobile.twitter.com"].includes(hostname)) return "x";
|
|
43
|
+
if (hostname === "mp.weixin.qq.com") return "wechat";
|
|
44
|
+
return "website";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function youtubeId(url: URL) {
|
|
48
|
+
if (url.hostname.replace(/^www\./u, "") === "youtu.be") return url.pathname.split("/").filter(Boolean)[0];
|
|
49
|
+
if (url.searchParams.get("v")) return url.searchParams.get("v") ?? undefined;
|
|
50
|
+
const parts = url.pathname.split("/").filter(Boolean);
|
|
51
|
+
if (["embed", "shorts", "live"].includes(parts[0])) return parts[1];
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function bilibiliPlayerUrl(url: URL) {
|
|
56
|
+
const match = url.pathname.match(/\/video\/(BV[0-9A-Za-z]+|av\d+)/u);
|
|
57
|
+
const identifier = match?.[1] ?? url.searchParams.get("bvid") ?? url.searchParams.get("aid");
|
|
58
|
+
if (!identifier) return null;
|
|
59
|
+
const key = identifier.toLowerCase().startsWith("av") || /^\d+$/u.test(identifier) ? "aid" : "bvid";
|
|
60
|
+
const value = identifier.toLowerCase().startsWith("av") ? identifier.slice(2) : identifier;
|
|
61
|
+
return `https://player.bilibili.com/player.html?${key}=${encodeURIComponent(value)}&high_quality=1&danmaku=0`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function playableUrl(provider: EmbedProvider, url: URL) {
|
|
65
|
+
if (provider === "youtube") {
|
|
66
|
+
const id = youtubeId(url);
|
|
67
|
+
return id && /^[0-9A-Za-z_-]+$/u.test(id) ? `https://www.youtube-nocookie.com/embed/${id}` : null;
|
|
68
|
+
}
|
|
69
|
+
if (provider === "bilibili") return bilibiliPlayerUrl(url);
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const providerCopy = {
|
|
74
|
+
youtube: { label: "YouTube", Icon: YoutubeLogo },
|
|
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 }) {
|
|
82
|
+
const data = readEmbedData(source);
|
|
83
|
+
if (!data) {
|
|
84
|
+
return <p className="external-embed-error">无法解析这个嵌入块,请检查 URL 与 YAML 格式。</p>;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const url = new URL(data.url);
|
|
88
|
+
const provider = providerFor(url);
|
|
89
|
+
const playerUrl = playableUrl(provider, url);
|
|
90
|
+
const { Icon, label } = providerCopy[provider];
|
|
91
|
+
|
|
92
|
+
if (playerUrl) {
|
|
93
|
+
return (
|
|
94
|
+
<figure className={`external-embed external-embed-${provider}`}>
|
|
95
|
+
<div className="external-embed-frame">
|
|
96
|
+
<iframe
|
|
97
|
+
src={playerUrl}
|
|
98
|
+
title={data.title ?? `${label} 嵌入内容`}
|
|
99
|
+
loading="lazy"
|
|
100
|
+
allow="accelerometer; autoplay; encrypted-media; fullscreen; picture-in-picture"
|
|
101
|
+
referrerPolicy="strict-origin-when-cross-origin"
|
|
102
|
+
allowFullScreen
|
|
103
|
+
/>
|
|
104
|
+
</div>
|
|
105
|
+
<figcaption>
|
|
106
|
+
<span><Icon size={17} />{label}</span>
|
|
107
|
+
<a href={data.url} target="_blank" rel="noopener noreferrer">在原网站打开<ArrowUpRight size={14} /></a>
|
|
108
|
+
</figcaption>
|
|
109
|
+
</figure>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return (
|
|
114
|
+
<a className={`external-embed-card external-embed-${provider}`} href={data.url} target="_blank" rel="noopener noreferrer">
|
|
115
|
+
{data.image && (
|
|
116
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
117
|
+
<img src={data.image} alt="" loading="lazy" />
|
|
118
|
+
)}
|
|
119
|
+
<span className="external-embed-copy">
|
|
120
|
+
<small><Icon size={16} />{label} · {url.hostname}</small>
|
|
121
|
+
<strong>{data.title ?? "打开这条外部内容"}</strong>
|
|
122
|
+
<p>{data.description ?? (provider === "x" || provider === "wechat" ? "平台不提供稳定的无脚本嵌入,Garden 会保留清晰、可访问的原文入口。" : "查看原网页中的完整内容与上下文。")}</p>
|
|
123
|
+
</span>
|
|
124
|
+
<ArrowUpRight size={21} />
|
|
125
|
+
</a>
|
|
126
|
+
);
|
|
127
|
+
}
|