diff --git a/src/components/RecommendedCard.tsx b/src/components/RecommendedCard.tsx index 6e02a58..84434db 100644 --- a/src/components/RecommendedCard.tsx +++ b/src/components/RecommendedCard.tsx @@ -1,9 +1,9 @@ -import { Download } from "lucide-react"; +import { Download, LoaderCircle } from "lucide-react"; import { Link } from "react-router-dom"; import type { Resource } from "../api"; import { assetUrl, postJSON } from "../api"; import { useI18n } from "../i18n"; -import { useMemo } from "react"; +import { useMemo, useState } from "react"; import { formatDateYmd } from "../utils/format"; import { officialRecommendationCoverFallbacks } from "./FigmaBanner"; import { @@ -31,6 +31,7 @@ export function RecommendedCard({ visualIndex?: number; }) { const { t } = useI18n(); + const [isDownloading, setIsDownloading] = useState(false); const cover = useMemo(() => { const original = r.coverImage || r.previewUrl; if (isPlaceholderAsset(original)) { @@ -85,29 +86,46 @@ export function RecommendedCard({ {dl ? ( ) : null} diff --git a/src/components/messageStream/bubbles/FileDocBubble.tsx b/src/components/messageStream/bubbles/FileDocBubble.tsx index c74498f..7b63ea3 100644 --- a/src/components/messageStream/bubbles/FileDocBubble.tsx +++ b/src/components/messageStream/bubbles/FileDocBubble.tsx @@ -1,4 +1,5 @@ -import { ArrowDownToLine } from "lucide-react"; +import { ArrowDownToLine, LoaderCircle } from "lucide-react"; +import { useState } from "react"; import { useI18n } from "../../../i18n"; import type { Attachment, Post } from "../../../types/post"; import { downloadAttachment } from "../utils/downloadFile"; @@ -11,12 +12,18 @@ import { formatBytes } from "../utils/formatBytes"; import { postDisplayText } from "../utils/postText"; function AttachmentRow({ postId, att }: { postId: string; att: Attachment }) { + const { t } = useI18n(); const isImageAsDoc = att.mime.startsWith("image/"); const { Icon, color } = fileIcon({ mime: att.mime, filename: att.filename }); const displayFilename = filenameWithExtension(att.filename, att.mime); + const [isDownloading, setIsDownloading] = useState(false); const handleDownload = () => { - void downloadAttachment(postId, att.id, displayFilename).catch(() => {}); + if (isDownloading) return; + setIsDownloading(true); + void downloadAttachment(postId, att.id, displayFilename) + .finally(() => setIsDownloading(false)) + .catch(() => {}); }; return ( @@ -24,8 +31,12 @@ function AttachmentRow({ postId, att }: { postId: string; att: Attachment }) {
@@ -53,7 +68,7 @@ function AttachmentRow({ postId, att }: { postId: string; att: Attachment }) { {middleEllipsisFilename(displayFilename)}
- {formatBytes(att.sizeBytes)} + {isDownloading ? t("downloading") : formatBytes(att.sizeBytes)}
diff --git a/src/components/messageStream/bubbles/ImageWithTextBubble.tsx b/src/components/messageStream/bubbles/ImageWithTextBubble.tsx index f072cff..8ec7af8 100644 --- a/src/components/messageStream/bubbles/ImageWithTextBubble.tsx +++ b/src/components/messageStream/bubbles/ImageWithTextBubble.tsx @@ -1,12 +1,17 @@ +import { ArrowDownToLine, LoaderCircle } from "lucide-react"; +import { useState } from "react"; import { useI18n } from "../../../i18n"; import type { Post } from "../../../types/post"; import { useLightbox } from "../overlays/ImageLightbox"; import { autolink } from "../utils/autolink"; +import { downloadAttachment } from "../utils/downloadFile"; +import { formatBytes } from "../utils/formatBytes"; import { postDisplayText } from "../utils/postText"; export function ImageWithTextBubble({ post }: { post: Post }) { const { openLightbox } = useLightbox(); - const { lang } = useI18n(); + const { lang, t } = useI18n(); + const [isDownloading, setIsDownloading] = useState(false); const att = post.attachments[0]; const text = postDisplayText(post, lang); if (!att) return null; @@ -29,6 +34,34 @@ export function ImageWithTextBubble({ post }: { post: Post }) { style={{ aspectRatio: ratio }} /> + {text ? (
diff --git a/src/components/messageStream/bubbles/VideoBubble.tsx b/src/components/messageStream/bubbles/VideoBubble.tsx index 24611f7..81413ee 100644 --- a/src/components/messageStream/bubbles/VideoBubble.tsx +++ b/src/components/messageStream/bubbles/VideoBubble.tsx @@ -1,4 +1,4 @@ -import { ArrowDownToLine, Play } from "lucide-react"; +import { ArrowDownToLine, LoaderCircle, Play } from "lucide-react"; import { useRef, useState } from "react"; import { useI18n } from "../../../i18n"; import type { Post } from "../../../types/post"; @@ -17,9 +17,10 @@ function formatDuration(sec: number | undefined): string { export function VideoBubble({ post }: { post: Post }) { const { openVideo } = useVideoPlayer(); - const { lang } = useI18n(); + const { lang, t } = useI18n(); const att = post.attachments[0]; const [playing, setPlaying] = useState(false); + const [isDownloading, setIsDownloading] = useState(false); const videoRef = useRef(null); const text = postDisplayText(post, lang); if (!att) return null; @@ -73,24 +74,43 @@ export function VideoBubble({ post }: { post: Post }) { type="button" onClick={(e) => { e.stopPropagation(); - void downloadAttachment(post.id, att.id, att.filename).catch( - () => {}, - ); + if (isDownloading) return; + setIsDownloading(true); + void downloadAttachment(post.id, att.id, att.filename) + .finally(() => setIsDownloading(false)) + .catch(() => {}); }} - className="group absolute left-3 top-3 z-10 inline-flex overflow-hidden rounded-full bg-black/45 text-xs text-white shadow-lg ring-1 ring-white/15 backdrop-blur-md transition hover:bg-black/60" - aria-label={`Download ${att.filename}`} + disabled={isDownloading} + className="group absolute left-3 top-3 z-10 inline-flex overflow-hidden rounded-full bg-black/45 text-xs text-white shadow-lg ring-1 ring-white/15 backdrop-blur-md transition hover:bg-black/60 disabled:cursor-wait" + aria-label={ + isDownloading ? t("downloading") : `Download ${att.filename}` + } + aria-busy={isDownloading} > - + {isDownloading ? ( + + ) : ( + + )} - {duration ? ( + {isDownloading ? ( + {t("downloading")} + ) : ( <> - {duration} - · + {duration ? ( + <> + {duration} + · + + ) : null} + {formatBytes(att.sizeBytes)} - ) : null} - {formatBytes(att.sizeBytes)} + )} - - {images.length > 1 ? ( <>