hd-garden 0.14.6 → 0.14.7
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/README.en.md +3 -3
- package/README.md +3 -3
- package/app/admin/page.tsx +3 -16
- package/app/admin/preview/[...path]/page.tsx +6 -8
- package/app/components/AdminDashboard.tsx +3 -258
- package/app/components/AdminUiPanels.tsx +5 -7
- package/app/components/ContentEmptyState.tsx +2 -10
- package/app/components/GardenAdminGuide.tsx +3 -22
- package/app/components/GardenContentGuide.tsx +0 -30
- package/app/components/MarkdownArticle.tsx +2 -3
- package/app/components/PageIntro.tsx +1 -1
- package/app/garden-modules.d.ts +2 -19
- package/app/globals.css +4 -256
- package/app/lib/content.ts +2 -8
- package/app/lib/fallback-site-config.ts +2 -6
- package/app/lib/garden-docs-meta.ts +1 -1
- package/app/lib/github-content.ts +8 -159
- package/app/lib/i18n.ts +8 -56
- package/app/lib/oss.ts +1 -1
- package/app/lib/repository-path.mjs +19 -2
- package/app/lib/ui-content.ts +4 -3
- package/app/lib/ui-model.ts +2 -0
- package/app/rss.xml/route.ts +1 -9
- package/app/site.config.ts +0 -1
- package/app/sitemap.ts +1 -10
- package/app/styles/a2.css +0 -39
- package/app/styles/shell.css +0 -9
- package/bin/garden.mjs +0 -1
- package/docs/handbook/ai-writing.md +2 -2
- package/docs/handbook/components/image.md +0 -1
- package/docs/handbook/components/link-card.md +0 -1
- package/docs/handbook/config.md +3 -8
- package/docs/handbook/deploy.md +1 -1
- package/docs/handbook/en/ai-writing.md +2 -2
- package/docs/handbook/en/components/image.md +0 -1
- package/docs/handbook/en/components/link-card.md +0 -1
- package/docs/handbook/en/config.md +2 -7
- package/docs/handbook/en/deploy.md +1 -1
- package/docs/handbook/en/index.md +1 -2
- package/docs/handbook/en/start.md +2 -7
- package/docs/handbook/en/writing.md +4 -28
- package/docs/handbook/index.md +4 -5
- package/docs/handbook/start.md +2 -7
- package/docs/handbook/writing.md +4 -28
- package/editors/dist/{garden-typora-0.14.6.zip → garden-typora-0.14.7.zip} +0 -0
- package/editors/dist/typora/manifest.json +1 -1
- package/editors/dist/typora/style.css +0 -39
- package/editors/dist/vscode/{garden-ui-0.14.6.vsix → garden-ui-0.14.7.vsix} +0 -0
- package/package.json +2 -2
- package/scripts/build-content.mjs +12 -92
- package/site.config.yaml +3 -6
- package/templates/site/AGENTS.en.md +2 -3
- package/templates/site/AGENTS.md +2 -3
- package/templates/site/site.config.en.yaml +1 -6
- package/templates/site/site.config.yaml +2 -7
- package/ui/core/workbench-eval.mjs +27 -0
- package/ui/garden/article-assets.mjs +13 -0
- package/ui/garden/post-frontmatter.mjs +31 -0
- package/ui/garden/search-index.mjs +4 -6
- package/ui/garden/site-nav.mjs +0 -1
- package/ui/garden/static-params.mjs +1 -1
- package/ui/preview/dist/garden-ui.css +0 -39
- package/ui/preview/dist/garden-ui.esm.js +1 -1
- package/ui/preview/dist/garden-ui.iife.js +1 -1
- package/app/api/admin/thoughts/[id]/route.ts +0 -44
- package/app/api/admin/thoughts/route.ts +0 -121
- package/app/components/FeedMore.tsx +0 -70
- package/app/components/ThoughtCard.tsx +0 -245
- package/app/components/ThoughtFeed.tsx +0 -41
- package/app/lib/public-thoughts.ts +0 -9
- package/app/thoughts/[slug]/page.tsx +0 -86
- package/app/thoughts/page/[n]/page.tsx +0 -44
- package/app/thoughts/page.tsx +0 -32
- package/public/images/garden/admin-studio.png +0 -0
- package/templates/site/content/thoughts/.gitkeep +0 -0
- package/ui/garden/paging.mjs +0 -30
package/README.en.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[中文](README.md)
|
|
4
4
|
|
|
5
|
-
Garden is a Claude-inspired personal blog framework. You write posts
|
|
5
|
+
Garden is a Claude-inspired personal blog framework. You write posts and multi-part columns in Markdown inside your own Git repository, and describe structured or interactive components in a single plain-language sentence.
|
|
6
6
|
|
|
7
7
|
Your site repository keeps only `site.config.yaml` and `content/`. Rendering, static export, and verification come from the `hd-garden` package.
|
|
8
8
|
|
|
@@ -96,9 +96,9 @@ The first page that references `.excalidraw` downloads the exporter (Chromium is
|
|
|
96
96
|
|
|
97
97
|
Start `make dev` and open `/garden/en` (Chinese at `/garden`), or read the markdown sources directly:
|
|
98
98
|
|
|
99
|
-
- **[Garden Handbook (English)](docs/handbook/en/index.md)** · **[Garden 使用说明(中文)](docs/handbook/index.md)**: [Quick Start](docs/handbook/en/start.md), [Writing with AI](docs/handbook/en/ai-writing.md), [Site & Column Configuration](docs/handbook/en/config.md), [Deploy to GitHub Pages](docs/handbook/en/deploy.md), [Writing Posts
|
|
99
|
+
- **[Garden Handbook (English)](docs/handbook/en/index.md)** · **[Garden 使用说明(中文)](docs/handbook/index.md)**: [Quick Start](docs/handbook/en/start.md), [Writing with AI](docs/handbook/en/ai-writing.md), [Site & Column Configuration](docs/handbook/en/config.md), [Deploy to GitHub Pages](docs/handbook/en/deploy.md), [Writing Posts](docs/handbook/en/writing.md), [How Reading Layout Works](docs/handbook/en/design.md), [12 Built-in Components](docs/handbook/en/components.md), and [Checking & Shortcuts](docs/handbook/en/workflow.md).
|
|
100
100
|
- **[Quick Start Guide](docs/quick-start.md)** and **[GitHub Pages Deployment](docs/github-pages.md)**: single public repository or private source + public output repository setup, plus Giscus comments.
|
|
101
|
-
- **[Admin Service](docs/admin-service.md)**: optional `/admin` server (local, Docker, or Cloudflare Workers) for
|
|
101
|
+
- **[Admin Service](docs/admin-service.md)**: optional `/admin` server (local, Docker, or Cloudflare Workers) for managing articles, uploading media to Alibaba Cloud OSS, and using the `/admin/ui` workbench.
|
|
102
102
|
|
|
103
103
|
## License
|
|
104
104
|
|
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[English](README.en.md)
|
|
4
4
|
|
|
5
|
-
Garden 是一个 Claude
|
|
5
|
+
Garden 是一个 Claude 风格的个人博客框架:文章和专栏用 Markdown 写,保存在自己的 Git 仓库里;图表、指标和交互组件用一句自然语言描述就行。
|
|
6
6
|
|
|
7
7
|
站点仓库只保存你的配置文件(`site.config.yaml`)与稿件(`content/`),页面渲染、静态构建和检查工具由 `hd-garden` 包提供。
|
|
8
8
|
|
|
@@ -98,9 +98,9 @@ npx garden editors install --yes # 把 Intelligent UI 插件装进
|
|
|
98
98
|
|
|
99
99
|
本地运行 `make dev` 后访问 `/garden`(英文版在 `/garden/en`),或直接阅读仓库内的文档:
|
|
100
100
|
|
|
101
|
-
- **[Garden 使用说明(中文)](docs/handbook/index.md)** · **[Garden Handbook (English)](docs/handbook/en/index.md)**:快速开始、[用 AI 写作](docs/handbook/ai-writing.md)、[站点与专栏配置](docs/handbook/config.md)、[发布到 GitHub Pages](docs/handbook/deploy.md)、[
|
|
101
|
+
- **[Garden 使用说明(中文)](docs/handbook/index.md)** · **[Garden Handbook (English)](docs/handbook/en/index.md)**:快速开始、[用 AI 写作](docs/handbook/ai-writing.md)、[站点与专栏配置](docs/handbook/config.md)、[发布到 GitHub Pages](docs/handbook/deploy.md)、[写文章](docs/handbook/writing.md)、[阅读排版](docs/handbook/design.md)、[组件](docs/handbook/components.md)(`garden` 围栏,以及链接卡片、来源胶囊和本地图片;说明图是 compose)与 [语法检查](docs/handbook/workflow.md)。
|
|
102
102
|
- **[从零开始](docs/quick-start.md)** 与 **[GitHub Pages 部署](docs/github-pages.md)**:单公开仓库或「私有源码仓库 + 公开页面仓库」的 Actions 配置与 Giscus 评论接入。
|
|
103
|
-
- **[独立管理后台](docs/admin-service.md)**:可选的 `/admin` 服务(本地、Docker 或 Cloudflare Workers
|
|
103
|
+
- **[独立管理后台](docs/admin-service.md)**:可选的 `/admin` 服务(本地、Docker 或 Cloudflare Workers),支持文章置顶、阿里云 OSS 媒体上传与 `/admin/ui` 组件工作台。
|
|
104
104
|
|
|
105
105
|
## 许可证
|
|
106
106
|
|
package/app/admin/page.tsx
CHANGED
|
@@ -2,8 +2,8 @@ import Link from "next/link";
|
|
|
2
2
|
import { ArrowSquareOut, GithubLogo } from "@phosphor-icons/react/ssr";
|
|
3
3
|
import { AdminDashboard } from "../components/AdminDashboard";
|
|
4
4
|
import { getAdminUser, isGitHubLoginAvailable } from "../lib/admin-auth";
|
|
5
|
-
import { columns, posts
|
|
6
|
-
import { isContentRepositoryConfigured
|
|
5
|
+
import { columns, posts } from "../lib/content";
|
|
6
|
+
import { isContentRepositoryConfigured } from "../lib/github-content";
|
|
7
7
|
import { getSiteUrl } from "../lib/site";
|
|
8
8
|
import { siteConfig } from "../site.config";
|
|
9
9
|
import { ui } from "../lib/i18n";
|
|
@@ -76,17 +76,6 @@ export default async function AdminPage({
|
|
|
76
76
|
);
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
-
let initialThoughts: Awaited<ReturnType<typeof listRepositoryThoughts>> = [];
|
|
80
|
-
let contentWarning = "";
|
|
81
|
-
if (!isContentRepositoryConfigured()) {
|
|
82
|
-
contentWarning = "尚未配置内容仓库写入权限;请设置 CONTENT_REPOSITORY 和 CONTENT_GITHUB_TOKEN。";
|
|
83
|
-
} else {
|
|
84
|
-
try {
|
|
85
|
-
initialThoughts = await listRepositoryThoughts();
|
|
86
|
-
} catch (error) {
|
|
87
|
-
contentWarning = error instanceof Error ? error.message : "无法读取 GitHub 内容仓库。";
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
79
|
const catalog = [
|
|
91
80
|
...posts.map((post) => ({
|
|
92
81
|
kind: "post" as const,
|
|
@@ -109,11 +98,9 @@ export default async function AdminPage({
|
|
|
109
98
|
return (
|
|
110
99
|
<AdminDashboard
|
|
111
100
|
userName={`@${user.login}`}
|
|
112
|
-
initialThoughts={initialThoughts}
|
|
113
|
-
staticThoughtCount={thoughts.length}
|
|
114
101
|
catalog={catalog}
|
|
115
102
|
publicSiteUrl={publicSiteUrl}
|
|
116
|
-
contentWarning={
|
|
103
|
+
contentWarning={isContentRepositoryConfigured() ? "" : "尚未配置内容仓库写入权限;请设置 CONTENT_REPOSITORY 和 CONTENT_GITHUB_TOKEN。"}
|
|
117
104
|
ui={{
|
|
118
105
|
modelName: modelConfig()?.model ?? null,
|
|
119
106
|
remaining: remainingDrafts(user.login),
|
|
@@ -7,11 +7,12 @@ import { MarkdownArticle } from "../../../components/MarkdownArticle";
|
|
|
7
7
|
import { repositoryContentPath } from "../../../lib/repository-path.mjs";
|
|
8
8
|
import { readMarkdown, readText } from "../../../lib/ui-content";
|
|
9
9
|
import { ui } from "../../../lib/i18n";
|
|
10
|
-
import {
|
|
10
|
+
import { rewriteArticleMedia } from "../../../../ui/garden/article-assets.mjs";
|
|
11
|
+
import { resolveSidecarPath } from "../../../../ui/core/fence.mjs";
|
|
11
12
|
import { STATIC_PLACEHOLDER, staticParams } from "../../../../ui/garden/static-params.mjs";
|
|
12
13
|
|
|
13
14
|
export const dynamic = process.env.STATIC_EXPORT === "1" ? "force-static" : "force-dynamic";
|
|
14
|
-
export const dynamicParams =
|
|
15
|
+
export const dynamicParams = process.env.STATIC_EXPORT !== "1";
|
|
15
16
|
export const metadata = { title: ui().adminTitlePreview, robots: { index: false, follow: false } };
|
|
16
17
|
|
|
17
18
|
/** Draft preview is served by the admin worker. The public static export has no paths here. */
|
|
@@ -24,9 +25,7 @@ const isRelative = (value: string) => !/^(?:[a-z][a-z0-9+.-]*:|\/|#)/iu.test(val
|
|
|
24
25
|
/** Same-name-directory files go through the authenticated, sandboxed proxy. */
|
|
25
26
|
function proxyAssets(content: string, post: string) {
|
|
26
27
|
const proxy = (relative: string) => `/api/admin/preview/asset?post=${encodeURIComponent(post)}&path=${encodeURIComponent(relative)}`;
|
|
27
|
-
return
|
|
28
|
-
link.image && isRelative(link.url) ? formatInlineLink(link, proxy(link.url)) : undefined,
|
|
29
|
-
);
|
|
28
|
+
return rewriteArticleMedia(content, (url: string) => isRelative(url) ? proxy(url) : url);
|
|
30
29
|
|
|
31
30
|
}
|
|
32
31
|
|
|
@@ -54,11 +53,10 @@ export default async function DraftPreviewPage({ params }: { params: Promise<{ p
|
|
|
54
53
|
}
|
|
55
54
|
const parsed = matter(file.source, { engines: { yaml: { parse: (input: string) => parseYaml(input) ?? {} } } });
|
|
56
55
|
const data = parsed.data as Record<string, unknown>;
|
|
57
|
-
const directory = post.slice(0, post.lastIndexOf("/"));
|
|
58
56
|
const dataFiles: Record<string, string | undefined> = {};
|
|
59
57
|
for (const src of collectDataSources(parsed.content)) {
|
|
60
|
-
const target =
|
|
61
|
-
dataFiles[src] = await readText(target).catch(() => undefined);
|
|
58
|
+
const target = resolveSidecarPath(post, src);
|
|
59
|
+
dataFiles[src] = target.path ? await readText(target.path).catch(() => undefined) : undefined;
|
|
62
60
|
}
|
|
63
61
|
const { content } = transformUiContent(parsed.content.trim(), {
|
|
64
62
|
mode: "preview",
|
|
@@ -1,23 +1,12 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import Link from "next/link";
|
|
4
|
-
import {
|
|
5
|
-
import { useEffect, useMemo, useState } from "react";
|
|
4
|
+
import { useEffect, useState } from "react";
|
|
6
5
|
import {
|
|
7
6
|
ArrowSquareOut,
|
|
8
|
-
ArrowUpRight,
|
|
9
7
|
CheckCircle,
|
|
10
8
|
Copy,
|
|
11
|
-
PaperPlaneTilt,
|
|
12
|
-
Plus,
|
|
13
|
-
Trash,
|
|
14
9
|
} from "@phosphor-icons/react";
|
|
15
|
-
import type { ContentEntry, MediaItem } from "../lib/content";
|
|
16
|
-
|
|
17
|
-
type ManagedThought = ContentEntry & {
|
|
18
|
-
id: string;
|
|
19
|
-
status: "draft" | "published";
|
|
20
|
-
};
|
|
21
10
|
|
|
22
11
|
type CatalogEntry = {
|
|
23
12
|
kind: "post" | "column";
|
|
@@ -29,98 +18,6 @@ type CatalogEntry = {
|
|
|
29
18
|
pinned?: boolean;
|
|
30
19
|
};
|
|
31
20
|
|
|
32
|
-
type AttachmentDraft = Partial<MediaItem> & {
|
|
33
|
-
type: MediaItem["type"];
|
|
34
|
-
src: string;
|
|
35
|
-
};
|
|
36
|
-
|
|
37
|
-
const emptyAttachment = (): AttachmentDraft => ({ type: "image", src: "" });
|
|
38
|
-
|
|
39
|
-
function MediaFields({
|
|
40
|
-
attachments,
|
|
41
|
-
onChange,
|
|
42
|
-
onUpload,
|
|
43
|
-
uploading,
|
|
44
|
-
}: {
|
|
45
|
-
attachments: AttachmentDraft[];
|
|
46
|
-
onChange: (next: AttachmentDraft[]) => void;
|
|
47
|
-
onUpload: (index: number, file: File) => void;
|
|
48
|
-
uploading: boolean;
|
|
49
|
-
}) {
|
|
50
|
-
return (
|
|
51
|
-
<section className="admin-attachments">
|
|
52
|
-
<div className="admin-field-heading">
|
|
53
|
-
<div>
|
|
54
|
-
<span>附件</span>
|
|
55
|
-
<small>一条随想里可以混放图片、音频、视频和链接。</small>
|
|
56
|
-
</div>
|
|
57
|
-
<button type="button" className="admin-quiet-button" onClick={() => onChange([...attachments, emptyAttachment()])}>
|
|
58
|
-
<Plus size={14} /> 添加附件
|
|
59
|
-
</button>
|
|
60
|
-
</div>
|
|
61
|
-
{attachments.map((item, index) => (
|
|
62
|
-
<div className="admin-attachment-row" key={index}>
|
|
63
|
-
<select
|
|
64
|
-
aria-label={`附件 ${index + 1} 类型`}
|
|
65
|
-
value={item.type}
|
|
66
|
-
onChange={(event) => {
|
|
67
|
-
const next = [...attachments];
|
|
68
|
-
next[index] = { ...next[index], type: event.target.value as MediaItem["type"] };
|
|
69
|
-
onChange(next);
|
|
70
|
-
}}
|
|
71
|
-
>
|
|
72
|
-
<option value="image">图片</option>
|
|
73
|
-
<option value="audio">音频</option>
|
|
74
|
-
<option value="video">视频</option>
|
|
75
|
-
<option value="link">链接</option>
|
|
76
|
-
</select>
|
|
77
|
-
<input
|
|
78
|
-
value={item.src}
|
|
79
|
-
onChange={(event) => {
|
|
80
|
-
const next = [...attachments];
|
|
81
|
-
next[index] = { ...next[index], src: event.target.value };
|
|
82
|
-
onChange(next);
|
|
83
|
-
}}
|
|
84
|
-
placeholder={item.type === "link" ? "https://…" : "https://… 或上传到 OSS"}
|
|
85
|
-
aria-label={`附件 ${index + 1} 地址`}
|
|
86
|
-
/>
|
|
87
|
-
{item.type !== "link" && (
|
|
88
|
-
<input
|
|
89
|
-
type="file"
|
|
90
|
-
accept={item.type === "image" ? "image/*" : item.type === "audio" ? "audio/*" : "video/*"}
|
|
91
|
-
aria-label={`上传附件 ${index + 1}`}
|
|
92
|
-
disabled={uploading}
|
|
93
|
-
onChange={(event) => {
|
|
94
|
-
const file = event.target.files?.[0];
|
|
95
|
-
if (file) onUpload(index, file);
|
|
96
|
-
event.currentTarget.value = "";
|
|
97
|
-
}}
|
|
98
|
-
/>
|
|
99
|
-
)}
|
|
100
|
-
<input
|
|
101
|
-
value={item.title ?? ""}
|
|
102
|
-
onChange={(event) => {
|
|
103
|
-
const next = [...attachments];
|
|
104
|
-
next[index] = { ...next[index], title: event.target.value };
|
|
105
|
-
onChange(next);
|
|
106
|
-
}}
|
|
107
|
-
placeholder="标题 / 说明(可选)"
|
|
108
|
-
aria-label={`附件 ${index + 1} 标题`}
|
|
109
|
-
/>
|
|
110
|
-
<button
|
|
111
|
-
type="button"
|
|
112
|
-
className="admin-icon-button"
|
|
113
|
-
aria-label={`删除附件 ${index + 1}`}
|
|
114
|
-
onClick={() => onChange(attachments.filter((_, attachmentIndex) => attachmentIndex !== index))}
|
|
115
|
-
>
|
|
116
|
-
<Trash size={16} />
|
|
117
|
-
</button>
|
|
118
|
-
</div>
|
|
119
|
-
))}
|
|
120
|
-
</section>
|
|
121
|
-
);
|
|
122
|
-
}
|
|
123
|
-
|
|
124
21
|
type UiOverview = {
|
|
125
22
|
modelName: string | null;
|
|
126
23
|
remaining: number;
|
|
@@ -168,138 +65,21 @@ function IntelligentUiCard({ ui }: { ui: UiOverview }) {
|
|
|
168
65
|
|
|
169
66
|
export function AdminDashboard({
|
|
170
67
|
userName,
|
|
171
|
-
initialThoughts,
|
|
172
|
-
staticThoughtCount,
|
|
173
68
|
catalog,
|
|
174
69
|
publicSiteUrl,
|
|
175
70
|
contentWarning,
|
|
176
71
|
ui,
|
|
177
72
|
}: {
|
|
178
73
|
userName: string;
|
|
179
|
-
initialThoughts: ManagedThought[];
|
|
180
|
-
staticThoughtCount: number;
|
|
181
74
|
catalog: CatalogEntry[];
|
|
182
75
|
publicSiteUrl: string;
|
|
183
76
|
contentWarning: string;
|
|
184
77
|
ui: UiOverview;
|
|
185
78
|
}) {
|
|
186
|
-
const [thoughts, setThoughts] = useState(initialThoughts);
|
|
187
|
-
const [title, setTitle] = useState("");
|
|
188
|
-
const [content, setContent] = useState("");
|
|
189
|
-
const [tags, setTags] = useState("");
|
|
190
|
-
const [attachments, setAttachments] = useState<AttachmentDraft[]>([]);
|
|
191
79
|
const [notice, setNotice] = useState("");
|
|
192
|
-
const [isSaving, setIsSaving] = useState(false);
|
|
193
|
-
const [uploadingCount, setUploadingCount] = useState(0);
|
|
194
80
|
const [distributionStatus, setDistributionStatus] = useState<Record<string, string>>({});
|
|
195
81
|
const [catalogEntries, setCatalogEntries] = useState(catalog);
|
|
196
82
|
const [pinningPath, setPinningPath] = useState<string | null>(null);
|
|
197
|
-
const publishedCount = useMemo(
|
|
198
|
-
() => thoughts.filter((thought) => thought.status === "published").length,
|
|
199
|
-
[thoughts],
|
|
200
|
-
);
|
|
201
|
-
|
|
202
|
-
async function uploadMedia(index: number, file: File) {
|
|
203
|
-
setNotice("");
|
|
204
|
-
setUploadingCount((count) => count + 1);
|
|
205
|
-
try {
|
|
206
|
-
const policyResponse = await fetch("/api/admin/uploads", {
|
|
207
|
-
method: "POST",
|
|
208
|
-
headers: { "Content-Type": "application/json" },
|
|
209
|
-
body: JSON.stringify({ name: file.name, type: file.type, size: file.size }),
|
|
210
|
-
});
|
|
211
|
-
const policy = (await policyResponse.json()) as {
|
|
212
|
-
endpoint?: string; key?: string; policy?: string; signature?: string; accessKeyId?: string; publicUrl?: string; error?: string;
|
|
213
|
-
};
|
|
214
|
-
if (!policyResponse.ok || !policy.endpoint || !policy.key || !policy.policy || !policy.signature || !policy.accessKeyId || !policy.publicUrl) {
|
|
215
|
-
throw new Error(policy.error ?? "无法获取 OSS 上传许可。");
|
|
216
|
-
}
|
|
217
|
-
const form = new FormData();
|
|
218
|
-
form.set("key", policy.key);
|
|
219
|
-
form.set("OSSAccessKeyId", policy.accessKeyId);
|
|
220
|
-
form.set("policy", policy.policy);
|
|
221
|
-
form.set("Signature", policy.signature);
|
|
222
|
-
form.set("success_action_status", "201");
|
|
223
|
-
form.set("Content-Type", file.type);
|
|
224
|
-
form.set("file", file);
|
|
225
|
-
const uploadResponse = await fetch(policy.endpoint, { method: "POST", body: form });
|
|
226
|
-
if (!uploadResponse.ok) throw new Error("OSS 未接受附件上传,请检查 Bucket CORS 和写入策略。");
|
|
227
|
-
setAttachments((current) => current.map((item, itemIndex) =>
|
|
228
|
-
itemIndex === index ? { ...item, src: policy.publicUrl!, mime: file.type } : item,
|
|
229
|
-
));
|
|
230
|
-
setNotice("附件已上传到阿里云 OSS;发布随想后会使用该公开媒体地址。");
|
|
231
|
-
} catch (error) {
|
|
232
|
-
setNotice(error instanceof Error ? error.message : "上传失败,请稍后重试。");
|
|
233
|
-
} finally {
|
|
234
|
-
setUploadingCount((count) => Math.max(0, count - 1));
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
async function publishThought(status: "draft" | "published") {
|
|
239
|
-
setNotice("");
|
|
240
|
-
setIsSaving(true);
|
|
241
|
-
const media = attachments.filter((item) => item.src.trim()).map((item) => ({
|
|
242
|
-
...item,
|
|
243
|
-
src: item.src.trim(),
|
|
244
|
-
...(item.title?.trim() ? { title: item.title.trim() } : {}),
|
|
245
|
-
}));
|
|
246
|
-
try {
|
|
247
|
-
const response = await fetch("/api/admin/thoughts", {
|
|
248
|
-
method: "POST",
|
|
249
|
-
headers: { "Content-Type": "application/json" },
|
|
250
|
-
body: JSON.stringify({
|
|
251
|
-
title,
|
|
252
|
-
content,
|
|
253
|
-
tags: tags.split(",").map((tag) => tag.trim()).filter(Boolean),
|
|
254
|
-
media,
|
|
255
|
-
status,
|
|
256
|
-
}),
|
|
257
|
-
});
|
|
258
|
-
const result = (await response.json()) as { thought?: ManagedThought; error?: string };
|
|
259
|
-
if (!response.ok || !result.thought) throw new Error(result.error ?? "保存失败。");
|
|
260
|
-
setThoughts((current) => [result.thought!, ...current]);
|
|
261
|
-
setTitle("");
|
|
262
|
-
setContent("");
|
|
263
|
-
setTags("");
|
|
264
|
-
setAttachments([]);
|
|
265
|
-
setNotice(status === "published" ? "随想已提交到 GitHub,Pages 将自动重新发布。" : "草稿已提交到 GitHub,仅后台可见。"
|
|
266
|
-
);
|
|
267
|
-
} catch (error) {
|
|
268
|
-
setNotice(error instanceof Error ? error.message : "保存失败,请稍后重试。");
|
|
269
|
-
} finally {
|
|
270
|
-
setIsSaving(false);
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
async function updateThought(id: string, status: "draft" | "published") {
|
|
275
|
-
const response = await fetch(`/api/admin/thoughts/${id}`, {
|
|
276
|
-
method: "PATCH",
|
|
277
|
-
headers: { "Content-Type": "application/json" },
|
|
278
|
-
body: JSON.stringify({ status }),
|
|
279
|
-
});
|
|
280
|
-
if (!response.ok) {
|
|
281
|
-
setNotice("状态更新失败,请稍后重试。");
|
|
282
|
-
return;
|
|
283
|
-
}
|
|
284
|
-
setThoughts((current) =>
|
|
285
|
-
current.map((thought) =>
|
|
286
|
-
thought.id === id
|
|
287
|
-
? { ...thought, status, date: status === "published" ? formatAuthorDate() : thought.date }
|
|
288
|
-
: thought,
|
|
289
|
-
),
|
|
290
|
-
);
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
async function deleteThought(id: string) {
|
|
294
|
-
if (!window.confirm("确定删除这条后台随想吗?此操作无法恢复。")) return;
|
|
295
|
-
const response = await fetch(`/api/admin/thoughts/${id}`, { method: "DELETE" });
|
|
296
|
-
if (!response.ok) {
|
|
297
|
-
setNotice("删除失败,请稍后重试。");
|
|
298
|
-
return;
|
|
299
|
-
}
|
|
300
|
-
setThoughts((current) => current.filter((thought) => thought.id !== id));
|
|
301
|
-
}
|
|
302
|
-
|
|
303
83
|
async function distribute(entry: CatalogEntry, platform: "x" | "csdn" | "zhihu" | "juejin") {
|
|
304
84
|
const key = `${entry.kind}:${entry.column ?? ""}:${entry.path}:${platform}`;
|
|
305
85
|
setDistributionStatus((current) => ({ ...current, [key]: "正在发送…" }));
|
|
@@ -374,11 +154,9 @@ export function AdminDashboard({
|
|
|
374
154
|
</header>
|
|
375
155
|
|
|
376
156
|
<section className="admin-hero">
|
|
377
|
-
<h1
|
|
378
|
-
<p
|
|
157
|
+
<h1>管理文章。</h1>
|
|
158
|
+
<p>正文仍在编辑器里改。这里置顶文章、管理交互,以及发到其他网站。</p>
|
|
379
159
|
<p className="admin-quiet-count">
|
|
380
|
-
{publishedCount + staticThoughtCount} 条公开随想
|
|
381
|
-
{" · "}
|
|
382
160
|
{catalogEntries.filter((entry) => entry.kind === "post").length} 篇博文
|
|
383
161
|
{" · "}
|
|
384
162
|
{catalogEntries.filter((entry) => entry.kind === "column").length} 篇专栏
|
|
@@ -390,39 +168,6 @@ export function AdminDashboard({
|
|
|
390
168
|
{notice && <p className="admin-notice"><CheckCircle size={16} /> {notice}</p>}
|
|
391
169
|
{contentWarning && <p className="admin-notice"><CheckCircle size={16} /> {contentWarning}</p>}
|
|
392
170
|
|
|
393
|
-
<section className="admin-grid">
|
|
394
|
-
<div className="admin-panel admin-compose-panel">
|
|
395
|
-
<div className="admin-panel-heading">
|
|
396
|
-
<h2>写一条</h2>
|
|
397
|
-
</div>
|
|
398
|
-
<label>标题<input value={title} onChange={(event) => setTitle(event.target.value)} placeholder="标题" /></label>
|
|
399
|
-
<label>正文<textarea value={content} onChange={(event) => setContent(event.target.value)} placeholder="写下来。可以用 Markdown。" rows={8} /></label>
|
|
400
|
-
<label>标签<input value={tags} onChange={(event) => setTags(event.target.value)} placeholder="用逗号分开,例如:夜晚, 散步" /></label>
|
|
401
|
-
<MediaFields attachments={attachments} onChange={setAttachments} onUpload={uploadMedia} uploading={uploadingCount > 0} />
|
|
402
|
-
<div className="admin-actions">
|
|
403
|
-
<button type="button" className="admin-secondary-button" disabled={isSaving || uploadingCount > 0} onClick={() => publishThought("draft")}>保存草稿</button>
|
|
404
|
-
<button type="button" className="admin-primary-button" disabled={isSaving || uploadingCount > 0} onClick={() => publishThought("published")}><PaperPlaneTilt size={16} /> {uploadingCount > 0 ? "正在上传附件" : isSaving ? "正在保存" : "发布到博客"}</button>
|
|
405
|
-
</div>
|
|
406
|
-
</div>
|
|
407
|
-
|
|
408
|
-
<div className="admin-panel admin-thoughts-panel">
|
|
409
|
-
<div className="admin-panel-heading">
|
|
410
|
-
<h2>草稿和已发布</h2>
|
|
411
|
-
</div>
|
|
412
|
-
<div className="admin-thought-list">
|
|
413
|
-
{thoughts.length === 0 ? <p className="admin-empty">还没有在这里写过随想。</p> : thoughts.map((thought) => (
|
|
414
|
-
<article key={thought.id}>
|
|
415
|
-
<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>
|
|
416
|
-
<div className="admin-item-actions">
|
|
417
|
-
{thought.status === "published" && <a href={`${publicSiteUrl}/thoughts/${thought.slug}`} target="_blank" rel="noreferrer" aria-label={`查看 ${thought.title}`}><ArrowUpRight size={17} /></a>}
|
|
418
|
-
<button type="button" onClick={() => updateThought(thought.id, thought.status === "published" ? "draft" : "published")}>{thought.status === "published" ? "撤回" : "发布"}</button>
|
|
419
|
-
<button type="button" aria-label={`删除 ${thought.title}`} onClick={() => deleteThought(thought.id)}><Trash size={16} /></button>
|
|
420
|
-
</div>
|
|
421
|
-
</article>
|
|
422
|
-
))}</div>
|
|
423
|
-
</div>
|
|
424
|
-
</section>
|
|
425
|
-
|
|
426
171
|
<section className="admin-panel admin-distribution-panel">
|
|
427
172
|
<div className="admin-panel-heading">
|
|
428
173
|
<h2>置顶,或发到别处</h2>
|
|
@@ -4,6 +4,7 @@ import {renderComposePreview} from "./garden/ComposePreview";
|
|
|
4
4
|
|
|
5
5
|
import Link from "next/link";
|
|
6
6
|
import { useEffect, useMemo, useState, useSyncExternalStore } from "react";
|
|
7
|
+
import { evaluateWorkbenchDraft } from "../../ui/core/workbench-eval.mjs";
|
|
7
8
|
import { buildFixPrompt } from "../../ui/core/fix-prompt.mjs";
|
|
8
9
|
import { CatalogPage, type RecipeSource } from "../../ui/react/CatalogPage";
|
|
9
10
|
import { EVALS_STORAGE_KEY as EVALS_KEY, STUDIO_START_KEY } from "./ui-studio-keys";
|
|
@@ -44,8 +45,7 @@ const STATUS_LABELS: Record<Block["status"], string> = { uncompiled: "未编译"
|
|
|
44
45
|
const publicPath = (path: string) => {
|
|
45
46
|
const posts = /^content\/posts\/(.+)\.md$/u.exec(path);
|
|
46
47
|
if (posts) return `/blog/${posts[1]}`;
|
|
47
|
-
|
|
48
|
-
return thoughts ? `/thoughts/${thoughts[1]}` : null;
|
|
48
|
+
return null;
|
|
49
49
|
};
|
|
50
50
|
|
|
51
51
|
export function AdminBlocks({ siteUrl }: { siteUrl: string }) {
|
|
@@ -191,11 +191,9 @@ export function AdminEvals({ cases, configured }: { cases: EvalCase[]; configure
|
|
|
191
191
|
});
|
|
192
192
|
const body = (await response.json()) as { fence?: string | null; note?: string | null; rounds?: number; diagnostics?: Array<{ severity: string }>; usage?: { total_tokens?: number }; error?: string };
|
|
193
193
|
const fence = body.fence ?? "";
|
|
194
|
-
const
|
|
195
|
-
const valid = response.ok &&
|
|
196
|
-
const components
|
|
197
|
-
const assert = !item.expect.assert || /\nexpect:/u.test(fence);
|
|
198
|
-
const note = !item.expect.note || Boolean(body.note);
|
|
194
|
+
const evaluation = evaluateWorkbenchDraft({ fence, note: body.note }, item.expect);
|
|
195
|
+
const valid = response.ok && evaluation.valid && !(body.diagnostics ?? []).some((entry) => entry.severity === "error");
|
|
196
|
+
const { components, assert, note } = evaluation;
|
|
199
197
|
collected.push({
|
|
200
198
|
id: item.id,
|
|
201
199
|
ok: valid && components && assert && note,
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import Link from "next/link";
|
|
4
|
-
import { ArrowRight, Article, Books
|
|
4
|
+
import { ArrowRight, Article, Books } from "@phosphor-icons/react/ssr";
|
|
5
5
|
import { siteHref } from "../lib/site-href";
|
|
6
6
|
import { ui } from "../lib/i18n";
|
|
7
7
|
|
|
8
|
-
export function ContentEmptyState({ kind }: { kind: "posts" | "
|
|
8
|
+
export function ContentEmptyState({ kind }: { kind: "posts" | "columns" }) {
|
|
9
9
|
const s = ui();
|
|
10
10
|
const emptyContent = {
|
|
11
11
|
posts: {
|
|
@@ -16,14 +16,6 @@ export function ContentEmptyState({ kind }: { kind: "posts" | "thoughts" | "colu
|
|
|
16
16
|
actionHref: "/",
|
|
17
17
|
actionLabel: s.emptyPostsCta,
|
|
18
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
19
|
columns: {
|
|
28
20
|
icon: Books,
|
|
29
21
|
eyebrow: s.emptyColumnsEyebrow,
|
|
@@ -1,11 +1,7 @@
|
|
|
1
1
|
const adminJobs = [
|
|
2
|
-
{
|
|
3
|
-
title: "写随想",
|
|
4
|
-
description: "保存草稿,或直接发布。文件写回 content/thoughts/,没有另一份数据库。",
|
|
5
|
-
},
|
|
6
2
|
{
|
|
7
3
|
title: "上传附件",
|
|
8
|
-
description: "
|
|
4
|
+
description: "通过后台上传 API 将图片、音频和视频传到对象存储,文章里只留下地址。",
|
|
9
5
|
},
|
|
10
6
|
{
|
|
11
7
|
title: "置顶",
|
|
@@ -23,7 +19,7 @@ const adminJobs = [
|
|
|
23
19
|
|
|
24
20
|
const adminFlow = [
|
|
25
21
|
["用 GitHub 登录", "只有名单里的账号能进来。"],
|
|
26
|
-
["做一件事", "
|
|
22
|
+
["做一件事", "上传、置顶,或发到别处。"],
|
|
27
23
|
["写回仓库", "后台把改动提交到你的内容仓库。"],
|
|
28
24
|
["网站更新", "main 变了之后,页面会重新发布。"],
|
|
29
25
|
];
|
|
@@ -36,25 +32,10 @@ export function GardenAdminGuide() {
|
|
|
36
32
|
<h2 id="garden-admin-title">后台</h2>
|
|
37
33
|
</div>
|
|
38
34
|
<p>
|
|
39
|
-
|
|
35
|
+
长文继续在编辑器里写。后台只做需要登录的事:附件、置顶、转发,以及文章里的交互。
|
|
40
36
|
</p>
|
|
41
37
|
</header>
|
|
42
38
|
|
|
43
|
-
<figure className="garden-admin-preview">
|
|
44
|
-
{/* vinext 的本地静态导出不提供 Next 图片优化端点,截图直接由 public 目录提供。 */}
|
|
45
|
-
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
46
|
-
<img
|
|
47
|
-
src="/images/garden/admin-studio.png"
|
|
48
|
-
alt="Garden 后台:上面写一条随想,下面是草稿和已发布"
|
|
49
|
-
width={920}
|
|
50
|
-
height={1033}
|
|
51
|
-
loading="lazy"
|
|
52
|
-
/>
|
|
53
|
-
<figcaption>
|
|
54
|
-
<strong>后台首页</strong>
|
|
55
|
-
<span>上面写一条随想。下面是草稿,以及可以置顶或转发的文章。</span>
|
|
56
|
-
</figcaption>
|
|
57
|
-
</figure>
|
|
58
39
|
|
|
59
40
|
<div className="garden-admin-capabilities">
|
|
60
41
|
{adminJobs.map((job) => (
|
|
@@ -41,21 +41,6 @@ cover: writing/first-post.jpg
|
|
|
41
41
|
</icon-list>
|
|
42
42
|
\`\`\``;
|
|
43
43
|
|
|
44
|
-
const thoughtSource = `---
|
|
45
|
-
title: 雨后的散步
|
|
46
|
-
date: 2026-08-03 18:30:00
|
|
47
|
-
tags: [生活]
|
|
48
|
-
media:
|
|
49
|
-
- type: image
|
|
50
|
-
src: ./assets/street.jpg
|
|
51
|
-
alt: 雨后的街道
|
|
52
|
-
- type: audio
|
|
53
|
-
src: ./assets/rain.mp3
|
|
54
|
-
title: 雨声
|
|
55
|
-
---
|
|
56
|
-
|
|
57
|
-
今天下班后多走了两公里。`;
|
|
58
|
-
|
|
59
44
|
const columnSource = `columns:
|
|
60
45
|
- slug: writing-system
|
|
61
46
|
title: 写作系统
|
|
@@ -96,21 +81,6 @@ const guides: Guide[] = [
|
|
|
96
81
|
source: postSource,
|
|
97
82
|
language: "markdown",
|
|
98
83
|
},
|
|
99
|
-
{
|
|
100
|
-
id: "write-thought",
|
|
101
|
-
label: "随想",
|
|
102
|
-
title: "写一条随想",
|
|
103
|
-
introduction: "几句话、一张图或一个链接。字段更少。也可以在后台写,写完同样进仓库。",
|
|
104
|
-
steps: [
|
|
105
|
-
<>新建 <code>content/thoughts/文件名.md</code>,或在后台填写。</>,
|
|
106
|
-
<>文件名就是地址。标题和日期写在开头。先存着就标成草稿。</>,
|
|
107
|
-
<>图片、音频、视频和链接按顺序放进 <code>media</code>。</>,
|
|
108
|
-
],
|
|
109
|
-
result: "它会出现在随想页,并且有自己的链接。",
|
|
110
|
-
path: "content/thoughts/walk-after-rain.md",
|
|
111
|
-
source: thoughtSource,
|
|
112
|
-
language: "markdown",
|
|
113
|
-
},
|
|
114
84
|
{
|
|
115
85
|
id: "make-column",
|
|
116
86
|
label: "专栏",
|
|
@@ -32,7 +32,7 @@ import { imageDimensions, lookupImageSize } from "../../ui/garden/image-dims.mjs
|
|
|
32
32
|
import { siteConfig, siteLinks } from "@garden/site-config";
|
|
33
33
|
import { articleLink, sameSiteMatcher } from "../../ui/garden/same-site.mjs";
|
|
34
34
|
import { columnRedirects, internalLinkResolver, readRedirects } from "../../ui/garden/internal-links.mjs";
|
|
35
|
-
import { columnHref, columns, gardenDocHref, gardenDocs, gardenDocsEn, postHref, posts
|
|
35
|
+
import { columnHref, columns, gardenDocHref, gardenDocs, gardenDocsEn, postHref, posts } from "../lib/content";
|
|
36
36
|
import { LinkCardRefresh } from "./LinkCardRefresh";
|
|
37
37
|
import { linkHost } from "../../scripts/link-meta-core.mjs";
|
|
38
38
|
import { FaviconProvider, SourceMark } from "./garden/patterns";
|
|
@@ -52,10 +52,9 @@ const siteRoute = internalLinkResolver({
|
|
|
52
52
|
...columnRedirects({ columns: [...new Set(columns.map((entry) => entry.column).filter(Boolean))].map((slug) => ({ slug })) }),
|
|
53
53
|
],
|
|
54
54
|
routes: [
|
|
55
|
-
"/", "/blog", "/columns", "/
|
|
55
|
+
"/", "/blog", "/columns", "/tags", "/about", "/garden", "/garden/en",
|
|
56
56
|
...posts.map(postHref),
|
|
57
57
|
...columns.map(columnHref),
|
|
58
|
-
...thoughts.map((thought) => `/thoughts/${thought.slug}`),
|
|
59
58
|
...gardenDocs.map(gardenDocHref),
|
|
60
59
|
...gardenDocsEn.map((doc) => siteHref(`/garden/en/${doc.path}`)),
|
|
61
60
|
],
|
|
@@ -8,7 +8,7 @@ type PageIntroProps = {
|
|
|
8
8
|
};
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
|
-
* Shared heading for top-level list pages (blog /
|
|
11
|
+
* Shared heading for top-level list pages (blog / columns).
|
|
12
12
|
* Same eyebrow + h1 + deck typography and padding so tab switches do not jump.
|
|
13
13
|
* Empty subtitle reserves no space.
|
|
14
14
|
*/
|