332 lines
12 KiB
TypeScript
332 lines
12 KiB
TypeScript
"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>
|
||
);
|
||
}
|