186 lines
6.3 KiB
TypeScript
186 lines
6.3 KiB
TypeScript
"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>
|
|
);
|
|
}
|