feat: 新增帖子最后回复展示、文章目录与三栏布局,优化点赞按钮与导航栏
这是一次包含多个功能升级的提交: 1. 后端新增批量查询帖子最后回复的逻辑,在前端帖子列表项中展示最后回复信息 2. 重构点赞按钮,新增文章样式变体并统一样式逻辑 3. 重写工作台导航栏,精简默认展示的菜单选项 4. 新增文章目录组件,为帖子详情页添加文章目录导航 5. 重构帖子详情页,实现三栏响应式布局,新增板块卡片、作者信息栏与同板块热议推荐 6. 重构帖子列表行组件,优化排版与信息展示逻辑 7. 重构帖子管理操作组件,使用下拉菜单整合所有管理功能 8. 新增全局通用的滚动容器与点赞按钮样式
This commit is contained in:
@@ -142,33 +142,37 @@ export default function Header({
|
||||
WebkitBackdropFilter: "blur(14px)",
|
||||
}}
|
||||
>
|
||||
<div className="max-w-[1440px] mx-auto px-4 sm:px-6 h-[68px] flex items-center justify-between gap-4">
|
||||
{/* 品牌 */}
|
||||
<Link href="/" className="flex items-center gap-2.5 shrink-0 group">
|
||||
<span
|
||||
className="w-9 h-9 rounded-2xl flex items-center justify-center transition-transform duration-300 group-hover:rotate-[-6deg]"
|
||||
style={{ background: "var(--accent)", color: "var(--accent-on)" }}
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z" />
|
||||
</svg>
|
||||
</span>
|
||||
<span className="leading-none">
|
||||
<span className="block font-extrabold text-[17px] tracking-tight" style={{ color: "var(--ink)" }}>
|
||||
姜十三
|
||||
<div className="max-w-[1440px] mx-auto px-4 sm:px-6 h-[68px] flex items-center gap-4 lg:gap-7">
|
||||
{/* 左组:品牌 + 主导航 */}
|
||||
<div className="flex items-center gap-3 lg:gap-5 shrink-0">
|
||||
<Link href="/" className="flex items-center gap-2.5 group">
|
||||
<span
|
||||
className="w-9 h-9 rounded-2xl flex items-center justify-center transition-transform duration-300 group-hover:rotate-[-6deg]"
|
||||
style={{ background: "var(--accent)", color: "var(--accent-on)" }}
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z" />
|
||||
</svg>
|
||||
</span>
|
||||
<span className="block text-[10.5px] font-semibold mt-0.5" style={{ color: "var(--ink-3)" }}>
|
||||
J13 社区
|
||||
<span className="leading-none">
|
||||
<span className="block font-extrabold text-[17px] tracking-tight" style={{ color: "var(--ink)" }}>
|
||||
姜十三
|
||||
</span>
|
||||
<span className="block text-[10.5px] font-semibold mt-0.5" style={{ color: "var(--ink-3)" }}>
|
||||
J13 社区
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</Link>
|
||||
</Link>
|
||||
<nav className="hidden lg:flex items-center gap-1 pl-3" style={{ borderLeft: "1px solid var(--line)" }}>
|
||||
<NavPill href="/" active={pathname === "/"} icon={<Home size={14} />}>
|
||||
首页
|
||||
</NavPill>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* 桌面端 */}
|
||||
<nav className="hidden lg:flex items-center gap-1">
|
||||
<NavPill href="/" active={pathname === "/"} icon={<Home size={14} />}>
|
||||
首页
|
||||
</NavPill>
|
||||
<form onSubmit={handleSearch} className="relative ml-2">
|
||||
{/* 中组:搜索居中 */}
|
||||
<form onSubmit={handleSearch} className="hidden lg:flex flex-1 justify-center">
|
||||
<div className="relative w-full max-w-[380px] focus-within:max-w-[440px] transition-[max-width] duration-200">
|
||||
<Search
|
||||
size={15}
|
||||
className="absolute left-4 top-1/2 -translate-y-1/2"
|
||||
@@ -182,7 +186,7 @@ export default function Header({
|
||||
onKeyDown={onSearchKeyDown}
|
||||
placeholder="搜索帖子..."
|
||||
aria-label="搜索帖子"
|
||||
className="w-48 focus:w-64 transition-[width] duration-200 pl-10 pr-10 py-2.5 text-sm rounded-full"
|
||||
className="w-full pl-10 pr-10 py-2.5 text-sm rounded-full"
|
||||
style={{
|
||||
background: "var(--panel)",
|
||||
color: "var(--ink)",
|
||||
@@ -195,8 +199,8 @@ export default function Header({
|
||||
>
|
||||
/
|
||||
</kbd>
|
||||
</form>
|
||||
</nav>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* 右侧操作区:桌面/移动共用铃铛(仅挂载一次,避免重复轮询),
|
||||
桌面额外显示主题/发帖/用户菜单,移动端收进汉堡抽屉 */}
|
||||
|
||||
@@ -10,7 +10,7 @@ interface LikeButtonProps {
|
||||
postId: string;
|
||||
liked: boolean;
|
||||
likeCount: number;
|
||||
variant?: "default" | "compact";
|
||||
variant?: "default" | "compact" | "article";
|
||||
}
|
||||
|
||||
export default function LikeButton({
|
||||
@@ -60,10 +60,13 @@ export default function LikeButton({
|
||||
}
|
||||
};
|
||||
|
||||
const compact =
|
||||
const sizeCls =
|
||||
variant === "compact"
|
||||
? "px-2.5 py-1.5 gap-1 text-[12.5px]"
|
||||
: "px-4 py-2 gap-1.5 text-sm";
|
||||
: variant === "article"
|
||||
? "px-7 py-3 gap-2 text-[15px] min-w-[148px]"
|
||||
: "px-4 py-2 gap-1.5 text-sm";
|
||||
const iconSize = variant === "compact" ? 13 : variant === "article" ? 18 : 15;
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -71,17 +74,13 @@ export default function LikeButton({
|
||||
disabled={loading}
|
||||
title={liked ? "取消点赞" : "点赞"}
|
||||
aria-pressed={liked}
|
||||
className={`inline-flex items-center font-semibold tabular-nums rounded-full border transition-colors duration-150 hover:border-[var(--accent)] disabled:opacity-50 ${compact}`}
|
||||
style={{
|
||||
borderColor: liked ? "var(--accent)" : "var(--line-2)",
|
||||
color: liked ? "var(--accent)" : "var(--ink-2)",
|
||||
background: liked ? "var(--accent-soft)" : "var(--panel)",
|
||||
}}
|
||||
className={`j13-like inline-flex items-center justify-center font-semibold tabular-nums rounded-full border disabled:opacity-50 ${liked ? "is-liked" : ""} ${sizeCls}`}
|
||||
>
|
||||
<span key={String(liked)} className={liked ? "j13-heart-pop inline-flex" : "inline-flex"}>
|
||||
<IconHeart size={variant === "compact" ? 13 : 15} filled={liked} />
|
||||
<IconHeart size={iconSize} filled={liked} />
|
||||
</span>
|
||||
<span>{count}</span>
|
||||
{variant === "article" && <span>{liked ? "已赞" : "点赞"}</span>}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Pencil, Trash2, Pin, Star } from "lucide-react";
|
||||
import { Pencil, Trash2, Pin, Star, SlidersHorizontal, ChevronDown, Check } from "lucide-react";
|
||||
import { apiMe, apiDeletePost, apiTogglePin, apiToggleRecommend, type User } from "@/lib/api";
|
||||
import { toast } from "@/lib/toast";
|
||||
|
||||
@@ -17,10 +17,12 @@ interface PostActionsProps {
|
||||
export default function PostActions({ postId, authorId, pinned, recommended }: PostActionsProps) {
|
||||
const router = useRouter();
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [isPinned, setIsPinned] = useState(pinned > 0);
|
||||
const [isRecommended, setIsRecommended] = useState(recommended);
|
||||
const [acting, setActing] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
apiMe()
|
||||
@@ -28,11 +30,28 @@ export default function PostActions({ postId, authorId, pinned, recommended }: P
|
||||
.catch(() => setUser(null));
|
||||
}, []);
|
||||
|
||||
// 点击外部 / Esc 关闭菜单(与通知铃铛同一交互模式)
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.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 () => {
|
||||
document.removeEventListener("mousedown", onDown);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const canManage = user && (user.id === authorId || user.role === "admin");
|
||||
const isAdmin = user?.role === "admin";
|
||||
if (!canManage) return null;
|
||||
|
||||
const handleDelete = async () => {
|
||||
setOpen(false);
|
||||
if (!confirm("确定要删除这篇帖子吗?此操作不可恢复。")) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
@@ -52,6 +71,7 @@ export default function PostActions({ postId, authorId, pinned, recommended }: P
|
||||
|
||||
const handlePin = async () => {
|
||||
setActing(true);
|
||||
setOpen(false);
|
||||
try {
|
||||
const res = await apiTogglePin(postId);
|
||||
setIsPinned(res.pinned > 0);
|
||||
@@ -66,6 +86,7 @@ export default function PostActions({ postId, authorId, pinned, recommended }: P
|
||||
|
||||
const handleRecommend = async () => {
|
||||
setActing(true);
|
||||
setOpen(false);
|
||||
try {
|
||||
const res = await apiToggleRecommend(postId);
|
||||
setIsRecommended(res.recommended);
|
||||
@@ -78,38 +99,89 @@ export default function PostActions({ postId, authorId, pinned, recommended }: P
|
||||
}
|
||||
};
|
||||
|
||||
if (!canManage) return null;
|
||||
|
||||
const itemCls =
|
||||
"w-full flex items-center gap-2.5 px-4 py-2.5 text-[13px] transition-colors hover:bg-[var(--panel-2)]";
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
{isAdmin && (
|
||||
<>
|
||||
<button
|
||||
onClick={handlePin}
|
||||
disabled={acting}
|
||||
className="btn btn-line btn-sm"
|
||||
style={isPinned ? { borderColor: "var(--accent)", color: "var(--accent)" } : undefined}
|
||||
>
|
||||
<Pin size={13} /> {isPinned ? "取消置顶" : "置顶"}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleRecommend}
|
||||
disabled={acting}
|
||||
className="btn btn-line btn-sm"
|
||||
style={
|
||||
isRecommended
|
||||
? { borderColor: "var(--gold)", color: "var(--gold)", background: "var(--gold-soft)" }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Star size={13} /> {isRecommended ? "取消推荐" : "推荐"}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<Link href={`/post/${postId}/edit`} className="btn btn-line btn-sm">
|
||||
<Pencil size={13} /> 编辑
|
||||
</Link>
|
||||
<button onClick={handleDelete} disabled={deleting} className="btn btn-danger btn-sm">
|
||||
<Trash2 size={13} /> {deleting ? "删除中..." : "删除"}
|
||||
<div className="relative" ref={menuRef}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
disabled={acting || deleting}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
className="btn btn-line btn-sm"
|
||||
style={open ? { borderColor: "var(--accent)", color: "var(--accent)" } : undefined}
|
||||
>
|
||||
<SlidersHorizontal size={13} /> 管理
|
||||
<ChevronDown
|
||||
size={13}
|
||||
style={{ transition: "transform .15s", transform: open ? "rotate(180deg)" : "none" }}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
role="menu"
|
||||
className="absolute right-0 mt-2 w-44 overflow-hidden z-50 j13-toast-in"
|
||||
style={{
|
||||
background: "var(--panel)",
|
||||
border: "1px solid var(--line)",
|
||||
borderRadius: "var(--r)",
|
||||
boxShadow: "var(--shadow-lg)",
|
||||
}}
|
||||
>
|
||||
{isAdmin && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={handlePin}
|
||||
className={itemCls}
|
||||
style={isPinned ? { color: "var(--accent)" } : { color: "var(--ink)" }}
|
||||
>
|
||||
<Pin size={14} className="shrink-0" />
|
||||
<span className="flex-1 text-left">{isPinned ? "取消置顶" : "置顶"}</span>
|
||||
{isPinned && <Check size={14} className="shrink-0" />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={handleRecommend}
|
||||
className={itemCls}
|
||||
style={isRecommended ? { color: "var(--gold)" } : { color: "var(--ink)" }}
|
||||
>
|
||||
<Star size={14} className="shrink-0" />
|
||||
<span className="flex-1 text-left">{isRecommended ? "取消推荐" : "推荐"}</span>
|
||||
{isRecommended && <Check size={14} className="shrink-0" />}
|
||||
</button>
|
||||
<div style={{ height: 1, background: "var(--line)" }} />
|
||||
</>
|
||||
)}
|
||||
<Link
|
||||
href={`/post/${postId}/edit`}
|
||||
role="menuitem"
|
||||
className={`${itemCls} items-center`}
|
||||
style={{ color: "var(--ink)" }}
|
||||
>
|
||||
<Pencil size={14} className="shrink-0" />
|
||||
<span className="flex-1 text-left">编辑帖子</span>
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={handleDelete}
|
||||
disabled={deleting}
|
||||
className={`${itemCls} items-center`}
|
||||
style={{ color: "var(--danger)" }}
|
||||
>
|
||||
<Trash2 size={14} className="shrink-0" />
|
||||
<span className="flex-1 text-left">{deleting ? "删除中..." : "删除帖子"}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,19 +1,29 @@
|
||||
import Link from "next/link";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import type { PostListItem } from "@/lib/api";
|
||||
import LikeButton from "./LikeButton";
|
||||
import Avatar from "./Avatar";
|
||||
import { IconComment, IconEye } from "./Icons";
|
||||
|
||||
function formatTime(dateStr: string) {
|
||||
const d = new Date(dateStr);
|
||||
return `${d.getFullYear()}.${String(d.getMonth() + 1).padStart(2, "0")}.${String(
|
||||
d.getDate()
|
||||
).padStart(2, "0")}`;
|
||||
/** 发帖/回复时间统一为相对时间:刚刚 / N分钟前 / N小时前 / N天前 / N个月前 / N年前 */
|
||||
function formatRelative(dateStr: string) {
|
||||
const t = new Date(dateStr).getTime();
|
||||
const diff = Date.now() - t;
|
||||
const min = 60_000,
|
||||
hour = 60 * min,
|
||||
day = 24 * hour;
|
||||
if (diff < min) return "刚刚";
|
||||
if (diff < hour) return `${Math.floor(diff / min)}分钟前`;
|
||||
if (diff < day) return `${Math.floor(diff / hour)}小时前`;
|
||||
if (diff < 30 * day) return `${Math.floor(diff / day)}天前`;
|
||||
if (diff < 365 * day) return `${Math.floor(diff / (30 * day))}个月前`;
|
||||
return `${Math.floor(diff / (365 * day))}年前`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 帖子列表行:头像 + 标题 + 作者元信息 + 统计 + 点赞
|
||||
* 用于首页/板块页,以细线串联,悬浮浅色反馈
|
||||
* 帖子列表行(参考社区信息流样式):
|
||||
* 大头像 + 加粗标题(徽章内联)+ 第二行「板块 · 作者 · 发布时间 ← 最后回复」+
|
||||
* 右侧评论数等统计垂直居中。用于首页/板块页/搜索结果。
|
||||
*/
|
||||
export default function PostRow({
|
||||
post,
|
||||
@@ -30,13 +40,16 @@ export default function PostRow({
|
||||
showViews?: boolean;
|
||||
showLike?: boolean;
|
||||
}) {
|
||||
const lastReply = post.last_reply;
|
||||
const replied = lastReply && new Date(lastReply.created_at).getTime() > new Date(post.created_at).getTime() + 60_000;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group relative flex items-center gap-3 sm:gap-4 px-4 sm:px-5 border-t transition-colors hover:bg-[var(--panel-2)] j13-fade-up"
|
||||
className="group relative flex items-center gap-3.5 sm:gap-4 px-4 sm:px-5 border-t transition-colors hover:bg-[var(--panel-2)] j13-fade-up"
|
||||
style={{
|
||||
borderColor: "var(--line)",
|
||||
paddingTop: compact ? 13 : 16,
|
||||
paddingBottom: compact ? 13 : 16,
|
||||
paddingTop: compact ? 15 : 18,
|
||||
paddingBottom: compact ? 15 : 18,
|
||||
animationDelay: `${Math.min(index, 9) * 35}ms`,
|
||||
}}
|
||||
>
|
||||
@@ -46,57 +59,74 @@ export default function PostRow({
|
||||
aria-label={post.user.nickname}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<Avatar name={post.user.nickname || post.user.username} size={compact ? 38 : 42} />
|
||||
<Avatar name={post.user.nickname || post.user.username} size={compact ? 44 : 46} />
|
||||
</Link>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
{(post.pinned > 0 || post.recommended) && (
|
||||
<div className="flex items-center gap-1.5 mb-1.5 flex-wrap">
|
||||
{post.pinned > 0 && <span className="badge-pin">置顶</span>}
|
||||
{post.recommended && <span className="badge-rec">推荐</span>}
|
||||
</div>
|
||||
)}
|
||||
<Link href={`/post/${post.id}`} className="stretch-link" aria-label={post.title}>
|
||||
<h3
|
||||
className="font-semibold text-[15px] sm:text-[16px] leading-snug truncate transition-colors group-hover:text-[var(--accent)]"
|
||||
style={{ color: "var(--ink)" }}
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{post.pinned > 0 && <span className="badge-pin shrink-0">置顶</span>}
|
||||
{post.recommended && <span className="badge-rec shrink-0">推荐</span>}
|
||||
<Link
|
||||
href={`/post/${post.id}`}
|
||||
className="stretch-link min-w-0 flex-1"
|
||||
aria-label={post.title}
|
||||
>
|
||||
{post.title}
|
||||
</h3>
|
||||
</Link>
|
||||
<div className="meta relative z-[2] flex items-center gap-2.5 mt-1.5 flex-wrap">
|
||||
<h3
|
||||
className="font-bold text-[16px] sm:text-[17px] leading-snug truncate transition-colors group-hover:text-[var(--accent)]"
|
||||
style={{ color: "var(--ink)" }}
|
||||
>
|
||||
{post.title}
|
||||
</h3>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="meta relative z-[2] flex items-center gap-x-2 gap-y-1 mt-1.5 flex-wrap text-[13px]">
|
||||
{boardHref && (
|
||||
<Link
|
||||
href={`/board/${post.board.id}`}
|
||||
className={`av-${post.board.id % 8} rounded-md px-1.5 py-px text-[11px] font-bold leading-[1.5] transition-opacity hover:opacity-85`}
|
||||
>
|
||||
{post.board.name}
|
||||
</Link>
|
||||
)}
|
||||
<Link
|
||||
href={`/u/${post.user.id}`}
|
||||
className="hover:text-[var(--accent)] transition-colors"
|
||||
className="font-semibold hover:text-[var(--accent)] transition-colors"
|
||||
style={{ color: "var(--ink-2)" }}
|
||||
>
|
||||
{post.user.nickname}
|
||||
</Link>
|
||||
{boardHref && (
|
||||
<>
|
||||
<span aria-hidden>·</span>
|
||||
<Link
|
||||
href={`/board/${post.board.id}`}
|
||||
className="hover:text-[var(--accent)] transition-colors"
|
||||
>
|
||||
{post.board.name}
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
<span aria-hidden>·</span>
|
||||
<span>{formatTime(post.created_at)}</span>
|
||||
<span className="tabular-nums">{formatRelative(post.created_at)}</span>
|
||||
{replied && (
|
||||
<span className="inline-flex items-center gap-1 min-w-0">
|
||||
<ArrowLeft size={13} className="shrink-0" aria-hidden />
|
||||
<Link
|
||||
href={`/u/${lastReply!.user.id}`}
|
||||
className="font-medium hover:text-[var(--accent)] transition-colors truncate max-w-[8em]"
|
||||
>
|
||||
{lastReply!.user.nickname || lastReply!.user.username}
|
||||
</Link>
|
||||
<span className="tabular-nums shrink-0">{formatRelative(lastReply!.created_at)}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="above-link flex items-center gap-2.5 sm:gap-3.5 shrink-0">
|
||||
<span
|
||||
className={`${showViews ? "hidden sm:inline-flex" : "inline-flex"} items-center gap-1.5 meta`}
|
||||
<div className="above-link flex items-center gap-3 sm:gap-4 shrink-0">
|
||||
<Link
|
||||
href={`/post/${post.id}`}
|
||||
tabIndex={-1}
|
||||
aria-label={`${post.comment_count} 条评论`}
|
||||
className={`${showViews ? "hidden sm:inline-flex" : "inline-flex"} items-center gap-1.5 meta tabular-nums text-[13.5px] hover:text-[var(--accent)] transition-colors`}
|
||||
>
|
||||
<IconComment size={15} />
|
||||
<IconComment size={17} />
|
||||
{post.comment_count}
|
||||
</span>
|
||||
</Link>
|
||||
{showViews && (
|
||||
<span className="hidden md:inline-flex items-center gap-1.5 meta" aria-label={`${post.view_count} 次浏览`}>
|
||||
<span
|
||||
className="hidden md:inline-flex items-center gap-1.5 meta tabular-nums"
|
||||
aria-label={`${post.view_count} 次浏览`}
|
||||
>
|
||||
<IconEye size={15} />
|
||||
{post.view_count}
|
||||
</span>
|
||||
|
||||
93
frontend/components/PostToc.tsx
Normal file
93
frontend/components/PostToc.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
export interface TocItem {
|
||||
level: 2 | 3;
|
||||
text: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 文章目录树:右栏 flex 限高列中的可收缩模块。
|
||||
* - 标题常驻,目录列表在超高时于面板内部滚动(细滚动条,不顶出侧栏);
|
||||
* - IntersectionObserver 高亮当前小节;
|
||||
* - 当前项被滚动到目录可视区外时,列表自动最小化跟随(首屏不滚动、尊重 reduced-motion)。
|
||||
*/
|
||||
export default function PostToc({ items }: { items: TocItem[] }) {
|
||||
const [active, setActive] = useState<string>(items[0]?.id ?? "");
|
||||
const navRef = useRef<HTMLElement>(null);
|
||||
// 首次挂载时 active 取首项,不触发跟随滚动,避免进页跳动
|
||||
const skipFollow = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
const heads = items
|
||||
.map((i) => document.getElementById(i.id))
|
||||
.filter((el): el is HTMLElement => Boolean(el));
|
||||
if (heads.length === 0) return;
|
||||
const obs = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const e of entries) if (e.isIntersecting) setActive(e.target.id);
|
||||
},
|
||||
// 顶部让出固定头部高度,底部提前切换到下一节
|
||||
{ rootMargin: "-80px 0px -70% 0px", threshold: 0 }
|
||||
);
|
||||
heads.forEach((h) => obs.observe(h));
|
||||
return () => obs.disconnect();
|
||||
}, [items]);
|
||||
|
||||
// active 变化时把当前目录项滚入列表可视区(block: nearest 仅在贴边时最小移动)
|
||||
useEffect(() => {
|
||||
if (skipFollow.current) {
|
||||
skipFollow.current = false;
|
||||
return;
|
||||
}
|
||||
const el = navRef.current?.querySelector<HTMLElement>(`[data-toc-id="${active}"]`);
|
||||
if (!el) return;
|
||||
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
el.scrollIntoView({ block: "nearest", behavior: reduce ? "auto" : "smooth" });
|
||||
}, [active]);
|
||||
|
||||
const jump = (id: string) => (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
document.getElementById(id)?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
};
|
||||
|
||||
return (
|
||||
<nav ref={navRef} aria-label="文章目录" className="panel p-4 flex min-h-0 flex-col">
|
||||
<p
|
||||
className="text-[11.5px] font-bold tracking-[0.08em] uppercase mb-2.5 px-1 shrink-0"
|
||||
style={{ color: "var(--ink-3)" }}
|
||||
>
|
||||
目录
|
||||
</p>
|
||||
<ul
|
||||
className="j13-rail-scroll space-y-0.5 pr-0.5"
|
||||
style={{ borderLeft: "1px solid var(--line)" }}
|
||||
>
|
||||
{items.map((it) => {
|
||||
const isActive = it.id === active;
|
||||
return (
|
||||
<li key={it.id}>
|
||||
<a
|
||||
data-toc-id={it.id}
|
||||
href={`#${it.id}`}
|
||||
onClick={jump(it.id)}
|
||||
className="block py-1.5 pr-1 text-[12.5px] leading-snug transition-colors"
|
||||
style={{
|
||||
paddingLeft: it.level === 3 ? 20 : 12,
|
||||
marginLeft: -1,
|
||||
borderLeft: `2px solid ${isActive ? "var(--accent)" : "transparent"}`,
|
||||
color: isActive ? "var(--accent)" : "var(--ink-2)",
|
||||
fontWeight: isActive ? 600 : 400,
|
||||
}}
|
||||
>
|
||||
{it.text}
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { PenSquare, Home, Bell, Settings, LogIn, UserPlus } from "lucide-react";
|
||||
import { PenSquare, Home } from "lucide-react";
|
||||
import type { BoardWithCount, User } from "@/lib/api";
|
||||
|
||||
// 首页壳内板块切换:仅替换中栏帖子流,不离开工作台
|
||||
@@ -54,28 +54,6 @@ export function WorkspaceNav({ boards, user }: { boards: BoardWithCount[]; user:
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ borderTop: "1px solid var(--line)" }} className="pt-3 space-y-0.5">
|
||||
{user ? (
|
||||
<>
|
||||
<Link href="/notifications" className="j13-raillink">
|
||||
<Bell size={16} className="shrink-0" /> 通知中心
|
||||
</Link>
|
||||
<Link href="/settings" className="j13-raillink">
|
||||
<Settings size={16} className="shrink-0" /> 账号设置
|
||||
</Link>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Link href="/login" className="j13-raillink">
|
||||
<LogIn size={16} className="shrink-0" /> 登录
|
||||
</Link>
|
||||
<Link href="/register" className="j13-raillink">
|
||||
<UserPlus size={16} className="shrink-0" /> 注册账号
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user