import { Archive, File, FileText, Image as ImageIcon, Link as LinkIcon, LoaderCircle, Music, Presentation, Video, type LucideIcon, } from "lucide-react"; import { DownloadCloudIcon } from "./icons/DownloadCloudIcon"; import { useState } from "react"; import { useNavigate } from "react-router-dom"; import { assetUrl, type Category } from "../api"; import { useI18n } from "../i18n"; import { useLocalizedPath } from "../useLocalizedPath"; import { resourceTypeLabel } from "../resourceTypeLabels"; import { cleanCategoryDisplayName } from "../utils/categoryDisplay"; import { formatDateYmd } from "../utils/format"; import { postToResource } from "../utils/postResourceAdapter"; import type { Post } from "../types/post"; 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 = { 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 (
); } /** Rank badge: medals for the top 3, muted tabular numbers afterwards. */ function RankBadge({ index }: { index: number }) { if (index < MEDALS.length) { return ( {MEDALS[index]} ); } return ( {index + 1} ); } function PopularRankRow({ post, index, categories, }: { post: Post; index: number; categories: Category[]; }) { const { t, lang } = useI18n(); const navigate = useNavigate(); const lp = useLocalizedPath(); 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 handleDownload = async () => { if (isDownloading || !r.downloadPostId || !r.downloadAttachmentId) return; setIsDownloading(true); try { await downloadAttachment( r.downloadPostId, r.downloadAttachmentId, r.title, ); } catch { showToast(t("downloadFail"), "error"); } finally { setIsDownloading(false); } }; return (
) : null}
); } function ComingSoonRankRow({ index }: { index: number }) { const { lang } = useI18n(); const label = lang === "zh-CN" ? "即将到来" : "Coming soon"; return ( ); } /** * "社群常用资料" 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 (
{items.map((post, index) => ( ))} {Array.from({ length: placeholderCount }).map((_, i) => ( ))}
); }