feat: 首页热门区改为「社群常用资料」榜单(封面+名次,零数字)
- 新增 PopularRankList:前3名奖牌🥇🥈🥉 + 4·5灰序号,封面缩略图, 类型·分类·更新时间,预览/下载按钮;与「最新更新」「官方推荐」版式区分 - 无封面时按资料类型渲染渐变+图标兜底(纯CSS),封面加载失败亦回退 - 分类名复用 cleanCategoryDisplayName,与全站一致(去掉括号后缀) - i18n popularSection 改为 社群常用资料 / Community Favorites - 新增后端接口契约文档 docs/specs/2026-05-29-popular-resources-section.md Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
272
src/components/PopularRankList.tsx
Normal file
272
src/components/PopularRankList.tsx
Normal file
@@ -0,0 +1,272 @@
|
||||
import {
|
||||
Archive,
|
||||
Download,
|
||||
Eye,
|
||||
File,
|
||||
FileText,
|
||||
Image as ImageIcon,
|
||||
Link as LinkIcon,
|
||||
LoaderCircle,
|
||||
Music,
|
||||
Presentation,
|
||||
Video,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { assetUrl, type Category } from "../api";
|
||||
import { useI18n } from "../i18n";
|
||||
import { resourceTypeLabel } from "../resourceTypeLabels";
|
||||
import { cleanCategoryDisplayName } from "../utils/categoryDisplay";
|
||||
import { formatDateYmd } from "../utils/format";
|
||||
import { postToResource } from "../utils/postResourceAdapter";
|
||||
import type { Attachment, Post } from "../types/post";
|
||||
import { useLightbox } from "./messageStream/overlays/ImageLightbox";
|
||||
import { useVideoPlayer } from "./messageStream/overlays/VideoPlayer";
|
||||
import { downloadAttachment } from "./messageStream/utils/downloadFile";
|
||||
import { useToast } from "./Toast";
|
||||
|
||||
const MEDALS = ["🥇", "🥈", "🥉"];
|
||||
const MAX_ITEMS = 5;
|
||||
|
||||
/**
|
||||
* Fallback cover shown when a resource has no real thumbnail. A gradient tinted
|
||||
* by resource type + the matching type icon — pure CSS, no asset needed. The
|
||||
* backend is expected to supply real cover/thumbnail images; this is graceful
|
||||
* degradation until it does.
|
||||
*/
|
||||
const TYPE_FALLBACK: Record<string, { gradient: string; Icon: LucideIcon }> = {
|
||||
ppt: { gradient: "from-[#7a5a22] to-[#3a2c12]", Icon: Presentation },
|
||||
video: { gradient: "from-[#27506a] to-[#16293a]", Icon: Video },
|
||||
pdf: { gradient: "from-[#2f5a3f] to-[#16291d]", Icon: FileText },
|
||||
text: { gradient: "from-[#2f5a3f] to-[#16291d]", Icon: FileText },
|
||||
image: { gradient: "from-[#463340] to-[#241a22]", Icon: ImageIcon },
|
||||
music: { gradient: "from-[#5a2f5a] to-[#291629]", Icon: Music },
|
||||
link: { gradient: "from-[#2f5a55] to-[#162926]", Icon: LinkIcon },
|
||||
archive: { gradient: "from-[#6a4a22] to-[#2f2012]", Icon: Archive },
|
||||
};
|
||||
const FALLBACK_DEFAULT = {
|
||||
gradient: "from-[#2a2b35] to-[#191a20]",
|
||||
Icon: File,
|
||||
};
|
||||
|
||||
function FallbackCover({ type }: { type: string }) {
|
||||
const { gradient, Icon } = TYPE_FALLBACK[type] ?? FALLBACK_DEFAULT;
|
||||
return (
|
||||
<div
|
||||
className={`flex h-full w-full items-center justify-center bg-gradient-to-br ${gradient}`}
|
||||
>
|
||||
<Icon className="h-6 w-6 text-white/85" strokeWidth={1.8} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Rank badge: medals for the top 3, muted tabular numbers afterwards. */
|
||||
function RankBadge({ index }: { index: number }) {
|
||||
if (index < MEDALS.length) {
|
||||
return (
|
||||
<span
|
||||
className="flex w-7 shrink-0 justify-center text-[22px] leading-none"
|
||||
aria-label={`No.${index + 1}`}
|
||||
>
|
||||
{MEDALS[index]}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span
|
||||
className="flex w-7 shrink-0 justify-center text-lg font-extrabold tabular-nums text-[#7d7e87]"
|
||||
aria-label={`No.${index + 1}`}
|
||||
>
|
||||
{index + 1}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function PopularRankRow({
|
||||
post,
|
||||
index,
|
||||
categories,
|
||||
}: {
|
||||
post: Post;
|
||||
index: number;
|
||||
categories: Category[];
|
||||
}) {
|
||||
const { t, lang } = useI18n();
|
||||
const navigate = useNavigate();
|
||||
const { openLightbox } = useLightbox();
|
||||
const { openVideo } = useVideoPlayer();
|
||||
const { showToast } = useToast();
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
const [coverFailed, setCoverFailed] = useState(false);
|
||||
|
||||
const r = postToResource(post, lang, categories);
|
||||
const cover = r.coverImage && !coverFailed ? assetUrl(r.coverImage) : "";
|
||||
const isTop3 = index < MEDALS.length;
|
||||
|
||||
const first: Attachment | undefined = post.attachments[0];
|
||||
const imageAttachments = post.attachments.filter((a) => a.kind === "image");
|
||||
|
||||
const handlePreview = () => {
|
||||
if (first?.kind === "video") {
|
||||
openVideo(first);
|
||||
return;
|
||||
}
|
||||
if (imageAttachments.length > 0) {
|
||||
openLightbox(imageAttachments, 0, r.title, post.id);
|
||||
return;
|
||||
}
|
||||
// Documents / links have no inline overlay — fall through to the detail page.
|
||||
navigate(`/resource/${post.id}`);
|
||||
};
|
||||
|
||||
const handleDownload = async () => {
|
||||
if (isDownloading || !r.downloadPostId || !r.downloadAttachmentId) return;
|
||||
setIsDownloading(true);
|
||||
try {
|
||||
await downloadAttachment(
|
||||
r.downloadPostId,
|
||||
r.downloadAttachmentId,
|
||||
r.title,
|
||||
);
|
||||
showToast(t("downloadOk"));
|
||||
} catch {
|
||||
showToast(t("downloadFail"), "error");
|
||||
} finally {
|
||||
setIsDownloading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<article className="relative flex items-center gap-3 rounded-2xl border border-ark-line bg-ark-panel p-3 transition hover:border-ark-gold/45 md:gap-4 md:p-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(`/resource/${post.id}`)}
|
||||
aria-label={r.title}
|
||||
className="absolute inset-0 z-0 rounded-2xl outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/70"
|
||||
/>
|
||||
|
||||
<RankBadge index={index} />
|
||||
|
||||
<div
|
||||
className={`relative z-10 flex h-[52px] w-[64px] shrink-0 items-center justify-center overflow-hidden rounded-lg bg-[#111116] md:h-[58px] md:w-[72px] ${
|
||||
isTop3 ? "ring-1 ring-ark-gold/45" : ""
|
||||
}`}
|
||||
>
|
||||
{cover ? (
|
||||
<img
|
||||
src={cover}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="h-full w-full object-cover"
|
||||
onError={() => setCoverFailed(true)}
|
||||
/>
|
||||
) : (
|
||||
<FallbackCover type={r.type} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="pointer-events-none relative z-10 flex min-w-0 flex-1 flex-col gap-1">
|
||||
<div className="line-clamp-2 text-sm font-bold leading-snug text-white md:text-base">
|
||||
{r.title}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-[#9b9ca6]">
|
||||
<span className="rounded-full bg-[#2a2b33] px-2 py-0.5 text-[#b9bac3]">
|
||||
{resourceTypeLabel(t, r.type)}
|
||||
</span>
|
||||
<span className="truncate">
|
||||
{cleanCategoryDisplayName(r.categoryName)}
|
||||
</span>
|
||||
<span className="text-[#55565e]">·</span>
|
||||
<time dateTime={r.updatedAt} className="shrink-0 text-ark-muted">
|
||||
{formatDateYmd(r.updatedAt)}
|
||||
</time>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 flex shrink-0 items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePreview}
|
||||
aria-label={t("preview")}
|
||||
title={t("preview")}
|
||||
className="flex h-9 w-9 items-center justify-center rounded-lg text-neutral-300 outline-none transition hover:bg-white/5 hover:text-ark-gold focus-visible:ring-2 focus-visible:ring-ark-gold/70"
|
||||
>
|
||||
<Eye className="h-[18px] w-[18px]" />
|
||||
</button>
|
||||
{r.isDownloadable ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDownload}
|
||||
disabled={isDownloading}
|
||||
aria-label={t("download")}
|
||||
title={t("download")}
|
||||
className="flex h-9 w-9 items-center justify-center rounded-lg text-neutral-300 outline-none transition hover:bg-white/5 hover:text-ark-gold focus-visible:ring-2 focus-visible:ring-ark-gold/70 disabled:opacity-60"
|
||||
>
|
||||
{isDownloading ? (
|
||||
<LoaderCircle className="h-[18px] w-[18px] animate-spin" />
|
||||
) : (
|
||||
<Download className="h-[18px] w-[18px]" />
|
||||
)}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function ComingSoonRankRow({ index }: { index: number }) {
|
||||
const { lang } = useI18n();
|
||||
const label = lang === "zh-CN" ? "即将到来" : "Coming soon";
|
||||
return (
|
||||
<article
|
||||
className="flex items-center gap-3 rounded-2xl border border-ark-line bg-ark-panel p-3 opacity-70 md:gap-4 md:p-4"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<RankBadge index={index} />
|
||||
<div className="flex h-[52px] w-[64px] shrink-0 items-center justify-center overflow-hidden rounded-lg md:h-[58px] md:w-[72px]">
|
||||
<FallbackCover type="" />
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<div className="text-sm font-bold text-white/80 md:text-base">
|
||||
{label}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* "社群常用资料" ranking list for the home page. Items arrive pre-sorted by the
|
||||
* backend popularity score (downloads / favorites / shares / admin weight); the
|
||||
* frontend only ever shows rank position, never any raw counts.
|
||||
*/
|
||||
export function PopularRankList({
|
||||
posts,
|
||||
categories,
|
||||
}: {
|
||||
posts: Post[];
|
||||
categories: Category[];
|
||||
}) {
|
||||
const items = posts.slice(0, MAX_ITEMS);
|
||||
const placeholderCount = Math.max(0, MAX_ITEMS - items.length);
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex max-w-[760px] flex-col gap-2.5 px-4 md:gap-3 md:px-0">
|
||||
{items.map((post, index) => (
|
||||
<PopularRankRow
|
||||
key={post.id}
|
||||
post={post}
|
||||
index={index}
|
||||
categories={categories}
|
||||
/>
|
||||
))}
|
||||
{Array.from({ length: placeholderCount }).map((_, i) => (
|
||||
<ComingSoonRankRow
|
||||
key={`popular-coming-soon-${i}`}
|
||||
index={items.length + i}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -39,7 +39,7 @@ const zhDict: Dict = {
|
||||
categorySection: "资料分类",
|
||||
officialSection: "官方推荐",
|
||||
latestSection: "最新更新",
|
||||
popularSection: "热门资料",
|
||||
popularSection: "社群常用资料",
|
||||
preview: "预览",
|
||||
download: "下载",
|
||||
downloading: "下载中…",
|
||||
@@ -169,7 +169,7 @@ const enDict: Dict = {
|
||||
categorySection: "Categories",
|
||||
officialSection: "Official recommendations",
|
||||
latestSection: "Latest updates",
|
||||
popularSection: "Popular assets",
|
||||
popularSection: "Community favorites",
|
||||
preview: "Preview",
|
||||
download: "Download",
|
||||
downloading: "Downloading…",
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
ComingSoonLatestUpdateRow,
|
||||
LatestUpdateRow,
|
||||
} from "../../components/LatestUpdateRow";
|
||||
import { PopularRankList } from "../../components/PopularRankList";
|
||||
import { RecommendedCard } from "../../components/RecommendedCard";
|
||||
import { SectionHeader } from "../../components/SectionHeader";
|
||||
import { MessageBubble } from "../../components/messageStream/MessageBubble";
|
||||
@@ -246,7 +247,6 @@ export function Home() {
|
||||
|
||||
const latestPlaceholderCount = Math.max(0, 5 - latest.length);
|
||||
const hasPopular = popular.length > 0 || popularPosts.length > 0;
|
||||
const popularPlaceholderCount = Math.max(0, 5 - popular.length);
|
||||
const recommendedDotCount = rec.length;
|
||||
const activeRecommendedDot =
|
||||
recommendedDotCount > 0
|
||||
@@ -513,27 +513,8 @@ export function Home() {
|
||||
viewAllLabel={t("viewAll")}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 md:hidden">
|
||||
{popularPosts.slice(0, 5).map((post) => (
|
||||
<MessageBubble key={post.id} post={post} />
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-7 hidden grid-cols-1 gap-3 min-[576px]:grid-cols-2 md:grid md:grid-cols-2 md:gap-4 lg:grid-cols-3 xl:grid-cols-5">
|
||||
{popular.map((r) => (
|
||||
<LatestUpdateRow
|
||||
key={r.id}
|
||||
r={r}
|
||||
iconKey={iconKeyForResource(r)}
|
||||
/>
|
||||
))}
|
||||
{Array.from({ length: popularPlaceholderCount }).map(
|
||||
(_, index) => (
|
||||
<ComingSoonLatestUpdateRow
|
||||
key={`popular-coming-soon-${index}`}
|
||||
index={popular.length + index}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
<div className="mt-4 md:mt-7">
|
||||
<PopularRankList posts={popularPosts} categories={cats} />
|
||||
</div>
|
||||
</section>
|
||||
</Reveal>
|
||||
|
||||
Reference in New Issue
Block a user