feat: 代码片段功能与 Markdown 内嵌片段渲染、文档截图等

This commit is contained in:
2026-10-09 18:53:38 +08:00
parent 5598e1cbb7
commit da72843c70
64 changed files with 7047 additions and 248 deletions

View File

@@ -0,0 +1,612 @@
"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 { hasAuthCookieHint } from "@/lib/cookies";
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" },
];
function CardSkeleton() {
return (
<div
className="rounded-xl overflow-hidden animate-pulse"
style={{ background: "var(--panel)", border: "1px solid var(--line)" }}
>
<div className="px-3.5 pt-3.5 pb-2.5">
<div className="h-2.5 w-16 rounded" style={{ background: "var(--panel-2)" }} />
<div className="mt-2.5 h-3.5 w-3/4 rounded" style={{ background: "var(--panel-2)" }} />
</div>
<div className="mx-3.5 rounded-lg h-[128px]" style={{ background: "var(--panel-2)" }} />
<div className="px-3.5 py-3 mt-1">
<div className="h-2.5 w-2/3 rounded" style={{ background: "var(--panel-2)" }} />
</div>
</div>
);
}
export default function SnippetExplorer({
initialItems,
initialTotal,
languages,
urlStyle,
stats,
}: {
initialItems: SnippetListItem[];
initialTotal: number;
languages: string[];
urlStyle: UrlStyle;
stats: SnippetStats | null;
}) {
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 isLoggedIn = typeof window !== "undefined" ? hasAuthCookieHint() : false;
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} />
{/* 工具台: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>
{/* 列表 / 空态 */}
{loading && items.length === 0 ? (
<div className="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{Array.from({ length: 8 }).map((_, i) => (
<CardSkeleton key={i} />
))}
</div>
) : 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>
);
}