feat: scroll to post bubble from recommended card + back-to-top button
Some checks failed
Deploy to Frontend Servers / deploy (push) Failing after 14s
Some checks failed
Deploy to Frontend Servers / deploy (push) Failing after 14s
Recommended cards already routed to /browse#post-<id>, but the stream had no logic to scroll to the target bubble — and the post might not be paged in yet. MessageStream now resolves the #post-<id> hash, auto-loads more pages until the bubble renders, scrolls to it, and gives it a brief gold highlight. Bubbles get scroll-mt so they clear the sticky header. Also adds a global floating back-to-top button (BackToTop) mounted in PublicLayout, shown after scrolling past 400px. Bundles related staging UI work already present in the working tree. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
45
src/components/BackToTop.tsx
Normal file
45
src/components/BackToTop.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { ArrowUp } from "lucide-react";
|
||||
import { AnimatePresence, m } from "framer-motion";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useI18n } from "../i18n";
|
||||
|
||||
const SHOW_AFTER = 400;
|
||||
|
||||
/**
|
||||
* Floating "back to top" button. Appears once the page is scrolled past
|
||||
* SHOW_AFTER px and smoothly returns the window to the top on click. Sits
|
||||
* above the mobile bottom nav and clears it on larger screens.
|
||||
*/
|
||||
export function BackToTop() {
|
||||
const { t } = useI18n();
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const update = () => setVisible(window.scrollY > SHOW_AFTER);
|
||||
update();
|
||||
window.addEventListener("scroll", update, { passive: true });
|
||||
return () => window.removeEventListener("scroll", update);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{visible ? (
|
||||
<m.button
|
||||
type="button"
|
||||
initial={{ opacity: 0, scale: 0.8, y: 8 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.8, y: 8 }}
|
||||
transition={{ type: "spring", stiffness: 380, damping: 26 }}
|
||||
onClick={() =>
|
||||
window.scrollTo({ top: 0, behavior: "smooth" })
|
||||
}
|
||||
className="fixed bottom-[94px] right-4 z-30 flex h-11 w-11 items-center justify-center rounded-full bg-ark-gold text-black shadow-lg shadow-black/40 outline-none transition hover:bg-ark-gold2 active:scale-95 focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg md:bottom-8 md:right-8"
|
||||
aria-label={t("backToTop")}
|
||||
title={t("backToTop")}
|
||||
>
|
||||
<ArrowUp className="h-5 w-5" strokeWidth={2.4} />
|
||||
</m.button>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Download, LoaderCircle } from "lucide-react";
|
||||
import { m } from "framer-motion";
|
||||
import { Link } from "react-router-dom";
|
||||
import type { Resource } from "../api";
|
||||
import { assetUrl } from "../api";
|
||||
@@ -11,13 +12,20 @@ import {
|
||||
downloadAttachment,
|
||||
downloadFile,
|
||||
} from "./messageStream/utils/downloadFile";
|
||||
import { useToast } from "./Toast";
|
||||
|
||||
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";
|
||||
"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]:max-xl:w-[273px] xl:w-[246.4px]";
|
||||
@@ -41,6 +49,7 @@ export function RecommendedCard({
|
||||
layout?: "carousel" | "grid";
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const { showToast } = useToast();
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
const figmaCover =
|
||||
officialRecommendationCoverFallbacks[
|
||||
@@ -73,18 +82,21 @@ export function RecommendedCard({
|
||||
r.downloadAttachmentId,
|
||||
displayTitle,
|
||||
);
|
||||
return;
|
||||
} else {
|
||||
await downloadFile(dl, displayTitle);
|
||||
}
|
||||
await downloadFile(dl, displayTitle);
|
||||
showToast(t("downloadOk"));
|
||||
} catch {
|
||||
/* ignore */
|
||||
showToast(t("downloadFail"), "error");
|
||||
} finally {
|
||||
setIsDownloading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<article
|
||||
<m.article
|
||||
whileHover={{ y: -4 }}
|
||||
transition={CARD_HOVER_SPRING}
|
||||
className={`${CARD_BASE_CLASS} ${
|
||||
layout === "grid" ? CARD_GRID_SIZE_CLASS : CARD_CAROUSEL_SIZE_CLASS
|
||||
} ${
|
||||
@@ -101,12 +113,9 @@ export function RecommendedCard({
|
||||
<img
|
||||
src={cover}
|
||||
alt=""
|
||||
className={`h-full w-full object-cover transition duration-300 ${
|
||||
useFigmaDesign
|
||||
? "group-hover:scale-[1.02]"
|
||||
: "group-hover:scale-[1.02]"
|
||||
}`}
|
||||
className="ark-img-fade h-full w-full object-cover transition duration-300 group-hover:scale-[1.02]"
|
||||
loading="lazy"
|
||||
onLoad={(e) => e.currentTarget.classList.add("is-loaded")}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-full w-full bg-gradient-to-br from-neutral-900 to-neutral-950" />
|
||||
@@ -164,8 +173,8 @@ export function RecommendedCard({
|
||||
type="button"
|
||||
className={
|
||||
useFigmaDesign
|
||||
? "flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-[#191921] text-ark-gold outline-none transition hover:bg-[#22232D] focus-visible:ring-2 focus-visible:ring-ark-gold/80 disabled:cursor-wait"
|
||||
: "shrink-0 rounded-lg p-1 text-ark-gold outline-none hover:bg-ark-gold/10 focus-visible:ring-2 focus-visible:ring-ark-gold/80 disabled:cursor-wait"
|
||||
? "flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-[#191921] text-ark-gold outline-none transition hover:bg-[#22232D] active:scale-95 focus-visible:ring-2 focus-visible:ring-ark-gold/80 disabled:cursor-wait"
|
||||
: "shrink-0 rounded-lg p-1 text-ark-gold 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")}
|
||||
@@ -179,11 +188,7 @@ export function RecommendedCard({
|
||||
>
|
||||
{isDownloading ? (
|
||||
<LoaderCircle
|
||||
className={
|
||||
useFigmaDesign
|
||||
? "h-5 w-5 animate-spin"
|
||||
: "h-5 w-5 animate-spin"
|
||||
}
|
||||
className="h-5 w-5 animate-spin"
|
||||
strokeWidth={2.2}
|
||||
/>
|
||||
) : useFigmaDesign ? (
|
||||
@@ -195,7 +200,7 @@ export function RecommendedCard({
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</m.article>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
24
src/components/Skeleton.tsx
Normal file
24
src/components/Skeleton.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { CSSProperties } from "react";
|
||||
|
||||
type SkeletonProps = {
|
||||
/** Sizing / rounding utilities, e.g. "h-[88px] w-full rounded-xl". */
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
};
|
||||
|
||||
/**
|
||||
* Reusable shimmer placeholder. Renders the reduced-motion-guarded
|
||||
* `.ark-skeleton` utility (defined in index.css), so the shimmer animation
|
||||
* automatically stops when the user prefers reduced motion. Pass the same
|
||||
* sizing/rounding classes as the real content it stands in for to avoid any
|
||||
* layout shift when the data loads.
|
||||
*/
|
||||
export function Skeleton({ className = "", style }: SkeletonProps) {
|
||||
return (
|
||||
<div
|
||||
className={`ark-skeleton ${className}`.trim()}
|
||||
style={style}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
}
|
||||
98
src/components/Toast.tsx
Normal file
98
src/components/Toast.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import { AnimatePresence, m } from "framer-motion";
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
|
||||
type ToastVariant = "success" | "error";
|
||||
|
||||
type ToastItem = {
|
||||
id: number;
|
||||
message: string;
|
||||
variant: ToastVariant;
|
||||
};
|
||||
|
||||
type ToastContextValue = {
|
||||
showToast: (message: string, variant?: ToastVariant) => void;
|
||||
};
|
||||
|
||||
const ToastContext = createContext<ToastContextValue | null>(null);
|
||||
|
||||
export function useToast(): ToastContextValue {
|
||||
const ctx = useContext(ToastContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useToast must be used within a ToastProvider");
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
const AUTO_DISMISS_MS = 3000;
|
||||
|
||||
/**
|
||||
* App-level toast host. Renders an aria-live region so screen readers announce
|
||||
* messages. Enter/exit animations go through framer's `m` (under MotionProvider),
|
||||
* so they automatically respect the user's reduced-motion preference.
|
||||
*/
|
||||
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
const [toasts, setToasts] = useState<ToastItem[]>([]);
|
||||
const idRef = useRef(0);
|
||||
|
||||
const dismiss = useCallback((id: number) => {
|
||||
setToasts((prev) => prev.filter((toast) => toast.id !== id));
|
||||
}, []);
|
||||
|
||||
const showToast = useCallback(
|
||||
(message: string, variant: ToastVariant = "success") => {
|
||||
const id = (idRef.current += 1);
|
||||
setToasts((prev) => [...prev, { id, message, variant }]);
|
||||
window.setTimeout(() => dismiss(id), AUTO_DISMISS_MS);
|
||||
},
|
||||
[dismiss],
|
||||
);
|
||||
|
||||
const value = useMemo(() => ({ showToast }), [showToast]);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={value}>
|
||||
{children}
|
||||
<div
|
||||
className="pointer-events-none fixed inset-x-0 bottom-[92px] z-[100] flex flex-col items-center gap-2 px-4 md:bottom-6"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
>
|
||||
<AnimatePresence initial={false}>
|
||||
{toasts.map((toast) => (
|
||||
<m.div
|
||||
key={toast.id}
|
||||
initial={{ opacity: 0, y: 12, scale: 0.96 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 8, scale: 0.96 }}
|
||||
transition={{ duration: 0.22, ease: [0.22, 1, 0.36, 1] }}
|
||||
role="status"
|
||||
className={[
|
||||
"pointer-events-auto flex max-w-[90vw] items-center gap-2 rounded-full border px-4 py-2.5 text-sm font-medium shadow-2xl shadow-black/50 backdrop-blur-md",
|
||||
toast.variant === "success"
|
||||
? "border-ark-gold/40 bg-[#1c1c21]/95 text-ark-gold2"
|
||||
: "border-red-500/40 bg-[#1c1c21]/95 text-red-300",
|
||||
].join(" ")}
|
||||
>
|
||||
<span
|
||||
className={[
|
||||
"h-2 w-2 shrink-0 rounded-full",
|
||||
toast.variant === "success" ? "bg-ark-gold" : "bg-red-400",
|
||||
].join(" ")}
|
||||
aria-hidden
|
||||
/>
|
||||
{toast.message}
|
||||
</m.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { useI18n } from "../../i18n";
|
||||
import type { Attachment } from "../../types/post";
|
||||
import { downloadAttachment } from "./utils/downloadFile";
|
||||
import { formatBytes } from "./utils/formatBytes";
|
||||
import { useToast } from "../Toast";
|
||||
|
||||
type AttachmentDownloadPillProps = {
|
||||
postId: string;
|
||||
@@ -20,15 +21,21 @@ export function AttachmentDownloadPill({
|
||||
className = "absolute left-2 top-2",
|
||||
}: AttachmentDownloadPillProps) {
|
||||
const { t } = useI18n();
|
||||
const { showToast } = useToast();
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
|
||||
const handleDownload = (e: MouseEvent<HTMLButtonElement>) => {
|
||||
const handleDownload = async (e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation();
|
||||
if (isDownloading) return;
|
||||
setIsDownloading(true);
|
||||
void downloadAttachment(postId, attachment.id, attachment.filename)
|
||||
.finally(() => setIsDownloading(false))
|
||||
.catch(() => {});
|
||||
try {
|
||||
await downloadAttachment(postId, attachment.id, attachment.filename);
|
||||
showToast(t("downloadOk"));
|
||||
} catch {
|
||||
showToast(t("downloadFail"), "error");
|
||||
} finally {
|
||||
setIsDownloading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -35,7 +35,7 @@ export function MessageBubble({ post }: { post: Post }) {
|
||||
return (
|
||||
<div
|
||||
id={`post-${post.id}`}
|
||||
className="mx-auto w-full max-w-[358px] md:max-w-[680px] lg:max-w-[900px] xl:max-w-[1120px]"
|
||||
className="mx-auto w-full max-w-[358px] scroll-mt-20 md:max-w-[680px] md:scroll-mt-24 lg:max-w-[900px] xl:max-w-[1120px]"
|
||||
>
|
||||
<article
|
||||
className={`relative w-full overflow-hidden rounded-2xl bg-[#272632] text-left shadow-sm ${
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { useLocation, useSearchParams } from "react-router-dom";
|
||||
import { postJSON } from "../../api";
|
||||
import { useI18n } from "../../i18n";
|
||||
import type { PostScope } from "../../types/post";
|
||||
import { Reveal } from "../../motion";
|
||||
import { Skeleton } from "../Skeleton";
|
||||
import { FilterChips } from "./FilterChips";
|
||||
import { MessageBubble } from "./MessageBubble";
|
||||
import { useGroupedByDay } from "./hooks/useGroupedByDay";
|
||||
@@ -15,6 +17,7 @@ export type MessageStreamProps = {
|
||||
export function MessageStream({ scope }: MessageStreamProps) {
|
||||
const { t, lang } = useI18n();
|
||||
const [sp, setSp] = useSearchParams();
|
||||
const { hash } = useLocation();
|
||||
|
||||
const type = sp.get("type") || "all";
|
||||
const q = (sp.get("q") || "").trim();
|
||||
@@ -65,6 +68,38 @@ export function MessageStream({ scope }: MessageStreamProps) {
|
||||
return () => io.disconnect();
|
||||
}, [loadMore]);
|
||||
|
||||
// When arriving with a `#post-<id>` hash (e.g. from a recommended card),
|
||||
// scroll to that bubble — loading more pages until it shows up — then give
|
||||
// it a brief highlight so the user can see where they landed.
|
||||
const targetPostId =
|
||||
hash.startsWith("#post-") ? hash.slice("#post-".length) : "";
|
||||
const handledTargetRef = useRef<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
handledTargetRef.current = "";
|
||||
}, [targetPostId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!targetPostId || handledTargetRef.current === targetPostId) return;
|
||||
|
||||
const el = document.getElementById(`post-${targetPostId}`);
|
||||
if (el) {
|
||||
handledTargetRef.current = targetPostId;
|
||||
const frame = window.requestAnimationFrame(() => {
|
||||
el.scrollIntoView({ block: "start", behavior: "smooth" });
|
||||
el.classList.add("ark-bubble-highlight");
|
||||
window.setTimeout(
|
||||
() => el.classList.remove("ark-bubble-highlight"),
|
||||
2000,
|
||||
);
|
||||
});
|
||||
return () => window.cancelAnimationFrame(frame);
|
||||
}
|
||||
|
||||
// Not loaded yet — keep paging until it appears or the stream is exhausted.
|
||||
if (hasMore && !isLoading) loadMore();
|
||||
}, [targetPostId, items, hasMore, isLoading, loadMore]);
|
||||
|
||||
const updateParam = (key: string, value: string) => {
|
||||
const n = new URLSearchParams(sp);
|
||||
if (!value || value === "all") n.delete(key);
|
||||
@@ -72,41 +107,64 @@ export function MessageStream({ scope }: MessageStreamProps) {
|
||||
setSp(n, { replace: true });
|
||||
};
|
||||
|
||||
const isInitialLoad = isLoading && items.length === 0;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-full md:max-w-[820px] lg:max-w-[1080px] xl:max-w-[1180px]">
|
||||
<FilterChips type={type} onTypeChange={(v) => updateParam("type", v)} />
|
||||
|
||||
<div className="flex flex-col gap-3 px-4 pt-4 md:px-0 md:pt-2">
|
||||
{groups.map((group) => (
|
||||
<div key={group.dayKey} className="flex flex-col gap-3">
|
||||
{group.items.map((post) => (
|
||||
<MessageBubble key={post.id} post={post} />
|
||||
{isInitialLoad ? (
|
||||
<>
|
||||
{Array.from({ length: 10 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="mx-auto w-full max-w-[358px] md:max-w-[680px] lg:max-w-[900px] xl:max-w-[1120px]"
|
||||
>
|
||||
<Skeleton
|
||||
className={`rounded-2xl ${
|
||||
i % 3 === 0 ? "h-[220px]" : "h-[80px]"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{groups.map((group) => (
|
||||
<div key={group.dayKey} className="flex flex-col gap-3">
|
||||
{group.items.map((post, index) => (
|
||||
<Reveal key={post.id} delay={Math.min(index, 8) * 0.05}>
|
||||
<MessageBubble post={post} />
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{!isLoading && !error && items.length === 0 ? (
|
||||
<p className="py-10 text-center text-sm text-neutral-400">
|
||||
{t("noResults")}
|
||||
</p>
|
||||
) : null}
|
||||
{!isLoading && !error && items.length === 0 ? (
|
||||
<p className="py-10 text-center text-sm text-neutral-400">
|
||||
{t("noResults")}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<div className="my-4 flex items-center justify-between gap-3 rounded-xl border border-red-900 bg-red-950/40 px-4 py-3 text-sm text-red-200">
|
||||
<span className="break-all">{error}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => reset()}
|
||||
className="shrink-0 rounded-full border border-red-700 px-3 py-1 text-xs text-red-100 hover:border-red-500"
|
||||
>
|
||||
{retryLabel}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{error ? (
|
||||
<div className="my-4 flex items-center justify-between gap-3 rounded-xl border border-red-900 bg-red-950/40 px-4 py-3 text-sm text-red-200">
|
||||
<span className="break-all">{error}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => reset()}
|
||||
className="shrink-0 rounded-full border border-red-700 px-3 py-1 text-xs text-red-100 hover:border-red-500"
|
||||
>
|
||||
{retryLabel}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="py-4 text-center text-xs text-neutral-500">…</div>
|
||||
) : null}
|
||||
{isLoading ? (
|
||||
<div className="py-4 text-center text-xs text-neutral-500">…</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div ref={sentinelRef} aria-hidden className="h-1" />
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { autolink } from "../utils/autolink";
|
||||
import { downloadAttachment } from "../utils/downloadFile";
|
||||
import { formatBytes } from "../utils/formatBytes";
|
||||
import { postDisplayText } from "../utils/postText";
|
||||
import { useToast } from "../../Toast";
|
||||
|
||||
const MAX_VISIBLE = 4;
|
||||
|
||||
@@ -32,14 +33,20 @@ function ImageListDownloadButton({
|
||||
attachment: Attachment;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const { showToast } = useToast();
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
|
||||
const handleDownload = () => {
|
||||
const handleDownload = async () => {
|
||||
if (isDownloading) return;
|
||||
setIsDownloading(true);
|
||||
void downloadAttachment(postId, attachment.id, attachment.filename)
|
||||
.finally(() => setIsDownloading(false))
|
||||
.catch(() => {});
|
||||
try {
|
||||
await downloadAttachment(postId, attachment.id, attachment.filename);
|
||||
showToast(t("downloadOk"));
|
||||
} catch {
|
||||
showToast(t("downloadFail"), "error");
|
||||
} finally {
|
||||
setIsDownloading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -11,19 +11,26 @@ import {
|
||||
} from "../utils/filenameDisplay";
|
||||
import { formatBytes } from "../utils/formatBytes";
|
||||
import { postDisplayText } from "../utils/postText";
|
||||
import { useToast } from "../../Toast";
|
||||
|
||||
function AttachmentRow({ postId, att }: { postId: string; att: Attachment }) {
|
||||
const { t } = useI18n();
|
||||
const { showToast } = useToast();
|
||||
const { Icon, color } = fileIcon({ mime: att.mime, filename: att.filename });
|
||||
const displayFilename = filenameWithExtension(att.filename, att.mime);
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
|
||||
const handleDownload = () => {
|
||||
const handleDownload = async () => {
|
||||
if (isDownloading) return;
|
||||
setIsDownloading(true);
|
||||
void downloadAttachment(postId, att.id, displayFilename)
|
||||
.finally(() => setIsDownloading(false))
|
||||
.catch(() => {});
|
||||
try {
|
||||
await downloadAttachment(postId, att.id, displayFilename);
|
||||
showToast(t("downloadOk"));
|
||||
} catch {
|
||||
showToast(t("downloadFail"), "error");
|
||||
} finally {
|
||||
setIsDownloading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -10,6 +10,7 @@ import { autolink } from "../utils/autolink";
|
||||
import { downloadAttachment } from "../utils/downloadFile";
|
||||
import { formatBytes } from "../utils/formatBytes";
|
||||
import { postDisplayText } from "../utils/postText";
|
||||
import { useToast } from "../../Toast";
|
||||
|
||||
const MAX_VISIBLE = 4;
|
||||
|
||||
@@ -173,14 +174,20 @@ function AttachmentListDownloadButton({
|
||||
attachment: Attachment;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const { showToast } = useToast();
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
|
||||
const handleDownload = () => {
|
||||
const handleDownload = async () => {
|
||||
if (isDownloading) return;
|
||||
setIsDownloading(true);
|
||||
void downloadAttachment(postId, attachment.id, attachment.filename)
|
||||
.finally(() => setIsDownloading(false))
|
||||
.catch(() => {});
|
||||
try {
|
||||
await downloadAttachment(postId, attachment.id, attachment.filename);
|
||||
showToast(t("downloadOk"));
|
||||
} catch {
|
||||
showToast(t("downloadFail"), "error");
|
||||
} finally {
|
||||
setIsDownloading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user