diff --git a/src/components/LatestUpdateCard.tsx b/src/components/LatestUpdateCard.tsx index 16d62b8..ef30bff 100644 --- a/src/components/LatestUpdateCard.tsx +++ b/src/components/LatestUpdateCard.tsx @@ -1,10 +1,17 @@ -import { Play } from "lucide-react"; -import { Link, useNavigate } from "react-router-dom"; +import { LoaderCircle, Play } from "lucide-react"; +import { useState } from "react"; +import { Link } from "react-router-dom"; import { FavoriteButton } from "../favorites/FavoriteButton"; import { useI18n } from "../i18n"; import { useLocalizedPath } from "../useLocalizedPath"; import type { Attachment, Post } from "../types/post"; import { DownloadCloudIcon } from "./icons/DownloadCloudIcon"; +import { + mediaSaveKindFromAttachment, + useSaveToAlbumGuide, +} from "./SaveToAlbumGuide"; +import { useToast } from "./Toast"; +import { downloadAttachment } from "./messageStream/utils/downloadFile"; import { fileIcon } from "./messageStream/utils/fileIcon"; import { filenameWithExtension, @@ -23,11 +30,24 @@ function LatestActions({ attachment?: Attachment; }) { const { t } = useI18n(); - const lp = useLocalizedPath(); - const navigate = useNavigate(); + const { showToast } = useToast(); + const { showSaveToAlbumGuide } = useSaveToAlbumGuide(); + const [isDownloading, setIsDownloading] = useState(false); - const goToPost = () => { - navigate(lp(`/browse?post=${encodeURIComponent(post.id)}`)); + const handleDownload = async () => { + if (!attachment || isDownloading) return; + setIsDownloading(true); + try { + await downloadAttachment(post.id, attachment.id, attachment.filename, { + sizeBytes: attachment.sizeBytes, + }); + const mediaKind = mediaSaveKindFromAttachment(attachment); + if (mediaKind) showSaveToAlbumGuide(mediaKind); + } catch { + showToast(t("downloadFail"), "error"); + } finally { + setIsDownloading(false); + } }; return ( @@ -39,13 +59,23 @@ function LatestActions({ onClick={(event) => { event.preventDefault(); event.stopPropagation(); - goToPost(); + void handleDownload(); }} - aria-label={t("download")} - title={t("download")} - className="relative z-20 flex h-9 w-9 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={isDownloading} + aria-label={ + isDownloading ? t("downloading") : `Download ${attachment.filename}` + } + aria-busy={isDownloading} + className="relative z-20 flex h-9 w-9 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" > - + {isDownloading ? ( + + ) : ( + + )} ) : null} diff --git a/src/components/PopularRankList.tsx b/src/components/PopularRankList.tsx index 12123df..c939352 100644 --- a/src/components/PopularRankList.tsx +++ b/src/components/PopularRankList.tsx @@ -4,6 +4,7 @@ import { FileText, Image as ImageIcon, Link as LinkIcon, + LoaderCircle, Music, Presentation, Video, @@ -20,6 +21,9 @@ 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 { mediaSaveKindFromType, useSaveToAlbumGuide } from "./SaveToAlbumGuide"; +import { useToast } from "./Toast"; import { FavoriteButton } from "../favorites/FavoriteButton"; const MEDALS = ["🥇", "🥈", "🥉"]; @@ -101,18 +105,31 @@ export function PopularRankRow({ const { t, lang } = useI18n(); const navigate = useNavigate(); const lp = useLocalizedPath(); + const { showToast } = useToast(); + const { showSaveToAlbumGuide } = useSaveToAlbumGuide(); + 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 = showRank && index < MEDALS.length; - const goToPost = () => { - const params = new URLSearchParams(); - if (browseSort) params.set("sort", browseSort); - params.set("post", post.id); - if (singlePostLink) params.set("single", "1"); - navigate(lp(`/browse?${params.toString()}`)); + const handleDownload = async () => { + if (isDownloading || !r.downloadPostId || !r.downloadAttachmentId) return; + setIsDownloading(true); + try { + await downloadAttachment( + r.downloadPostId, + r.downloadAttachmentId, + r.title, + ); + const mediaKind = mediaSaveKindFromType(r.type); + if (mediaKind) showSaveToAlbumGuide(mediaKind); + } catch { + showToast(t("downloadFail"), "error"); + } finally { + setIsDownloading(false); + } }; return ( @@ -125,7 +142,13 @@ export function PopularRankRow({ > ) : null} diff --git a/src/components/RecommendedCard.tsx b/src/components/RecommendedCard.tsx index 2dbbb2b..f3566ca 100644 --- a/src/components/RecommendedCard.tsx +++ b/src/components/RecommendedCard.tsx @@ -1,13 +1,20 @@ +import { Download, LoaderCircle } from "lucide-react"; import { m } from "framer-motion"; -import { Link, useNavigate } from "react-router-dom"; +import { Link } from "react-router-dom"; import type { Resource } from "../api"; import { assetUrl } from "../api"; import { useI18n } from "../i18n"; import { useLocalizedPath } from "../useLocalizedPath"; -import { useMemo } from "react"; +import { useMemo, useState } from "react"; import { formatDateYmd } from "../utils/format"; import { DownloadCloudIcon } from "./icons/DownloadCloudIcon"; import { officialRecommendationCoverFallbacks } from "./FigmaBanner"; +import { + downloadAttachment, + downloadFile, +} from "./messageStream/utils/downloadFile"; +import { mediaSaveKindFromType, useSaveToAlbumGuide } from "./SaveToAlbumGuide"; +import { useToast } from "./Toast"; import { FavoriteButton } from "../favorites/FavoriteButton"; function isPlaceholderAsset(path: string | undefined | null) { @@ -46,7 +53,9 @@ export function RecommendedCard({ }) { const { t } = useI18n(); const lp = useLocalizedPath(); - const navigate = useNavigate(); + const { showToast } = useToast(); + const { showSaveToAlbumGuide } = useSaveToAlbumGuide(); + const [isDownloading, setIsDownloading] = useState(false); const figmaCover = officialRecommendationCoverFallbacks[ visualIndex % officialRecommendationCoverFallbacks.length @@ -68,10 +77,26 @@ export function RecommendedCard({ ? assetUrl(r.fileUrl || r.previewUrl) : ""; - const goToPost = () => { - // Same destination as the card-wide overlay link, so the user lands on the - // post and can choose exactly which attachment to download. - navigate(lp(`/resource/${r.id}`)); + 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); + } + const mediaKind = mediaSaveKindFromType(r.type); + if (mediaKind) showSaveToAlbumGuide(mediaKind); + } catch { + showToast(t("downloadFail"), "error"); + } finally { + setIsDownloading(false); + } }; return ( @@ -168,18 +193,29 @@ export function RecommendedCard({ type="button" className={ useFigmaDesign - ? "relative z-20 flex h-9 w-9 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" - : "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" + ? "relative z-20 flex h-9 w-9 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={t("download")} - aria-label={t("download")} + title={isDownloading ? t("downloading") : t("download")} + aria-label={isDownloading ? t("downloading") : t("download")} + aria-busy={isDownloading} + disabled={isDownloading} onClick={(e) => { e.preventDefault(); e.stopPropagation(); - goToPost(); + void handleDownload(); }} > - + {isDownloading ? ( + + ) : useFigmaDesign ? ( + + ) : ( + + )} ) : null}