feat: 代码片段功能与 Markdown 内嵌片段渲染、文档截图等
This commit is contained in:
612
frontend/components/SnippetExplorer.tsx
Normal file
612
frontend/components/SnippetExplorer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user