Files
jiang13-bbs/frontend/components/SnippetExplorer.tsx

589 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useMemo, useState, useEffect, useCallback } from "react";
import Link from "next/link";
import {
Search,
X,
Copy,
Eye,
MessageSquare,
GitFork,
Plus,
Lock,
Loader2,
ArrowLeft,
ArrowRight,
RotateCcw,
} from "lucide-react";
import type { SnippetListItem, SnippetSort, SnippetStats } from "@/lib/api";
import { fetchSnippets, apiMySnippets } from "@/lib/api";
import { snippetPath, type UrlStyle } from "@/lib/urlStyle";
import { formatRelative } from "@/lib/format";
import Avatar from "@/components/Avatar";
import { toast } from "@/lib/toast";
import { siteCodeToHtml } from "@/components/markdown/shikiHighlighter";
import SnippetLangSelect from "@/components/SnippetLangSelect";
import SnippetHero from "@/components/SnippetHero";
import { snippetLangColor } from "@/lib/snippetLangColors";
const MONO = "var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)";
function plainExcerpt(md: string): string {
return md
.replace(/!\[[^\]]*\]\([^)]*\)/g, "")
.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
.replace(/[*_~`#>]+/g, "")
.replace(/\s+/g, " ")
.trim();
}
/** 编辑器窗格的三色圆点(灰阶令牌,明暗主题都不穿帮) */
function WindowDots() {
return (
<span className="inline-flex items-center gap-[5px] shrink-0" aria-hidden>
<span className="w-[7px] h-[7px] rounded-full" style={{ background: "var(--ink-3)", opacity: 0.55 }} />
<span className="w-[7px] h-[7px] rounded-full" style={{ background: "var(--ink-3)", opacity: 0.35 }} />
<span className="w-[7px] h-[7px] rounded-full" style={{ background: "var(--ink-3)", opacity: 0.2 }} />
</span>
);
}
/** 语言标识:小圆点 + 等宽语言名 */
function LangTag({ lang }: { lang: string }) {
const c = snippetLangColor(lang);
return (
<span className="inline-flex items-center gap-1.5 min-w-0">
<span
className="w-[6px] h-[6px] rounded-full shrink-0"
style={{ background: c, boxShadow: `0 0 0 2px color-mix(in srgb, ${c} 22%, transparent)` }}
/>
<span
className="text-[10.5px] font-semibold uppercase tracking-[0.08em] truncate"
style={{ fontFamily: MONO, color: c }}
>
{lang}
</span>
</span>
);
}
/**
* 从 shiki 输出提取高亮信息:
* - 双主题 CSS 变量定义在 <pre style> 上(--shiki-light/--shiki-dark …),必须保留
* - <code> 内部按行拆分自绘 gutter
*/
function extractShiki(html: string): { vars: Record<string, string>; lines: string[] } | null {
const codeM = /<code[^>]*>([\s\S]*?)<\/code>/i.exec(html);
if (!codeM) return null;
const vars: Record<string, string> = {};
const preM = /<pre[^>]*\sstyle="([^"]*)"[^>]*>/i.exec(html);
if (preM) {
for (const m of preM[1].matchAll(/(--[\w-]+)\s*:\s*([^;]+)/g)) {
vars[m[1]] = m[2].trim();
}
}
return { vars, lines: codeM[1].split("\n") };
}
const PREVIEW_LINES = 5;
/** 代码预览:shiki 客户端高亮 + 自绘行号 gutter;未着色前回退纯文本 */
function SnippetPreview({ code, language }: { code: string; language: string }) {
const [hl, setHl] = useState<{ vars: Record<string, string>; lines: string[] } | null>(null);
const plainLines = useMemo(() => code.split("\n").slice(0, PREVIEW_LINES), [code]);
const longPreview = code.split("\n").length > PREVIEW_LINES;
useEffect(() => {
let cancelled = false;
const head = code.split("\n").slice(0, PREVIEW_LINES).join("\n");
siteCodeToHtml(head, language)
.then((html) => {
if (cancelled) return;
const parsed = extractShiki(html);
// shiki 末行通常带尾换行产生空串,截到预览行数
if (parsed && parsed.lines.length > 0) {
setHl({ vars: parsed.vars, lines: parsed.lines.slice(0, PREVIEW_LINES) });
}
})
.catch(() => {
/* 保持纯文本 */
});
return () => {
cancelled = true;
};
}, [code, language]);
const rows = hl?.lines ?? plainLines;
const highlighted = hl !== null;
return (
<div className="relative">
<pre
className={`j13-sn-code m-0 text-[11.5px] leading-[1.7] overflow-hidden max-h-[98px] ${
highlighted ? "" : "whitespace-pre"
}`}
style={{
fontFamily: MONO,
color: "var(--ink-2)",
// 继承 shiki 双主题变量,token span 的 var(--shiki-*) 才能解析
...(hl?.vars ?? {}),
} as React.CSSProperties}
>
{rows.map((ln, i) => (
<div key={i} className="flex min-w-0">
<span
className="shrink-0 w-6 pr-2 text-right select-none tabular-nums"
style={{ color: "var(--ink-3)", opacity: 0.45, borderRight: "1px solid var(--line)", marginRight: 9 }}
>
{i + 1}
</span>
{highlighted ? (
<span className="truncate min-w-0" dangerouslySetInnerHTML={{ __html: ln || " " }} />
) : (
<span className="truncate">{ln || " "}</span>
)}
</div>
))}
</pre>
{longPreview ? (
<div
className="absolute bottom-0 left-0 right-0 h-7 pointer-events-none"
style={{ background: "linear-gradient(to top, var(--panel-2), transparent)" }}
/>
) : null}
</div>
);
}
/** 自定义语言下拉:见共享组件 SnippetLangSelect */
function Stat({ icon, value, label }: { icon: React.ReactNode; value: number; label: string }) {
return (
<span className="inline-flex items-center gap-1" title={label}>
<span style={{ color: "var(--ink-3)" }}>{icon}</span>
<span className="tabular-nums" style={{ color: "var(--ink-2)" }}>
{value}
</span>
</span>
);
}
function SnippetCard({ item, urlStyle }: { item: SnippetListItem; urlStyle: UrlStyle }) {
const ownerName = item.user ? item.user.nickname || item.user.username : "";
const excerpt = plainExcerpt(item.description ?? "");
const href = snippetPath(urlStyle, item.id);
const langColor = snippetLangColor(item.language);
return (
<Link
href={href}
className="j13-snc group flex flex-col h-full min-w-0 rounded-xl overflow-hidden transition-[transform,box-shadow,border-color] duration-200 hover:-translate-y-0.5 hover:shadow-[var(--shadow-lg)] hover:border-[color-mix(in_srgb,var(--accent)_45%,var(--line))]"
style={{ background: "var(--panel)", border: "1px solid var(--line)", ["--sn-lang" as string]: langColor }}
>
{/* 卡头:语言 + 标题 + 徽标 */}
<div className="px-3.5 pt-3.5 pb-2.5">
<div className="flex items-center gap-2 min-w-0">
<LangTag lang={item.language} />
<span className="ml-auto inline-flex items-center gap-1 shrink-0">
{item.visibility === "private" && (
<span
className="inline-flex items-center gap-0.5 text-[10px] font-medium px-1.5 py-0.5 rounded-md"
style={{ background: "var(--panel-2)", color: "var(--ink-3)" }}
>
<Lock size={9} /> 私有
</span>
)}
{item.forked_from_id ? (
<span
className="inline-flex items-center gap-0.5 text-[10px] font-medium px-1.5 py-0.5 rounded-md"
style={{ background: "var(--panel-2)", color: "var(--ink-3)" }}
>
<GitFork size={9} /> Fork
</span>
) : null}
</span>
</div>
<h2
className="mt-2 text-[14px] font-bold leading-snug line-clamp-2 group-hover:text-[var(--accent)] transition-colors"
style={{ color: "var(--ink)" }}
>
{item.title}
</h2>
{excerpt ? (
<p className="mt-1 text-[12px] leading-relaxed line-clamp-1" style={{ color: "var(--ink-3)" }}>
{excerpt}
</p>
) : null}
</div>
{/* 代码窗格:标题栏 + gutter + shiki 高亮 */}
<div
className="mx-3.5 rounded-lg overflow-hidden"
style={{ border: "1px solid var(--line)", background: "var(--panel-2)" }}
>
<div
className="flex items-center gap-2 px-2.5 py-1.5"
style={{ borderBottom: "1px solid var(--line)" }}
>
<WindowDots />
<span className="text-[10px] truncate" style={{ fontFamily: MONO, color: "var(--ink-3)" }}>
snippet.{item.language === "plaintext" ? "txt" : item.language}
</span>
<span
className="ml-auto shrink-0 text-[9px] font-semibold uppercase tracking-[0.1em] px-1.5 py-px rounded"
style={{ fontFamily: MONO, color: langColor, background: `color-mix(in srgb, ${langColor} 12%, transparent)` }}
>
{item.language}
</span>
</div>
<div className="px-0 pt-1.5 pb-2">
<SnippetPreview code={item.preview ?? ""} language={item.language} />
</div>
</div>
{/* 卡脚:统计 + 作者 */}
<div
className="mt-auto px-3.5 py-2.5 flex items-center gap-3 text-[11px]"
style={{ borderTop: "1px solid var(--line)" }}
>
<Stat icon={<Copy size={11} />} value={item.copy_count} label="复制数" />
<Stat icon={<Eye size={11} />} value={item.view_count} label="浏览数" />
<Stat icon={<MessageSquare size={11} />} value={item.comment_count} label="评论数" />
<Stat icon={<GitFork size={11} />} value={item.fork_count} label="Fork 数" />
<span className="ml-auto inline-flex items-center gap-1.5 min-w-0 shrink-0">
{item.user ? (
<>
<Avatar size={15} name={ownerName} src={item.user.avatar} />
<span className="truncate max-w-[4.5rem]" style={{ color: "var(--ink-3)" }} title={ownerName}>
{ownerName}
</span>
<span style={{ color: "var(--ink-3)" }}>·</span>
</>
) : null}
<span className="shrink-0" style={{ color: "var(--ink-3)" }}>
{formatRelative(item.created_at)}
</span>
</span>
</div>
</Link>
);
}
const SORT_OPTIONS: { value: SnippetSort; label: string }[] = [
{ value: "latest", label: "最新" },
{ value: "copy", label: "复制" },
{ value: "comment", label: "评论" },
{ value: "fork", label: "Fork" },
];
export default function SnippetExplorer({
initialItems,
initialTotal,
languages,
urlStyle,
stats,
isLoggedIn,
}: {
initialItems: SnippetListItem[];
initialTotal: number;
languages: string[];
urlStyle: UrlStyle;
stats: SnippetStats | null;
isLoggedIn: boolean;
}) {
const [items, setItems] = useState(initialItems);
const [total, setTotal] = useState(initialTotal);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [query, setQuery] = useState("");
const [lang, setLang] = useState("");
const [sort, setSort] = useState<SnippetSort>("latest");
const [scope, setScope] = useState<"public" | "mine">("public");
const pageSize = 12;
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const filtering = !!query || (scope === "public" && !!lang);
const load = useCallback(
async (p: number) => {
setLoading(true);
try {
const res =
scope === "mine"
? await apiMySnippets(query, p, pageSize)
: await fetchSnippets({ q: query, lang, sort, page: p, size: pageSize });
setItems(res.items);
setTotal(res.total);
setPage(p);
} catch (e) {
toast(e instanceof Error ? e.message : "加载失败");
} finally {
setLoading(false);
}
},
[scope, query, lang, sort]
);
// 筛选条件变化时回到第一页
useEffect(() => {
const t = setTimeout(() => load(1), 300);
return () => clearTimeout(t);
}, [load]);
const switchScope = (next: "public" | "mine") => {
if (next === scope) return;
setScope(next);
setQuery("");
setLang("");
setSort("latest");
};
return (
<div>
{/* 未来科技感 Hero:网格 + 扫描线 + 统计矩阵 */}
<SnippetHero stats={stats} isLoggedIn={isLoggedIn} />
{/* 工具台:IDE 风格软底工具条,控件浮于其上(无框中框) */}
<div
className="rounded-xl p-1.5 flex flex-col sm:flex-row sm:items-center gap-1.5"
style={{ background: "var(--panel-2)", border: "1px solid var(--line)" }}
>
{isLoggedIn && (
<div
className="flex items-center gap-0.5 rounded-lg p-0.5 shrink-0"
style={{ background: "var(--panel)" }}
role="group"
aria-label="片段范围"
>
{(
[
{ value: "public", label: "广场" },
{ value: "mine", label: "我的" },
] as const
).map((opt) => {
const active = scope === opt.value;
return (
<button
key={opt.value}
type="button"
onClick={() => switchScope(opt.value)}
aria-pressed={active}
className="whitespace-nowrap px-3 h-8 rounded-md text-[12px] font-medium transition-colors"
style={
active
? {
background: "var(--accent-soft)",
color: "var(--accent)",
boxShadow: "inset 0 0 0 1px color-mix(in srgb, var(--accent) 25%, transparent)",
}
: { color: "var(--ink-3)" }
}
>
{opt.label}
</button>
);
})}
</div>
)}
<div className="relative flex-1 min-w-0">
<Search
size={14}
className="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none"
style={{ color: "var(--ink-3)" }}
aria-hidden
/>
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={scope === "mine" ? "搜索我的片段…" : "搜索标题、描述或代码…"}
aria-label="搜索片段"
className="j13-sn-search w-full h-9 rounded-lg pr-8 text-[13px]"
style={{ background: "var(--panel)", border: "1px solid transparent", color: "var(--ink)", paddingLeft: 34 }}
/>
{query ? (
<button
type="button"
onClick={() => setQuery("")}
aria-label="清除搜索"
className="absolute right-2 top-1/2 -translate-y-1/2 w-6 h-6 flex items-center justify-center rounded-md hover:bg-[var(--panel-2)] transition-colors"
style={{ color: "var(--ink-3)" }}
>
<X size={13} />
</button>
) : null}
</div>
{scope === "public" && (
<SnippetLangSelect value={lang} onChange={setLang} languages={languages} />
)}
{scope === "public" && (
<div
className="flex items-center gap-0.5 rounded-lg p-0.5 shrink-0 overflow-x-auto"
style={{ background: "var(--panel)" }}
role="group"
aria-label="排序方式"
>
{SORT_OPTIONS.map((opt) => {
const active = sort === opt.value;
return (
<button
key={opt.value}
type="button"
onClick={() => setSort(opt.value)}
aria-pressed={active}
className="whitespace-nowrap px-2.5 h-8 rounded-md text-[12px] font-medium transition-colors"
style={
active
? {
background: "var(--accent-soft)",
color: "var(--accent)",
boxShadow: "inset 0 0 0 1px color-mix(in srgb, var(--accent) 25%, transparent)",
}
: { color: "var(--ink-3)" }
}
>
{opt.label}
</button>
);
})}
</div>
)}
</div>
{/* 结果统计 */}
<div className="mt-3 flex items-center gap-2 text-[12px]" style={{ color: "var(--ink-3)" }}>
{loading ? (
<Loader2 size={12} className="animate-spin" />
) : (
<span className="tabular-nums" style={{ fontFamily: MONO }}>
<span style={{ color: "var(--accent)" }}>$</span>{" "}
{scope === "mine" ? "ls ~/snippets --count" : "ls --count"}
</span>
)}
<span>
共 <b className="tabular-nums" style={{ color: "var(--ink-2)" }}>{total}</b> 个片段
{loading ? ",加载中…" : ""}
</span>
</div>
{/* 列表 / 空态 */}
{items.length === 0 && loading ? null : items.length === 0 && !loading ? (
filtering ? (
<div
className="mt-4 rounded-xl px-6 py-12 text-center"
style={{ background: "var(--panel)", border: "1px solid var(--line)" }}
>
<RotateCcw size={22} className="mx-auto mb-3" style={{ color: "var(--ink-3)" }} />
<p className="text-[14px] font-semibold" style={{ color: "var(--ink-2)" }}>
没有找到匹配的片段
</p>
<button
type="button"
className="btn btn-line btn-sm mt-4"
onClick={() => {
setQuery("");
setLang("");
}}
>
清除筛选条件
</button>
</div>
) : (
<div
className="mt-4 max-w-lg mx-auto rounded-xl overflow-hidden"
style={{ background: "var(--panel)", border: "1px solid var(--line)", boxShadow: "var(--shadow-sm)" }}
>
<div
className="flex items-center gap-2 px-3.5 py-2"
style={{ borderBottom: "1px solid var(--line)", background: "var(--panel-2)" }}
>
<WindowDots />
<span className="text-[10.5px]" style={{ fontFamily: MONO, color: "var(--ink-3)" }}>
snippets — zsh
</span>
</div>
<div className="px-6 py-10 text-center">
<p
className="inline-block text-left text-[12.5px] leading-[1.9]"
style={{ fontFamily: MONO, color: "var(--ink-2)" }}
>
<span style={{ color: "var(--accent)" }}>$</span>{" "}
{scope === "mine" ? "ls ~/snippets" : "snippets list --public"}
<br />
<span style={{ color: "var(--ink-3)" }}>
# {scope === "mine" ? "你还没有片段,新建一个或从广场 Fork" : "还没有公开片段"}
</span>
<span className="animate-pulse" style={{ color: "var(--accent)" }}>
▍
</span>
</p>
<p className="mt-5 text-[13.5px]" style={{ color: "var(--ink-2)" }}>
{scope === "mine" ? "Fork 的副本会默认设为私有,只在这里可见" : "成为第一个分享代码片段的人"}
</p>
<div className="mt-4 flex items-center justify-center gap-2">
{scope === "mine" ? (
<>
<Link href="/snippets/new" className="btn btn-primary btn-sm inline-flex items-center gap-1.5">
<Plus size={14} /> 新建片段
</Link>
<button
type="button"
onClick={() => switchScope("public")}
className="btn btn-line btn-sm"
>
去广场逛逛
</button>
</>
) : isLoggedIn ? (
<Link href="/snippets/new" className="btn btn-primary btn-sm inline-flex items-center gap-1.5">
<Plus size={14} /> 新建片段
</Link>
) : (
<Link href="/login?redirect=/snippets/new" className="btn btn-primary btn-sm">
登录后分享
</Link>
)}
</div>
</div>
</div>
)
) : (
<div className="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{items.map((item) => (
<SnippetCard key={item.id} item={item} urlStyle={urlStyle} />
))}
</div>
)}
{/* 分页 */}
{totalPages > 1 && (
<div className="mt-7 flex items-center justify-center gap-3">
<button
type="button"
disabled={page <= 1 || loading}
onClick={() => load(page - 1)}
className="btn btn-line btn-sm inline-flex items-center gap-1"
>
<ArrowLeft size={13} /> 上一页
</button>
<span className="text-[12px] tabular-nums" style={{ fontFamily: MONO, color: "var(--ink-3)" }}>
{page} / {totalPages}
</span>
<button
type="button"
disabled={page >= totalPages || loading}
onClick={() => load(page + 1)}
className="btn btn-line btn-sm inline-flex items-center gap-1"
>
下一页 <ArrowRight size={13} />
</button>
</div>
)}
</div>
);
}