feat: 代码片段功能与 Markdown 内嵌片段渲染、文档截图等
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowDown, ArrowUp, BookOpen, ExternalLink, Home, Plus, Trash2, Trophy } from "lucide-react";
|
||||
import { ArrowDown, ArrowUp, BookOpen, CodeXml, ExternalLink, Home, Plus, Trash2, Trophy } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { AdminSettingsActions, AdminSettingsGroup, AdminSwitch } from "@/components/admin";
|
||||
import { apiUpdateSiteSettings, type PublicSettings } from "@/lib/api";
|
||||
@@ -19,6 +19,7 @@ const BUILTIN_ICONS: Record<HeaderNavBuiltinKey, React.ReactNode> = {
|
||||
home: <Home size={14} />,
|
||||
leaderboard: <Trophy size={14} />,
|
||||
library: <BookOpen size={14} />,
|
||||
snippets: <CodeXml size={14} />,
|
||||
};
|
||||
|
||||
/** 内置项目标页说明(实际地址随伪静态风格变化,不允许改地址) */
|
||||
@@ -26,6 +27,7 @@ const BUILTIN_HINTS: Record<HeaderNavBuiltinKey, string> = {
|
||||
home: "站点首页 /",
|
||||
leaderboard: "排行榜页(地址随伪静态风格变化)",
|
||||
library: "书库页(地址随伪静态风格变化)",
|
||||
snippets: "片段广场页(地址随伪静态风格变化)",
|
||||
};
|
||||
|
||||
type BuiltinSwitches = Record<HeaderNavBuiltinKey, boolean>;
|
||||
@@ -42,6 +44,7 @@ function switchesOf(items: HeaderNavItem[]): BuiltinSwitches {
|
||||
home: present.has("home"),
|
||||
leaderboard: present.has("leaderboard"),
|
||||
library: present.has("library"),
|
||||
snippets: present.has("snippets"),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
277
frontend/app/admin/snippets/SnippetsAdmin.tsx
Normal file
277
frontend/app/admin/snippets/SnippetsAdmin.tsx
Normal file
@@ -0,0 +1,277 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { CodeXml, Copy, Eye, EyeOff, GitFork, Loader2, MessagesSquare, Trash2 } from "lucide-react";
|
||||
import {
|
||||
AdminEmpty,
|
||||
AdminPageHeader,
|
||||
AdminPager,
|
||||
AdminSearchField,
|
||||
AdminSegmented,
|
||||
} from "@/components/admin";
|
||||
import ConfirmDialog from "@/components/ConfirmDialog";
|
||||
import Avatar from "@/components/Avatar";
|
||||
import {
|
||||
apiAdminDeleteSnippet,
|
||||
apiAdminSetSnippetVisibility,
|
||||
apiAdminSnippets,
|
||||
type SnippetListItem,
|
||||
} from "@/lib/api";
|
||||
import { formatRelative } from "@/lib/format";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { snippetPath } from "@/lib/urlStyle";
|
||||
import { useUrlStyle } from "@/components/SiteBrandProvider";
|
||||
|
||||
type VisibilityFilter = "all" | "public" | "private";
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
/** 片段管理:全量列表(含私有),支持搜索、可见性筛选、下架/上架与删除。 */
|
||||
export default function SnippetsAdmin() {
|
||||
const urlStyle = useUrlStyle();
|
||||
const [items, setItems] = useState<SnippetListItem[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [vis, setVis] = useState<VisibilityFilter>("all");
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [q, setQ] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [confirm, setConfirm] = useState<
|
||||
| { kind: "delete"; item: SnippetListItem }
|
||||
| { kind: "hide" | "show"; item: SnippetListItem }
|
||||
| null
|
||||
>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const load = useCallback(async (p: number, v: VisibilityFilter, kw: string) => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const res = await apiAdminSnippets({
|
||||
q: kw || undefined,
|
||||
visibility: v === "all" ? undefined : v,
|
||||
page: p,
|
||||
size: PAGE_SIZE,
|
||||
});
|
||||
setItems(res.items);
|
||||
setTotal(res.total);
|
||||
setPage(p);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "加载失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load(1, "all", "");
|
||||
}, [load]);
|
||||
|
||||
const runConfirm = async () => {
|
||||
if (!confirm) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
if (confirm.kind === "delete") {
|
||||
await apiAdminDeleteSnippet(confirm.item.id);
|
||||
toast("片段已删除", "ok");
|
||||
} else {
|
||||
const next = confirm.kind === "hide" ? "private" : "public";
|
||||
await apiAdminSetSnippetVisibility(confirm.item.id, next);
|
||||
toast(next === "private" ? "已下架为私有" : "已恢复为公开", "ok");
|
||||
}
|
||||
setConfirm(null);
|
||||
await load(page, vis, q);
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : "操作失败");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<AdminPageHeader
|
||||
icon={CodeXml}
|
||||
title="片段管理"
|
||||
description="查看全站代码片段(含私有),可下架违规公开片段或删除。"
|
||||
/>
|
||||
|
||||
<div
|
||||
className="rounded-2xl overflow-hidden"
|
||||
style={{ border: "1px solid var(--line)", background: "var(--panel)" }}
|
||||
>
|
||||
<div
|
||||
className="flex items-center justify-between gap-2 flex-wrap px-4 sm:px-5 py-3"
|
||||
style={{ borderBottom: "1px solid var(--line)" }}
|
||||
>
|
||||
<AdminSegmented<VisibilityFilter>
|
||||
ariaLabel="可见性筛选"
|
||||
size="sm"
|
||||
value={vis}
|
||||
onChange={(v) => void load(1, v, q)}
|
||||
options={[
|
||||
{ key: "all", label: "全部" },
|
||||
{ key: "public", label: "公开" },
|
||||
{ key: "private", label: "私有" },
|
||||
]}
|
||||
/>
|
||||
<AdminSearchField
|
||||
value={keyword}
|
||||
onChange={setKeyword}
|
||||
onSubmit={() => {
|
||||
setQ(keyword.trim());
|
||||
void load(1, vis, keyword.trim());
|
||||
}}
|
||||
onClear={() => {
|
||||
setKeyword("");
|
||||
setQ("");
|
||||
void load(1, vis, "");
|
||||
}}
|
||||
placeholder="搜索标题或描述…"
|
||||
ariaLabel="搜索片段"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error ? <p className="alert-error m-3 text-[12.5px]">{error}</p> : null}
|
||||
|
||||
{loading && items.length === 0 ? (
|
||||
<div className="flex justify-center py-14">
|
||||
<Loader2 size={20} className="animate-spin" style={{ color: "var(--ink-3)" }} />
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<AdminEmpty
|
||||
icon={<CodeXml size={20} />}
|
||||
title="暂无片段"
|
||||
description={q ? "没有匹配搜索条件的片段。" : "还没有用户发布代码片段。"}
|
||||
/>
|
||||
) : (
|
||||
<ul className="divide-y" style={{ borderColor: "var(--line)" }}>
|
||||
{items.map((it) => (
|
||||
<li key={it.id} className="px-4 sm:px-5 py-3 flex items-center gap-3 flex-wrap">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 min-w-0 flex-wrap">
|
||||
<Link
|
||||
href={snippetPath(urlStyle, it.id)}
|
||||
target="_blank"
|
||||
className="truncate text-[14px] font-semibold hover:underline"
|
||||
style={{ color: "var(--ink)" }}
|
||||
>
|
||||
{it.title}
|
||||
</Link>
|
||||
<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 inline-flex items-center gap-1 rounded-full px-1.5 py-px text-[10.5px]"
|
||||
style={{ background: "var(--panel-2)", color: "var(--ink-3)" }}
|
||||
>
|
||||
<EyeOff size={10} /> 私有
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
className="shrink-0 inline-flex items-center gap-1 rounded-full px-1.5 py-px text-[10.5px]"
|
||||
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
|
||||
>
|
||||
<Eye size={10} /> 公开
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-2.5 text-[11.5px] flex-wrap" style={{ color: "var(--ink-3)" }}>
|
||||
{it.user ? (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Avatar name={it.user.nickname} src={it.user.avatar} size={14} />
|
||||
{it.user.nickname}
|
||||
</span>
|
||||
) : (
|
||||
<span>用户 #{it.user_id}</span>
|
||||
)}
|
||||
<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">
|
||||
<MessagesSquare size={11} /> {it.comment_count}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<GitFork size={11} /> {it.fork_count}
|
||||
</span>
|
||||
<span>{formatRelative(it.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{it.visibility === "public" ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm inline-flex items-center gap-1"
|
||||
onClick={() => setConfirm({ kind: "hide", item: it })}
|
||||
>
|
||||
<EyeOff size={13} /> 下架
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm inline-flex items-center gap-1"
|
||||
onClick={() => setConfirm({ kind: "show", item: it })}
|
||||
>
|
||||
<Eye size={13} /> 上架
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm inline-flex items-center gap-1"
|
||||
style={{ color: "var(--danger)" }}
|
||||
onClick={() => setConfirm({ kind: "delete", item: it })}
|
||||
>
|
||||
<Trash2 size={13} /> 删除
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<AdminPager
|
||||
page={page}
|
||||
total={total}
|
||||
pageSize={PAGE_SIZE}
|
||||
loading={loading}
|
||||
onChange={(p) => void load(p, vis, q)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!confirm}
|
||||
title={
|
||||
confirm?.kind === "delete"
|
||||
? "删除片段"
|
||||
: confirm?.kind === "hide"
|
||||
? "下架片段"
|
||||
: "上架片段"
|
||||
}
|
||||
message={
|
||||
confirm?.kind === "delete"
|
||||
? `确定删除片段「${confirm.item.title}」吗?删除后前台不可见。`
|
||||
: confirm?.kind === "hide"
|
||||
? `确定将「${confirm?.item.title}」下架为私有吗?仅作者本人可见。`
|
||||
: `确定将「${confirm?.item.title ?? ""}」恢复为公开吗?`
|
||||
}
|
||||
danger={confirm?.kind !== "show"}
|
||||
busy={busy}
|
||||
confirmLabel={
|
||||
confirm?.kind === "delete" ? "确认删除" : confirm?.kind === "hide" ? "确认下架" : "确认上架"
|
||||
}
|
||||
onCancel={() => !busy && setConfirm(null)}
|
||||
onConfirm={() => void runConfirm()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
20
frontend/app/admin/snippets/page.tsx
Normal file
20
frontend/app/admin/snippets/page.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { Metadata } from "next";
|
||||
import { cookies } from "next/headers";
|
||||
import { authCookieHeader } from "@/lib/cookies";
|
||||
import { getMeCached } from "@/lib/serverData";
|
||||
import { isStaff } from "@/lib/roles";
|
||||
import Forbidden from "../Forbidden";
|
||||
import SnippetsAdmin from "./SnippetsAdmin";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "片段管理",
|
||||
};
|
||||
|
||||
export default async function AdminSnippetsPage() {
|
||||
const cookie = authCookieHeader(await cookies());
|
||||
const me = await getMeCached(cookie || undefined);
|
||||
if (!me.user || !isStaff(me.user.role)) {
|
||||
return <Forbidden text="片段管理仅管理员及以上角色可操作。" />;
|
||||
}
|
||||
return <SnippetsAdmin />;
|
||||
}
|
||||
@@ -874,6 +874,10 @@ export default function ChatRoomClient({
|
||||
router.refresh();
|
||||
return;
|
||||
}
|
||||
if (data.status === "joined") {
|
||||
void refreshMembers();
|
||||
return;
|
||||
}
|
||||
if (data.status === "kicked") {
|
||||
toast("你已被移出该群");
|
||||
} else {
|
||||
@@ -883,7 +887,7 @@ export default function ChatRoomClient({
|
||||
}
|
||||
);
|
||||
return off;
|
||||
}, [roomId, router]);
|
||||
}, [roomId, router, refreshMembers]);
|
||||
|
||||
// 禁言/解禁实时通知:更新成员列表 + 插入系统提示
|
||||
useEffect(() => {
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
--ink: #161a22;
|
||||
--ink-2: #4b5260;
|
||||
--ink-3: #5b6270;
|
||||
--ink-4: #949aab; /* 比 --ink-3 更浅:首页帖子列表 meta 文字(作者/时间/统计) */
|
||||
--line: #e4e7ec; /* 装饰性分隔/卡片边(非交互,配合阴影与底色高差) */
|
||||
--line-2: #7c8698; /* 功能性边界:输入框/按钮/分页等可交互控件,满足 1.4.11 ≥3:1 */
|
||||
|
||||
@@ -139,6 +140,7 @@
|
||||
--ink: #eef1f6;
|
||||
--ink-2: #b6bdc9;
|
||||
--ink-3: #98a1b2;
|
||||
--ink-4: #71798a; /* 比 --ink-3 更淡:首页帖子列表 meta 文字(作者/时间/统计) */
|
||||
--line: rgba(238, 241, 246, 0.08); /* 装饰性分隔/卡片边 */
|
||||
--line-2: rgba(238, 241, 246, 0.38); /* 功能性控件边界,1.4.11 ≥3:1 */
|
||||
|
||||
@@ -4006,6 +4008,35 @@ a.j13-mention-token:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* 验证码发送按钮:与输入框同圆角,主色浅底强调可点;hover 实心浮起 */
|
||||
.btn-code {
|
||||
background: var(--accent-soft);
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 28%, transparent);
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
border-radius: var(--r-sm);
|
||||
padding: 10px 16px;
|
||||
}
|
||||
.btn-code:hover:not(:disabled) {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: var(--accent-on);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 6px rgba(16, 24, 40, 0.1),
|
||||
0 8px 20px color-mix(in srgb, var(--accent) 34%, transparent);
|
||||
}
|
||||
.btn-code:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
box-shadow: none;
|
||||
}
|
||||
.btn-code:disabled {
|
||||
background: transparent;
|
||||
border-color: var(--line-2);
|
||||
color: var(--ink-3);
|
||||
font-weight: 500;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: var(--danger-soft);
|
||||
color: var(--danger);
|
||||
@@ -6425,8 +6456,10 @@ a.j13-mention-token:hover {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* ----- 点赞按钮:未赞描边态,hover 同色系填充;已赞为浅底实心态 ----- */
|
||||
.j13-like {
|
||||
/* ----- 点赞/收藏按钮:未激活描边态,hover 主色浅底轻浮起;激活态浅底主色描边 ----- */
|
||||
.j13-like,
|
||||
.j13-fav {
|
||||
border-radius: 999px;
|
||||
border-color: var(--line-2);
|
||||
color: var(--ink-2);
|
||||
background: var(--panel);
|
||||
@@ -6434,41 +6467,82 @@ a.j13-mention-token:hover {
|
||||
border-color 0.15s ease,
|
||||
color 0.15s ease,
|
||||
background-color 0.15s ease,
|
||||
box-shadow 0.15s ease;
|
||||
box-shadow 0.15s ease,
|
||||
transform 0.15s ease;
|
||||
}
|
||||
.j13-like svg {
|
||||
.j13-like svg,
|
||||
.j13-fav svg {
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
.j13-like:hover:not(:disabled),
|
||||
.j13-like.is-liked {
|
||||
.j13-fav:hover:not(:disabled),
|
||||
.j13-like.is-liked,
|
||||
.j13-fav.is-favorited {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
/* 已赞态 hover 再加深一档,并给同色系投影;心形轻微放大(不改变布局) */
|
||||
.j13-like:hover:not(:disabled) {
|
||||
/* hover 浮起 + 同色系投影;心形/书签轻微放大(不改变布局) */
|
||||
.j13-like:hover:not(:disabled),
|
||||
.j13-fav:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 10px color-mix(in srgb, var(--accent) 22%, transparent);
|
||||
}
|
||||
.j13-like.is-liked:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, var(--accent) 17%, transparent);
|
||||
.j13-like:active:not(:disabled),
|
||||
.j13-fav:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
box-shadow: none;
|
||||
}
|
||||
.j13-like:hover:not(:disabled) svg {
|
||||
/* 文章页大按钮:12px 圆角矩形 + 幽灵底,弱化存在感;激活态浅底主色描边(文字恒为主色,保证可读) */
|
||||
.j13-like-article {
|
||||
border-radius: var(--r-sm);
|
||||
border-color: transparent;
|
||||
background: color-mix(in srgb, var(--ink) 5%, transparent);
|
||||
}
|
||||
.j13-like-article:hover:not(:disabled) {
|
||||
border-color: color-mix(in srgb, var(--accent) 25%, transparent);
|
||||
}
|
||||
.j13-like-article.is-liked,
|
||||
.j13-like-article.is-favorited {
|
||||
color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
border-color: color-mix(in srgb, var(--accent) 35%, transparent);
|
||||
box-shadow: none;
|
||||
}
|
||||
.j13-like-article.is-liked:hover:not(:disabled),
|
||||
.j13-like-article.is-favorited:hover:not(:disabled) {
|
||||
color: var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 18%, transparent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.j13-like:hover:not(:disabled) svg,
|
||||
.j13-fav:hover:not(:disabled) svg {
|
||||
transform: scale(1.15);
|
||||
}
|
||||
.j13-like:active:not(:disabled) svg {
|
||||
.j13-like:active:not(:disabled) svg,
|
||||
.j13-fav:active:not(:disabled) svg {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
.j13-like:focus-visible {
|
||||
.j13-like:focus-visible,
|
||||
.j13-fav:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.j13-like,
|
||||
.j13-like svg {
|
||||
.j13-fav,
|
||||
.j13-like svg,
|
||||
.j13-fav svg {
|
||||
transition: none;
|
||||
}
|
||||
.j13-like:hover:not(:disabled),
|
||||
.j13-fav:hover:not(:disabled),
|
||||
.j13-like:active:not(:disabled),
|
||||
.j13-fav:active:not(:disabled),
|
||||
.j13-like:hover:not(:disabled) svg,
|
||||
.j13-like:active:not(:disabled) svg {
|
||||
.j13-fav:hover:not(:disabled) svg,
|
||||
.j13-like:active:not(:disabled) svg,
|
||||
.j13-fav:active:not(:disabled) svg {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
@@ -7134,6 +7208,10 @@ a.j13-mention-token:hover {
|
||||
color: var(--ink-3);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
/* 帖子列表行:除标题外的 meta 文字(作者/时间/回复/评论数/浏览数/板块胶囊)调浅一档,
|
||||
让标题(var(--ink))更突出;hover 仍可被 above-link 的 accent 覆盖。 */
|
||||
.j13-post-row .meta { color: var(--ink-4); }
|
||||
.j13-post-row .j13-board-tag { color: var(--ink-4); }
|
||||
.link-accent { color: var(--accent); }
|
||||
.link-accent:hover { color: var(--accent-hi); }
|
||||
|
||||
@@ -9074,12 +9152,13 @@ html.dark .prose-content pre[data-theme] span {
|
||||
|
||||
/* ============================================================
|
||||
前台 main 纸面 + 首页三栏「白纸一体」
|
||||
main 铺 --main-bg(浅色纯白 / 暗色比画布略亮一档),左右外缘
|
||||
1px --line 描边与页头下边线围合成框;站长配置了自定义站点
|
||||
背景图时让位(保持透出画布)。左栏无框化,内容直接坐在纸面上;
|
||||
右栏保留卡片板块感(j13-module 原样)。
|
||||
main 恒定铺 --main-bg(浅色纯白 / 暗色比画布略亮一档),左右外缘
|
||||
1px --line 描边与页头下边线围合成框;即使站长配置了自定义站点
|
||||
背景图也不让位——header/main/footer 连成一张白纸保证内容可读,
|
||||
背景图只在页缘(1440px 画布之外)露出。左栏无框化,内容直接坐
|
||||
在纸面上;右栏保留卡片板块感(j13-module 原样)。
|
||||
============================================================ */
|
||||
html:not(.j13-has-site-bg) #main {
|
||||
#main {
|
||||
background: var(--main-bg);
|
||||
border-left: 1px solid var(--line);
|
||||
border-right: 1px solid var(--line);
|
||||
@@ -10399,3 +10478,209 @@ html.j13-reduce-chrome .j13-att-card.is-fresh {
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
/* ===== 片段广场:编辑器窗格卡片 ===== */
|
||||
/* 卡片内 shiki 双主题着色(独立作用域,不依赖 .prose-content)。
|
||||
新版 shiki 的 token span 只声明 --shiki-light/--shiki-dark 变量,color 由这里应用 */
|
||||
.j13-sn-code {
|
||||
color: var(--shiki-light, var(--ink-2));
|
||||
}
|
||||
.j13-sn-code span[style*="--shiki-light"] {
|
||||
color: var(--shiki-light);
|
||||
background: transparent;
|
||||
}
|
||||
html.dark .j13-sn-code {
|
||||
color: var(--shiki-dark, var(--ink-2));
|
||||
}
|
||||
html.dark .j13-sn-code span[style*="--shiki-dark"] {
|
||||
color: var(--shiki-dark) !important;
|
||||
font-style: var(--shiki-dark-font-style) !important;
|
||||
font-weight: var(--shiki-dark-font-weight) !important;
|
||||
}
|
||||
/* 语言下拉弹层 */
|
||||
.j13-sn-lang-menu {
|
||||
animation: j13-sn-pop 140ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
transform-origin: top left;
|
||||
}
|
||||
@keyframes j13-sn-pop {
|
||||
from { opacity: 0; transform: translateY(-4px) scale(0.98); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
/* 搜索软聚焦:光晕替代硬边框 */
|
||||
.j13-sn-search {
|
||||
transition: box-shadow 160ms ease, border-color 160ms ease, background 160ms ease;
|
||||
}
|
||||
.j13-sn-search:focus {
|
||||
outline: none;
|
||||
border-color: color-mix(in srgb, var(--accent) 45%, var(--line));
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 14%, transparent);
|
||||
}
|
||||
|
||||
/* ===== 片段广场:赛博控制台 Hero(明暗双主题自适应) ===== */
|
||||
.j13-hero {
|
||||
/* 浅色:白玻璃底 + 墨色文字,辉光收敛 */
|
||||
--hero-bg: #f7f9fc;
|
||||
--hero-ink: #0f172a;
|
||||
--hero-ink-2: #475569;
|
||||
--hero-ink-3: #8593ab;
|
||||
--hero-line: rgba(15, 23, 42, 0.10);
|
||||
--hero-chip-bg: rgba(15, 23, 42, 0.035);
|
||||
--hero-chip-icon: rgba(15, 23, 42, 0.06);
|
||||
--hero-track: rgba(15, 23, 42, 0.07);
|
||||
background:
|
||||
radial-gradient(ellipse 60% 80% at 12% -20%, color-mix(in srgb, var(--accent) 10%, transparent), transparent 65%),
|
||||
radial-gradient(ellipse 45% 70% at 88% 115%, rgba(13, 148, 136, 0.08), transparent 70%),
|
||||
radial-gradient(ellipse 35% 45% at 60% 40%, rgba(124, 58, 237, 0.06), transparent 70%),
|
||||
var(--hero-bg);
|
||||
border: 1px solid var(--line);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
html.dark .j13-hero {
|
||||
/* 深色:深夜底 + 霓虹文字,辉光拉满 */
|
||||
--hero-bg: #0a0e16;
|
||||
--hero-ink: #e8edf7;
|
||||
--hero-ink-2: #9aa7c2;
|
||||
--hero-ink-3: #5f6b88;
|
||||
--hero-line: rgba(148, 163, 205, 0.16);
|
||||
--hero-chip-bg: rgba(148, 163, 205, 0.07);
|
||||
--hero-chip-icon: rgba(148, 163, 205, 0.11);
|
||||
--hero-track: rgba(148, 163, 205, 0.10);
|
||||
background:
|
||||
radial-gradient(ellipse 60% 80% at 12% -20%, color-mix(in srgb, var(--accent) 26%, transparent), transparent 65%),
|
||||
radial-gradient(ellipse 45% 70% at 88% 115%, rgba(94, 234, 212, 0.14), transparent 70%),
|
||||
radial-gradient(ellipse 35% 45% at 60% 40%, rgba(167, 139, 250, 0.10), transparent 70%),
|
||||
var(--hero-bg);
|
||||
border: 1px solid rgba(148, 163, 205, 0.22);
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(10, 14, 22, 0.6),
|
||||
0 18px 50px -18px color-mix(in srgb, var(--accent) 28%, transparent),
|
||||
inset 0 1px 0 rgba(232, 237, 247, 0.06);
|
||||
}
|
||||
/* 网格底纹(铺满整个面板,仅底部极淡渐出避免压文字) */
|
||||
.j13-hero-grid {
|
||||
background-image:
|
||||
linear-gradient(rgba(15, 23, 42, 0.05) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(15, 23, 42, 0.05) 1px, transparent 1px);
|
||||
background-size: 30px 30px;
|
||||
mask-image: linear-gradient(to bottom, black 82%, rgba(0, 0, 0, 0.35) 100%);
|
||||
-webkit-mask-image: linear-gradient(to bottom, black 82%, rgba(0, 0, 0, 0.35) 100%);
|
||||
}
|
||||
html.dark .j13-hero-grid {
|
||||
background-image:
|
||||
linear-gradient(rgba(148, 163, 205, 0.09) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(148, 163, 205, 0.09) 1px, transparent 1px);
|
||||
}
|
||||
/* 水平扫描光束(自上而下巡航) */
|
||||
.j13-hero-scan {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 72px;
|
||||
top: -72px;
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
transparent,
|
||||
color-mix(in srgb, var(--accent) 8%, transparent) 60%,
|
||||
color-mix(in srgb, var(--accent) 30%, transparent)
|
||||
);
|
||||
filter: blur(0.5px);
|
||||
animation: j13-hero-scan 8s cubic-bezier(0.4, 0, 0.2, 1) infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
@keyframes j13-hero-scan {
|
||||
0% { transform: translateY(0); opacity: 0; }
|
||||
8% { opacity: 1; }
|
||||
60% { opacity: 1; }
|
||||
68%, 100% { transform: translateY(140%); opacity: 0; }
|
||||
}
|
||||
/* 大标题霓虹渐变 */
|
||||
.j13-hero-title {
|
||||
background: linear-gradient(100deg, var(--hero-ink) 20%, var(--accent) 55%, #5eead4 85%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
text-shadow: 0 0 34px color-mix(in srgb, var(--accent) 32%, transparent);
|
||||
}
|
||||
/* 统计数字进入 */
|
||||
.j13-hero-stat {
|
||||
animation: j13-hero-rise 560ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
}
|
||||
@keyframes j13-hero-rise {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
/* 语言均衡条:加载展开 */
|
||||
.j13-hero-langbar {
|
||||
transform-origin: left center;
|
||||
animation: j13-hero-bar 800ms cubic-bezier(0.22, 1, 0.36, 1) 260ms both;
|
||||
}
|
||||
@keyframes j13-hero-bar {
|
||||
from { transform: scaleX(0); }
|
||||
to { transform: scaleX(1); }
|
||||
}
|
||||
/* 呼吸状态灯 */
|
||||
.j13-hero-pulse {
|
||||
animation: j13-hero-pulse 2.2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes j13-hero-pulse {
|
||||
0%, 100% { opacity: 1; box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 55%, transparent); }
|
||||
50% { opacity: 0.6; box-shadow: 0 0 0 6px transparent; }
|
||||
}
|
||||
/* 光标闪烁 */
|
||||
.j13-hero-caret {
|
||||
animation: j13-hero-caret 1.1s steps(2, start) infinite;
|
||||
}
|
||||
@keyframes j13-hero-caret {
|
||||
to { visibility: hidden; }
|
||||
}
|
||||
/* 语言跑马灯 */
|
||||
.j13-hero-ticker {
|
||||
overflow: hidden;
|
||||
mask-image: linear-gradient(90deg, transparent, black 6%, black 94%, transparent);
|
||||
-webkit-mask-image: linear-gradient(90deg, transparent, black 6%, black 94%, transparent);
|
||||
}
|
||||
.j13-hero-ticker-track {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2.25rem;
|
||||
white-space: nowrap;
|
||||
animation: j13-hero-ticker 36s linear infinite;
|
||||
}
|
||||
.j13-hero-ticker:hover .j13-hero-ticker-track {
|
||||
animation-play-state: paused;
|
||||
}
|
||||
@keyframes j13-hero-ticker {
|
||||
to { transform: translateX(-50%); }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.j13-hero-scan, .j13-hero-pulse, .j13-hero-caret, .j13-hero-ticker-track { animation: none; }
|
||||
.j13-hero-stat, .j13-hero-langbar { animation-duration: 1ms; }
|
||||
}
|
||||
|
||||
/* ===== 片段卡片:语言霓虹 ===== */
|
||||
.j13-snc {
|
||||
position: relative;
|
||||
}
|
||||
/* 顶部霓虹语言条 */
|
||||
.j13-snc::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 14px;
|
||||
right: 14px;
|
||||
height: 2px;
|
||||
border-radius: 2px;
|
||||
background: linear-gradient(90deg, var(--sn-lang, var(--accent)), transparent 75%);
|
||||
opacity: 0.75;
|
||||
transition: opacity 200ms ease, left 200ms ease, right 200ms ease;
|
||||
}
|
||||
.j13-snc:hover::before {
|
||||
opacity: 1;
|
||||
left: 10px;
|
||||
right: 10px;
|
||||
}
|
||||
.j13-snc:hover {
|
||||
box-shadow:
|
||||
0 10px 34px -14px color-mix(in srgb, var(--sn-lang, var(--accent)) 45%, transparent),
|
||||
var(--shadow-lg) !important;
|
||||
}
|
||||
|
||||
|
||||
@@ -190,7 +190,7 @@ export default function RegisterClient({
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line shrink-0"
|
||||
className="btn btn-code shrink-0"
|
||||
disabled={sending || countdown > 0 || !email}
|
||||
onClick={async () => {
|
||||
// 点击即锁定,防止重复发送
|
||||
@@ -201,7 +201,7 @@ export default function RegisterClient({
|
||||
email,
|
||||
purpose: "register",
|
||||
});
|
||||
setSentMessage(r.message);
|
||||
setSentMessage("验证码已发送,请查收邮箱");
|
||||
startCountdown();
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
@@ -211,7 +211,7 @@ export default function RegisterClient({
|
||||
}}
|
||||
>
|
||||
{sending
|
||||
? "正在排队…"
|
||||
? "发送中…"
|
||||
: countdown > 0
|
||||
? `${countdown}s 后可重发`
|
||||
: "发送验证码"}
|
||||
|
||||
@@ -118,7 +118,7 @@ export default function ResetPasswordPage() {
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line shrink-0"
|
||||
className="btn btn-code shrink-0"
|
||||
disabled={sending || busy || !email}
|
||||
onClick={() => void sendCode()}
|
||||
>
|
||||
|
||||
@@ -5,9 +5,10 @@ import {
|
||||
fetchAnnouncementsPage,
|
||||
fetchPublishedSitePages,
|
||||
fetchLibraryList,
|
||||
fetchSnippets,
|
||||
} from "@/lib/api";
|
||||
import { canonicalPath, resolveSiteOrigin } from "@/lib/siteUrl";
|
||||
import { announcementPath, postPath, pagePath, leaderboardPath, libraryPath, libraryDocPath } from "@/lib/urlStyle";
|
||||
import { announcementPath, postPath, pagePath, leaderboardPath, libraryPath, libraryDocPath, snippetsPath, snippetPath } from "@/lib/urlStyle";
|
||||
import { getPublicSettingsCached } from "@/lib/serverData";
|
||||
|
||||
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
@@ -40,6 +41,12 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
changeFrequency: "daily",
|
||||
priority: 0.5,
|
||||
},
|
||||
{
|
||||
url: url(snippetsPath(style)),
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "daily",
|
||||
priority: 0.5,
|
||||
},
|
||||
{
|
||||
url: url("/about"),
|
||||
lastModified: new Date(),
|
||||
@@ -104,5 +111,20 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
/* 忽略 */
|
||||
}
|
||||
|
||||
try {
|
||||
const { items } = await fetchSnippets({ page: 1, size: 200 });
|
||||
for (const it of items) {
|
||||
if (it.visibility !== "public") continue;
|
||||
entries.push({
|
||||
url: url(snippetPath(style, it.id)),
|
||||
lastModified: new Date(it.updated_at || it.created_at),
|
||||
changeFrequency: "weekly",
|
||||
priority: 0.4,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
/* 忽略 */
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
111
frontend/app/snippets/[id]/edit/page.tsx
Normal file
111
frontend/app/snippets/[id]/edit/page.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense, useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { apiMe, apiGetSnippet } from "@/lib/api";
|
||||
import SnippetEditor from "@/components/SnippetEditor";
|
||||
|
||||
function EditSnippetInner({ id }: { id: number }) {
|
||||
const router = useRouter();
|
||||
const [ready, setReady] = useState(false);
|
||||
const [snippet, setSnippet] = useState<{
|
||||
title: string;
|
||||
language: string;
|
||||
description: string;
|
||||
code: string;
|
||||
visibility: "public" | "private";
|
||||
user_id: number;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const me = await apiMe().catch(() => null);
|
||||
if (cancelled) return;
|
||||
if (!me?.user) {
|
||||
router.push(`/login?redirect=/snippets/${id}/edit`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const data = await apiGetSnippet(id);
|
||||
if (cancelled) return;
|
||||
if (!data) {
|
||||
router.push("/snippets");
|
||||
return;
|
||||
}
|
||||
// 仅 owner 或 staff 可编辑(后端会再次校验)
|
||||
setSnippet({
|
||||
title: data.snippet.title,
|
||||
language: data.snippet.language,
|
||||
description: data.snippet.description,
|
||||
code: data.snippet.code,
|
||||
visibility: data.snippet.visibility,
|
||||
user_id: data.snippet.user_id,
|
||||
});
|
||||
setReady(true);
|
||||
} catch {
|
||||
if (!cancelled) router.push("/snippets");
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [router, id]);
|
||||
|
||||
if (!ready || !snippet) {
|
||||
return (
|
||||
<div className="j13-compose-stage is-studio px-8 py-16 text-center">
|
||||
<p className="meta">正在加载片段…</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SnippetEditor
|
||||
mode="edit"
|
||||
snippetId={id}
|
||||
initialTitle={snippet.title}
|
||||
initialLanguage={snippet.language}
|
||||
initialDescription={snippet.description}
|
||||
initialCode={snippet.code}
|
||||
initialVisibility={snippet.visibility}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function EditSnippetPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const [id, setId] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
params.then((p) => {
|
||||
const numId = Number(p.id);
|
||||
if (Number.isInteger(numId) && numId > 0) setId(numId);
|
||||
});
|
||||
}, [params]);
|
||||
|
||||
if (id === null) {
|
||||
return (
|
||||
<div className="j13-compose-stage is-studio px-8 py-16 text-center">
|
||||
<p className="meta">正在加载…</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="j13-compose-page">
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="j13-compose-stage is-studio px-8 py-16 text-center">
|
||||
<p className="meta">正在加载片段…</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<EditSnippetInner id={id} />
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
253
frontend/app/snippets/[id]/page.tsx
Normal file
253
frontend/app/snippets/[id]/page.tsx
Normal file
@@ -0,0 +1,253 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { cookies } from "next/headers";
|
||||
import { GitFork, Copy, Eye, MessageSquare } from "lucide-react";
|
||||
import {
|
||||
fetchSnippetById,
|
||||
fetchSnippetComments,
|
||||
fetchSnippetForks,
|
||||
} from "@/lib/api";
|
||||
import { getPublicSettingsCached } from "@/lib/serverData";
|
||||
import { requestCanonical } from "@/lib/canonicalMeta";
|
||||
import { snippetPath, userPath, snippetsPath } from "@/lib/urlStyle";
|
||||
import { postViewerCookieHeader } from "@/lib/cookies";
|
||||
import { formatRelative } from "@/lib/format";
|
||||
import MarkdownBody from "@/components/MarkdownBody";
|
||||
import Avatar from "@/components/Avatar";
|
||||
import SnippetCodeView from "@/components/SnippetCodeView";
|
||||
import SnippetCommentSection from "@/components/SnippetCommentSection";
|
||||
import SnippetCommentCountChip from "@/components/SnippetCommentCountChip";
|
||||
import SnippetActions from "@/components/SnippetActions";
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||
const { id } = await params;
|
||||
const numId = Number(id);
|
||||
if (!Number.isInteger(numId) || numId <= 0) return { title: "片段不存在" };
|
||||
const cookieHeader = postViewerCookieHeader(await cookies());
|
||||
const data = await fetchSnippetById(numId, cookieHeader).catch(() => null);
|
||||
if (!data) return { title: "片段不存在" };
|
||||
const s = data.snippet;
|
||||
return {
|
||||
title: s.title,
|
||||
description: s.description ? s.description.slice(0, 150) : `${s.language} 代码片段`,
|
||||
...(await requestCanonical(snippetPath((await getPublicSettingsCached()).url_style, s.id))),
|
||||
};
|
||||
}
|
||||
|
||||
export default async function SnippetDetailPage({ params }: PageProps) {
|
||||
const { id } = await params;
|
||||
const numId = Number(id);
|
||||
if (!Number.isInteger(numId) || numId <= 0) notFound();
|
||||
|
||||
const cookieHeader = postViewerCookieHeader(await cookies());
|
||||
const [detailRes, commentsRes, forksRes, settings] = await Promise.all([
|
||||
fetchSnippetById(numId, cookieHeader).catch(() => null),
|
||||
fetchSnippetComments(numId, 1, 20, cookieHeader).catch(() => ({ items: [], total: 0 })),
|
||||
fetchSnippetForks(numId, 1, 6).catch(() => ({ items: [], total: 0 })),
|
||||
getPublicSettingsCached(),
|
||||
]);
|
||||
if (!detailRes) notFound();
|
||||
|
||||
const snippet = detailRes.snippet;
|
||||
const urlStyle = settings.url_style;
|
||||
const isOwner = false; // 客户端组件中通过 apiMe 校正
|
||||
const isStaff = false;
|
||||
|
||||
return (
|
||||
<div className="max-w-[1440px] mx-auto min-w-0">
|
||||
<div className="mb-4 flex items-center gap-2 text-[12.5px] meta">
|
||||
<Link
|
||||
href={snippetsPath(urlStyle)}
|
||||
className="hover:underline inline-flex items-center gap-1.5"
|
||||
>
|
||||
← 片段广场
|
||||
</Link>
|
||||
{snippet.forked_from && (
|
||||
<>
|
||||
<span style={{ color: "var(--ink-3)" }}>·</span>
|
||||
<Link
|
||||
href={snippetPath(urlStyle, snippet.forked_from.id)}
|
||||
className="inline-flex items-center gap-1 hover:underline"
|
||||
>
|
||||
<GitFork size={11} /> Fork 自:{snippet.forked_from.title}
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<article className="panel px-4 py-6 sm:p-8">
|
||||
{/* 头部 */}
|
||||
<header>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span
|
||||
className="text-[10.5px] font-semibold px-1.5 py-0.5 rounded-md uppercase tracking-wide"
|
||||
style={{
|
||||
color: "var(--accent)",
|
||||
background: "var(--accent-soft)",
|
||||
border: "1px solid color-mix(in srgb, var(--accent) 30%, transparent)",
|
||||
}}
|
||||
>
|
||||
{snippet.language}
|
||||
</span>
|
||||
{snippet.visibility === "private" && (
|
||||
<span
|
||||
className="text-[10px] font-medium px-1.5 py-0.5 rounded-md"
|
||||
style={{
|
||||
background: "var(--panel-2)",
|
||||
color: "var(--ink-3)",
|
||||
border: "1px solid var(--line)",
|
||||
}}
|
||||
>
|
||||
私有
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h1
|
||||
className="mt-2.5 text-[23px] sm:text-[28px] leading-tight font-bold"
|
||||
style={{ color: "var(--ink)" }}
|
||||
>
|
||||
{snippet.title}
|
||||
</h1>
|
||||
|
||||
{/* 作者 + 统计 */}
|
||||
<div className="mt-4 flex items-center gap-x-4 gap-y-2.5 flex-wrap">
|
||||
{snippet.user && (
|
||||
<Link href={userPath(urlStyle, snippet.user.id)} className="flex items-center gap-2 group min-w-0">
|
||||
<Avatar
|
||||
size={30}
|
||||
name={snippet.user.nickname || snippet.user.username}
|
||||
src={snippet.user.avatar}
|
||||
/>
|
||||
<span className="min-w-0">
|
||||
<span
|
||||
className="block text-[13.5px] font-semibold leading-tight truncate group-hover:text-[var(--accent)] transition-colors"
|
||||
style={{ color: "var(--ink)" }}
|
||||
>
|
||||
{snippet.user.nickname || snippet.user.username}
|
||||
</span>
|
||||
<span className="block text-[11px] leading-tight meta truncate">
|
||||
@{snippet.user.username}
|
||||
</span>
|
||||
</span>
|
||||
</Link>
|
||||
)}
|
||||
<span className="meta text-[12px]">{formatRelative(snippet.created_at)}</span>
|
||||
{snippet.updated_at !== snippet.created_at && (
|
||||
<span className="meta text-[12px]">(编辑于 {formatRelative(snippet.updated_at)})</span>
|
||||
)}
|
||||
|
||||
<span className="flex items-center gap-1.5 flex-wrap sm:ml-auto">
|
||||
<StatChip icon={<Copy size={12} />} value={snippet.copy_count} label="复制" />
|
||||
<StatChip icon={<Eye size={12} />} value={snippet.view_count} label="查看" />
|
||||
<SnippetCommentCountChip
|
||||
snippetId={snippet.id}
|
||||
initial={snippet.comment_count}
|
||||
icon={<MessageSquare size={12} />}
|
||||
label="评论"
|
||||
/>
|
||||
<a
|
||||
href={snippet.fork_count > 0 ? "#snippet-forks" : undefined}
|
||||
className="inline-flex rounded-full"
|
||||
style={{ cursor: snippet.fork_count > 0 ? "pointer" : "default" }}
|
||||
title={snippet.fork_count > 0 ? "查看 Fork 演化" : "还没有人 Fork"}
|
||||
>
|
||||
<StatChip icon={<GitFork size={12} />} value={snippet.fork_count} label="Fork" />
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 操作 */}
|
||||
<div className="mt-4 pt-4" style={{ borderTop: "1px solid var(--line)" }}>
|
||||
<SnippetActions
|
||||
snippet={snippet}
|
||||
urlStyle={urlStyle}
|
||||
isOwner={isOwner}
|
||||
isStaff={isStaff}
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* 代码窗格 */}
|
||||
<section className="mt-6">
|
||||
<SnippetCodeView
|
||||
code={snippet.code}
|
||||
language={snippet.language}
|
||||
snippetId={snippet.id}
|
||||
defaultFold={true}
|
||||
foldLines={25}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{snippet.description && (
|
||||
<section className="mt-7">
|
||||
<h2 className="text-[13px] font-bold mb-2.5 flex items-center gap-2" style={{ color: "var(--ink)" }}>
|
||||
<span
|
||||
className="w-1 h-1 rounded-full"
|
||||
style={{ background: "var(--accent)" }}
|
||||
aria-hidden
|
||||
/>
|
||||
说明
|
||||
</h2>
|
||||
<MarkdownBody content={snippet.description} />
|
||||
</section>
|
||||
)}
|
||||
|
||||
{forksRes.total > 0 && (
|
||||
<section id="snippet-forks" className="mt-7 scroll-mt-24">
|
||||
<h2 className="text-[13px] font-bold mb-2.5 flex items-center gap-2" style={{ color: "var(--ink)" }}>
|
||||
<span
|
||||
className="w-1 h-1 rounded-full"
|
||||
style={{ background: "var(--accent)" }}
|
||||
aria-hidden
|
||||
/>
|
||||
Fork({forksRes.total})
|
||||
</h2>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{forksRes.items.slice(0, 5).map((f) => (
|
||||
<Link
|
||||
key={f.id}
|
||||
href={snippetPath(urlStyle, f.id)}
|
||||
className="inline-flex items-center gap-2 self-start max-w-full rounded-lg px-2.5 py-1.5 text-[12.5px] transition-colors hover:bg-[var(--panel-2)]"
|
||||
style={{ color: "var(--ink-2)", border: "1px solid var(--line)" }}
|
||||
>
|
||||
<GitFork size={12} style={{ color: "var(--ink-3)" }} />
|
||||
<span className="truncate">{f.title}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* 评论 */}
|
||||
<section className="mt-8 pt-7" style={{ borderTop: "1px solid var(--line)" }}>
|
||||
<SnippetCommentSection
|
||||
snippetId={snippet.id}
|
||||
initialItems={commentsRes.items}
|
||||
initialTotal={commentsRes.total}
|
||||
urlStyle={urlStyle}
|
||||
/>
|
||||
</section>
|
||||
</article>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatChip({ icon, value, label }: { icon: React.ReactNode; value: number; label: string }) {
|
||||
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={`${value} ${label}`}
|
||||
>
|
||||
<span style={{ color: "var(--ink-3)" }}>{icon}</span>
|
||||
{value}
|
||||
<span className="hidden sm:inline">{label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
52
frontend/app/snippets/new/page.tsx
Normal file
52
frontend/app/snippets/new/page.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { apiMe } from "@/lib/api";
|
||||
import SnippetEditor from "@/components/SnippetEditor";
|
||||
|
||||
function NewSnippetInner() {
|
||||
const router = useRouter();
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
apiMe()
|
||||
.then((res) => {
|
||||
if (!res.user) {
|
||||
router.push("/login?redirect=/snippets/new");
|
||||
return;
|
||||
}
|
||||
setReady(true);
|
||||
})
|
||||
.catch(() => {
|
||||
router.push("/login?redirect=/snippets/new");
|
||||
});
|
||||
}, [router]);
|
||||
|
||||
if (!ready) {
|
||||
return (
|
||||
<div className="j13-compose-stage is-studio px-8 py-16 text-center">
|
||||
<p className="meta">正在准备编辑器…</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <SnippetEditor mode="create" />;
|
||||
}
|
||||
|
||||
export default function NewSnippetPage() {
|
||||
return (
|
||||
<div className="j13-compose-page">
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="j13-compose-stage is-studio px-8 py-16 text-center">
|
||||
<p className="meta">正在准备编辑器…</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<NewSnippetInner />
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
49
frontend/app/snippets/page.tsx
Normal file
49
frontend/app/snippets/page.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import type { Metadata } from "next";
|
||||
import { fetchSnippets, fetchSnippetLanguages, fetchSnippetStats, type SnippetStats } from "@/lib/api";
|
||||
import { getPublicSettingsCached } from "@/lib/serverData";
|
||||
import { requestCanonical } from "@/lib/canonicalMeta";
|
||||
import { snippetsPath } from "@/lib/urlStyle";
|
||||
import SnippetExplorer from "@/components/SnippetExplorer";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const settings = await getPublicSettingsCached();
|
||||
return {
|
||||
title: "代码片段",
|
||||
description: settings.site_description,
|
||||
...(await requestCanonical(snippetsPath(settings.url_style))),
|
||||
};
|
||||
}
|
||||
|
||||
export default async function SnippetsPage() {
|
||||
const settings = await getPublicSettingsCached();
|
||||
|
||||
let initialItems: Awaited<ReturnType<typeof fetchSnippets>>["items"] = [];
|
||||
let initialTotal = 0;
|
||||
let languages: string[] = [];
|
||||
let stats: SnippetStats | null = null;
|
||||
try {
|
||||
const [listRes, langRes, statsRes] = await Promise.all([
|
||||
fetchSnippets({ size: 12 }),
|
||||
fetchSnippetLanguages(),
|
||||
fetchSnippetStats(),
|
||||
]);
|
||||
initialItems = listRes.items;
|
||||
initialTotal = listRes.total;
|
||||
languages = langRes.languages;
|
||||
stats = statsRes;
|
||||
} catch {
|
||||
/* 降级空态 */
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-w-0 pt-5 sm:pt-7">
|
||||
<SnippetExplorer
|
||||
initialItems={initialItems}
|
||||
initialTotal={initialTotal}
|
||||
languages={languages}
|
||||
urlStyle={settings.url_style}
|
||||
stats={stats}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user