Skip to content

Commit 0261839

Browse files
authored
Merge pull request #230 from Crokily/feat/Crokily/mdEditor
feat: 新增仿notion设计的markdown编辑器
2 parents d3a8e69 + f60a363 commit 0261839

25 files changed

+12681
-4424
lines changed

.env.sample

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,3 +44,10 @@ POSTGRES_PRISMA_URL=
4444
NEXT_PUBLIC_STACK_PROJECT_ID=
4545
NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY=
4646
STACK_SECRET_SERVER_KEY=
47+
48+
# R2的存储桶,用于提供图片自动上传服务
49+
R2_ACCOUNT_ID=?
50+
R2_ACCESS_KEY_ID=?
51+
R2_SECRET_ACCESS_KEY=?
52+
R2_BUCKET_NAME=?
53+
R2_PUBLIC_URL=?

.github/workflows/sync-uuid.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@ concurrency:
2222
jobs:
2323
backfill:
2424
# 防止 fork、限定 main、并避免机器人循环
25-
if: (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/feat/contributor') &&
25+
if:
26+
(github.ref == 'refs/heads/main' || github.ref == 'refs/heads/feat/contributor') &&
2627
github.actor != 'github-actions[bot]'
2728
runs-on: ubuntu-latest
2829
permissions:

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ next-env.d.ts
4848
.package-lock.json
4949

5050
# Agents.md
51-
Agents.md
51+
AGENTS.md
5252

5353
# Environment variables
5454
.env
@@ -58,3 +58,4 @@ Agents.md
5858
/generated/prisma
5959

6060
.idea
61+
.claude

app/api/upload/route.ts

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import { auth } from "@/auth";
2+
import { NextRequest, NextResponse } from "next/server";
3+
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
4+
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
5+
import { sanitizeDocumentSlug, sanitizeResourceKey } from "@/lib/sanitizer";
6+
7+
/**
8+
* R2 配置
9+
* Cloudflare R2 兼容 S3 API,使用 AWS SDK 连接
10+
*/
11+
const r2Client = new S3Client({
12+
region: "auto",
13+
endpoint: `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
14+
credentials: {
15+
accessKeyId: process.env.R2_ACCESS_KEY_ID!,
16+
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
17+
},
18+
});
19+
20+
interface UploadRequest {
21+
filename: string;
22+
contentType: string;
23+
articleSlug: string;
24+
}
25+
26+
/**
27+
* @description POST /api/upload - 生成 R2 预签名 URL,用于客户端直接上传图片
28+
* @param request - NextRequest 对象,请求体包含以下字段:
29+
* - filename: 文件名
30+
* - contentType: 文件 MIME 类型
31+
* - articleSlug: 文章 slug(用于组织文件路径)
32+
* @returns NextResponse - 返回 JSON 对象:
33+
* - uploadUrl: 预签名上传 URL(用于 PUT 请求)
34+
* - publicUrl: 图片的公开访问 URL
35+
* - key: R2 对象键
36+
*/
37+
export async function POST(request: NextRequest) {
38+
try {
39+
// 验证用户身份
40+
const session = await auth();
41+
42+
if (!session?.user?.id) {
43+
return NextResponse.json({ error: "未授权访问" }, { status: 401 });
44+
}
45+
46+
// 验证环境变量
47+
if (
48+
!process.env.R2_ACCOUNT_ID ||
49+
!process.env.R2_ACCESS_KEY_ID ||
50+
!process.env.R2_SECRET_ACCESS_KEY ||
51+
!process.env.R2_BUCKET_NAME ||
52+
!process.env.R2_PUBLIC_URL
53+
) {
54+
console.error("R2 环境变量未配置");
55+
return NextResponse.json(
56+
{ error: "服务器配置错误:R2 未配置" },
57+
{ status: 500 },
58+
);
59+
}
60+
61+
// 解析请求体
62+
const body = (await request.json()) as UploadRequest;
63+
const { filename, contentType, articleSlug } = body;
64+
65+
// 验证请求参数
66+
if (!filename || !contentType || !articleSlug) {
67+
return NextResponse.json(
68+
{ error: "缺少必要参数:filename, contentType, articleSlug" },
69+
{ status: 400 },
70+
);
71+
}
72+
73+
// 验证文件类型
74+
if (!contentType.startsWith("image/")) {
75+
return NextResponse.json(
76+
{ error: "仅支持图片类型文件" },
77+
{ status: 400 },
78+
);
79+
}
80+
81+
// 生成唯一的对象键
82+
// 格式:users/{userId}/{article-slug}/{timestamp}-{filename}
83+
const timestamp = Date.now();
84+
const userId = session.user.id;
85+
const sanitizedSlug = sanitizeDocumentSlug(articleSlug);
86+
const sanitizedFilename = sanitizeResourceKey(filename);
87+
const key = `users/${userId}/${sanitizedSlug}/${timestamp}-${sanitizedFilename}`;
88+
89+
// 创建 PutObject 命令
90+
const command = new PutObjectCommand({
91+
Bucket: process.env.R2_BUCKET_NAME,
92+
Key: key,
93+
ContentType: contentType,
94+
});
95+
96+
// 生成预签名 URL(15 分钟有效期)
97+
const uploadUrl = await getSignedUrl(r2Client, command, {
98+
expiresIn: 900,
99+
});
100+
101+
// 生成公开访问 URL
102+
const publicUrl = `${process.env.R2_PUBLIC_URL}/${key}`;
103+
104+
return NextResponse.json({
105+
uploadUrl,
106+
publicUrl,
107+
key,
108+
});
109+
} catch (error) {
110+
console.error("生成预签名 URL 失败:", error);
111+
return NextResponse.json(
112+
{
113+
error: "生成上传链接失败",
114+
details: error instanceof Error ? error.message : "未知错误",
115+
},
116+
{ status: 500 },
117+
);
118+
}
119+
}

app/components/Contribute.tsx

Lines changed: 43 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -12,18 +12,24 @@ import {
1212
DialogTrigger,
1313
} from "@/components/ui/dialog";
1414
import { Input } from "@/components/ui/input";
15-
import { ExternalLink, Plus, Sparkles } from "lucide-react";
15+
import { ExternalLink, Sparkles } from "lucide-react";
1616
import styles from "./Contribute.module.css";
17+
import { useRouter } from "next/navigation";
1718

1819
// --- antd
1920
import { TreeSelect } from "antd";
20-
import type { DefaultOptionType } from "antd/es/select";
2121
import { DataNode } from "antd/es/tree";
2222
import { buildDocsNewUrl } from "@/lib/github";
23-
24-
type DirNode = { name: string; path: string; children?: DirNode[] };
25-
26-
const FILENAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]+$/;
23+
import {
24+
FILENAME_PATTERN,
25+
normalizeFilenameBase,
26+
type DirNode,
27+
} from "@/lib/submission";
28+
import {
29+
CREATE_SUBDIR_SUFFIX,
30+
toTreeSelectData,
31+
} from "@/app/components/contribute/tree-utils";
32+
import { sanitizeDocumentSlug } from "@/lib/sanitizer";
2733

2834
// 统一调用工具函数生成 GitHub 新建链接,路径规则与 Edit 按钮一致
2935
function buildGithubNewUrl(dirPath: string, filename: string, title: string) {
@@ -44,35 +50,8 @@ Write your content here.
4450
return buildDocsNewUrl(dirPath, params);
4551
}
4652

47-
// ✅ 用纯文本 label + 一级节点 selectable:false
48-
function toTreeSelectData(tree: DirNode[]): DefaultOptionType[] {
49-
return tree.map((l1) => ({
50-
key: l1.path,
51-
value: l1.path,
52-
label: l1.name,
53-
selectable: false, // ✅ 一级不可选
54-
children: [
55-
...(l1.children || []).map((l2) => ({
56-
key: l2.path,
57-
value: l2.path,
58-
label: `${l1.name} / ${l2.name}`, // 纯文本,方便搜索
59-
isLeaf: true,
60-
})),
61-
{
62-
key: `${l1.path}/__create__`,
63-
value: `${l1.path}/__create__`,
64-
label: (
65-
<span className="inline-flex items-center">
66-
<Plus className="mr-1 h-3.5 w-3.5" />
67-
在「{l1.name}」下新建二级子栏目…
68-
</span>
69-
),
70-
},
71-
],
72-
}));
73-
}
74-
7553
export function Contribute() {
54+
const router = useRouter();
7655
const [open, setOpen] = useState(false);
7756
const [tree, setTree] = useState<DirNode[]>([]);
7857
const [loading, setLoading] = useState(false);
@@ -85,22 +64,26 @@ export function Contribute() {
8564
const [articleFile, setArticleFile] = useState("");
8665
const [articleFileTouched, setArticleFileTouched] = useState(false);
8766

88-
const trimmedArticleFile = useMemo(() => articleFile.trim(), [articleFile]);
67+
const normalizedArticleFile = useMemo(
68+
() => normalizeFilenameBase(articleFile),
69+
[articleFile],
70+
);
8971
const { isFileNameValid, fileNameError } = useMemo(() => {
90-
if (!trimmedArticleFile) {
72+
if (!normalizedArticleFile) {
9173
return {
9274
isFileNameValid: false,
9375
fileNameError: "请填写文件名。",
9476
};
9577
}
96-
if (!FILENAME_PATTERN.test(trimmedArticleFile)) {
78+
if (!FILENAME_PATTERN.test(normalizedArticleFile)) {
9779
return {
9880
isFileNameValid: false,
99-
fileNameError: "文件名仅支持英文、数字、连字符或下划线。",
81+
fileNameError:
82+
"文件名仅支持字母、数字、连字符或下划线,并需以字母或数字开头。",
10083
};
10184
}
10285
return { isFileNameValid: true, fileNameError: "" };
103-
}, [trimmedArticleFile]);
86+
}, [normalizedArticleFile]);
10487

10588
useEffect(() => {
10689
let mounted = true;
@@ -125,22 +108,31 @@ export function Contribute() {
125108

126109
const options = useMemo(() => toTreeSelectData(tree), [tree]);
127110

111+
const sanitizedSubdir = useMemo(
112+
() => sanitizeDocumentSlug(newSub, ""),
113+
[newSub],
114+
);
115+
128116
const finalDirPath = useMemo(() => {
129117
if (!selectedKey) return "";
130-
if (selectedKey.endsWith("/__create__")) {
118+
if (selectedKey.endsWith(CREATE_SUBDIR_SUFFIX)) {
131119
const l1 = selectedKey.split("/")[0];
132-
if (!newSub.trim()) return "";
133-
return `${l1}/${newSub.trim().replace(/\s+/g, "-")}`;
120+
if (!l1 || !sanitizedSubdir) return "";
121+
return `${l1}/${sanitizedSubdir}`;
134122
}
135123
return selectedKey;
136-
}, [selectedKey, newSub]);
124+
}, [selectedKey, sanitizedSubdir]);
137125

138126
const canProceed = !!finalDirPath && isFileNameValid;
139127

140128
const handleOpenGithub = () => {
141129
if (!canProceed) return;
142-
const filename = trimmedArticleFile.toLowerCase();
130+
if (!normalizedArticleFile) return;
131+
const filename = normalizedArticleFile;
143132
const title = articleTitle || filename;
133+
if (filename !== articleFile) {
134+
setArticleFile(filename);
135+
}
144136
window.open(
145137
buildGithubNewUrl(finalDirPath, filename, title),
146138
"_blank",
@@ -173,6 +165,10 @@ export function Contribute() {
173165
bg-gradient-to-r from-sky-300 via-sky-400 to-blue-600
174166
dark:from-indigo-950 dark:via-slate-900 dark:to-black
175167
hover:shadow-[0_25px_60px_-12px] hover:scale-[1.03] transition-all duration-300 ease-out"
168+
onClick={(event) => {
169+
event.preventDefault();
170+
router.push("/editor");
171+
}}
176172
>
177173
{/* Day gradient shimmer */}
178174
<span
@@ -276,7 +272,7 @@ export function Contribute() {
276272
/>
277273
</div>
278274

279-
{selectedKey.endsWith("/__create__") && (
275+
{selectedKey.endsWith(CREATE_SUBDIR_SUFFIX) && (
280276
<div className="space-y-1">
281277
<label className="text-sm font-medium">新建二级子栏目名称</label>
282278
<Input
@@ -285,7 +281,8 @@ export function Contribute() {
285281
onChange={(e) => setNewSub(e.target.value)}
286282
/>
287283
<p className="text-xs text-muted-foreground">
288-
将创建路径:{selectedKey.split("/")[0]} / {newSub || "<未填写>"}
284+
将创建路径:{selectedKey.split("/")[0]} /{" "}
285+
{sanitizedSubdir || "<未填写>"}
289286
</p>
290287
</div>
291288
)}

0 commit comments

Comments
 (0)