feat: 代码片段功能与 Markdown 内嵌片段渲染、文档截图等
This commit is contained in:
185
frontend/components/SnippetPickerModal.tsx
Normal file
185
frontend/components/SnippetPickerModal.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { CodeXml, Eye, GitFork, Copy, Search } from "lucide-react";
|
||||
import Modal from "@/components/Modal";
|
||||
import {
|
||||
apiMySnippets,
|
||||
apiSearchSnippets,
|
||||
type SnippetListItem,
|
||||
} from "@/lib/api";
|
||||
import { formatRelative } from "@/lib/format";
|
||||
import { toast } from "@/lib/toast";
|
||||
|
||||
type Tab = "public" | "mine";
|
||||
|
||||
/**
|
||||
* 「插入片段」选择器:搜索公开广场 / 我的片段,点击插入 [snippet id=N] 指令。
|
||||
*/
|
||||
export default function SnippetPickerModal({
|
||||
open,
|
||||
onClose,
|
||||
onPick,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onPick: (id: number) => void;
|
||||
}) {
|
||||
const [tab, setTab] = useState<Tab>("public");
|
||||
const [q, setQ] = useState("");
|
||||
const [items, setItems] = useState<SnippetListItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const seq = useRef(0);
|
||||
|
||||
// 打开时重置
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setTab("public");
|
||||
setQ("");
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const mySeq = ++seq.current;
|
||||
setLoading(true);
|
||||
const timer = setTimeout(() => {
|
||||
const req =
|
||||
tab === "mine" ? apiMySnippets(q.trim(), 1, 20) : apiSearchSnippets({ q: q.trim(), size: 20 });
|
||||
req
|
||||
.then((res) => {
|
||||
if (seq.current !== mySeq) return;
|
||||
setItems(res.items);
|
||||
setLoaded(true);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (seq.current !== mySeq) return;
|
||||
toast(e instanceof Error ? e.message : "加载失败");
|
||||
})
|
||||
.finally(() => {
|
||||
if (seq.current === mySeq) setLoading(false);
|
||||
});
|
||||
}, 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [open, tab, q]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title="插入代码片段"
|
||||
description="选择要嵌入的片段,正文中将插入 [snippet id=N] 指令"
|
||||
onClose={onClose}
|
||||
dismissOnOverlay
|
||||
maxWidthClass="max-w-lg"
|
||||
panelClassName="p-0 flex flex-col max-h-[80vh]"
|
||||
bodyClassName="flex flex-col min-h-0"
|
||||
>
|
||||
<div className="px-5 pt-4 pb-3 flex items-center gap-2" style={{ borderBottom: "1px solid var(--line)" }}>
|
||||
<div
|
||||
className="flex rounded-lg p-0.5 text-[12.5px] font-semibold shrink-0"
|
||||
style={{ background: "var(--panel-2)" }}
|
||||
role="tablist"
|
||||
aria-label="片段来源"
|
||||
>
|
||||
{(
|
||||
[
|
||||
["public", "公开广场"],
|
||||
["mine", "我的片段"],
|
||||
] as [Tab, string][]
|
||||
).map(([key, label]) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab === key}
|
||||
className="px-3 py-1 rounded-md transition-colors"
|
||||
style={
|
||||
tab === key
|
||||
? { background: "var(--panel)", color: "var(--ink)", boxShadow: "0 1px 2px rgba(0,0,0,0.08)" }
|
||||
: { color: "var(--ink-3)" }
|
||||
}
|
||||
onClick={() => setTab(key)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="relative flex-1 min-w-0">
|
||||
<Search
|
||||
size={13}
|
||||
className="absolute left-2.5 top-1/2 -translate-y-1/2 pointer-events-none"
|
||||
style={{ color: "var(--ink-3)" }}
|
||||
/>
|
||||
<input
|
||||
autoFocus
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
placeholder="搜索标题或描述…"
|
||||
aria-label="搜索片段"
|
||||
className="w-full rounded-lg pl-7 pr-3 py-1.5 text-[13px]"
|
||||
style={{ background: "var(--bg)", border: "1px solid var(--line)", color: "var(--ink)" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-3 py-2">
|
||||
{loading && !loaded ? (
|
||||
<p className="meta text-center py-10 text-[12.5px]">加载中…</p>
|
||||
) : items.length === 0 ? (
|
||||
<p className="meta text-center py-10 text-[12.5px]">
|
||||
{tab === "mine" ? "还没有自己的片段,去片段广场新建一个吧" : "没有匹配的片段"}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-1">
|
||||
{items.map((it) => (
|
||||
<li key={it.id}>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full text-left rounded-xl px-3 py-2.5 transition-colors hover:bg-[var(--panel-2)]"
|
||||
onClick={() => {
|
||||
onPick(it.id);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<span className="flex items-center gap-2 min-w-0">
|
||||
<CodeXml size={14} className="shrink-0" style={{ color: "var(--accent)" }} />
|
||||
<span className="truncate text-[13.5px] font-semibold" style={{ color: "var(--ink)" }}>
|
||||
{it.title}
|
||||
</span>
|
||||
<span
|
||||
className="shrink-0 rounded-full px-1.5 py-px text-[10.5px] font-semibold"
|
||||
style={{ background: "var(--panel-2)", color: "var(--ink-3)" }}
|
||||
>
|
||||
{it.language}
|
||||
</span>
|
||||
{it.visibility === "private" && (
|
||||
<span
|
||||
className="shrink-0 rounded-full px-1.5 py-px text-[10.5px]"
|
||||
style={{ background: "var(--panel-2)", color: "var(--ink-3)" }}
|
||||
>
|
||||
私有
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="mt-1 flex items-center gap-3 text-[11.5px]" style={{ color: "var(--ink-3)" }}>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Copy size={11} /> {it.copy_count}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Eye size={11} /> {it.view_count}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<GitFork size={11} /> {it.fork_count}
|
||||
</span>
|
||||
<span className="ml-auto shrink-0">{formatRelative(it.created_at)}</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user