Files
jiang13-bbs/frontend/components/PostRow.tsx
freefire ff2ab286fb feat: 书库导入导出/图片变体/书籍搜索/小组件运行时等
新增:
- 书库导入导出(library_import/library_export)及测试
- 图片变体生成(image_variants)与响应式图片(responsiveImage)
- 书籍搜索(bookSearch)+ BookSearch/LibrarySearchGrid 组件
- 小组件运行时(widgetRuntime)与静态检查(widgetLint)
- 上传缓存中间件(upload_cache)与字体 CSS 提取脚本

其它:
- 后端 handlers/services 全量调整
- 前端页面、组件、库函数与配置更新
2026-10-01 03:06:05 +08:00

180 lines
7.0 KiB
TypeScript

import Link from "next/link";
import { ArrowLeft, Pin, Star } from "lucide-react";
import type { PostListItem } from "@/lib/api";
import { formatRelative } from "@/lib/format";
import { getPublicSettingsCached } from "@/lib/serverData";
import { postPath, userPath } from "@/lib/urlStyle";
import { normalizePostType, postTypeLabel, typeStatusLabel, typeStatusClass } from "@/lib/postMeta";
import LikeButton from "./LikeButton";
import Avatar from "./Avatar";
import { IconComment, IconEye } from "./Icons";
/**
* 帖子列表行(参考社区信息流样式):
* 大头像 + 标题(徽章内联)+ 第二行「板块 · 作者 · 发布时间 ← 最后回复」+
* 右侧评论数等统计垂直居中。用于首页/板块页/搜索结果。
*
* 点击:标题 stretch-link 铺满整行进帖;仅头像/作者/回复者/点赞等加 above-link 抬层。
* 板块胶囊仅展示,不可点,避免误进板块筛选。
*/
export default async function PostRow({
post,
showBoard = false,
index = 0,
compact = false,
showViews = true,
showLike = true,
}: {
post: PostListItem;
index?: number;
/** 是否展示板块胶囊(不可点击,仅标识) */
showBoard?: boolean;
compact?: boolean;
showViews?: boolean;
showLike?: boolean;
}) {
const settings = await getPublicSettingsCached();
const allowComments = settings.allow_comments !== false;
const urlStyle = settings.url_style;
const lastReply = post.last_reply;
const replied =
allowComments &&
lastReply &&
new Date(lastReply.created_at).getTime() > new Date(post.created_at).getTime() + 60_000;
const pt = normalizePostType(post.post_type);
const statusBadge = pt !== "discussion" ? typeStatusLabel(pt, post.type_status) : "";
const statusClass = statusBadge ? typeStatusClass(pt, post.type_status) : "";
// 移动端徽章过多会挤占标题字数:仅隐藏「进行中」(开放式状态,详情页仍可见),终态徽章保留
const hideStatusOnMobile = statusBadge === "进行中";
const authorName = post.user.nickname || post.user.username;
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 ${
compact ? "gap-2.5 sm:gap-3" : "gap-3.5 sm:gap-4"
}`}
style={{
borderColor: "var(--line)",
paddingTop: compact ? 15 : 22,
paddingBottom: compact ? 15 : 22,
animationDelay: `${Math.min(index, 9) * 35}ms`,
}}
>
<Link
href={userPath(urlStyle, post.user.id)}
className="shrink-0 above-link"
aria-label={authorName}
tabIndex={-1}
>
<Avatar name={authorName} src={post.user.avatar || undefined} size={compact ? 38 : 46} square />
</Link>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 min-w-0">
{/* 仅置顶/推荐在标题左侧 */}
{post.pinned > 0 && (
<span className="badge-pin shrink-0" title="置顶" aria-label="置顶">
<Pin size={12} fill="currentColor" strokeWidth={2.2} aria-hidden />
</span>
)}
{post.recommended && (
<span className="badge-rec shrink-0" title="推荐" aria-label="推荐">
<Star size={12} fill="currentColor" strokeWidth={2.2} aria-hidden />
</span>
)}
<div className="min-w-0 flex items-center gap-2 flex-1 overflow-hidden">
<Link
href={postPath(urlStyle, post.id)}
className="stretch-link min-w-0 shrink"
aria-label={post.title}
>
<h2 className="text-[15px] font-normal leading-snug truncate text-[var(--ink)] transition-colors group-hover:text-[var(--accent)]">
{post.title}
</h2>
</Link>
{/* 其余徽章紧跟标题右侧 */}
{post.status === "pending" && <span className="badge-pending shrink-0">待审</span>}
{pt !== "discussion" && pt !== "question" && (
<span className="badge-type shrink-0">{postTypeLabel(post.post_type)}</span>
)}
{statusBadge && statusClass && (
<span
className={`${statusClass} shrink-0${hideStatusOnMobile ? " hidden sm:inline-flex" : ""}`}
>
{statusBadge}
</span>
)}
</div>
</div>
<div
className={`meta flex items-center gap-x-2 gap-y-1 flex-wrap ${
compact ? "mt-1 text-[12.5px]" : "mt-1.5 text-[13px]"
}`}
>
{showBoard && (
<span className="j13-board-tag">{post.board.name}</span>
)}
<Link
href={userPath(urlStyle, post.user.id)}
className="above-link font-medium hover:text-[var(--accent)] transition-colors"
>
{authorName}
</Link>
<span aria-hidden>·</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={userPath(urlStyle, lastReply!.user.id)}
className="above-link 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>
)}
{!replied && allowComments && post.comment_count === 0 && (
<span className="hidden sm:inline" aria-hidden>
· 暂无回复
</span>
)}
</div>
</div>
<div className="flex items-center gap-3 sm:gap-4 shrink-0">
{allowComments ? (
<Link
href={postPath(urlStyle, post.id)}
tabIndex={-1}
aria-label={`${post.comment_count} 条评论`}
className={`${showViews ? "hidden sm:inline-flex" : "inline-flex"} above-link items-center gap-1.5 meta tabular-nums text-[13.5px] hover:text-[var(--accent)] transition-colors`}
>
<IconComment size={17} />
{post.comment_count}
</Link>
) : null}
{showViews && (
<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>
)}
{showLike && (
<div className="above-link">
<LikeButton
postId={String(post.id)}
liked={post.liked}
likeCount={post.like_count}
variant="compact"
/>
</div>
)}
</div>
</div>
);
}