Files
Arkie-Library-Frontend/src/components/PopularRankList.tsx

243 lines
7.7 KiB
TypeScript
Raw Normal View History

import {
Archive,
Download,
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 { 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<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 { 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 (
<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"
decoding="async"
className="h-full w-full object-fill"
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">
{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>
);
}