2026-05-29 14:53:52 +08:00
|
|
|
import {
|
|
|
|
|
Archive,
|
|
|
|
|
File,
|
|
|
|
|
FileText,
|
|
|
|
|
Image as ImageIcon,
|
|
|
|
|
Link as LinkIcon,
|
|
|
|
|
LoaderCircle,
|
|
|
|
|
Music,
|
|
|
|
|
Presentation,
|
|
|
|
|
Video,
|
|
|
|
|
type LucideIcon,
|
|
|
|
|
} from "lucide-react";
|
2026-05-31 02:04:26 +08:00
|
|
|
import { DownloadCloudIcon } from "./icons/DownloadCloudIcon";
|
2026-05-29 14:53:52 +08:00
|
|
|
import { useState } from "react";
|
|
|
|
|
import { useNavigate } from "react-router-dom";
|
|
|
|
|
import { assetUrl, type Category } from "../api";
|
|
|
|
|
import { useI18n } from "../i18n";
|
2026-06-01 16:35:40 +08:00
|
|
|
import { useLocalizedPath } from "../useLocalizedPath";
|
2026-05-29 14:53:52 +08:00
|
|
|
import { resourceTypeLabel } from "../resourceTypeLabels";
|
|
|
|
|
import { cleanCategoryDisplayName } from "../utils/categoryDisplay";
|
|
|
|
|
import { formatDateYmd } from "../utils/format";
|
|
|
|
|
import { postToResource } from "../utils/postResourceAdapter";
|
2026-05-29 17:58:30 +08:00
|
|
|
import type { Post } from "../types/post";
|
2026-05-29 14:53:52 +08:00
|
|
|
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();
|
2026-06-01 16:35:40 +08:00
|
|
|
const lp = useLocalizedPath();
|
2026-05-29 14:53:52 +08:00
|
|
|
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 (
|
2026-05-31 02:48:18 +08:00
|
|
|
<article className="relative flex items-center gap-3 rounded-2xl border border-[#27292E] bg-[#272632] p-3 transition hover:border-ark-gold/55 md:gap-4 md:p-4">
|
2026-05-29 14:53:52 +08:00
|
|
|
<button
|
|
|
|
|
type="button"
|
2026-05-30 17:52:50 +08:00
|
|
|
onClick={() =>
|
2026-06-01 16:35:40 +08:00
|
|
|
navigate(
|
|
|
|
|
lp(`/browse?sort=popular&post=${encodeURIComponent(post.id)}`),
|
|
|
|
|
)
|
2026-05-30 17:52:50 +08:00
|
|
|
}
|
2026-05-29 14:53:52 +08:00
|
|
|
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 ? (
|
2026-05-30 02:51:09 +08:00
|
|
|
<img
|
|
|
|
|
src={cover}
|
|
|
|
|
alt=""
|
|
|
|
|
loading="lazy"
|
|
|
|
|
decoding="async"
|
|
|
|
|
className="h-full w-full object-fill"
|
|
|
|
|
onError={() => setCoverFailed(true)}
|
|
|
|
|
/>
|
2026-05-29 14:53:52 +08:00
|
|
|
) : (
|
|
|
|
|
<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>
|
2026-05-30 23:33:23 +08:00
|
|
|
<time dateTime={post.publishedAt} className="shrink-0 text-ark-muted">
|
|
|
|
|
{formatDateYmd(post.publishedAt)}
|
2026-05-29 14:53:52 +08:00
|
|
|
</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")}
|
2026-05-31 02:04:26 +08:00
|
|
|
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-[#191921] text-white outline-none transition hover:bg-[#22232D] focus-visible:ring-2 focus-visible:ring-ark-gold/70 disabled:cursor-wait"
|
2026-05-29 14:53:52 +08:00
|
|
|
>
|
|
|
|
|
{isDownloading ? (
|
2026-05-31 02:56:34 +08:00
|
|
|
<LoaderCircle
|
|
|
|
|
className="h-5 w-5 animate-spin"
|
|
|
|
|
strokeWidth={2.3}
|
|
|
|
|
/>
|
2026-05-29 14:53:52 +08:00
|
|
|
) : (
|
2026-05-31 02:04:26 +08:00
|
|
|
<DownloadCloudIcon className="h-6 w-6" />
|
2026-05-29 14:53:52 +08:00
|
|
|
)}
|
|
|
|
|
</button>
|
|
|
|
|
) : null}
|
|
|
|
|
</div>
|
|
|
|
|
</article>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function ComingSoonRankRow({ index }: { index: number }) {
|
|
|
|
|
const { lang } = useI18n();
|
|
|
|
|
const label = lang === "zh-CN" ? "即将到来" : "Coming soon";
|
|
|
|
|
return (
|
|
|
|
|
<article
|
2026-05-31 02:48:18 +08:00
|
|
|
className="flex items-center gap-3 rounded-2xl border border-[#27292E] bg-[#272632] p-3 opacity-70 md:gap-4 md:p-4"
|
2026-05-29 14:53:52 +08:00
|
|
|
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 (
|
2026-05-31 02:39:54 +08:00
|
|
|
<div className="mx-auto flex max-w-full flex-col gap-2.5 px-4 md:max-w-[680px] md:gap-3 md:px-0 lg:max-w-[900px] xl:max-w-[1120px]">
|
2026-05-29 14:53:52 +08:00
|
|
|
{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>
|
|
|
|
|
);
|
|
|
|
|
}
|