589 lines
21 KiB
TypeScript
589 lines
21 KiB
TypeScript
"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>
|
||
);
|
||
}
|