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

266 lines
9.1 KiB
TypeScript
Raw Normal View History

2026-05-27 12:23:06 +08:00
import { Download, LoaderCircle } from "lucide-react";
import { m } from "framer-motion";
2026-05-16 00:18:22 +08:00
import { Link } from "react-router-dom";
import type { Resource } from "../api";
import { assetUrl } from "../api";
2026-05-16 00:18:22 +08:00
import { useI18n } from "../i18n";
2026-06-01 16:35:40 +08:00
import { useLocalizedPath } from "../useLocalizedPath";
2026-05-27 12:23:06 +08:00
import { useMemo, useState } from "react";
2026-05-16 00:18:22 +08:00
import { formatDateYmd } from "../utils/format";
2026-05-28 15:55:37 +08:00
import { DownloadCloudIcon } from "./icons/DownloadCloudIcon";
import { officialRecommendationCoverFallbacks } from "./FigmaBanner";
import {
downloadAttachment,
downloadFile,
} from "./messageStream/utils/downloadFile";
2026-06-01 23:00:28 +08:00
import { mediaSaveKindFromType, useSaveToAlbumGuide } from "./SaveToAlbumGuide";
import { useToast } from "./Toast";
2026-06-02 00:36:11 +08:00
import { FavoriteButton } from "../favorites/FavoriteButton";
2026-05-16 00:18:22 +08:00
function isPlaceholderAsset(path: string | undefined | null) {
return !path || path.includes("placeholder-cover");
}
const CARD_BASE_CLASS =
"group flex shrink-0 flex-col overflow-hidden rounded-xl border bg-[#1D1E23] transition hover:border-ark-gold/55 hover:shadow-lg hover:shadow-black/30";
const CARD_HOVER_SPRING = {
type: "spring",
stiffness: 380,
damping: 26,
} as const;
const CARD_CAROUSEL_SIZE_CLASS =
"w-[208px] md:w-[240px] lg:w-[246.4px] min-[1100px]:w-[273px]";
const CARD_GRID_SIZE_CLASS = "w-full max-w-[360px] md:max-w-none";
2026-05-16 00:18:22 +08:00
2026-05-26 12:07:13 +08:00
type RecommendedResource = Resource & {
downloadPostId?: string;
downloadAttachmentId?: string;
};
2026-05-16 00:18:22 +08:00
export function RecommendedCard({
r,
visualIndex = 0,
useFigmaDesign = false,
layout = "carousel",
2026-05-16 00:18:22 +08:00
}: {
2026-05-26 12:07:13 +08:00
r: RecommendedResource;
2026-05-16 00:18:22 +08:00
visualIndex?: number;
useFigmaDesign?: boolean;
layout?: "carousel" | "grid";
2026-05-16 00:18:22 +08:00
}) {
const { t } = useI18n();
2026-06-01 16:35:40 +08:00
const lp = useLocalizedPath();
const { showToast } = useToast();
2026-06-01 23:00:28 +08:00
const { showSaveToAlbumGuide } = useSaveToAlbumGuide();
2026-05-27 12:23:06 +08:00
const [isDownloading, setIsDownloading] = useState(false);
const figmaCover =
officialRecommendationCoverFallbacks[
visualIndex % officialRecommendationCoverFallbacks.length
];
2026-05-16 00:18:22 +08:00
const cover = useMemo(() => {
const original = r.coverImage || r.previewUrl;
if (isPlaceholderAsset(original)) {
return useFigmaDesign ? "" : figmaCover;
2026-05-16 00:18:22 +08:00
}
return assetUrl(original);
}, [figmaCover, r.coverImage, r.previewUrl, useFigmaDesign]);
2026-05-28 15:49:08 +08:00
const displayTitle = r.title;
2026-05-28 15:55:37 +08:00
const displayCategoryName = r.categoryName;
2026-05-28 15:49:08 +08:00
const dateStr = formatDateYmd(r.updatedAt);
const dateTime = r.updatedAt;
2026-05-16 00:18:22 +08:00
const dl =
r.isDownloadable && (r.fileUrl || r.previewUrl)
? assetUrl(r.fileUrl || r.previewUrl)
: "";
2026-05-28 15:55:37 +08:00
const handleDownload = async () => {
if (isDownloading) return;
setIsDownloading(true);
try {
if (r.downloadPostId && r.downloadAttachmentId) {
await downloadAttachment(
r.downloadPostId,
r.downloadAttachmentId,
displayTitle,
);
} else {
await downloadFile(dl, displayTitle);
2026-05-28 15:55:37 +08:00
}
2026-06-01 23:00:28 +08:00
const mediaKind = mediaSaveKindFromType(r.type);
if (mediaKind) showSaveToAlbumGuide(mediaKind);
2026-05-28 15:55:37 +08:00
} catch {
showToast(t("downloadFail"), "error");
2026-05-28 15:55:37 +08:00
} finally {
setIsDownloading(false);
}
};
2026-05-16 00:18:22 +08:00
return (
<m.article
whileHover={{ y: -4 }}
transition={CARD_HOVER_SPRING}
className={`relative ${CARD_BASE_CLASS} ${
layout === "grid" ? CARD_GRID_SIZE_CLASS : CARD_CAROUSEL_SIZE_CLASS
} ${
2026-05-28 15:55:37 +08:00
useFigmaDesign
? "border-[#27292E] bg-[#1D1E23]"
2026-05-28 15:55:37 +08:00
: "border-transparent md:border-ark-line md:bg-ark-panel"
}`}
>
2026-05-16 00:18:22 +08:00
<Link
2026-06-01 16:35:40 +08:00
to={lp(`/resource/${r.id}`)}
aria-label={displayTitle}
className="absolute inset-0 z-10 outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/70"
/>
<div
className={
useFigmaDesign
? "relative block h-[131px] overflow-hidden bg-[#1D1E23]"
: "relative block aspect-[208/108] overflow-hidden bg-[#111116]"
}
>
2026-05-16 00:18:22 +08:00
{cover ? (
<img
src={cover}
alt=""
className="ark-img-fade h-full w-full object-cover transition duration-300 group-hover:scale-[1.02]"
2026-05-16 00:18:22 +08:00
loading="lazy"
decoding="async"
onLoad={(e) => e.currentTarget.classList.add("is-loaded")}
2026-05-16 00:18:22 +08:00
/>
) : (
<div className="h-full w-full bg-gradient-to-br from-neutral-900 to-neutral-950" />
)}
2026-05-28 15:55:37 +08:00
{!useFigmaDesign && r.badgeLabel ? (
2026-05-16 00:18:22 +08:00
<span className="absolute left-3 top-3 rounded-md bg-ark-gold px-2.5 py-1 text-xs font-semibold text-black">
{r.badgeLabel}
</span>
) : null}
</div>
2026-05-28 15:55:37 +08:00
<div
className={
useFigmaDesign
? "flex h-[143px] flex-col px-4 py-4"
2026-05-28 15:55:37 +08:00
: "flex min-h-[131px] flex-1 flex-col p-4 pt-3 md:min-h-[121px]"
}
>
<div className="flex flex-col gap-1.5">
<h3
className={
useFigmaDesign
? "text-base font-semibold leading-[23px] text-white line-clamp-2 transition-colors group-hover:text-ark-gold2"
: "text-[15px] font-semibold leading-[21.72px] text-white line-clamp-2 transition-colors group-hover:text-ark-gold2 md:text-base md:font-bold md:leading-snug"
}
>
2026-05-28 15:55:37 +08:00
{displayTitle}
</h3>
2026-05-28 15:55:37 +08:00
{useFigmaDesign ? (
<div className="truncate text-[12px] font-normal leading-[17.376px] text-[#A8A9AE]">
{displayCategoryName}
</div>
) : null}
</div>
<div
className={
useFigmaDesign
? "mt-auto flex h-10 items-end justify-between gap-2"
: "mt-auto flex items-center justify-between gap-2 pt-4 text-[12px] leading-[17.38px] text-ark-muted"
}
2026-05-16 00:18:22 +08:00
>
2026-05-28 15:55:37 +08:00
<div
className={
useFigmaDesign
? "min-w-0 truncate pb-[1px] text-[12px] font-normal leading-[17.376px] text-[#A8A9AE]"
: "min-w-0 truncate"
}
>
{useFigmaDesign ? null : (
<>
<span className="text-neutral-400">{displayCategoryName}</span>
<span className="mx-1.5 text-ark-line">·</span>
</>
)}
<time dateTime={dateTime}>{dateStr}</time>
2026-05-16 00:18:22 +08:00
</div>
<div className="relative z-20 flex shrink-0 items-center gap-2">
<FavoriteButton resourceId={r.id} size="sm" />
{dl ? (
<button
type="button"
className={
useFigmaDesign
? "relative z-20 flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-[#191921] text-white outline-none transition hover:bg-[#22232D] active:scale-95 focus-visible:ring-2 focus-visible:ring-ark-gold/80 disabled:cursor-wait"
: "relative z-20 shrink-0 rounded-lg p-1 text-white outline-none transition hover:bg-ark-gold/10 active:scale-95 focus-visible:ring-2 focus-visible:ring-ark-gold/80 disabled:cursor-wait"
}
title={isDownloading ? t("downloading") : t("download")}
aria-label={isDownloading ? t("downloading") : t("download")}
aria-busy={isDownloading}
disabled={isDownloading}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
void handleDownload();
}}
>
{isDownloading ? (
<LoaderCircle
className="h-5 w-5 animate-spin"
strokeWidth={2.2}
/>
) : useFigmaDesign ? (
<DownloadCloudIcon className="h-6 w-6" />
) : (
<Download className="h-5 w-5" strokeWidth={2.2} />
)}
</button>
) : null}
</div>
2026-05-16 00:18:22 +08:00
</div>
</div>
</m.article>
2026-05-16 00:18:22 +08:00
);
}
export function ComingSoonRecommendedCard({
visualIndex = 0,
}: {
visualIndex?: number;
}) {
const cover =
officialRecommendationCoverFallbacks[
visualIndex % officialRecommendationCoverFallbacks.length
2026-05-16 00:18:22 +08:00
];
return (
<article
className={`${CARD_BASE_CLASS} ${CARD_CAROUSEL_SIZE_CLASS} cursor-default border-transparent opacity-95 md:border-ark-line md:bg-ark-panel`}
2026-05-16 00:18:22 +08:00
aria-label="即将到来"
>
2026-05-28 15:31:45 +08:00
<div className="relative block h-[108px] overflow-hidden bg-black md:aspect-[246.4/138.6] md:h-auto">
2026-05-16 00:18:22 +08:00
<img
src={cover}
alt=""
className="h-full w-full object-cover opacity-75 grayscale-[15%]"
loading="lazy"
decoding="async"
2026-05-16 00:18:22 +08:00
/>
<span className="absolute left-3 top-3 rounded-md bg-ark-gold px-2.5 py-1 text-xs font-semibold text-black">
</span>
</div>
2026-05-28 15:31:45 +08:00
<div className="flex min-h-[131px] flex-1 flex-col p-4 pt-3 md:min-h-[121px]">
<div className="text-[15px] font-semibold leading-[21.72px] text-white line-clamp-2 md:text-base md:font-bold md:leading-snug">
2026-05-16 00:18:22 +08:00
</div>
<div className="mt-auto pt-4 text-xs text-ark-muted">
</div>
</div>
</article>
);
}