Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
6e7536d
chore(deps): add @milkdown/crepe and update related dependencies in p…
Crokily Nov 7, 2025
065bbf3
Test selective commit with improved pre-commit hook
Crokily Nov 14, 2025
9060f48
Fix: 修复了回调参数的error,目前未登录的页面能有可回调的登录页了
Crokily Nov 14, 2025
875dd76
Feat: 第一阶段的编辑器组件
Crokily Nov 14, 2025
4d682bb
feat: enhance EditorMetadataForm and MarkdownEditor with improved inp…
Crokily Nov 15, 2025
037f9c0
chore: update .gitignore to include .claude and enhance pre-commit ho…
Crokily Nov 15, 2025
bfa8af3
Fix: 修复了标签不能正常分割的问题
Crokily Nov 16, 2025
ef0f53f
chore: update .gitignore to change Agents.md to AGENTS.md for consist…
Crokily Nov 16, 2025
99f07f9
Fix: 修复了粘贴图片逻辑没走blob的问题
Crokily Nov 16, 2025
01f4468
chore: add R2 storage configuration to .env.sample for image upload s…
Crokily Nov 16, 2025
e093559
chore(deps): add AWS SDK S3 client and request presigner to package.j…
Crokily Nov 16, 2025
1509e2d
feat: implement image upload functionality to Cloudflare R2 with pre-…
Crokily Nov 16, 2025
871d832
feat: 对 MarkdownEditor 进行了解耦重构,并修复了删除同步问题
Crokily Nov 16, 2025
2a52b07
refactor: 抽离投稿目录与文件名工具
Crokily Nov 16, 2025
b56effe
feat: 编辑器接入 GitHub 投稿流程
Crokily Nov 16, 2025
d13d9ea
test: 添加一个编辑器生成的测试稿件
Crokily Nov 16, 2025
15c63ee
chore(deps): add @milkdown/kit dependency to package.json and update …
Crokily Nov 16, 2025
c032a48
chore(deps): add @types/mdast and unist-util-visit dependencies to pa…
Crokily Nov 16, 2025
5f07377
refactor: 移动 remarkImage 配置至 source.config.ts,并禁用 Next.js 图片优化以应对 Ver…
Crokily Nov 16, 2025
eafc34b
refactor: 使用原生 <img> 标签替代 Next.js Image 组件以解决 Vercel 配额限制和运行时问题
Crokily Nov 16, 2025
efb7d77
fix: correct JavaScript syntax highlighting in documentation
Crokily Nov 16, 2025
503b03b
refactor: improve tag input handling in EditorMetadataForm for better…
Crokily Nov 16, 2025
eb0adbd
chore: remove deprecated test markdown file from agents-todo document…
Crokily Nov 16, 2025
6b21a3c
feat: add navigation to editor in Contribute component
Crokily Nov 16, 2025
d33dbbf
Merge branch 'main' into feat/Crokily/mdEditor
Crokily Nov 16, 2025
f44f5df
chore(deps): update pnpm-lock.yaml to standardize package names and v…
Crokily Nov 16, 2025
15e1988
refactor: remove eslint directive for <img> tag in MDX components to …
Crokily Nov 16, 2025
66bfa55
fix: change overflow property in MarkdownEditor component to improve …
Crokily Nov 16, 2025
e00ceaf
fix: 修改 upload api 的注释以符合 API COMMENT 的规范
Crokily Dec 6, 2025
d8ac95b
Merge branch 'main' into feat/Crokily/mdEditor
Crokily Dec 12, 2025
4508c2d
feat: 创建sanitizer.ts内有2种模式的文件名处理函数
Crokily Dec 12, 2025
dafa97f
feat: 文件名的处理代码统一使用宽松模式函数,图片等则使用严格清理
Crokily Dec 12, 2025
001b99d
fix: 修复了copilot提出的一个报错处理不完善的问题
Crokily Dec 12, 2025
f60a363
fix: 还原预提交脚本
Crokily Dec 12, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .env.sample
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,10 @@ POSTGRES_PRISMA_URL=
NEXT_PUBLIC_STACK_PROJECT_ID=
NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY=
STACK_SECRET_SERVER_KEY=

