Files
jiang13-forum/frontend/src/components/RightPanel.tsx

248 lines
9.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Flame, ListTree, MessageCircle, Tags, Sparkles } from 'lucide-react';
import { useLocation, useSearchParams } from 'react-router-dom';
import { Skeleton } from '@/components/ui/skeleton';
import type { PostItem, RecentComment, TagCount, User } from '../api/types';
import type { PostHeading } from '../utils/postHeadings';
import { useSiteBranding } from '../hooks/useSiteBranding';
import { formatShortDateTime } from '../utils/content';
import TagCloud from './TagCloud';
import UserLink from './UserLink';
import ArticleOutline from './ArticleOutline';
import PostAuthorCard from './PostAuthorCard';
export type PostDetailAside = {
author?: User | null;
publishedAt?: string;
viewCount?: number;
headings: PostHeading[];
scrollRoot?: HTMLElement | null;
outlineTitle?: string;
};
interface Props {
hot: PostItem[];
recentComments: RecentComment[];
tags?: TagCount[];
tagsLoading?: boolean;
onPostClick: (id: number, opts?: { floor?: number }) => void;
/** 首次拉取中,显示骨架避免空态闪烁 */
loading?: boolean;
/** 帖子详情:右侧顶部展示作者与目录 */
postDetail?: PostDetailAside | null;
}
function hotRankClass(index: number): string {
if (index === 0) return 'widget-rank widget-rank--1';
if (index === 1) return 'widget-rank widget-rank--2';
if (index === 2) return 'widget-rank widget-rank--3';
return 'widget-rank';
}
function HotSkeleton() {
return (
<div className="widget-skeleton" aria-busy="true" aria-label="热门加载中">
{Array.from({ length: 6 }, (_, i) => (
<div key={i} className="widget-item widget-item--skeleton">
<Skeleton className="skeleton--widget-rank" />
<Skeleton className="skeleton--widget-title" style={{ width: `${62 + (i % 4) * 8}%` }} />
</div>
))}
</div>
);
}
function CommentSkeleton() {
return (
<div className="widget-skeleton" aria-busy="true" aria-label="评论加载中">
{Array.from({ length: 5 }, (_, i) => (
<div key={i} className="widget-item widget-item--comment widget-item--skeleton">
<Skeleton className="skeleton--widget-avatar" />
<Skeleton className="skeleton--widget-title" style={{ width: `${55 + (i % 3) * 12}%` }} />
<Skeleton className="skeleton--widget-time" />
</div>
))}
</div>
);
}
export default function RightPanel({
hot,
recentComments,
tags = [],
tagsLoading = false,
onPostClick,
loading = false,
postDetail = null,
}: Props) {
const { branding } = useSiteBranding();
const loc = useLocation();
const [params] = useSearchParams();
const activeTag = params.get('tag') || '';
const hotList = hot?.slice(0, 8) ?? [];
const commentList = recentComments?.slice(0, 6) ?? [];
// 站点首页:右侧品牌块承担唯一 h1板块/搜索等页面由 Feed 标题作 h1
const isSiteHome = loc.pathname === '/'
&& !params.get('board')
&& !params.get('keyword')
&& !params.get('tag');
const description = branding.description?.trim() || '';
const slogan = branding.slogan?.trim() || '';
// 有独立简介时展示简介;否则用欢迎语,避免与页脚 slogan 三连重复
const aboutText = description || '欢迎参与讨论,发帖、评论,一起把小圈子聊热。';
// 帖子很少时热门几乎等于主列表,改显示欢迎引导
const showHot = loading || hotList.length >= 4;
const showWelcome = !loading && hotList.length > 0 && hotList.length < 4;
const isPostDetail = !!postDetail;
return (
<div className={`aside-panel-inner${isPostDetail ? ' aside-panel-inner--post-detail' : ''}`}>
{isPostDetail && (
<>
<PostAuthorCard
author={postDetail.author}
publishedAt={postDetail.publishedAt}
viewCount={postDetail.viewCount}
/>
<div className="widget-card widget-card--outline">
<div className="widget-card-head">
<ListTree className="widget-card-icon widget-card-icon--outline" aria-hidden />
{postDetail.outlineTitle || '文章目录'}
</div>
<div className="widget-card-body widget-outline-body">
<ArticleOutline
headings={postDetail.headings}
scrollRoot={postDetail.scrollRoot}
title={postDetail.outlineTitle || '文章目录'}
className="article-outline--aside"
/>
</div>
</div>
</>
)}
{!isPostDetail && showWelcome && (
<div className="widget-card widget-card--welcome">
<div className="widget-card-head">
<Sparkles className="widget-card-icon widget-card-icon--welcome" aria-hidden />
</div>
<div className="widget-card-body widget-welcome-body">
<p></p>
<ul>
<li></li>
<li></li>
<li></li>
</ul>
</div>
</div>
)}
{!isPostDetail && showHot && (
<div className="widget-card">
<div className="widget-card-head">
<Flame className="widget-card-icon widget-card-icon--hot" aria-hidden />
</div>
<div className="widget-card-body">
{loading && hotList.length === 0 ? (
<HotSkeleton />
) : hotList.length === 0 ? (
<div className="widget-empty"></div>
) : hotList.map((item, i) => (
<button
key={item.id}
type="button"
className="widget-item"
onClick={() => onPostClick(item.id)}
>
<span className={hotRankClass(i)}>{i + 1}</span>
<span className="widget-item-title">{item.title}</span>
</button>
))}
</div>
</div>
)}
{!isPostDetail && (
<div className="widget-card widget-card--tags">
<div className="widget-card-head">
<Tags className="widget-card-icon widget-card-icon--tags" aria-hidden />
</div>
<div className="widget-card-body widget-card-body--tags">
<TagCloud tags={tags} loading={tagsLoading} activeTag={activeTag} />
</div>
</div>
)}
{!isPostDetail && (
<div className="widget-card">
<div className="widget-card-head">
<MessageCircle className="widget-card-icon widget-card-icon--notice" aria-hidden />
</div>
<div className="widget-card-body">
{loading && commentList.length === 0 ? (
<CommentSkeleton />
) : commentList.length === 0 ? (
<div className="widget-empty"></div>
) : commentList.map(item => (
<div
key={item.id}
className="widget-item widget-item--comment"
title={item.post_title ? `${item.author} · ${item.post_title}` : item.author}
>
{item.user_id ? (
<UserLink
user={{ id: item.user_id, nickname: item.author, avatar: item.avatar }}
showAvatar={false}
showName={false}
stopPropagation
className="widget-item-avatar user-link--avatar-only"
>
{item.avatar
? <img src={item.avatar} alt="" loading="lazy" decoding="async" />
: (item.author?.[0] || '?')}
</UserLink>
) : (
<span className="widget-item-avatar" aria-hidden>
{item.avatar
? <img src={item.avatar} alt="" loading="lazy" decoding="async" />
: (item.author?.[0] || '?')}
</span>
)}
<button
type="button"
className="widget-item-comment-main"
onClick={() => onPostClick(item.post_id, item.floor > 0 ? { floor: item.floor } : undefined)}
>
<span className="widget-item-title">{item.excerpt}</span>
<span className="widget-item-time">{formatShortDateTime(item.created_at)}</span>
</button>
</div>
))}
</div>
</div>
)}
{!isPostDetail && (
<div className="widget-card widget-card--about">
<div className="widget-card-body">
<div className="widget-about-text">
{isSiteHome ? (
<h1 className="widget-about-title">{branding.name}</h1>
) : (
<p className="widget-about-title">{branding.name}</p>
)}
<p className="widget-about-desc">{aboutText}</p>
{description && slogan && slogan !== description && (
<p className="widget-about-slogan">{slogan}</p>
)}
</div>
</div>
</div>
)}
</div>
);
}