feat: 代码片段功能与 Markdown 内嵌片段渲染、文档截图等
This commit is contained in:
197
frontend/components/SnippetActions.tsx
Normal file
197
frontend/components/SnippetActions.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { GitFork, History, Pencil, Trash2, Copy, Check } from "lucide-react";
|
||||
import { apiForkSnippet, apiDeleteSnippet, apiMe } from "@/lib/api";
|
||||
import type { SnippetDetail } from "@/lib/api";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { snippetPath, snippetsPath, type UrlStyle } from "@/lib/urlStyle";
|
||||
import Modal from "@/components/Modal";
|
||||
import SnippetVersionModal from "@/components/SnippetVersionModal";
|
||||
|
||||
export default function SnippetActions({
|
||||
snippet,
|
||||
urlStyle,
|
||||
isOwner: initialIsOwner,
|
||||
isStaff: initialIsStaff,
|
||||
}: {
|
||||
snippet: SnippetDetail;
|
||||
urlStyle: UrlStyle;
|
||||
isOwner: boolean;
|
||||
isStaff: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [isOwner, setIsOwner] = useState(initialIsOwner);
|
||||
const [isStaff, setIsStaff] = useState(initialIsStaff);
|
||||
const [forking, setForking] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [historyOpen, setHistoryOpen] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
apiMe()
|
||||
.then((res) => {
|
||||
if (res.user) {
|
||||
setIsOwner(res.user.id === snippet.user_id);
|
||||
setIsStaff(["admin", "moderator"].includes(res.user.role));
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [snippet.user_id]);
|
||||
|
||||
const onFork = async () => {
|
||||
if (forking) return;
|
||||
setForking(true);
|
||||
try {
|
||||
const res = await apiForkSnippet(snippet.id);
|
||||
toast("已复制为你的私有副本,随便改改吧", "ok");
|
||||
// 直接进入编辑页,让"Fork 后可以演化"这件事立刻可见
|
||||
router.push(`/snippets/${res.snippet.id}/edit`);
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : "Fork 失败");
|
||||
} finally {
|
||||
setForking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onDelete = async () => {
|
||||
if (deleting) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
await apiDeleteSnippet(snippet.id);
|
||||
toast("已删除", "ok");
|
||||
router.push(snippetsPath(urlStyle));
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : "删除失败");
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
setDeleteOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onCopyLink = async () => {
|
||||
try {
|
||||
const url = `${window.location.origin}${snippetPath(urlStyle, snippet.id)}`;
|
||||
await navigator.clipboard.writeText(url);
|
||||
setCopied(true);
|
||||
toast("链接已复制", "ok");
|
||||
window.setTimeout(() => setCopied(false), 1600);
|
||||
} catch {
|
||||
toast("复制失败");
|
||||
}
|
||||
};
|
||||
|
||||
const btnBase =
|
||||
"h-8 px-3 inline-flex items-center gap-1.5 rounded-full text-[12.5px] font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed";
|
||||
const btnNormal = {
|
||||
background: "var(--panel)",
|
||||
border: "1px solid var(--line)",
|
||||
color: "var(--ink-2)",
|
||||
} as const;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCopyLink}
|
||||
className={btnBase}
|
||||
style={btnNormal}
|
||||
title="复制片段链接"
|
||||
>
|
||||
{copied ? <Check size={13.5} /> : <Copy size={13.5} />}
|
||||
{copied ? "已复制" : "复制链接"}
|
||||
</button>
|
||||
|
||||
{snippet.visibility === "public" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onFork}
|
||||
disabled={forking}
|
||||
className={btnBase}
|
||||
style={btnNormal}
|
||||
title="把这份代码复制为你的私有副本,可任意修改,互不影响"
|
||||
>
|
||||
<GitFork size={13.5} />
|
||||
{forking ? "创建副本中…" : "Fork 副本"}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setHistoryOpen(true)}
|
||||
className={btnBase}
|
||||
style={btnNormal}
|
||||
title="查看版本历史"
|
||||
>
|
||||
<History size={13.5} /> 版本历史
|
||||
</button>
|
||||
|
||||
{isOwner && (
|
||||
<Link
|
||||
href={`/snippets/${snippet.id}/edit`}
|
||||
className={btnBase}
|
||||
style={btnNormal}
|
||||
title="编辑片段"
|
||||
>
|
||||
<Pencil size={13.5} /> 编辑
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{(isOwner || isStaff) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDeleteOpen(true)}
|
||||
className={btnBase}
|
||||
style={{ ...btnNormal, color: "var(--danger)" }}
|
||||
title="删除片段"
|
||||
>
|
||||
<Trash2 size={13.5} /> 删除
|
||||
</button>
|
||||
)}
|
||||
</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={onDelete}
|
||||
disabled={deleting}
|
||||
>
|
||||
{deleting ? "删除中…" : "确认删除"}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p className="text-[13.5px]" style={{ color: "var(--ink-2)" }}>
|
||||
此操作不可撤销。
|
||||
</p>
|
||||
</Modal>
|
||||
|
||||
<SnippetVersionModal
|
||||
open={historyOpen}
|
||||
onClose={() => setHistoryOpen(false)}
|
||||
snippetId={snippet.id}
|
||||
currentCode={snippet.code}
|
||||
currentTitle={snippet.title}
|
||||
currentLanguage={snippet.language}
|
||||
currentDescription={snippet.description}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user