# R2的存储桶,用于提供图片自动上传服务
R2_ACCOUNT_ID=?
R2_ACCESS_KEY_ID=?
R2_SECRET_ACCESS_KEY=?
R2_BUCKET_NAME=?
R2_PUBLIC_URL=?
3 changes: 2 additions & 1 deletion .github/workflows/sync-uuid.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ concurrency:
jobs:
backfill:
# 防止 fork、限定 main、并避免机器人循环
if: (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/feat/contributor') &&
if:
(github.ref == 'refs/heads/main' || github.ref == 'refs/heads/feat/contributor') &&
github.actor != 'github-actions[bot]'
runs-on: ubuntu-latest
permissions:
Expand Down
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ next-env.d.ts
.package-lock.json

# Agents.md
Agents.md
AGENTS.md

# Environment variables
.env
Expand All @@ -58,3 +58,4 @@ Agents.md
/generated/prisma

.idea
.claude
119 changes: 119 additions & 0 deletions app/api/upload/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { auth } from "@/auth";
import { NextRequest, NextResponse } from "next/server";
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { sanitizeDocumentSlug, sanitizeResourceKey } from "@/lib/sanitizer";

/**
* R2 配置
* Cloudflare R2 兼容 S3 API,使用 AWS SDK 连接
*/
const r2Client = new S3Client({
region: "auto",
endpoint: `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: process.env.R2_ACCESS_KEY_ID!,
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
},
});

interface UploadRequest {
filename: string;
contentType: string;
articleSlug: string;
}

/**
* @description POST /api/upload - 生成 R2 预签名 URL,用于客户端直接上传图片
* @param request - NextRequest 对象,请求体包含以下字段:
* - filename: 文件名
* - contentType: 文件 MIME 类型
* - articleSlug: 文章 slug(用于组织文件路径)
* @returns NextResponse - 返回 JSON 对象:
* - uploadUrl: 预签名上传 URL(用于 PUT 请求)
* - publicUrl: 图片的公开访问 URL
* - key: R2 对象键
*/
export async function POST(request: NextRequest) {
try {
// 验证用户身份
const session = await auth();

if (!session?.user?.id) {
return NextResponse.json({ error: "未授权访问" }, { status: 401 });
}

// 验证环境变量
if (
!process.env.R2_ACCOUNT_ID ||
!process.env.R2_ACCESS_KEY_ID ||
!process.env.R2_SECRET_ACCESS_KEY ||
!process.env.R2_BUCKET_NAME ||
!process.env.R2_PUBLIC_URL
) {
console.error("R2 环境变量未配置");
return NextResponse.json(
{ error: "服务器配置错误:R2 未配置" },
{ status: 500 },
);
}

// 解析请求体
const body = (await request.json()) as UploadRequest;
const { filename, contentType, articleSlug } = body;

// 验证请求参数
if (!filename || !contentType || !articleSlug) {
return NextResponse.json(
{ error: "缺少必要参数:filename, contentType, articleSlug" },
{ status: 400 },
);
}

// 验证文件类型
if (!contentType.startsWith("image/")) {
return NextResponse.json(
{ error: "仅支持图片类型文件" },
{ status: 400 },
);
}

// 生成唯一的对象键
// 格式:users/{userId}/{article-slug}/{timestamp}-{filename}
const timestamp = Date.now();
const userId = session.user.id;
const sanitizedSlug = sanitizeDocumentSlug(articleSlug);
const sanitizedFilename = sanitizeResourceKey(filename);
const key = `users/${userId}/${sanitizedSlug}/${timestamp}-${sanitizedFilename}`;

// 创建 PutObject 命令
const command = new PutObjectCommand({
Bucket: process.env.R2_BUCKET_NAME,
Key: key,
ContentType: contentType,
});

// 生成预签名 URL(15 分钟有效期)
const uploadUrl = await getSignedUrl(r2Client, command, {
expiresIn: 900,
});

// 生成公开访问 URL
const publicUrl = `${process.env.R2_PUBLIC_URL}/${key}`;

return NextResponse.json({
uploadUrl,
publicUrl,
key,
});
} catch (error) {
console.error("生成预签名 URL 失败:", error);
return NextResponse.json(
{
error: "生成上传链接失败",
details: error instanceof Error ? error.message : "未知错误",
},
{ status: 500 },
);
}
}
89 changes: 43 additions & 46 deletions app/components/Contribute.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,18 +12,24 @@ import {
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { ExternalLink, Plus, Sparkles } from "lucide-react";
import { ExternalLink, Sparkles } from "lucide-react";
import styles from "./Contribute.module.css";
import { useRouter } from "next/navigation";

// --- antd
import { TreeSelect } from "antd";
import type { DefaultOptionType } from "antd/es/select";
import { DataNode } from "antd/es/tree";
import { buildDocsNewUrl } from "@/lib/github";

type DirNode = { name: string; path: string; children?: DirNode[] };

const FILENAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]+$/;
import {
FILENAME_PATTERN,
normalizeFilenameBase,
type DirNode,
} from "@/lib/submission";
import {
CREATE_SUBDIR_SUFFIX,
toTreeSelectData,
} from "@/app/components/contribute/tree-utils";
import { sanitizeDocumentSlug } from "@/lib/sanitizer";

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

// ✅ 用纯文本 label + 一级节点 selectable:false
function toTreeSelectData(tree: DirNode[]): DefaultOptionType[] {
return tree.map((l1) => ({
key: l1.path,
value: l1.path,
label: l1.name,
selectable: false, // ✅ 一级不可选
children: [
...(l1.children || []).map((l2) => ({
key: l2.path,
value: l2.path,
label: `${l1.name} / ${l2.name}`, // 纯文本,方便搜索
isLeaf: true,
})),
{
key: `${l1.path}/__create__`,
value: `${l1.path}/__create__`,
label: (
<span className="inline-flex items-center">
<Plus className="mr-1 h-3.5 w-3.5" />
在「{l1.name}」下新建二级子栏目…
</span>
),
},
],
}));
}

export function Contribute() {
const router = useRouter();
const [open, setOpen] = useState(false);
const [tree, setTree] = useState<DirNode[]>([]);
const [loading, setLoading] = useState(false);
Expand All @@ -85,22 +64,26 @@ export function Contribute() {
const [articleFile, setArticleFile] = useState("");
const [articleFileTouched, setArticleFileTouched] = useState(false);

const trimmedArticleFile = useMemo(() => articleFile.trim(), [articleFile]);
const normalizedArticleFile = useMemo(
() => normalizeFilenameBase(articleFile),
[articleFile],
);
const { isFileNameValid, fileNameError } = useMemo(() => {
if (!trimmedArticleFile) {
if (!normalizedArticleFile) {
return {
isFileNameValid: false,
fileNameError: "请填写文件名。",
};
}
if (!FILENAME_PATTERN.test(trimmedArticleFile)) {
if (!FILENAME_PATTERN.test(normalizedArticleFile)) {
return {
isFileNameValid: false,
fileNameError: "文件名仅支持英文、数字、连字符或下划线。",
fileNameError:
"文件名仅支持字母、数字、连字符或下划线,并需以字母或数字开头。",
};
}
return { isFileNameValid: true, fileNameError: "" };
}, [trimmedArticleFile]);
}, [normalizedArticleFile]);

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

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

const sanitizedSubdir = useMemo(
() => sanitizeDocumentSlug(newSub, ""),
[newSub],
);

const finalDirPath = useMemo(() => {
if (!selectedKey) return "";
if (selectedKey.endsWith("/__create__")) {
if (selectedKey.endsWith(CREATE_SUBDIR_SUFFIX)) {
const l1 = selectedKey.split("/")[0];
if (!newSub.trim()) return "";
return `${l1}/${newSub.trim().replace(/\s+/g, "-")}`;
if (!l1 || !sanitizedSubdir) return "";
return `${l1}/${sanitizedSubdir}`;
}
return selectedKey;
}, [selectedKey, newSub]);
}, [selectedKey, sanitizedSubdir]);

const canProceed = !!finalDirPath && isFileNameValid;

const handleOpenGithub = () => {
if (!canProceed) return;
const filename = trimmedArticleFile.toLowerCase();
if (!normalizedArticleFile) return;
const filename = normalizedArticleFile;
const title = articleTitle || filename;
if (filename !== articleFile) {
setArticleFile(filename);
}
window.open(
buildGithubNewUrl(finalDirPath, filename, title),
"_blank",
Expand Down Expand Up @@ -173,6 +165,10 @@ export function Contribute() {
bg-gradient-to-r from-sky-300 via-sky-400 to-blue-600
dark:from-indigo-950 dark:via-slate-900 dark:to-black
hover:shadow-[0_25px_60px_-12px] hover:scale-[1.03] transition-all duration-300 ease-out"
onClick={(event) => {
event.preventDefault();
router.push("/editor");
}}
>
{/* Day gradient shimmer */}
<span
Expand Down Expand Up @@ -276,7 +272,7 @@ export function Contribute() {
/>
</div>

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