Files
Arkie-Library-Frontend/src/components/messageStream/bubbles/FileDocBubble.tsx
TerryM 2ef26390be feat(stream): bubble footer with timestamp and inline favorite/download
Match the Figma 4206-6509 card layout for /browse: every bubble now
renders a bottom row with the publish timestamp on the left and the
action buttons on the right. Image, album, video, text and link cards
show only the FavoriteButton; file-document cards show the
FavoriteButton plus a new BubbleAttachmentDownloadButton sized to
match. Removes the absolute-positioned favorite from the default
variant, drops the right-aligned timestamp block, and strips the inline
per-row download button from FileDocBubble's default variant since the
download now lives in the footer. The 'latest' masonry variant is
untouched so the home page continues to use LatestFileCard's existing
internal footer.
2026-06-03 21:20:53 +08:00

206 lines
7.1 KiB
TypeScript

import { LoaderCircle } from "lucide-react";
import { DownloadCloudIcon } from "../../icons/DownloadCloudIcon";
import { useState } from "react";
import { useI18n } from "../../../i18n";
import type { Attachment, Post } from "../../../types/post";
import { downloadAttachment } from "../utils/downloadFile";
import { fileIcon } from "../utils/fileIcon";
import { filenameWithExtension, splitFilename } from "../utils/filenameDisplay";
import { formatBytes } from "../utils/formatBytes";
import { formatDateTime } from "../utils/formatTime";
import { postDisplayText } from "../utils/postText";
import { CollapsibleText } from "../CollapsibleText";
import {
mediaSaveKindFromAttachment,
useSaveToAlbumGuide,
} from "../../SaveToAlbumGuide";
import { useToast } from "../../Toast";
import { FavoriteButton } from "../../../favorites/FavoriteButton";
import type { MessageBubbleVariant } from "../MessageBubble";
function AttachmentRow({ att }: { postId: string; att: Attachment }) {
const { Icon, color } = fileIcon({ mime: att.mime, filename: att.filename });
const displayFilename = filenameWithExtension(att.filename, att.mime);
const [previewFailed, setPreviewFailed] = useState(false);
const isImage = att.kind === "image" || att.mime.startsWith("image/");
const previewUrl =
att.thumbnailUrl ?? att.posterUrl ?? (isImage ? att.url : undefined);
return (
<div className="group flex min-h-[64px] items-center gap-3">
{previewUrl && !previewFailed ? (
<img
src={previewUrl}
alt=""
loading="lazy"
decoding="async"
onError={() => setPreviewFailed(true)}
className="h-16 w-16 shrink-0 rounded-lg object-fill"
/>
) : (
<div
className="flex h-16 w-16 shrink-0 items-center justify-center rounded-lg"
style={{ backgroundColor: color }}
aria-hidden="true"
>
<Icon className="h-9 w-9 text-white" strokeWidth={2.1} />
</div>
)}
<div className="min-w-0 flex-1">
<div
className="flex min-w-0 items-baseline text-[15px] font-medium leading-6 text-ark-gold group-hover:text-ark-gold2"
title={displayFilename}
>
{(() => {
const { base, ext } = splitFilename(displayFilename);
const tailChars = Math.min(4, base.length);
const head = base.slice(0, base.length - tailChars);
const tail = base.slice(base.length - tailChars) + ext;
return (
<>
<span className="min-w-0 truncate">{head}</span>
<span className="shrink-0 whitespace-pre">{tail}</span>
</>
);
})()}
</div>
<div className="text-[12px] font-medium leading-[19.2px] text-[#A8A9AE]">
{formatBytes(att.sizeBytes)}
</div>
</div>
</div>
);
}
function LatestFileCard({ post }: { post: Post }) {
const { t, lang } = useI18n();
const { showToast } = useToast();
const { showSaveToAlbumGuide } = useSaveToAlbumGuide();
const [isDownloading, setIsDownloading] = useState(false);
const att = post.attachments[0];
const text = postDisplayText(post, lang);
if (!att) return null;
const { Icon, color } = fileIcon({ mime: att.mime, filename: att.filename });
const displayFilename = filenameWithExtension(att.filename, att.mime);
const handleDownload = async () => {
if (isDownloading) return;
setIsDownloading(true);
try {
await downloadAttachment(post.id, att.id, displayFilename);
const mediaKind = mediaSaveKindFromAttachment(att);
if (mediaKind) showSaveToAlbumGuide(mediaKind);
} catch {
showToast(t("downloadFail"), "error");
} finally {
setIsDownloading(false);
}
};
return (
<div className="flex h-[188px] flex-col gap-6 px-4 py-4">
<div className="flex h-[52px] min-w-0 items-center gap-4">
<div
className="flex h-[52px] w-[52px] shrink-0 items-center justify-center rounded-full"
style={{ backgroundColor: color }}
aria-hidden="true"
>
<Icon className="h-8 w-8 text-white" strokeWidth={2.1} />
</div>
<div className="min-w-0 flex-1">
<div
className="flex min-w-0 items-baseline text-[15px] font-medium leading-6 text-ark-gold"
title={displayFilename}
>
{(() => {
const { base, ext } = splitFilename(displayFilename);
const tailChars = Math.min(8, base.length);
const head = base.slice(0, base.length - tailChars);
const tail = base.slice(base.length - tailChars) + ext;
return (
<>
<span className="min-w-0 truncate">{head}</span>
<span className="shrink-0 whitespace-pre">{tail}</span>
</>
);
})()}
</div>
<div className="text-[12px] font-medium leading-[19.2px] text-[#A8A9AE]">
{isDownloading ? t("downloading") : formatBytes(att.sizeBytes)}
</div>
</div>
</div>
{text ? (
<div className="message-stream-copyable-text line-clamp-2 min-h-[48px] select-text whitespace-pre-wrap break-words text-[15px] font-medium leading-6 text-white">
{text}
</div>
) : (
<div className="min-h-[48px]" />
)}
<div className="mt-auto flex h-10 items-end justify-between gap-3">
<time
dateTime={post.publishedAt}
className="text-[12px] font-medium leading-[19.2px] text-[#A8A9AE]"
>
{formatDateTime(post.publishedAt)}
</time>
<div className="flex shrink-0 items-center gap-2">
<FavoriteButton resourceId={post.id} size="sm" />
<button
type="button"
onClick={handleDownload}
disabled={isDownloading}
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-[#191921] transition hover:bg-[#22232D] disabled:cursor-wait"
aria-label={
isDownloading ? t("downloading") : `Download ${att.filename}`
}
aria-busy={isDownloading}
>
{isDownloading ? (
<LoaderCircle
className="h-5 w-5 animate-spin text-[#A8A9AE]"
strokeWidth={2.3}
/>
) : (
<DownloadCloudIcon />
)}
</button>
</div>
</div>
</div>
);
}
export function FileDocBubble({
post,
variant = "default",
}: {
post: Post;
variant?: MessageBubbleVariant;
}) {
const { lang } = useI18n();
const text = postDisplayText(post, lang);
if (variant === "latest") {
return <LatestFileCard post={post} />;
}
return (
<div className="flex flex-col gap-3">
{post.attachments.map((att) => (
<AttachmentRow key={att.id} postId={post.id} att={att} />
))}
{text ? (
<CollapsibleText className="message-stream-copyable-text select-text whitespace-pre-wrap break-words text-[15px] font-medium leading-6 text-neutral-100">
{text}
</CollapsibleText>
) : null}
</div>
);
}