feat: 代码片段功能与 Markdown 内嵌片段渲染、文档截图等
This commit is contained in:
331
frontend/components/SnippetEditor.tsx
Normal file
331
frontend/components/SnippetEditor.tsx
Normal file
@@ -0,0 +1,331 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ArrowLeft, CodeXml, Eye, EyeOff, Trash2 } from "lucide-react";
|
||||
import { apiCreateSnippet, apiUpdateSnippet, apiDeleteSnippet, fetchSnippetLanguages, type SnippetInput, type SnippetVisibility } from "@/lib/api";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { snippetPath, snippetsPath, type UrlStyle } from "@/lib/urlStyle";
|
||||
import Modal from "@/components/Modal";
|
||||
import SnippetLangSelect from "@/components/SnippetLangSelect";
|
||||
import { hasAuthCookieHint } from "@/lib/cookies";
|
||||
import { useSiteBrand } from "@/components/SiteBrandProvider";
|
||||
|
||||
const TITLE_MAX = 120;
|
||||
const CODE_MAX = 64 * 1024; // 64KB
|
||||
const DESC_MAX = 8 * 1024; // 8KB
|
||||
|
||||
const VISIBILITY_OPTIONS: { value: SnippetVisibility; label: string; desc: string }[] = [
|
||||
{ value: "private", label: "私有", desc: "仅自己可见" },
|
||||
{ value: "public", label: "公开", desc: "所有人可见,出现在广场" },
|
||||
];
|
||||
|
||||
export default function SnippetEditor({
|
||||
mode,
|
||||
snippetId,
|
||||
initialTitle = "",
|
||||
initialLanguage = "plaintext",
|
||||
initialDescription = "",
|
||||
initialCode = "",
|
||||
initialVisibility = "private",
|
||||
}: {
|
||||
mode: "create" | "edit";
|
||||
snippetId?: number;
|
||||
initialTitle?: string;
|
||||
initialLanguage?: string;
|
||||
initialDescription?: string;
|
||||
initialCode?: string;
|
||||
initialVisibility?: SnippetVisibility;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const { url_style: urlStyle } = useSiteBrand();
|
||||
const [title, setTitle] = useState(initialTitle);
|
||||
const [language, setLanguage] = useState(initialLanguage);
|
||||
const [description, setDescription] = useState(initialDescription);
|
||||
const [code, setCode] = useState(initialCode);
|
||||
const [visibility, setVisibility] = useState<SnippetVisibility>(initialVisibility);
|
||||
const [languages, setLanguages] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const codeRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasAuthCookieHint()) {
|
||||
router.push("/login?redirect=/snippets/new");
|
||||
return;
|
||||
}
|
||||
fetchSnippetLanguages()
|
||||
.then((res) => setLanguages(res.languages))
|
||||
.catch(() => {});
|
||||
}, [router]);
|
||||
|
||||
// Tab 键缩进
|
||||
const handleCodeKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === "Tab") {
|
||||
e.preventDefault();
|
||||
const el = e.currentTarget;
|
||||
const start = el.selectionStart;
|
||||
const end = el.selectionEnd;
|
||||
const newValue = code.substring(0, start) + " " + code.substring(end);
|
||||
setCode(newValue);
|
||||
requestAnimationFrame(() => {
|
||||
el.selectionStart = el.selectionEnd = start + 2;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const codeBytes = new TextEncoder().encode(code).length;
|
||||
const descBytes = new TextEncoder().encode(description).length;
|
||||
|
||||
const canSubmit =
|
||||
!loading &&
|
||||
title.trim().length > 0 &&
|
||||
code.trim().length > 0 &&
|
||||
codeBytes <= CODE_MAX &&
|
||||
descBytes <= DESC_MAX;
|
||||
|
||||
const handleSubmit = async (e?: React.FormEvent) => {
|
||||
e?.preventDefault();
|
||||
if (!canSubmit) return;
|
||||
setLoading(true);
|
||||
const input: SnippetInput = {
|
||||
title: title.trim(),
|
||||
language,
|
||||
description: description.trim(),
|
||||
code: code.trim(),
|
||||
visibility,
|
||||
};
|
||||
try {
|
||||
if (mode === "create") {
|
||||
const res = await apiCreateSnippet(input);
|
||||
toast("创建成功", "ok");
|
||||
router.push(snippetPath(urlStyle, res.snippet.id));
|
||||
} else if (snippetId) {
|
||||
const res = await apiUpdateSnippet(snippetId, input);
|
||||
toast("已保存", "ok");
|
||||
router.push(snippetPath(urlStyle, res.snippet.id));
|
||||
}
|
||||
} catch (err) {
|
||||
toast(err instanceof Error ? err.message : "操作失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!snippetId || deleting) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
await apiDeleteSnippet(snippetId);
|
||||
toast("已删除", "ok");
|
||||
router.push(snippetsPath(urlStyle));
|
||||
} catch (err) {
|
||||
toast(err instanceof Error ? err.message : "删除失败");
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
setDeleteOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="j13-compose-stage is-studio">
|
||||
<header className="j13-compose-toolbar">
|
||||
<div className="j13-compose-toolbar-lead">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.back()}
|
||||
className="j13-compose-back"
|
||||
aria-label="返回"
|
||||
data-tip="返回"
|
||||
>
|
||||
<ArrowLeft size={18} strokeWidth={2.2} />
|
||||
</button>
|
||||
<div className="j13-compose-toolbar-copy min-w-0">
|
||||
<h1 className="j13-compose-toolbar-title">
|
||||
{mode === "create" ? "新建片段" : "编辑片段"}
|
||||
</h1>
|
||||
<p className="j13-compose-toolbar-sub">
|
||||
{mode === "create" ? "保存一段可复用的代码" : "修改片段内容"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="j13-compose-toolbar-actions">
|
||||
{mode === "edit" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDeleteOpen(true)}
|
||||
className="btn btn-line btn-sm"
|
||||
style={{ color: "var(--danger)" }}
|
||||
>
|
||||
<Trash2 size={14} /> 删除
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!canSubmit}
|
||||
className="btn btn-primary min-w-[5.5rem] h-9 px-4 text-[13.5px] font-semibold"
|
||||
>
|
||||
{loading ? "保存中…" : mode === "create" ? "创建" : "保存"}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="j13-compose-studio">
|
||||
<div className="j13-compose-studio-canvas">
|
||||
{/* 标题 */}
|
||||
<div className="j13-compose-title-row">
|
||||
<label className="sr-only" htmlFor="snippet-title">
|
||||
标题
|
||||
</label>
|
||||
<input
|
||||
id="snippet-title"
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value.slice(0, TITLE_MAX))}
|
||||
required
|
||||
maxLength={TITLE_MAX}
|
||||
placeholder="片段标题"
|
||||
className="j13-compose-title"
|
||||
disabled={loading}
|
||||
/>
|
||||
<span className="j13-compose-title-count tabular-nums">
|
||||
{title.length}/{TITLE_MAX}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 语言 + 可见性 */}
|
||||
<div className="flex flex-wrap items-center gap-3 px-4 sm:px-6 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<CodeXml size={14} style={{ color: "var(--ink-3)" }} />
|
||||
<SnippetLangSelect
|
||||
value={language}
|
||||
onChange={setLanguage}
|
||||
languages={languages}
|
||||
allowAll={false}
|
||||
variant="editor"
|
||||
ariaLabel="编程语言"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 rounded-full p-0.5" style={{ background: "var(--panel-2)" }}>
|
||||
{VISIBILITY_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => setVisibility(opt.value)}
|
||||
className={`px-3 py-1 rounded-full text-[12px] font-medium transition-colors ${
|
||||
visibility === opt.value ? "" : "hover:bg-[var(--panel)]"
|
||||
}`}
|
||||
style={
|
||||
visibility === opt.value
|
||||
? { background: "var(--accent-soft)", color: "var(--accent)" }
|
||||
: { color: "var(--ink-2)" }
|
||||
}
|
||||
disabled={loading}
|
||||
>
|
||||
{opt.value === "public" ? <Eye size={12} className="inline mr-1" /> : <EyeOff size={12} className="inline mr-1" />}
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span className="meta text-[11.5px]">
|
||||
{VISIBILITY_OPTIONS.find((o) => o.value === visibility)?.desc}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 代码编辑区 */}
|
||||
<div className="j13-compose-canvas">
|
||||
<label className="sr-only" htmlFor="snippet-code">
|
||||
代码
|
||||
</label>
|
||||
<textarea
|
||||
ref={codeRef}
|
||||
id="snippet-code"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
onKeyDown={handleCodeKeyDown}
|
||||
placeholder="粘贴或输入代码…"
|
||||
className="w-full min-h-[320px] px-4 py-3 text-[13.5px] font-mono resize-y"
|
||||
style={{
|
||||
background: "var(--bg)",
|
||||
border: "1px solid var(--line)",
|
||||
color: "var(--ink)",
|
||||
borderRadius: "var(--r)",
|
||||
tabSize: 2,
|
||||
}}
|
||||
disabled={loading}
|
||||
/>
|
||||
<div className="mt-1 flex justify-end">
|
||||
<span
|
||||
className="meta text-[11px] tabular-nums"
|
||||
style={codeBytes > CODE_MAX ? { color: "var(--danger)", fontWeight: 600 } : undefined}
|
||||
>
|
||||
{codeBytes > 1024 ? `${(codeBytes / 1024).toFixed(1)} KB` : `${codeBytes} B`} / 64 KB
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 描述 */}
|
||||
<div className="px-4 sm:px-6 pb-6">
|
||||
<label className="field-label" htmlFor="snippet-desc">
|
||||
描述(可选)
|
||||
</label>
|
||||
<textarea
|
||||
id="snippet-desc"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="用 Markdown 描述这段代码的用途、用法或注意事项…"
|
||||
rows={4}
|
||||
className="w-full rounded-lg px-4 py-3 text-[13.5px]"
|
||||
style={{
|
||||
background: "var(--bg)",
|
||||
border: "1px solid var(--line)",
|
||||
color: "var(--ink)",
|
||||
}}
|
||||
disabled={loading}
|
||||
/>
|
||||
<div className="mt-1 flex justify-end">
|
||||
<span
|
||||
className="meta text-[11px] tabular-nums"
|
||||
style={descBytes > DESC_MAX ? { color: "var(--danger)", fontWeight: 600 } : undefined}
|
||||
>
|
||||
{descBytes > 1024 ? `${(descBytes / 1024).toFixed(1)} KB` : `${descBytes} B`} / 8 KB
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
open={deleteOpen}
|
||||
title="删除片段"
|
||||
description="删除后不可恢复,确定要删除这个代码片段吗?"
|
||||
onClose={() => setDeleteOpen(false)}
|
||||
dismissOnOverlay
|
||||
maxWidthClass="max-w-sm"
|
||||
footer={
|
||||
<>
|
||||
<button type="button" className="btn btn-line btn-sm" onClick={() => setDeleteOpen(false)}>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
style={{ background: "var(--danger)", borderColor: "var(--danger)" }}
|
||||
onClick={handleDelete}
|
||||
disabled={deleting}
|
||||
>
|
||||
{deleting ? "删除中…" : "确认删除"}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p className="text-[13.5px]" style={{ color: "var(--ink-2)" }}>
|
||||
此操作不可撤销。
|
||||
</p>
|
||||
</Modal>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user