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.
206 lines
7.1 KiB
TypeScript
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>
|
|
);
|
|
}
|