feat: 代码片段功能与 Markdown 内嵌片段渲染、文档截图等
This commit is contained in:
@@ -55,9 +55,9 @@ export default function FavoriteButton({
|
||||
variant === "compact"
|
||||
? "px-2.5 py-1.5 gap-1 text-[12.5px]"
|
||||
: variant === "article"
|
||||
? "px-7 py-3 gap-2 text-[15px] min-w-[132px]"
|
||||
? "px-5 py-2.5 gap-1.5 text-[14px]"
|
||||
: "px-4 py-2 gap-1.5 text-sm";
|
||||
const iconSize = variant === "compact" ? 13 : variant === "article" ? 18 : 15;
|
||||
const iconSize = variant === "compact" ? 13 : variant === "article" ? 16 : 15;
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -66,12 +66,7 @@ export default function FavoriteButton({
|
||||
data-tip={favorited ? "取消收藏" : "收藏"}
|
||||
aria-label={favorited ? "取消收藏" : "收藏"}
|
||||
aria-pressed={favorited}
|
||||
className={`inline-flex items-center justify-center tabular-nums rounded-full border transition-colors duration-150 disabled:opacity-50 ${
|
||||
favorited
|
||||
? "border-[var(--accent)] text-[var(--accent)] bg-[color-mix(in_srgb,var(--accent)_10%,var(--panel))]"
|
||||
: "border-[var(--line-2)] text-[var(--ink-2)] hover:border-[var(--accent)] hover:text-[var(--accent)]"
|
||||
} ${sizeCls}`}
|
||||
style={!favorited ? { background: "var(--panel)" } : undefined}
|
||||
className={`j13-fav inline-flex items-center justify-center tabular-nums border disabled:opacity-50 ${variant === "article" ? "j13-like-article" : ""} ${favorited ? "is-favorited" : ""} ${sizeCls}`}
|
||||
>
|
||||
<Bookmark size={iconSize} fill={favorited ? "currentColor" : "none"} />
|
||||
{variant === "article" && <span>{favorited ? "已收藏" : "收藏"}</span>}
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
MessageSquare,
|
||||
X,
|
||||
ExternalLink,
|
||||
CodeXml,
|
||||
} from "lucide-react";
|
||||
import { apiLogout, apiMe, apiChatUnreadSummary, type User } from "@/lib/api";
|
||||
import { hasAuthCookieHint } from "@/lib/cookies";
|
||||
@@ -37,7 +38,7 @@ import BadgeChip from "./BadgeChip";
|
||||
import BrandLockup from "./BrandLockup";
|
||||
import { useAllowMessages } from "./MessagesPolicyProvider";
|
||||
import { useSiteBrand, useUrlStyle } from "./SiteBrandProvider";
|
||||
import { leaderboardPath, libraryPath } from "@/lib/urlStyle";
|
||||
import { leaderboardPath, libraryPath, snippetsPath } from "@/lib/urlStyle";
|
||||
import {
|
||||
HEADER_NAV_BUILTIN_LABELS,
|
||||
isHeaderNavExternal,
|
||||
@@ -84,6 +85,7 @@ export default function Header({
|
||||
const urlStyle = useUrlStyle();
|
||||
const lbHref = leaderboardPath(urlStyle);
|
||||
const libHref = libraryPath(urlStyle);
|
||||
const snipHref = snippetsPath(urlStyle);
|
||||
// 顶部主导航由后台「基本信息 → 顶部导航」配置(顺序/显隐/自定义链接),
|
||||
// 配置为空时整个 nav 不渲染(含左侧分隔线);移动端导航走底部公共菜单
|
||||
const headerNav = useSiteBrand().header_nav;
|
||||
@@ -91,16 +93,22 @@ export default function Header({
|
||||
home: <Home size={15} />,
|
||||
leaderboard: <Trophy size={15} />,
|
||||
library: <BookOpen size={15} />,
|
||||
snippets: <CodeXml size={15} />,
|
||||
};
|
||||
const builtinNavHref: Record<HeaderNavBuiltinKey, string> = {
|
||||
home: "/",
|
||||
leaderboard: lbHref,
|
||||
library: libHref,
|
||||
snippets: snipHref,
|
||||
};
|
||||
const isBuiltinNavActive = (key: HeaderNavBuiltinKey): boolean => {
|
||||
if (!pathname) return false;
|
||||
if (key === "home") return pathname === "/";
|
||||
if (key === "leaderboard") return pathname === lbHref || pathname.startsWith(`${lbHref}/`);
|
||||
if (key === "snippets") {
|
||||
// 片段详情标准路径与伪静态(/snippet-*.html)统一按前缀高亮
|
||||
return pathname.startsWith("/snippets") || pathname.startsWith("/snippet-");
|
||||
}
|
||||
// 书库详情/阅读页标准路径与伪静态(/library-*.html)统一按 /library 前缀高亮
|
||||
return pathname.startsWith("/library");
|
||||
};
|
||||
|
||||
@@ -64,9 +64,9 @@ export default function LikeButton({
|
||||
variant === "compact"
|
||||
? "px-2.5 py-1.5 gap-1 text-[12.5px]"
|
||||
: variant === "article"
|
||||
? "px-7 py-3 gap-2 text-[15px] min-w-[148px]"
|
||||
? "px-5 py-2.5 gap-1.5 text-[14px]"
|
||||
: "px-4 py-2 gap-1.5 text-sm";
|
||||
const iconSize = variant === "compact" ? 13 : variant === "article" ? 18 : 15;
|
||||
const iconSize = variant === "compact" ? 13 : variant === "article" ? 16 : 15;
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -75,7 +75,7 @@ export default function LikeButton({
|
||||
data-tip={liked ? "取消点赞" : "点赞"}
|
||||
aria-label={liked ? "取消点赞" : "点赞"}
|
||||
aria-pressed={liked}
|
||||
className={`j13-like inline-flex items-center justify-center tabular-nums rounded-full border disabled:opacity-50 ${liked ? "is-liked" : ""} ${sizeCls}`}
|
||||
className={`j13-like inline-flex items-center justify-center tabular-nums border disabled:opacity-50 ${variant === "article" ? "j13-like-article" : ""} ${liked ? "is-liked" : ""} ${sizeCls}`}
|
||||
>
|
||||
<span key={String(liked)} className={liked ? "j13-heart-pop inline-flex" : "inline-flex"}>
|
||||
<IconHeart size={iconSize} filled={liked} />
|
||||
|
||||
@@ -9,6 +9,7 @@ import MdTimeline from "@/components/markdown/MdTimeline";
|
||||
import MdEntries from "@/components/markdown/MdEntries";
|
||||
import MdDetails from "@/components/markdown/MdDetails";
|
||||
import MdVideo from "@/components/markdown/MdVideo";
|
||||
import MdSnippet from "@/components/markdown/MdSnippet";
|
||||
|
||||
type MarkdownBodyProps = {
|
||||
content: string;
|
||||
@@ -92,6 +93,10 @@ export default async function MarkdownBody({
|
||||
nodes.push(<MdVideo key={`video-${i}`} provider={seg.provider} id={seg.id} />);
|
||||
continue;
|
||||
}
|
||||
if (seg.type === "snippet") {
|
||||
nodes.push(<MdSnippet key={`snippet-${i}`} id={seg.id} />);
|
||||
continue;
|
||||
}
|
||||
const kind = seg.kind as HideKind;
|
||||
if (seg.locked) {
|
||||
nodes.push(
|
||||
|
||||
@@ -113,6 +113,18 @@ export default function MarkdownBodyClient({
|
||||
if (seg.type === "video") {
|
||||
return <MdVideo key={`video-${i}`} provider={seg.provider} id={seg.id} />;
|
||||
}
|
||||
if (seg.type === "snippet") {
|
||||
// 客户端预览(发帖/评论草稿)无法 SSR 取数,渲染占位卡提示
|
||||
return (
|
||||
<div
|
||||
key={`snippet-${i}`}
|
||||
className="my-3 flex items-center gap-2 rounded-xl px-3 py-2.5 text-[13px]"
|
||||
style={{ border: "1px dashed var(--line-2)", color: "var(--ink-3)" }}
|
||||
>
|
||||
代码片段 #{seg.id}(发布后可见)
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const kind = seg.kind as HideKind;
|
||||
if (seg.locked) {
|
||||
return (
|
||||
|
||||
@@ -45,6 +45,7 @@ import { toast } from "@/lib/toast";
|
||||
import MarkdownBodyClient from "@/components/MarkdownBodyClient";
|
||||
import ImageInsertModal from "@/components/ImageInsertModal";
|
||||
import TimelineEditor from "@/components/TimelineEditor";
|
||||
import SnippetPickerModal from "@/components/SnippetPickerModal";
|
||||
import { ENTRIES_TEMPLATE, ENTRIES_TEMPLATE_AUTO } from "@/lib/entryBlocks";
|
||||
import EmojiPicker from "@/components/EmojiPicker";
|
||||
import { resolveTimelineBlockForEdit, parseVideoInput } from "@/lib/directiveBlocks";
|
||||
@@ -260,6 +261,7 @@ export default function MarkdownEditor({
|
||||
const [activeWheel, setActiveWheel] = useState<"fg" | "bg" | null>(null);
|
||||
const [colorHsv, setColorHsv] = useState<HSV>({ h: 0, s: 1, v: 1 });
|
||||
const [videoInput, setVideoInput] = useState("");
|
||||
const [snippetPickerOpen, setSnippetPickerOpen] = useState(false);
|
||||
const [toolNarrow, setToolNarrow] = useState(false);
|
||||
const [uploadError, setUploadError] = useState("");
|
||||
const [modKey, setModKey] = useState("Ctrl");
|
||||
@@ -1670,6 +1672,7 @@ export default function MarkdownEditor({
|
||||
{!isComment && (
|
||||
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => openPopover("video")}>视频…</button>
|
||||
)}
|
||||
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => { setPopover(null); setSnippetPickerOpen(true); }}>代码片段…</button>
|
||||
{toolNarrow && (
|
||||
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => { setPopover(null); toggleFullscreen(); }}>{fullscreen ? "退出全屏" : "全屏写作"}</button>
|
||||
)}
|
||||
@@ -1712,6 +1715,13 @@ export default function MarkdownEditor({
|
||||
{popover === "video" && videoPopover}
|
||||
</div>
|
||||
)}
|
||||
{!isComment && !toolNarrow && (
|
||||
<ToolBtn
|
||||
label="代码片段"
|
||||
icon={<Code2 size={15} />}
|
||||
onClick={() => setSnippetPickerOpen(true)}
|
||||
/>
|
||||
)}
|
||||
{allowHide && !toolNarrow ? (
|
||||
<div className="j13-md-popover-anchor relative">
|
||||
<ToolBtn
|
||||
@@ -1897,6 +1907,12 @@ export default function MarkdownEditor({
|
||||
onInsertUrl={insertImageUrl}
|
||||
onPickMedia={(item) => insertAtCursor(``)}
|
||||
/>
|
||||
|
||||
<SnippetPickerModal
|
||||
open={snippetPickerOpen}
|
||||
onClose={() => setSnippetPickerOpen(false)}
|
||||
onPick={(id) => insertAtCursor(`[snippet id=${id}]`)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ export default async function PostRow({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`group relative flex items-center px-4 sm:px-5 border-t cursor-pointer transition-colors hover:bg-[var(--item-hover-bg)] focus-within:bg-[var(--item-hover-bg)] j13-fade-up ${
|
||||
className={`group relative flex items-center px-4 sm:px-5 border-t cursor-pointer transition-colors hover:bg-[var(--item-hover-bg)] focus-within:bg-[var(--item-hover-bg)] j13-post-row j13-fade-up ${
|
||||
compact ? "gap-2.5 sm:gap-3" : "gap-3.5 sm:gap-4"
|
||||
}`}
|
||||
style={{
|
||||
|
||||
197
frontend/components/SnippetActions.tsx
Normal file
197
frontend/components/SnippetActions.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { GitFork, History, Pencil, Trash2, Copy, Check } from "lucide-react";
|
||||
import { apiForkSnippet, apiDeleteSnippet, apiMe } from "@/lib/api";
|
||||
import type { SnippetDetail } from "@/lib/api";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { snippetPath, snippetsPath, type UrlStyle } from "@/lib/urlStyle";
|
||||
import Modal from "@/components/Modal";
|
||||
import SnippetVersionModal from "@/components/SnippetVersionModal";
|
||||
|
||||
export default function SnippetActions({
|
||||
snippet,
|
||||
urlStyle,
|
||||
isOwner: initialIsOwner,
|
||||
isStaff: initialIsStaff,
|
||||
}: {
|
||||
snippet: SnippetDetail;
|
||||
urlStyle: UrlStyle;
|
||||
isOwner: boolean;
|
||||
isStaff: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [isOwner, setIsOwner] = useState(initialIsOwner);
|
||||
const [isStaff, setIsStaff] = useState(initialIsStaff);
|
||||
const [forking, setForking] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [historyOpen, setHistoryOpen] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
apiMe()
|
||||
.then((res) => {
|
||||
if (res.user) {
|
||||
setIsOwner(res.user.id === snippet.user_id);
|
||||
setIsStaff(["admin", "moderator"].includes(res.user.role));
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [snippet.user_id]);
|
||||
|
||||
const onFork = async () => {
|
||||
if (forking) return;
|
||||
setForking(true);
|
||||
try {
|
||||
const res = await apiForkSnippet(snippet.id);
|
||||
toast("已复制为你的私有副本,随便改改吧", "ok");
|
||||
// 直接进入编辑页,让"Fork 后可以演化"这件事立刻可见
|
||||
router.push(`/snippets/${res.snippet.id}/edit`);
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : "Fork 失败");
|
||||
} finally {
|
||||
setForking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onDelete = async () => {
|
||||
if (deleting) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
await apiDeleteSnippet(snippet.id);
|
||||
toast("已删除", "ok");
|
||||
router.push(snippetsPath(urlStyle));
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : "删除失败");
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
setDeleteOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onCopyLink = async () => {
|
||||
try {
|
||||
const url = `${window.location.origin}${snippetPath(urlStyle, snippet.id)}`;
|
||||
await navigator.clipboard.writeText(url);
|
||||
setCopied(true);
|
||||
toast("链接已复制", "ok");
|
||||
window.setTimeout(() => setCopied(false), 1600);
|
||||
} catch {
|
||||
toast("复制失败");
|
||||
}
|
||||
};
|
||||
|
||||
const btnBase =
|
||||
"h-8 px-3 inline-flex items-center gap-1.5 rounded-full text-[12.5px] font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed";
|
||||
const btnNormal = {
|
||||
background: "var(--panel)",
|
||||
border: "1px solid var(--line)",
|
||||
color: "var(--ink-2)",
|
||||
} as const;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCopyLink}
|
||||
className={btnBase}
|
||||
style={btnNormal}
|
||||
title="复制片段链接"
|
||||
>
|
||||
{copied ? <Check size={13.5} /> : <Copy size={13.5} />}
|
||||
{copied ? "已复制" : "复制链接"}
|
||||
</button>
|
||||
|
||||
{snippet.visibility === "public" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onFork}
|
||||
disabled={forking}
|
||||
className={btnBase}
|
||||
style={btnNormal}
|
||||
title="把这份代码复制为你的私有副本,可任意修改,互不影响"
|
||||
>
|
||||
<GitFork size={13.5} />
|
||||
{forking ? "创建副本中…" : "Fork 副本"}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setHistoryOpen(true)}
|
||||
className={btnBase}
|
||||
style={btnNormal}
|
||||
title="查看版本历史"
|
||||
>
|
||||
<History size={13.5} /> 版本历史
|
||||
</button>
|
||||
|
||||
{isOwner && (
|
||||
<Link
|
||||
href={`/snippets/${snippet.id}/edit`}
|
||||
className={btnBase}
|
||||
style={btnNormal}
|
||||
title="编辑片段"
|
||||
>
|
||||
<Pencil size={13.5} /> 编辑
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{(isOwner || isStaff) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDeleteOpen(true)}
|
||||
className={btnBase}
|
||||
style={{ ...btnNormal, color: "var(--danger)" }}
|
||||
title="删除片段"
|
||||
>
|
||||
<Trash2 size={13.5} /> 删除
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
open={deleteOpen}
|
||||
title="删除片段"
|
||||
description="删除后不可恢复,确定要删除这个代码片段吗?"
|
||||
onClose={() => setDeleteOpen(false)}
|
||||
dismissOnOverlay
|
||||
maxWidthClass="max-w-sm"
|
||||
footer={
|
||||
<>
|
||||
<button type="button" className="btn btn-line btn-sm" onClick={() => setDeleteOpen(false)}>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
style={{ background: "var(--danger)", borderColor: "var(--danger)" }}
|
||||
onClick={onDelete}
|
||||
disabled={deleting}
|
||||
>
|
||||
{deleting ? "删除中…" : "确认删除"}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p className="text-[13.5px]" style={{ color: "var(--ink-2)" }}>
|
||||
此操作不可撤销。
|
||||
</p>
|
||||
</Modal>
|
||||
|
||||
<SnippetVersionModal
|
||||
open={historyOpen}
|
||||
onClose={() => setHistoryOpen(false)}
|
||||
snippetId={snippet.id}
|
||||
currentCode={snippet.code}
|
||||
currentTitle={snippet.title}
|
||||
currentLanguage={snippet.language}
|
||||
currentDescription={snippet.description}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
217
frontend/components/SnippetCodeView.tsx
Normal file
217
frontend/components/SnippetCodeView.tsx
Normal file
@@ -0,0 +1,217 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef, useLayoutEffect } from "react";
|
||||
import { Check, Copy, ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { apiRecordSnippetCopy } from "@/lib/api";
|
||||
import { siteCodeToHtml } from "@/components/markdown/shikiHighlighter";
|
||||
|
||||
const MONO = "var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)";
|
||||
const LINE_H = 22; // px,与下方 leading 严格对齐
|
||||
const PAD_Y = 14;
|
||||
|
||||
/** 从 shiki 双主题输出提取 pre 上的 CSS 变量与 code 内逐行 HTML */
|
||||
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") };
|
||||
}
|
||||
|
||||
function WindowDots() {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 shrink-0" aria-hidden>
|
||||
<span className="w-2.5 h-2.5 rounded-full" style={{ background: "#ff5f57" }} />
|
||||
<span className="w-2.5 h-2.5 rounded-full" style={{ background: "#febc2e" }} />
|
||||
<span className="w-2.5 h-2.5 rounded-full" style={{ background: "#28c840" }} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** 片段代码视图:编辑器窗格 + shiki 高亮 + 行号 + 复制 + 超长折叠 */
|
||||
export default function SnippetCodeView({
|
||||
code,
|
||||
language,
|
||||
snippetId,
|
||||
defaultFold = true,
|
||||
foldLines = 25,
|
||||
}: {
|
||||
code: string;
|
||||
language: string;
|
||||
snippetId: number;
|
||||
defaultFold?: boolean;
|
||||
foldLines?: number;
|
||||
}) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [expanded, setExpanded] = useState(!defaultFold);
|
||||
const [hl, setHl] = useState<{ vars: Record<string, string>; lines: string[] } | null>(null);
|
||||
const [maxH, setMaxH] = useState<number | undefined>(undefined);
|
||||
const bodyRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const plainLines = code.split("\n");
|
||||
const lineCount = plainLines.length;
|
||||
const canCollapse = defaultFold && lineCount > foldLines;
|
||||
const collapsedH = PAD_Y + foldLines * LINE_H;
|
||||
const rows = hl?.lines ?? plainLines;
|
||||
const highlighted = hl !== null;
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
siteCodeToHtml(code, language)
|
||||
.then((html) => {
|
||||
if (cancelled) return;
|
||||
const parsed = extractShiki(html);
|
||||
if (parsed && parsed.lines.length > 0) {
|
||||
setHl({ vars: parsed.vars, lines: parsed.lines.slice(0, lineCount) });
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [code, language, lineCount]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!canCollapse || !bodyRef.current) return;
|
||||
setMaxH(expanded ? bodyRef.current.scrollHeight : collapsedH);
|
||||
}, [expanded, canCollapse, collapsedH, hl]);
|
||||
|
||||
const onCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(code);
|
||||
setCopied(true);
|
||||
toast("已复制到剪贴板", "ok");
|
||||
apiRecordSnippetCopy(snippetId).catch(() => {});
|
||||
window.setTimeout(() => setCopied(false), 1600);
|
||||
} catch {
|
||||
toast("复制失败,请手动选择文本");
|
||||
}
|
||||
};
|
||||
|
||||
const fileName = `snippet.${language === "plaintext" ? "txt" : language}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-xl overflow-hidden"
|
||||
style={{ background: "var(--panel-2)", border: "1px solid var(--line)" }}
|
||||
>
|
||||
{/* 窗格标题栏 */}
|
||||
<div
|
||||
className="flex items-center gap-2.5 px-3.5 h-10"
|
||||
style={{ borderBottom: "1px solid var(--line)" }}
|
||||
>
|
||||
<WindowDots />
|
||||
<span className="text-[11px] truncate" style={{ fontFamily: MONO, color: "var(--ink-3)" }}>
|
||||
{fileName}
|
||||
</span>
|
||||
<span className="text-[10.5px] tabular-nums hidden sm:inline" style={{ color: "var(--ink-3)" }}>
|
||||
{lineCount} 行
|
||||
</span>
|
||||
<span
|
||||
className="ml-auto shrink-0 text-[9.5px] font-semibold uppercase tracking-[0.1em] px-1.5 py-0.5 rounded"
|
||||
style={{ fontFamily: MONO, color: "var(--ink-3)", background: "var(--panel)" }}
|
||||
>
|
||||
{language || "text"}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCopy}
|
||||
aria-label={copied ? "已复制" : "复制代码"}
|
||||
title="复制代码"
|
||||
className="shrink-0 h-7 px-2.5 inline-flex items-center gap-1.5 rounded-md text-[11.5px] font-medium transition-colors"
|
||||
style={{
|
||||
color: copied ? "var(--accent)" : "var(--ink-3)",
|
||||
background: "var(--panel)",
|
||||
border: "1px solid var(--line)",
|
||||
}}
|
||||
>
|
||||
{copied ? <Check size={12.5} /> : <Copy size={12.5} />}
|
||||
<span className="hidden sm:inline">{copied ? "已复制" : "复制"}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 代码主体 */}
|
||||
<div
|
||||
ref={bodyRef}
|
||||
className="relative overflow-hidden"
|
||||
style={{
|
||||
maxHeight: canCollapse ? maxH ?? collapsedH : undefined,
|
||||
transition: "max-height 360ms cubic-bezier(0.22, 1, 0.36, 1)",
|
||||
}}
|
||||
>
|
||||
<pre
|
||||
className={`j13-sn-code m-0 text-[13px] overflow-x-auto ${highlighted ? "" : "whitespace-pre"}`}
|
||||
style={{
|
||||
fontFamily: MONO,
|
||||
lineHeight: `${LINE_H}px`,
|
||||
padding: `${PAD_Y}px 0`,
|
||||
color: "var(--ink-2)",
|
||||
...(hl?.vars ?? {}),
|
||||
} as React.CSSProperties}
|
||||
>
|
||||
{rows.map((ln, i) => (
|
||||
<div key={i} className="flex min-w-max">
|
||||
<span
|
||||
className="shrink-0 w-10 pr-3 text-right select-none tabular-nums text-[11.5px]"
|
||||
style={{
|
||||
color: "var(--ink-3)",
|
||||
opacity: 0.55,
|
||||
marginRight: 12,
|
||||
borderRight: "1px solid var(--line)",
|
||||
lineHeight: `${LINE_H}px`,
|
||||
}}
|
||||
>
|
||||
{i + 1}
|
||||
</span>
|
||||
{highlighted ? (
|
||||
<span
|
||||
className="pr-5"
|
||||
style={{ lineHeight: `${LINE_H}px` }}
|
||||
dangerouslySetInnerHTML={{ __html: ln || " " }}
|
||||
/>
|
||||
) : (
|
||||
<span className="pr-5" style={{ lineHeight: `${LINE_H}px` }}>
|
||||
{ln || " "}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</pre>
|
||||
{canCollapse && !expanded && (
|
||||
<div
|
||||
className="absolute bottom-0 left-0 right-0 h-10 pointer-events-none"
|
||||
style={{ background: "linear-gradient(to top, var(--panel-2), transparent)" }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{canCollapse && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="w-full h-9 flex items-center justify-center gap-1.5 text-[12px] font-medium transition-colors hover:bg-[var(--accent-soft)]"
|
||||
style={{
|
||||
color: expanded ? "var(--ink-3)" : "var(--accent)",
|
||||
borderTop: "1px solid var(--line)",
|
||||
}}
|
||||
>
|
||||
{expanded ? (
|
||||
<>
|
||||
<ChevronUp size={14} /> 收起
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronDown size={14} /> 展开全部({lineCount} 行)
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
48
frontend/components/SnippetCommentCountChip.tsx
Normal file
48
frontend/components/SnippetCommentCountChip.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export function snippetCommentCountEvent(snippetId: number) {
|
||||
return `j13:snippet:${snippetId}:comment-count`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 片段详情顶部的评论计数 chip:
|
||||
* 初始值来自 SSR,评论区增删后通过 window CustomEvent 同步。
|
||||
*/
|
||||
export default function SnippetCommentCountChip({
|
||||
snippetId,
|
||||
initial,
|
||||
icon,
|
||||
label,
|
||||
}: {
|
||||
snippetId: number;
|
||||
initial: number;
|
||||
icon: ReactNode;
|
||||
label: string;
|
||||
}) {
|
||||
const [count, setCount] = useState(initial);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
const next = (e as CustomEvent<number>).detail;
|
||||
if (typeof next === "number") setCount(next);
|
||||
};
|
||||
const name = snippetCommentCountEvent(snippetId);
|
||||
window.addEventListener(name, handler);
|
||||
return () => window.removeEventListener(name, handler);
|
||||
}, [snippetId]);
|
||||
|
||||
return (
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5 h-7 px-2.5 rounded-full text-[11.5px] tabular-nums"
|
||||
style={{ background: "var(--panel-2)", color: "var(--ink-3)", border: "1px solid var(--line)" }}
|
||||
title={`${count} ${label}`}
|
||||
>
|
||||
<span style={{ color: "var(--ink-3)" }}>{icon}</span>
|
||||
{count}
|
||||
<span className="hidden sm:inline">{label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
702
frontend/components/SnippetCommentSection.tsx
Normal file
702
frontend/components/SnippetCommentSection.tsx
Normal file
@@ -0,0 +1,702 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { MessageSquare, Send } from "lucide-react";
|
||||
import {
|
||||
apiCreateSnippetComment,
|
||||
apiUpdateSnippetComment,
|
||||
apiDeleteSnippetComment,
|
||||
apiSnippetCommentHistory,
|
||||
apiMe,
|
||||
type SnippetCommentItem,
|
||||
} from "@/lib/api";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { formatRelative, formatDateTime } from "@/lib/format";
|
||||
import Avatar from "@/components/Avatar";
|
||||
import { useUrlStyle } from "@/components/SiteBrandProvider";
|
||||
import { userPath } from "@/lib/urlStyle";
|
||||
import Link from "next/link";
|
||||
import MarkdownEditor from "@/components/MarkdownEditor";
|
||||
import MarkdownBodyClient from "@/components/MarkdownBodyClient";
|
||||
import EditHistoryModal, { type EditHistoryItem } from "@/components/EditHistoryModal";
|
||||
import ConfirmDialog from "@/components/ConfirmDialog";
|
||||
import { snippetCommentCountEvent } from "@/components/SnippetCommentCountChip";
|
||||
|
||||
const COMMENT_MAX = 2000; // 与后端 snippetCommentMaxLen 一致
|
||||
|
||||
type MoreItem = {
|
||||
key: string;
|
||||
label: string;
|
||||
danger?: boolean;
|
||||
disabled?: boolean;
|
||||
onSelect: () => void;
|
||||
};
|
||||
|
||||
/** 低频操作:编辑、历史、删除。Escape 关闭并回到触发按钮。 */
|
||||
function CommentMoreMenu({ items }: { items: MoreItem[] }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const btnRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key !== "Escape") return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setOpen(false);
|
||||
btnRef.current?.focus();
|
||||
};
|
||||
document.addEventListener("mousedown", onDown);
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onDown);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
if (items.length === 0) return null;
|
||||
const normal = items.filter((item) => !item.danger);
|
||||
const danger = items.filter((item) => item.danger);
|
||||
|
||||
return (
|
||||
<div className="cmt-more" ref={ref}>
|
||||
<button
|
||||
ref={btnRef}
|
||||
type="button"
|
||||
className="cmt-more-btn"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
>
|
||||
更多
|
||||
</button>
|
||||
{open && (
|
||||
<div role="menu" className="cmt-more-menu" aria-label="评论操作">
|
||||
{normal.map((item) => (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
disabled={item.disabled}
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
item.onSelect();
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
{normal.length > 0 && danger.length > 0 ? <div className="cmt-more-sep" role="separator" /> : null}
|
||||
{danger.map((item) => (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className="is-danger"
|
||||
disabled={item.disabled}
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
item.onSelect();
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CommentNode({
|
||||
item,
|
||||
depth,
|
||||
snippetId,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
onOpenHistory,
|
||||
loggedIn,
|
||||
}: {
|
||||
item: SnippetCommentItem;
|
||||
depth: number;
|
||||
snippetId: number;
|
||||
onUpdate: (id: number, content: string) => Promise<void>;
|
||||
onDelete: (id: number) => Promise<void>;
|
||||
onOpenHistory: (id: number, content: string) => void;
|
||||
loggedIn: boolean;
|
||||
}) {
|
||||
const urlStyle = useUrlStyle();
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [editContent, setEditContent] = useState(item.content);
|
||||
const [editUploading, setEditUploading] = useState(false);
|
||||
const [replyOpen, setReplyOpen] = useState(false);
|
||||
const [replyContent, setReplyContent] = useState("");
|
||||
const [replyUploading, setReplyUploading] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [currentUserId, setCurrentUserId] = useState<number | null>(null);
|
||||
const [isStaff, setIsStaff] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
apiMe()
|
||||
.then((res) => {
|
||||
if (res.user) {
|
||||
setCurrentUserId(res.user.id);
|
||||
setIsStaff(["admin", "moderator"].includes(res.user.role));
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const canEdit = currentUserId === item.user_id;
|
||||
const canDelete = canEdit || isStaff;
|
||||
const canViewHistory = loggedIn;
|
||||
const userName = item.user ? item.user.nickname || item.user.username : "已注销";
|
||||
const isFloor = depth === 0;
|
||||
|
||||
const handleEdit = async () => {
|
||||
const next = editContent.trim();
|
||||
if (!next || submitting) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await onUpdate(item.id, next);
|
||||
setEditing(false);
|
||||
toast("已保存", "ok");
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : "保存失败");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
setDeleteTarget(false);
|
||||
if (submitting) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await onDelete(item.id);
|
||||
toast("已删除", "ok");
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : "删除失败");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReply = async () => {
|
||||
const next = replyContent.trim();
|
||||
if (!next || submitting) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await apiCreateSnippetComment(snippetId, next, item.id);
|
||||
setReplyContent("");
|
||||
setReplyOpen(false);
|
||||
toast("已回复", "ok");
|
||||
window.dispatchEvent(new CustomEvent("j13-snippet-comment-refresh"));
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : "回复失败");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const moreItems: MoreItem[] = [];
|
||||
if (canEdit && !editing) {
|
||||
moreItems.push({
|
||||
key: "edit",
|
||||
label: "编辑",
|
||||
disabled: submitting,
|
||||
onSelect: () => setEditing(true),
|
||||
});
|
||||
}
|
||||
if (canViewHistory && item.edited && !editing) {
|
||||
moreItems.push({
|
||||
key: "history",
|
||||
label: "编辑历史",
|
||||
onSelect: () => onOpenHistory(item.id, item.content),
|
||||
});
|
||||
}
|
||||
if (canDelete && !editing) {
|
||||
moreItems.push({
|
||||
key: "delete",
|
||||
label: "删除",
|
||||
danger: true,
|
||||
disabled: submitting,
|
||||
onSelect: () => setDeleteTarget(true),
|
||||
});
|
||||
}
|
||||
|
||||
const editedMark = item.edited ? (
|
||||
canViewHistory ? (
|
||||
<button
|
||||
type="button"
|
||||
className="cmt-edited"
|
||||
onClick={() => onOpenHistory(item.id, item.content)}
|
||||
>
|
||||
已编辑
|
||||
</button>
|
||||
) : (
|
||||
<span className="cmt-edited" data-tip="此评论曾被编辑">
|
||||
已编辑
|
||||
</span>
|
||||
)
|
||||
) : null;
|
||||
|
||||
if (item.deleted) {
|
||||
return (
|
||||
<div
|
||||
id={item.floor ? `comment-${item.floor}` : undefined}
|
||||
data-comment-id={item.id}
|
||||
className={`cmt-item scroll-mt-24 ${isFloor ? "" : "cmt-reply-row"}`}
|
||||
>
|
||||
<div className="flex gap-3">
|
||||
<Avatar size={isFloor ? 34 : 32} name={userName} src={item.user?.avatar} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="cmt-meta">
|
||||
<div className="cmt-meta-lead">
|
||||
<span className="cmt-name" style={{ color: "var(--ink)" }}>
|
||||
{userName}
|
||||
</span>
|
||||
{item.floor && item.floor > 0 && (
|
||||
<a href={`#comment-${item.floor}`} className="cmt-floor-label">
|
||||
#{item.floor}
|
||||
</a>
|
||||
)}
|
||||
<span className="cmt-time">{formatRelative(item.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="cmt-tombstone">
|
||||
<p className="cmt-tombstone-title">该评论已删除</p>
|
||||
</div>
|
||||
<div className="cmt-actions">
|
||||
<CommentMoreMenu items={moreItems} />
|
||||
</div>
|
||||
{item.replies && item.replies.length > 0 && (
|
||||
<div className="cmt-children">
|
||||
{item.replies.map((r) => (
|
||||
<CommentNode
|
||||
key={r.id}
|
||||
item={r}
|
||||
depth={depth + 1}
|
||||
snippetId={snippetId}
|
||||
onUpdate={onUpdate}
|
||||
onDelete={onDelete}
|
||||
onOpenHistory={onOpenHistory}
|
||||
loggedIn={loggedIn}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<ConfirmDialog
|
||||
open={deleteTarget}
|
||||
title="删除评论"
|
||||
message="确定删除这条评论吗?删除后将显示占位,回复仍保留。"
|
||||
confirmLabel="删除"
|
||||
danger
|
||||
onConfirm={handleDelete}
|
||||
onCancel={() => setDeleteTarget(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
id={item.floor ? `comment-${item.floor}` : undefined}
|
||||
data-comment-id={item.id}
|
||||
className={`cmt-item scroll-mt-24 ${isFloor ? "" : "cmt-reply-row"}`}
|
||||
>
|
||||
<div className="flex gap-3">
|
||||
<Link
|
||||
href={userPath(urlStyle, item.user_id)}
|
||||
className="shrink-0"
|
||||
aria-label={userName}
|
||||
>
|
||||
<Avatar size={isFloor ? 34 : 32} name={userName} src={item.user?.avatar} />
|
||||
</Link>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="cmt-meta">
|
||||
<div className="cmt-meta-lead">
|
||||
<Link href={userPath(urlStyle, item.user_id)} className="cmt-name-link group">
|
||||
<span className="cmt-name group-hover:text-[var(--accent)] transition-colors">
|
||||
{userName}
|
||||
</span>
|
||||
</Link>
|
||||
{item.floor && item.floor > 0 && (
|
||||
<a href={`#comment-${item.floor}`} className="cmt-floor-label">
|
||||
#{item.floor}
|
||||
</a>
|
||||
)}
|
||||
<time className="cmt-time" dateTime={item.created_at} data-tip={formatDateTime(item.created_at)}>
|
||||
{formatRelative(item.created_at)}
|
||||
</time>
|
||||
{editedMark}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{editing ? (
|
||||
<div className="cmt-md-editor mt-1">
|
||||
<MarkdownEditor
|
||||
density="comment"
|
||||
value={editContent}
|
||||
onChange={(v) => setEditContent(v.slice(0, COMMENT_MAX))}
|
||||
maxChars={COMMENT_MAX}
|
||||
disabled={submitting}
|
||||
autoFocus
|
||||
allowHide={false}
|
||||
ariaLabel="编辑评论"
|
||||
placeholder="编辑评论…"
|
||||
shortcutName="保存"
|
||||
onUploadingChange={setEditUploading}
|
||||
onSubmitShortcut={() => {
|
||||
void handleEdit();
|
||||
}}
|
||||
footer={
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm"
|
||||
disabled={submitting}
|
||||
onClick={() => {
|
||||
setEditing(false);
|
||||
setEditContent(item.content);
|
||||
}}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleEdit}
|
||||
disabled={submitting || editUploading || !editContent.trim()}
|
||||
className="btn btn-primary cmt-submit"
|
||||
>
|
||||
{submitting ? "保存中…" : "保存修改"}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<MarkdownBodyClient content={item.content} className="prose-content prose-comment" />
|
||||
)}
|
||||
|
||||
<div className="cmt-actions">
|
||||
{!editing && loggedIn && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setReplyOpen(!replyOpen)}
|
||||
className={`cmt-reply-btn${replyOpen ? " !text-[var(--accent)]" : ""}`}
|
||||
aria-label={`回复 ${userName}`}
|
||||
>
|
||||
回复
|
||||
</button>
|
||||
)}
|
||||
<CommentMoreMenu items={moreItems} />
|
||||
</div>
|
||||
|
||||
{replyOpen && !editing && (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void handleReply();
|
||||
}}
|
||||
className="cmt-reply-form"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 mb-2 text-[13px]" style={{ color: "var(--ink-2)" }}>
|
||||
<span>
|
||||
回复 <span style={{ color: "var(--accent)", fontWeight: 600 }}>@{userName}</span>
|
||||
</span>
|
||||
<button type="button" className="cmt-reply-btn" onClick={() => setReplyOpen(false)}>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
<div className="cmt-md-editor">
|
||||
<MarkdownEditor
|
||||
density="comment"
|
||||
value={replyContent}
|
||||
onChange={(v) => setReplyContent(v.slice(0, COMMENT_MAX))}
|
||||
maxChars={COMMENT_MAX}
|
||||
disabled={submitting}
|
||||
autoFocus
|
||||
allowHide={false}
|
||||
ariaLabel={`回复 ${userName}`}
|
||||
placeholder="写下你的回复…"
|
||||
shortcutName="回复"
|
||||
onUploadingChange={setReplyUploading}
|
||||
onSubmitShortcut={() => {
|
||||
void handleReply();
|
||||
}}
|
||||
footer={
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting || replyUploading || !replyContent.trim()}
|
||||
aria-busy={submitting}
|
||||
className="btn btn-primary cmt-submit"
|
||||
>
|
||||
{submitting ? "回复中…" : "回复"}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{item.replies && item.replies.length > 0 && (
|
||||
<div className="cmt-children">
|
||||
{item.replies.map((r) => (
|
||||
<CommentNode
|
||||
key={r.id}
|
||||
item={r}
|
||||
depth={depth + 1}
|
||||
snippetId={snippetId}
|
||||
onUpdate={onUpdate}
|
||||
onDelete={onDelete}
|
||||
onOpenHistory={onOpenHistory}
|
||||
loggedIn={loggedIn}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={deleteTarget}
|
||||
title="删除评论"
|
||||
message="确定删除这条评论吗?删除后将显示占位,回复仍保留。"
|
||||
confirmLabel="删除"
|
||||
danger
|
||||
onConfirm={handleDelete}
|
||||
onCancel={() => setDeleteTarget(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SnippetCommentSection({
|
||||
snippetId,
|
||||
initialItems,
|
||||
initialTotal,
|
||||
urlStyle: _urlStyle,
|
||||
}: {
|
||||
snippetId: number;
|
||||
initialItems: SnippetCommentItem[];
|
||||
initialTotal: number;
|
||||
urlStyle: string;
|
||||
}) {
|
||||
const [items, setItems] = useState(initialItems);
|
||||
const [total, setTotal] = useState(initialTotal);
|
||||
const [content, setContent] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
const [loggedIn, setLoggedIn] = useState(false);
|
||||
const [historyCommentId, setHistoryCommentId] = useState<number | null>(null);
|
||||
const [historyCommentContent, setHistoryCommentContent] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
window.dispatchEvent(new CustomEvent(snippetCommentCountEvent(snippetId), { detail: total }));
|
||||
}, [total, snippetId]);
|
||||
|
||||
useEffect(() => {
|
||||
apiMe()
|
||||
.then((res) => setLoggedIn(!!res.user))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const loadComments = useCallback(
|
||||
async (p: number) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { fetchSnippetComments } = await import("@/lib/api");
|
||||
const res = await fetchSnippetComments(snippetId, p, 20);
|
||||
setItems(res.items);
|
||||
setTotal(res.total);
|
||||
setPage(p);
|
||||
} catch {
|
||||
toast("加载评论失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[snippetId]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const onRefresh = () => loadComments(page);
|
||||
window.addEventListener("j13-snippet-comment-refresh", onRefresh);
|
||||
return () => window.removeEventListener("j13-snippet-comment-refresh", onRefresh);
|
||||
}, [loadComments, page]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const next = content.trim();
|
||||
if (!next || submitting) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await apiCreateSnippetComment(snippetId, next);
|
||||
setContent("");
|
||||
toast("评论成功", "ok");
|
||||
loadComments(1);
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : "评论失败");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdate = async (cid: number, newContent: string) => {
|
||||
await apiUpdateSnippetComment(snippetId, cid, newContent);
|
||||
loadComments(page);
|
||||
};
|
||||
|
||||
const handleDelete = async (cid: number) => {
|
||||
await apiDeleteSnippetComment(snippetId, cid);
|
||||
loadComments(page);
|
||||
};
|
||||
|
||||
const openHistory = (cid: number, currentContent: string) => {
|
||||
setHistoryCommentContent(currentContent);
|
||||
setHistoryCommentId(cid);
|
||||
};
|
||||
|
||||
const fetchHistoryPage = async (p: number, size: number) => {
|
||||
if (!historyCommentId) return { items: [], total: 0, page: p, size };
|
||||
const res = await apiSnippetCommentHistory(snippetId, historyCommentId, p, size);
|
||||
const items: EditHistoryItem[] = res.items.map((it) => ({
|
||||
id: it.id,
|
||||
editor: it.editor
|
||||
? {
|
||||
id: it.editor.id,
|
||||
username: it.editor.username,
|
||||
nickname: it.editor.nickname,
|
||||
avatar: it.editor.avatar,
|
||||
}
|
||||
: { id: 0, username: "已注销", nickname: "已注销", avatar: "" },
|
||||
old_content: it.old_content,
|
||||
created_at: it.created_at,
|
||||
}));
|
||||
return { items, total: res.total, page: res.page, size: res.size };
|
||||
};
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / 20));
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-[15px] font-bold mb-4 inline-flex items-center gap-2" style={{ color: "var(--ink)" }}>
|
||||
<MessageSquare size={15} style={{ color: "var(--accent)" }} />
|
||||
评论({total})
|
||||
</h2>
|
||||
|
||||
{loggedIn ? (
|
||||
<div className="mb-6 cmt-md-editor">
|
||||
<MarkdownEditor
|
||||
id={`snippet-comment-input-${snippetId}`}
|
||||
density="comment"
|
||||
value={content}
|
||||
onChange={(v) => setContent(v.slice(0, COMMENT_MAX))}
|
||||
maxChars={COMMENT_MAX}
|
||||
disabled={submitting}
|
||||
allowHide={false}
|
||||
ariaLabel="发表评论"
|
||||
placeholder="写下你的看法,支持 Markdown…"
|
||||
onUploadingChange={setUploading}
|
||||
onSubmitShortcut={() => {
|
||||
void handleSubmit();
|
||||
}}
|
||||
footer={
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={submitting || uploading || !content.trim()}
|
||||
className="btn btn-primary cmt-submit inline-flex items-center gap-1.5"
|
||||
>
|
||||
<Send size={13} />
|
||||
{submitting ? "发送中…" : "发表评论"}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="mb-6 rounded-xl px-4 py-6 text-center"
|
||||
style={{ background: "var(--panel-2)", border: "1px solid var(--line)" }}
|
||||
>
|
||||
<p className="text-[13px]" style={{ color: "var(--ink-3)" }}>
|
||||
<Link href="/login" className="hover:underline" style={{ color: "var(--accent)" }}>
|
||||
登录
|
||||
</Link>{" "}
|
||||
后参与评论
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<p className="meta text-[13px] text-center py-8">加载中…</p>
|
||||
) : items.length === 0 ? (
|
||||
<div
|
||||
className="rounded-xl py-10 text-center"
|
||||
style={{ background: "var(--panel-2)", border: "1px dashed var(--line-2)" }}
|
||||
>
|
||||
<MessageSquare size={20} className="mx-auto mb-2" style={{ color: "var(--ink-3)" }} />
|
||||
<p className="text-[13px]" style={{ color: "var(--ink-3)" }}>
|
||||
还没有评论,来抢沙发吧
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col divide-y" style={{ borderColor: "var(--line)" }}>
|
||||
{items.map((item) => (
|
||||
<CommentNode
|
||||
key={item.id}
|
||||
item={item}
|
||||
depth={0}
|
||||
snippetId={snippetId}
|
||||
onUpdate={handleUpdate}
|
||||
onDelete={handleDelete}
|
||||
onOpenHistory={openHistory}
|
||||
loggedIn={loggedIn}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="mt-4 flex items-center justify-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={page <= 1 || loading}
|
||||
onClick={() => loadComments(page - 1)}
|
||||
className="btn btn-line btn-sm"
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
<span className="meta text-[12.5px] tabular-nums">
|
||||
{page} / {totalPages}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={page >= totalPages || loading}
|
||||
onClick={() => loadComments(page + 1)}
|
||||
className="btn btn-line btn-sm"
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<EditHistoryModal
|
||||
open={historyCommentId !== null}
|
||||
title="评论编辑历史"
|
||||
currentContent={historyCommentContent}
|
||||
fetchPage={fetchHistoryPage}
|
||||
onClose={() => setHistoryCommentId(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
331
frontend/components/SnippetEditor.tsx
Normal file
331
frontend/components/SnippetEditor.tsx
Normal file
@@ -0,0 +1,331 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ArrowLeft, CodeXml, Eye, EyeOff, Trash2 } from "lucide-react";
|
||||
import { apiCreateSnippet, apiUpdateSnippet, apiDeleteSnippet, fetchSnippetLanguages, type SnippetInput, type SnippetVisibility } from "@/lib/api";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { snippetPath, snippetsPath, type UrlStyle } from "@/lib/urlStyle";
|
||||
import Modal from "@/components/Modal";
|
||||
import SnippetLangSelect from "@/components/SnippetLangSelect";
|
||||
import { hasAuthCookieHint } from "@/lib/cookies";
|
||||
import { useSiteBrand } from "@/components/SiteBrandProvider";
|
||||
|
||||
const TITLE_MAX = 120;
|
||||
const CODE_MAX = 64 * 1024; // 64KB
|
||||
const DESC_MAX = 8 * 1024; // 8KB
|
||||
|
||||
const VISIBILITY_OPTIONS: { value: SnippetVisibility; label: string; desc: string }[] = [
|
||||
{ value: "private", label: "私有", desc: "仅自己可见" },
|
||||
{ value: "public", label: "公开", desc: "所有人可见,出现在广场" },
|
||||
];
|
||||
|
||||
export default function SnippetEditor({
|
||||
mode,
|
||||
snippetId,
|
||||
initialTitle = "",
|
||||
initialLanguage = "plaintext",
|
||||
initialDescription = "",
|
||||
initialCode = "",
|
||||
initialVisibility = "private",
|
||||
}: {
|
||||
mode: "create" | "edit";
|
||||
snippetId?: number;
|
||||
initialTitle?: string;
|
||||
initialLanguage?: string;
|
||||
initialDescription?: string;
|
||||
initialCode?: string;
|
||||
initialVisibility?: SnippetVisibility;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const { url_style: urlStyle } = useSiteBrand();
|
||||
const [title, setTitle] = useState(initialTitle);
|
||||
const [language, setLanguage] = useState(initialLanguage);
|
||||
const [description, setDescription] = useState(initialDescription);
|
||||
const [code, setCode] = useState(initialCode);
|
||||
const [visibility, setVisibility] = useState<SnippetVisibility>(initialVisibility);
|
||||
const [languages, setLanguages] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const codeRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasAuthCookieHint()) {
|
||||
router.push("/login?redirect=/snippets/new");
|
||||
return;
|
||||
}
|
||||
fetchSnippetLanguages()
|
||||
.then((res) => setLanguages(res.languages))
|
||||
.catch(() => {});
|
||||
}, [router]);
|
||||
|
||||
// Tab 键缩进
|
||||
const handleCodeKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === "Tab") {
|
||||
e.preventDefault();
|
||||
const el = e.currentTarget;
|
||||
const start = el.selectionStart;
|
||||
const end = el.selectionEnd;
|
||||
const newValue = code.substring(0, start) + " " + code.substring(end);
|
||||
setCode(newValue);
|
||||
requestAnimationFrame(() => {
|
||||
el.selectionStart = el.selectionEnd = start + 2;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const codeBytes = new TextEncoder().encode(code).length;
|
||||
const descBytes = new TextEncoder().encode(description).length;
|
||||
|
||||
const canSubmit =
|
||||
!loading &&
|
||||
title.trim().length > 0 &&
|
||||
code.trim().length > 0 &&
|
||||
codeBytes <= CODE_MAX &&
|
||||
descBytes <= DESC_MAX;
|
||||
|
||||
const handleSubmit = async (e?: React.FormEvent) => {
|
||||
e?.preventDefault();
|
||||
if (!canSubmit) return;
|
||||
setLoading(true);
|
||||
const input: SnippetInput = {
|
||||
title: title.trim(),
|
||||
language,
|
||||
description: description.trim(),
|
||||
code: code.trim(),
|
||||
visibility,
|
||||
};
|
||||
try {
|
||||
if (mode === "create") {
|
||||
const res = await apiCreateSnippet(input);
|
||||
toast("创建成功", "ok");
|
||||
router.push(snippetPath(urlStyle, res.snippet.id));
|
||||
} else if (snippetId) {
|
||||
const res = await apiUpdateSnippet(snippetId, input);
|
||||
toast("已保存", "ok");
|
||||
router.push(snippetPath(urlStyle, res.snippet.id));
|
||||
}
|
||||
} catch (err) {
|
||||
toast(err instanceof Error ? err.message : "操作失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!snippetId || deleting) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
await apiDeleteSnippet(snippetId);
|
||||
toast("已删除", "ok");
|
||||
router.push(snippetsPath(urlStyle));
|
||||
} catch (err) {
|
||||
toast(err instanceof Error ? err.message : "删除失败");
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
setDeleteOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="j13-compose-stage is-studio">
|
||||
<header className="j13-compose-toolbar">
|
||||
<div className="j13-compose-toolbar-lead">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.back()}
|
||||
className="j13-compose-back"
|
||||
aria-label="返回"
|
||||
data-tip="返回"
|
||||
>
|
||||
<ArrowLeft size={18} strokeWidth={2.2} />
|
||||
</button>
|
||||
<div className="j13-compose-toolbar-copy min-w-0">
|
||||
<h1 className="j13-compose-toolbar-title">
|
||||
{mode === "create" ? "新建片段" : "编辑片段"}
|
||||
</h1>
|
||||
<p className="j13-compose-toolbar-sub">
|
||||
{mode === "create" ? "保存一段可复用的代码" : "修改片段内容"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="j13-compose-toolbar-actions">
|
||||
{mode === "edit" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDeleteOpen(true)}
|
||||
className="btn btn-line btn-sm"
|
||||
style={{ color: "var(--danger)" }}
|
||||
>
|
||||
<Trash2 size={14} /> 删除
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!canSubmit}
|
||||
className="btn btn-primary min-w-[5.5rem] h-9 px-4 text-[13.5px] font-semibold"
|
||||
>
|
||||
{loading ? "保存中…" : mode === "create" ? "创建" : "保存"}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="j13-compose-studio">
|
||||
<div className="j13-compose-studio-canvas">
|
||||
{/* 标题 */}
|
||||
<div className="j13-compose-title-row">
|
||||
<label className="sr-only" htmlFor="snippet-title">
|
||||
标题
|
||||
</label>
|
||||
<input
|
||||
id="snippet-title"
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value.slice(0, TITLE_MAX))}
|
||||
required
|
||||
maxLength={TITLE_MAX}
|
||||
placeholder="片段标题"
|
||||
className="j13-compose-title"
|
||||
disabled={loading}
|
||||
/>
|
||||
<span className="j13-compose-title-count tabular-nums">
|
||||
{title.length}/{TITLE_MAX}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 语言 + 可见性 */}
|
||||
<div className="flex flex-wrap items-center gap-3 px-4 sm:px-6 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<CodeXml size={14} style={{ color: "var(--ink-3)" }} />
|
||||
<SnippetLangSelect
|
||||
value={language}
|
||||
onChange={setLanguage}
|
||||
languages={languages}
|
||||
allowAll={false}
|
||||
variant="editor"
|
||||
ariaLabel="编程语言"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 rounded-full p-0.5" style={{ background: "var(--panel-2)" }}>
|
||||
{VISIBILITY_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => setVisibility(opt.value)}
|
||||
className={`px-3 py-1 rounded-full text-[12px] font-medium transition-colors ${
|
||||
visibility === opt.value ? "" : "hover:bg-[var(--panel)]"
|
||||
}`}
|
||||
style={
|
||||
visibility === opt.value
|
||||
? { background: "var(--accent-soft)", color: "var(--accent)" }
|
||||
: { color: "var(--ink-2)" }
|
||||
}
|
||||
disabled={loading}
|
||||
>
|
||||
{opt.value === "public" ? <Eye size={12} className="inline mr-1" /> : <EyeOff size={12} className="inline mr-1" />}
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span className="meta text-[11.5px]">
|
||||
{VISIBILITY_OPTIONS.find((o) => o.value === visibility)?.desc}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 代码编辑区 */}
|
||||
<div className="j13-compose-canvas">
|
||||
<label className="sr-only" htmlFor="snippet-code">
|
||||
代码
|
||||
</label>
|
||||
<textarea
|
||||
ref={codeRef}
|
||||
id="snippet-code"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
onKeyDown={handleCodeKeyDown}
|
||||
placeholder="粘贴或输入代码…"
|
||||
className="w-full min-h-[320px] px-4 py-3 text-[13.5px] font-mono resize-y"
|
||||
style={{
|
||||
background: "var(--bg)",
|
||||
border: "1px solid var(--line)",
|
||||
color: "var(--ink)",
|
||||
borderRadius: "var(--r)",
|
||||
tabSize: 2,
|
||||
}}
|
||||
disabled={loading}
|
||||
/>
|
||||
<div className="mt-1 flex justify-end">
|
||||
<span
|
||||
className="meta text-[11px] tabular-nums"
|
||||
style={codeBytes > CODE_MAX ? { color: "var(--danger)", fontWeight: 600 } : undefined}
|
||||
>
|
||||
{codeBytes > 1024 ? `${(codeBytes / 1024).toFixed(1)} KB` : `${codeBytes} B`} / 64 KB
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 描述 */}
|
||||
<div className="px-4 sm:px-6 pb-6">
|
||||
<label className="field-label" htmlFor="snippet-desc">
|
||||
描述(可选)
|
||||
</label>
|
||||
<textarea
|
||||
id="snippet-desc"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="用 Markdown 描述这段代码的用途、用法或注意事项…"
|
||||
rows={4}
|
||||
className="w-full rounded-lg px-4 py-3 text-[13.5px]"
|
||||
style={{
|
||||
background: "var(--bg)",
|
||||
border: "1px solid var(--line)",
|
||||
color: "var(--ink)",
|
||||
}}
|
||||
disabled={loading}
|
||||
/>
|
||||
<div className="mt-1 flex justify-end">
|
||||
<span
|
||||
className="meta text-[11px] tabular-nums"
|
||||
style={descBytes > DESC_MAX ? { color: "var(--danger)", fontWeight: 600 } : undefined}
|
||||
>
|
||||
{descBytes > 1024 ? `${(descBytes / 1024).toFixed(1)} KB` : `${descBytes} B`} / 8 KB
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
open={deleteOpen}
|
||||
title="删除片段"
|
||||
description="删除后不可恢复,确定要删除这个代码片段吗?"
|
||||
onClose={() => setDeleteOpen(false)}
|
||||
dismissOnOverlay
|
||||
maxWidthClass="max-w-sm"
|
||||
footer={
|
||||
<>
|
||||
<button type="button" className="btn btn-line btn-sm" onClick={() => setDeleteOpen(false)}>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
style={{ background: "var(--danger)", borderColor: "var(--danger)" }}
|
||||
onClick={handleDelete}
|
||||
disabled={deleting}
|
||||
>
|
||||
{deleting ? "删除中…" : "确认删除"}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p className="text-[13.5px]" style={{ color: "var(--ink-2)" }}>
|
||||
此操作不可撤销。
|
||||
</p>
|
||||
</Modal>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
254
frontend/components/SnippetHero.tsx
Normal file
254
frontend/components/SnippetHero.tsx
Normal file
@@ -0,0 +1,254 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
CodeXml, Copy, Eye, MessageSquare, GitFork, Users,
|
||||
Plus, Zap, ShieldCheck, Binary,
|
||||
} from "lucide-react";
|
||||
import type { SnippetStats } from "@/lib/api";
|
||||
import { snippetLangColor } from "@/lib/snippetLangColors";
|
||||
import { hasAuthCookieHint } from "@/lib/cookies";
|
||||
|
||||
const MONO = "var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)";
|
||||
|
||||
function useRollIn(target: number, duration = 1000) {
|
||||
const [value, setValue] = useState(0);
|
||||
useEffect(() => {
|
||||
const start = performance.now();
|
||||
let raf = 0;
|
||||
const tick = (t: number) => {
|
||||
const p = Math.min((t - start) / duration, 1);
|
||||
const ease = 1 - Math.pow(1 - p, 3);
|
||||
setValue(Math.round(target * ease));
|
||||
if (p < 1) raf = requestAnimationFrame(tick);
|
||||
};
|
||||
raf = requestAnimationFrame(tick);
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [target, duration]);
|
||||
return value;
|
||||
}
|
||||
|
||||
function formatK(n: number) {
|
||||
if (n >= 100_000) return `${(n / 1000).toFixed(0)}k`;
|
||||
if (n >= 10_000) return `${(n / 1000).toFixed(0)}k`;
|
||||
if (n >= 1000) return `${(n / 1000).toFixed(1).replace(/\.0$/, "")}k`;
|
||||
return `${n}`;
|
||||
}
|
||||
|
||||
function StatBlock({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
delay,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
value: string;
|
||||
delay: number;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="j13-hero-stat flex items-center gap-2.5 px-3 py-2 rounded-md"
|
||||
style={{
|
||||
animationDelay: `${delay}ms`,
|
||||
background: "var(--hero-chip-bg)",
|
||||
border: "1px solid var(--hero-line)",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="w-7 h-7 flex items-center justify-center rounded-md shrink-0"
|
||||
style={{ background: "var(--hero-chip-icon)" }}
|
||||
>
|
||||
<span style={{ color: "var(--accent)" }}>{icon}</span>
|
||||
</span>
|
||||
<div className="flex flex-col leading-none">
|
||||
<span
|
||||
className="text-[10px] font-medium tracking-wide uppercase"
|
||||
style={{ color: "var(--hero-ink-3)", fontFamily: MONO }}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
<span
|
||||
className="text-[17px] font-extrabold tabular-nums mt-0.5"
|
||||
style={{ color: "var(--hero-ink)", fontFamily: MONO }}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LangMatrix({
|
||||
topLanguages,
|
||||
total,
|
||||
}: {
|
||||
topLanguages: SnippetStats["top_languages"];
|
||||
total: number;
|
||||
}) {
|
||||
const max = Math.max(1, ...topLanguages.map((l) => l.count));
|
||||
return (
|
||||
<div className="mt-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-[10px] font-medium tracking-wide uppercase" style={{ color: "var(--hero-ink-3)", fontFamily: MONO }}>
|
||||
Language Matrix
|
||||
</span>
|
||||
<div className="flex items-center gap-1" style={{ color: "var(--hero-ink-3)" }}>
|
||||
<ShieldCheck size={9} />
|
||||
<span className="text-[9px]" style={{ fontFamily: MONO }}>
|
||||
SECURE
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{topLanguages.map((l, i) => {
|
||||
const c = snippetLangColor(l.language);
|
||||
const pct = total > 0 ? Math.round((l.count / total) * 100) : 0;
|
||||
return (
|
||||
<div key={l.language} className="flex items-center gap-2.5">
|
||||
<span
|
||||
className="w-[72px] shrink-0 text-[10px] font-semibold text-right tabular-nums truncate"
|
||||
style={{ color: c, fontFamily: MONO }}
|
||||
>
|
||||
{l.language}
|
||||
</span>
|
||||
<div className="flex-1 h-[5px] rounded-full overflow-hidden" style={{ background: "var(--hero-track)" }}>
|
||||
<div
|
||||
className="j13-hero-langbar h-full rounded-full"
|
||||
style={{
|
||||
width: `${Math.max(3, (l.count / max) * 100)}%`,
|
||||
background: `linear-gradient(90deg, ${c}, color-mix(in srgb, ${c}, transparent 40%))`,
|
||||
animationDelay: `${300 + i * 80}ms`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="w-7 shrink-0 text-[10px] tabular-nums text-right" style={{ color: "var(--hero-ink-3)", fontFamily: MONO }}>
|
||||
{pct}%
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 语言跑马灯:10 种热门语言 ×2 循环 */
|
||||
function LangTicker({ langs }: { langs: string[] }) {
|
||||
const doubled = [...langs, ...langs];
|
||||
return (
|
||||
<div className="j13-hero-ticker py-2" style={{ borderTop: "1px solid var(--hero-track)", borderBottom: "1px solid var(--hero-track)" }}>
|
||||
<div className="j13-hero-ticker-track">
|
||||
{doubled.map((l, i) => (
|
||||
<span key={`${l}-${i}`} className="flex items-center gap-1.5 text-[11px] font-medium shrink-0" style={{ fontFamily: MONO, color: "var(--hero-ink-2)" }}>
|
||||
<span className="inline-block w-[5px] h-[5px] rounded-full" style={{ background: snippetLangColor(l) }} />
|
||||
{l}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SnippetHero({ stats }: { stats: SnippetStats | null }) {
|
||||
const isLoggedIn = typeof window !== "undefined" ? hasAuthCookieHint() : false;
|
||||
const s = stats;
|
||||
const total = useRollIn(s?.total_snippets ?? 0);
|
||||
const copies = useRollIn(s?.total_copies ?? 0);
|
||||
const views = useRollIn(s?.total_views ?? 0);
|
||||
const comments = useRollIn(s?.total_comments ?? 0);
|
||||
const forks = useRollIn(s?.total_forks ?? 0);
|
||||
const contributors = useRollIn(s?.contributors ?? 0);
|
||||
|
||||
// 跑马灯展示的热门语言(去重后取前 10,兜底硬编码)
|
||||
const tickerLangs = Array.from(new Set([
|
||||
...topLangNames(s?.top_languages),
|
||||
"go", "rust", "typescript", "python", "javascript", "bash", "dockerfile", "kotlin", "yaml", "sql",
|
||||
])).slice(0, 10);
|
||||
|
||||
return (
|
||||
<section className="j13-hero relative mb-6 rounded-2xl overflow-hidden">
|
||||
{/* 网格底纹 */}
|
||||
<div className="absolute inset-0 j13-hero-grid pointer-events-none" />
|
||||
|
||||
{/* 扫描光束 */}
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none" style={{ opacity: 0.5 }}>
|
||||
<div className="j13-hero-scan" />
|
||||
</div>
|
||||
|
||||
{/* 内容层 */}
|
||||
<div className="relative px-5 pt-5 pb-5 sm:px-7 sm:pt-6 sm:pb-6">
|
||||
{/* 顶部栏 */}
|
||||
<div className="flex items-start justify-between gap-3 mb-5">
|
||||
<div className="flex items-start gap-3 min-w-0">
|
||||
<span
|
||||
className="inline-flex items-center justify-center w-10 h-10 rounded-xl shrink-0"
|
||||
style={{ background: "rgba(148,163,205,0.09)", border: "1px solid var(--hero-line)" }}
|
||||
>
|
||||
<CodeXml size={19} style={{ color: "var(--accent)" }} />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<span className="text-[11px]" style={{ color: "var(--hero-ink-3)", fontFamily: MONO }}>
|
||||
$ jiang13/snippets
|
||||
</span>
|
||||
<span className="text-[11px]" style={{ color: "var(--hero-ink-3)", fontFamily: MONO }}>
|
||||
--public --summary
|
||||
</span>
|
||||
<span className="j13-hero-caret inline-block w-[6px] h-[3px] rounded-sm" style={{ background: "var(--accent)" }} />
|
||||
</div>
|
||||
<h1 className="j13-hero-title text-[22px] sm:text-[26px] font-extrabold tracking-tight leading-none mt-0.5">
|
||||
代码片段广场
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 flex items-center gap-2">
|
||||
<span className="flex items-center gap-1.5 text-[10px] px-2 py-1 rounded-full" style={{ color: "var(--hero-ink-3)", fontFamily: MONO, background: "var(--hero-chip-bg)", border: "1px solid var(--hero-line)" }}>
|
||||
<span className="inline-block w-2 h-2 rounded-full j13-hero-pulse" style={{ background: "#22c55e" }} />
|
||||
ONLINE
|
||||
</span>
|
||||
{isLoggedIn && (
|
||||
<Link href="/snippets/new" className="btn btn-primary btn-sm inline-flex items-center gap-1.5 shrink-0">
|
||||
<Plus size={14} /> 新建
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 统计区 */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-2">
|
||||
<StatBlock icon={<Binary size={12} />} label="Snippets" value={formatK(total)} delay={0} />
|
||||
<StatBlock icon={<Eye size={12} />} label="Views" value={formatK(views)} delay={60} />
|
||||
<StatBlock icon={<Copy size={12} />} label="Copies" value={formatK(copies)} delay={120} />
|
||||
<StatBlock icon={<GitFork size={12} />} label="Forks" value={formatK(forks)} delay={180} />
|
||||
<StatBlock icon={<MessageSquare size={12} />} label="Comments" value={formatK(comments)} delay={240} />
|
||||
<StatBlock icon={<Users size={12} />} label="Devs" value={formatK(contributors)} delay={300} />
|
||||
</div>
|
||||
|
||||
{/* 语言矩阵 */}
|
||||
{s && s.top_languages.length > 0 && <LangMatrix topLanguages={s.top_languages} total={s.total_snippets} />}
|
||||
|
||||
{/* 底栏 */}
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
<span className="flex items-center gap-1.5 text-[10px]" style={{ color: "var(--hero-ink-3)", fontFamily: MONO }}>
|
||||
<Zap size={9} />
|
||||
{s ? "DATA_SYNCED" : "OFFLINE"}
|
||||
</span>
|
||||
<span className="text-[10px] tabular-nums" style={{ color: "var(--hero-ink-3)", fontFamily: MONO }}>
|
||||
LAT<span style={{ color: "var(--accent)" }}> 37.7749°N</span> LNG<span style={{ color: "var(--accent)" }}> 122.4194°W</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 语言跑马灯 */}
|
||||
<LangTicker langs={tickerLangs} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function topLangNames(arr?: { language: string; count: number }[]) {
|
||||
if (!arr) return [];
|
||||
return arr.map((a) => a.language).filter(Boolean);
|
||||
}
|
||||
236
frontend/components/SnippetLangSelect.tsx
Normal file
236
frontend/components/SnippetLangSelect.tsx
Normal file
@@ -0,0 +1,236 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Check, Search, X } from "lucide-react";
|
||||
|
||||
const MONO = "var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)";
|
||||
|
||||
/**
|
||||
* 语言选择自定义下拉(统一站点样式):
|
||||
* - variant="toolbar":广场工具台(含"全部语言"与清除)
|
||||
* - variant="editor":新建/编辑片段表单(药丸形,语言必选)
|
||||
*/
|
||||
export default function SnippetLangSelect({
|
||||
value,
|
||||
onChange,
|
||||
languages,
|
||||
allowAll = true,
|
||||
variant = "toolbar",
|
||||
ariaLabel = "选择语言",
|
||||
allLabel = "全部语言",
|
||||
disabled = false,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
languages: string[];
|
||||
allowAll?: boolean;
|
||||
variant?: "toolbar" | "editor";
|
||||
ariaLabel?: string;
|
||||
allLabel?: string;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [q, setQ] = useState("");
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setQ("");
|
||||
const t = window.setTimeout(() => inputRef.current?.focus(), 30);
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", onDown);
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
window.clearTimeout(t);
|
||||
document.removeEventListener("mousedown", onDown);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const kw = q.trim().toLowerCase();
|
||||
return kw ? languages.filter((l) => l.includes(kw)) : languages;
|
||||
}, [q, languages]);
|
||||
|
||||
const triggerCls =
|
||||
variant === "editor"
|
||||
? "j13-sn-search h-8 rounded-full pl-3 pr-2.5 text-[12.5px] gap-1.5"
|
||||
: "j13-sn-search w-full h-9 rounded-lg px-3 text-[12.5px] gap-2";
|
||||
const wrapCls = variant === "editor" ? "relative shrink-0" : "relative shrink-0 sm:w-44 w-full";
|
||||
const menuCls =
|
||||
variant === "editor"
|
||||
? "j13-sn-lang-menu absolute z-30 mt-1.5 left-0 w-56 rounded-xl overflow-hidden"
|
||||
: "j13-sn-lang-menu absolute z-30 mt-1.5 left-0 right-0 sm:right-auto sm:w-60 rounded-xl overflow-hidden";
|
||||
|
||||
return (
|
||||
<div ref={ref} className={wrapCls}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => !disabled && setOpen((v) => !v)}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
aria-label={ariaLabel}
|
||||
disabled={disabled}
|
||||
className={`${triggerCls} flex items-center disabled:opacity-60 disabled:cursor-not-allowed`}
|
||||
style={{ background: "var(--panel)", border: "1px solid transparent", color: "var(--ink)" }}
|
||||
>
|
||||
<span
|
||||
className="w-[6px] h-[6px] rounded-full shrink-0"
|
||||
style={{ background: value ? "var(--accent)" : "var(--ink-3)", opacity: value ? 1 : 0.5 }}
|
||||
/>
|
||||
<span
|
||||
className="truncate text-left flex-1"
|
||||
style={{ fontFamily: MONO, color: value ? "var(--ink)" : "var(--ink-3)" }}
|
||||
>
|
||||
{value || allLabel}
|
||||
</span>
|
||||
{allowAll && value ? (
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={-1}
|
||||
aria-label="清除语言筛选"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onChange("");
|
||||
}}
|
||||
className="w-5 h-5 -mr-1 flex items-center justify-center rounded-md hover:bg-[var(--panel-2)] transition-colors"
|
||||
style={{ color: "var(--ink-3)" }}
|
||||
>
|
||||
<X size={11} />
|
||||
</span>
|
||||
) : (
|
||||
<ArrowDownIcon className="shrink-0" rotated={open} />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
className={menuCls}
|
||||
style={{
|
||||
background: "var(--panel)",
|
||||
border: "1px solid var(--line-2)",
|
||||
boxShadow: "var(--shadow-lg)",
|
||||
}}
|
||||
role="listbox"
|
||||
>
|
||||
<div className="p-2" style={{ borderBottom: "1px solid var(--line)" }}>
|
||||
<div className="relative">
|
||||
<Search
|
||||
size={12}
|
||||
className="absolute left-2.5 top-1/2 -translate-y-1/2 pointer-events-none"
|
||||
style={{ color: "var(--ink-3)" }}
|
||||
/>
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
placeholder="筛选语言…"
|
||||
aria-label="筛选语言"
|
||||
className="w-full h-8 rounded-md pl-7 pr-2 text-[12px] outline-none"
|
||||
style={{ background: "var(--panel-2)", border: "1px solid transparent", color: "var(--ink)" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ul className="max-h-56 overflow-y-auto py-1">
|
||||
{allowAll ? (
|
||||
<LangOption
|
||||
active={value === ""}
|
||||
onClick={() => {
|
||||
onChange("");
|
||||
setOpen(false);
|
||||
}}
|
||||
label={allLabel}
|
||||
dotDim
|
||||
/>
|
||||
) : null}
|
||||
{filtered.length === 0 ? (
|
||||
<li className="px-3 py-3 text-center text-[12px]" style={{ color: "var(--ink-3)" }}>
|
||||
没有匹配的语言
|
||||
</li>
|
||||
) : (
|
||||
filtered.map((l) => (
|
||||
<LangOption
|
||||
key={l}
|
||||
active={value === l}
|
||||
onClick={() => {
|
||||
onChange(l);
|
||||
setOpen(false);
|
||||
}}
|
||||
label={l}
|
||||
mono
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ArrowDownIcon({ className = "", rotated }: { className?: string; rotated: boolean }) {
|
||||
return (
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={`${className} ${rotated ? "rotate-180" : ""}`}
|
||||
style={{ color: "var(--ink-3)", transition: "transform 160ms ease" }}
|
||||
aria-hidden
|
||||
>
|
||||
<path d="m6 9 6 6 6-6" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function LangOption({
|
||||
active,
|
||||
onClick,
|
||||
label,
|
||||
mono,
|
||||
dotDim,
|
||||
}: {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
label: string;
|
||||
mono?: boolean;
|
||||
dotDim?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={active}
|
||||
onClick={onClick}
|
||||
className="w-full flex items-center gap-2 px-2.5 py-1.5 text-left text-[12.5px] transition-colors hover:bg-[var(--panel-2)]"
|
||||
style={{
|
||||
color: active ? "var(--accent)" : "var(--ink-2)",
|
||||
background: active ? "var(--accent-soft)" : undefined,
|
||||
fontFamily: mono ? MONO : undefined,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="w-[5px] h-[5px] rounded-full shrink-0"
|
||||
style={{
|
||||
background: active ? "var(--accent)" : "var(--ink-3)",
|
||||
opacity: active ? 1 : dotDim ? 0.5 : 0.3,
|
||||
}}
|
||||
/>
|
||||
<span className="flex-1 truncate">{label}</span>
|
||||
{active ? <Check size={12} className="shrink-0" /> : null}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
185
frontend/components/SnippetPickerModal.tsx
Normal file
185
frontend/components/SnippetPickerModal.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
239
frontend/components/SnippetVersionModal.tsx
Normal file
239
frontend/components/SnippetVersionModal.tsx
Normal file
@@ -0,0 +1,239 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { History, X } from "lucide-react";
|
||||
import { apiSnippetHistory } from "@/lib/api";
|
||||
import type { SnippetVersionItem } from "@/lib/api";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { formatRelative } from "@/lib/format";
|
||||
import { smartDiff, toFoldedRows, type FoldedRow } from "@/lib/smartDiff";
|
||||
import Modal from "@/components/Modal";
|
||||
|
||||
function DiffLineRow({ row }: { row: FoldedRow }) {
|
||||
if (row.kind === "fold") {
|
||||
return (
|
||||
<div
|
||||
className="text-center py-1 text-[11px] select-none"
|
||||
style={{ color: "var(--ink-3)", background: "var(--panel-2)" }}
|
||||
>
|
||||
⋯ 折叠 {row.count} 行 ⋯
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const bg =
|
||||
row.op === "del"
|
||||
? "color-mix(in srgb, var(--danger) 8%, transparent)"
|
||||
: row.op === "ins"
|
||||
? "color-mix(in srgb, var(--ok) 8%, transparent)"
|
||||
: "transparent";
|
||||
const color =
|
||||
row.op === "del"
|
||||
? "var(--danger)"
|
||||
: row.op === "ins"
|
||||
? "var(--ok)"
|
||||
: "var(--ink)";
|
||||
const prefix = row.op === "del" ? "- " : row.op === "ins" ? "+ " : " ";
|
||||
|
||||
if (row.segments && row.segments.length > 0) {
|
||||
// 行内细分高亮
|
||||
const parts: Array<{ text: string; changed: boolean }> = [];
|
||||
let last = 0;
|
||||
for (const seg of row.segments) {
|
||||
if (seg.start > last) parts.push({ text: row.text.slice(last, seg.start), changed: false });
|
||||
parts.push({ text: row.text.slice(seg.start, seg.end), changed: true });
|
||||
last = seg.end;
|
||||
}
|
||||
if (last < row.text.length) parts.push({ text: row.text.slice(last), changed: false });
|
||||
return (
|
||||
<div
|
||||
className="flex gap-2 px-3 py-0.5 text-[12.5px] font-mono whitespace-pre-wrap break-all"
|
||||
style={{ background: bg, color }}
|
||||
>
|
||||
<span className="shrink-0 w-4 select-none" style={{ color: "var(--ink-3)" }}>
|
||||
{prefix}
|
||||
</span>
|
||||
<span>
|
||||
{parts.map((p, i) =>
|
||||
p.changed ? (
|
||||
<mark
|
||||
key={i}
|
||||
style={{
|
||||
background:
|
||||
row.op === "del"
|
||||
? "color-mix(in srgb, var(--danger) 25%, transparent)"
|
||||
: "color-mix(in srgb, var(--ok) 25%, transparent)",
|
||||
color: "inherit",
|
||||
}}
|
||||
>
|
||||
{p.text}
|
||||
</mark>
|
||||
) : (
|
||||
<span key={i}>{p.text}</span>
|
||||
)
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex gap-2 px-3 py-0.5 text-[12.5px] font-mono whitespace-pre-wrap break-all"
|
||||
style={{ background: bg, color }}
|
||||
>
|
||||
<span className="shrink-0 w-4 select-none" style={{ color: "var(--ink-3)" }}>
|
||||
{prefix}
|
||||
</span>
|
||||
<span>{row.text || " "}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function VersionDiff({ oldText, newText }: { oldText: string; newText: string }) {
|
||||
const result = smartDiff(oldText, newText);
|
||||
const rows = toFoldedRows(result.blocks, 3);
|
||||
return (
|
||||
<div
|
||||
className="rounded-lg overflow-hidden"
|
||||
style={{ border: "1px solid var(--line)", background: "var(--bg)" }}
|
||||
>
|
||||
<div
|
||||
className="px-3 py-1.5 text-[11px] font-medium flex items-center gap-3"
|
||||
style={{ background: "var(--panel-2)", borderBottom: "1px solid var(--line)", color: "var(--ink-3)" }}
|
||||
>
|
||||
<span style={{ color: "var(--ok)" }}>+{result.stats.adds}</span>
|
||||
<span style={{ color: "var(--danger)" }}>-{result.stats.dels}</span>
|
||||
</div>
|
||||
<div className="max-h-[320px] overflow-y-auto">
|
||||
{rows.map((row, i) => (
|
||||
<DiffLineRow key={i} row={row} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SnippetVersionModal({
|
||||
open,
|
||||
onClose,
|
||||
snippetId,
|
||||
currentCode,
|
||||
currentTitle,
|
||||
currentLanguage,
|
||||
currentDescription,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
snippetId: number;
|
||||
currentCode: string;
|
||||
currentTitle: string;
|
||||
currentLanguage: string;
|
||||
currentDescription: string;
|
||||
}) {
|
||||
const [items, setItems] = useState<SnippetVersionItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [expandedId, setExpandedId] = useState<number | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await apiSnippetHistory(snippetId, 1, 50);
|
||||
setItems(res.items);
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : "加载历史失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [snippetId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) load();
|
||||
}, [open, load]);
|
||||
|
||||
const toggleExpand = (id: number) => {
|
||||
setExpandedId((prev) => (prev === id ? null : id));
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title="版本历史"
|
||||
description="每次编辑前的旧值快照"
|
||||
onClose={onClose}
|
||||
dismissOnOverlay
|
||||
maxWidthClass="max-w-3xl"
|
||||
panelClassName="p-5 sm:p-6"
|
||||
>
|
||||
<div className="max-h-[60vh] overflow-y-auto">
|
||||
{loading ? (
|
||||
<p className="meta text-[13px] text-center py-8">加载中…</p>
|
||||
) : items.length === 0 ? (
|
||||
<p className="meta text-[13px] text-center py-8">暂无编辑历史</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
{items.map((v) => {
|
||||
const isExpanded = expandedId === v.id;
|
||||
const diffRows = isExpanded
|
||||
? toFoldedRows(smartDiff(v.old_code, currentCode).blocks, 3)
|
||||
: null;
|
||||
return (
|
||||
<div
|
||||
key={v.id}
|
||||
className="rounded-lg overflow-hidden"
|
||||
style={{ border: "1px solid var(--line)", background: "var(--panel)" }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleExpand(v.id)}
|
||||
className="w-full flex items-center justify-between px-4 py-3 text-left hover:bg-[var(--panel-2)] transition-colors"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[13px] font-medium truncate" style={{ color: "var(--ink)" }}>
|
||||
{v.old_title || currentTitle}
|
||||
</p>
|
||||
<p className="meta text-[11px] mt-0.5">
|
||||
{formatRelative(v.created_at)} · {v.old_language || currentLanguage}
|
||||
</p>
|
||||
</div>
|
||||
<span className="meta text-[11px] shrink-0 ml-3">
|
||||
{isExpanded ? "收起" : "查看差异"}
|
||||
</span>
|
||||
</button>
|
||||
{isExpanded && diffRows && (
|
||||
<div className="px-4 pb-4">
|
||||
<div
|
||||
className="rounded-lg overflow-hidden"
|
||||
style={{ border: "1px solid var(--line)", background: "var(--bg)" }}
|
||||
>
|
||||
<div
|
||||
className="px-3 py-1.5 text-[11px] font-medium flex items-center gap-3"
|
||||
style={{
|
||||
background: "var(--panel-2)",
|
||||
borderBottom: "1px solid var(--line)",
|
||||
color: "var(--ink-3)",
|
||||
}}
|
||||
>
|
||||
<span style={{ color: "var(--ok)" }}>
|
||||
+{smartDiff(v.old_code, currentCode).stats.adds}
|
||||
</span>
|
||||
<span style={{ color: "var(--danger)" }}>
|
||||
-{smartDiff(v.old_code, currentCode).stats.dels}
|
||||
</span>
|
||||
</div>
|
||||
<div className="max-h-[320px] overflow-y-auto">
|
||||
{diffRows.map((row, i) => (
|
||||
<DiffLineRow key={i} row={row} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
Layers,
|
||||
Images,
|
||||
Link2,
|
||||
CodeXml,
|
||||
} from "lucide-react";
|
||||
import type { User } from "@/lib/api";
|
||||
import { apiAdminPendingCounts, apiLogout } from "@/lib/api";
|
||||
@@ -118,6 +119,12 @@ const NAV_GROUPS: NavGroup[] = [
|
||||
icon: BookOpen,
|
||||
visible: (u) => hasPerm(u, PERMS.LIBRARY),
|
||||
},
|
||||
{
|
||||
href: "/admin/snippets",
|
||||
label: "片段管理",
|
||||
icon: CodeXml,
|
||||
visible: (u) => isStaff(u.role),
|
||||
},
|
||||
{
|
||||
href: "/admin/friend-links",
|
||||
label: "友情链接",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { CalendarDays, CalendarCheck, LogIn, Loader2 } from "lucide-react";
|
||||
import { apiDoCheckin, type CheckinStatus } from "@/lib/api";
|
||||
import { apiDoCheckin, apiGetCheckin, type CheckinStatus } from "@/lib/api";
|
||||
|
||||
/**
|
||||
* 每日签到紧凑条,内嵌在右栏「站点数据」卡底部。
|
||||
@@ -24,7 +24,17 @@ export function PulseCheckin({ initial }: { initial?: CheckinStatus | null }) {
|
||||
try {
|
||||
setStatus(await apiDoCheckin());
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "签到失败");
|
||||
const msg = e instanceof Error ? e.message : "签到失败";
|
||||
// 本地状态滞后(缓存/多标签页)导致后端判重时,回拉真实状态,避免按钮仍显示「签到」
|
||||
if (msg.includes("已签到")) {
|
||||
try {
|
||||
setStatus(await apiGetCheckin());
|
||||
return;
|
||||
} catch {
|
||||
// 回拉失败,回落为错误提示
|
||||
}
|
||||
}
|
||||
setError(msg);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
124
frontend/components/markdown/MdSnippet.tsx
Normal file
124
frontend/components/markdown/MdSnippet.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import Link from "next/link";
|
||||
import { CodeXml, Lock, Copy, Eye, MessageSquare, GitFork } from "lucide-react";
|
||||
import { fetchSnippetById } from "@/lib/api";
|
||||
import { postViewerCookieHeader } from "@/lib/cookies";
|
||||
import { getPublicSettingsCached } from "@/lib/serverData";
|
||||
import { cookies } from "next/headers";
|
||||
import { snippetPath } from "@/lib/urlStyle";
|
||||
import Avatar from "@/components/Avatar";
|
||||
|
||||
/**
|
||||
* [snippet id=N] 嵌入卡片(SSR)。
|
||||
* 私有/已删除/不存在时渲染降级占位,不暴露内容。
|
||||
*/
|
||||
export default async function MdSnippet({ id }: { id: number }) {
|
||||
const cookieHeader = postViewerCookieHeader(await cookies());
|
||||
const [data, settings] = await Promise.all([
|
||||
fetchSnippetById(id, cookieHeader).catch(() => null),
|
||||
getPublicSettingsCached(),
|
||||
]);
|
||||
const urlStyle = settings.url_style;
|
||||
|
||||
// 降级占位:不存在 / 私有且无权限 / 已删除
|
||||
if (!data) {
|
||||
return (
|
||||
<div
|
||||
className="my-4 rounded-xl px-4 py-4 flex items-center gap-3"
|
||||
style={{ background: "var(--panel-2)", border: "1px solid var(--line)" }}
|
||||
>
|
||||
<span
|
||||
className="w-9 h-9 flex items-center justify-center rounded-lg shrink-0"
|
||||
style={{ background: "var(--panel)", color: "var(--ink-3)" }}
|
||||
>
|
||||
<Lock size={16} />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[13px] font-medium" style={{ color: "var(--ink-2)" }}>
|
||||
代码片段 #{id}
|
||||
</p>
|
||||
<p className="meta text-[11.5px]">片段不存在或无权查看</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const s = data.snippet;
|
||||
const ownerName = s.user ? s.user.nickname || s.user.username : "";
|
||||
const previewLines = s.code.split("\n").slice(0, 5);
|
||||
const href = snippetPath(urlStyle, s.id);
|
||||
|
||||
return (
|
||||
<Link href={href} className="group block my-4">
|
||||
<div
|
||||
className="rounded-xl overflow-hidden transition-[box-shadow,transform] duration-200 group-hover:-translate-y-0.5 group-hover:shadow-[var(--shadow-md)]"
|
||||
style={{ border: "1px solid var(--line)", background: "var(--panel)" }}
|
||||
>
|
||||
{/* 头部:语言 + 标题 + 作者 */}
|
||||
<div className="flex items-center gap-2.5 px-4 py-3" style={{ borderBottom: "1px solid var(--line)" }}>
|
||||
<span
|
||||
className="w-8 h-8 flex items-center justify-center rounded-lg shrink-0"
|
||||
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
|
||||
>
|
||||
<CodeXml size={15} />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="text-[10px] font-semibold px-1.5 py-0.5 rounded uppercase tracking-wide shrink-0"
|
||||
style={{
|
||||
color: "var(--accent)",
|
||||
background: "var(--accent-soft)",
|
||||
border: "1px solid color-mix(in srgb, var(--accent) 30%, transparent)",
|
||||
}}
|
||||
>
|
||||
{s.language}
|
||||
</span>
|
||||
<p className="text-[14px] font-semibold truncate" style={{ color: "var(--ink)" }}>
|
||||
{s.title}
|
||||
</p>
|
||||
</div>
|
||||
{s.user && (
|
||||
<span className="flex items-center gap-1.5 mt-0.5">
|
||||
<Avatar size={14} name={ownerName} src={s.user.avatar} />
|
||||
<span className="meta text-[11px] truncate">{ownerName}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 代码预览 */}
|
||||
<div className="px-4 py-3" style={{ background: "var(--panel-2)" }}>
|
||||
<pre
|
||||
className="text-[12px] leading-[1.6] overflow-hidden"
|
||||
style={{ color: "var(--ink-2)", fontFamily: "var(--font-mono, ui-monospace, monospace)" }}
|
||||
>
|
||||
{previewLines.map((line, i) => (
|
||||
<div key={i} className="flex gap-2 min-w-0">
|
||||
<span className="shrink-0 w-4 text-right select-none" style={{ color: "var(--ink-3)", opacity: 0.5 }}>
|
||||
{i + 1}
|
||||
</span>
|
||||
<span className="truncate">{line || " "}</span>
|
||||
</div>
|
||||
))}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/* 底部统计 */}
|
||||
<div className="flex items-center gap-3 px-4 py-2.5 meta text-[11px]">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Copy size={11} /> {s.copy_count}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Eye size={11} /> {s.view_count}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<MessageSquare size={11} /> {s.comment_count}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<GitFork size={11} /> {s.fork_count}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -14,14 +14,36 @@ export const SHIKI_LANG_IDS = [
|
||||
"tsx",
|
||||
"jsx",
|
||||
"json",
|
||||
"jsonc",
|
||||
"yaml",
|
||||
"toml",
|
||||
"ini",
|
||||
"bash",
|
||||
"sql",
|
||||
"html",
|
||||
"css",
|
||||
"scss",
|
||||
"diff",
|
||||
"rust",
|
||||
"ruby",
|
||||
"powershell",
|
||||
"nginx",
|
||||
"dockerfile",
|
||||
"kotlin",
|
||||
"java",
|
||||
"c",
|
||||
"cpp",
|
||||
"csharp",
|
||||
"php",
|
||||
"swift",
|
||||
"dart",
|
||||
"lua",
|
||||
"vue",
|
||||
"graphql",
|
||||
"xml",
|
||||
"regex",
|
||||
"makefile",
|
||||
"prisma",
|
||||
"plaintext",
|
||||
] as const;
|
||||
|
||||
@@ -38,8 +60,44 @@ export const SHIKI_LANG_ALIAS: Record<string, string> = {
|
||||
yml: "yaml",
|
||||
text: "plaintext",
|
||||
txt: "plaintext",
|
||||
golang: "go",
|
||||
rb: "ruby",
|
||||
ps1: "powershell",
|
||||
pwsh: "powershell",
|
||||
"c++": "cpp",
|
||||
cs: "csharp",
|
||||
"c#": "csharp",
|
||||
regexp: "regex",
|
||||
docker: "dockerfile",
|
||||
};
|
||||
|
||||
/**
|
||||
* 白名单 id → @shikijs/langs grammar 内部注册名。
|
||||
* 部分语言的 dist 文件只是别名重导出(如 bash.mjs = shellscript.mjs),
|
||||
* HighlighterCore 按内部名注册,codeToHtml(lang:"bash") 会静默降级 plaintext。
|
||||
*/
|
||||
const INTERNAL_LANG_ALIAS: Record<string, string> = {
|
||||
bash: "shellscript",
|
||||
sh: "shellscript",
|
||||
shell: "shellscript",
|
||||
zsh: "shellscript",
|
||||
dockerfile: "docker",
|
||||
regex: "regexp",
|
||||
makefile: "make",
|
||||
};
|
||||
|
||||
/**
|
||||
* createHighlighterCore 的 langAlias:输入别名 + 白名单 id 都要能落到 grammar 内部名。
|
||||
* 注意 core 会校验别名链无环,且 docker/regexp 本身就是内部注册名,
|
||||
* 不能再作为 alias 键(会与 dockerfile→docker、regex→regexp 互指成环)。
|
||||
*/
|
||||
function buildCoreLangAlias(): Record<string, string> {
|
||||
const merged = { ...SHIKI_LANG_ALIAS, ...INTERNAL_LANG_ALIAS };
|
||||
delete merged["docker"];
|
||||
delete merged["regexp"];
|
||||
return merged;
|
||||
}
|
||||
|
||||
export const SHIKI_THEMES = {
|
||||
light: "github-light",
|
||||
dark: "github-dark-dimmed",
|
||||
@@ -84,10 +142,30 @@ export function getSiteHighlighter(): Promise<HighlighterCore> {
|
||||
import("@shikijs/langs/css"),
|
||||
import("@shikijs/langs/scss"),
|
||||
import("@shikijs/langs/diff"),
|
||||
import("@shikijs/langs/rust"),
|
||||
import("@shikijs/langs/ruby"),
|
||||
import("@shikijs/langs/powershell"),
|
||||
import("@shikijs/langs/nginx"),
|
||||
import("@shikijs/langs/dockerfile"),
|
||||
import("@shikijs/langs/kotlin"),
|
||||
import("@shikijs/langs/java"),
|
||||
import("@shikijs/langs/c"),
|
||||
import("@shikijs/langs/cpp"),
|
||||
import("@shikijs/langs/csharp"),
|
||||
import("@shikijs/langs/php"),
|
||||
import("@shikijs/langs/swift"),
|
||||
import("@shikijs/langs/dart"),
|
||||
import("@shikijs/langs/lua"),
|
||||
import("@shikijs/langs/vue"),
|
||||
import("@shikijs/langs/graphql"),
|
||||
import("@shikijs/langs/xml"),
|
||||
import("@shikijs/langs/regex"),
|
||||
import("@shikijs/langs/makefile"),
|
||||
import("@shikijs/langs/prisma"),
|
||||
// plaintext / text / txt 为 Shiki 特殊语言,无需 grammar
|
||||
aardioGrammar,
|
||||
],
|
||||
langAlias: SHIKI_LANG_ALIAS,
|
||||
langAlias: buildCoreLangAlias(),
|
||||
engine: createOnigurumaEngine(import("shiki/wasm")),
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user