2026-06-03 08:18:05 +08:00
|
|
|
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,
|
|
|
|
|
splitFilename,
|
|
|
|
|
} from "./messageStream/utils/filenameDisplay";
|
|
|
|
|
import { formatBytes } from "./messageStream/utils/formatBytes";
|
|
|
|
|
import { formatDateTime } from "./messageStream/utils/formatTime";
|
|
|
|
|
import { postDisplayText } from "./messageStream/utils/postText";
|
|
|
|
|
import { autolink } from "./messageStream/utils/autolink";
|
|
|
|
|
|
|
|
|
|
function LatestActions({
|
|
|
|
|
post,
|
|
|
|
|
attachment,
|
|
|
|
|
}: {
|
|
|
|
|
post: Post;
|
|
|
|
|
attachment?: Attachment;
|
|
|
|
|
}) {
|
|
|
|
|
const { t } = useI18n();
|
|
|
|
|
const { showToast } = useToast();
|
|
|
|
|
const { showSaveToAlbumGuide } = useSaveToAlbumGuide();
|
|
|
|
|
const [isDownloading, setIsDownloading] = useState(false);
|
|
|
|
|
|
|
|
|
|
const handleDownload = async () => {
|
|
|
|
|
if (!attachment || isDownloading) return;
|
|
|
|
|
setIsDownloading(true);
|
|
|
|
|
try {
|
|
|
|
|
await downloadAttachment(post.id, attachment.id, attachment.filename);
|
|
|
|
|
const mediaKind = mediaSaveKindFromAttachment(attachment);
|
|
|
|
|
if (mediaKind) showSaveToAlbumGuide(mediaKind);
|
|
|
|
|
} catch {
|
|
|
|
|
showToast(t("downloadFail"), "error");
|
|
|
|
|
} finally {
|
|
|
|
|
setIsDownloading(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="relative z-20 flex shrink-0 items-center gap-2">
|
|
|
|
|
<FavoriteButton resourceId={post.id} size="sm" />
|
|
|
|
|
{attachment ? (
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={(event) => {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
event.stopPropagation();
|
|
|
|
|
void handleDownload();
|
|
|
|
|
}}
|
|
|
|
|
disabled={isDownloading}
|
|
|
|
|
aria-label={
|
|
|
|
|
isDownloading ? t("downloading") : `Download ${attachment.filename}`
|
|
|
|
|
}
|
|
|
|
|
aria-busy={isDownloading}
|
|
|
|
|
className="relative z-20 flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-[#191921] transition hover:bg-[#22232D] disabled:cursor-wait"
|
|
|
|
|
>
|
|
|
|
|
{isDownloading ? (
|
|
|
|
|
<LoaderCircle
|
|
|
|
|
className="h-5 w-5 animate-spin text-[#A8A9AE]"
|
|
|
|
|
strokeWidth={2.3}
|
|
|
|
|
/>
|
|
|
|
|
) : (
|
|
|
|
|
<DownloadCloudIcon />
|
|
|
|
|
)}
|
|
|
|
|
</button>
|
|
|
|
|
) : null}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function Footer({ post, attachment }: { post: Post; attachment?: Attachment }) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex min-h-16 items-center justify-between gap-3 px-4 py-3">
|
|
|
|
|
<time
|
|
|
|
|
dateTime={post.publishedAt}
|
|
|
|
|
className="shrink-0 text-[12px] font-medium leading-[19.2px] text-[#A8A9AE]"
|
|
|
|
|
>
|
|
|
|
|
{formatDateTime(post.publishedAt)}
|
|
|
|
|
</time>
|
|
|
|
|
<LatestActions post={post} attachment={attachment} />
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function attachmentPreview(att: Attachment | undefined): string {
|
|
|
|
|
if (!att) return "";
|
|
|
|
|
return att.thumbnailUrl ?? att.posterUrl ?? att.thumbUrl ?? att.url ?? "";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function FileCard({ post, att }: { post: Post; att: Attachment }) {
|
|
|
|
|
const { lang } = useI18n();
|
|
|
|
|
const { Icon, color } = fileIcon({ mime: att.mime, filename: att.filename });
|
|
|
|
|
const displayFilename = filenameWithExtension(att.filename, att.mime);
|
|
|
|
|
const text = postDisplayText(post, lang);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<article className="relative flex flex-col overflow-hidden rounded-2xl bg-[#272632] text-left">
|
|
|
|
|
<CardLink postId={post.id} />
|
|
|
|
|
<div className="flex min-h-[52px] min-w-0 items-center gap-4 px-4 pt-3">
|
|
|
|
|
<div
|
|
|
|
|
className="flex h-[52px] w-[52px] shrink-0 items-center justify-center rounded-full"
|
|
|
|
|
style={{ backgroundColor: color }}
|
|
|
|
|
aria-hidden
|
|
|
|
|
>
|
|
|
|
|
<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]">
|
|
|
|
|
{formatBytes(att.sizeBytes)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="whitespace-pre-wrap break-words px-4 pt-3 text-[15px] font-medium leading-6 text-white">
|
|
|
|
|
{text || post.title || displayFilename}
|
|
|
|
|
</div>
|
|
|
|
|
<Footer post={post} attachment={att} />
|
|
|
|
|
</article>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-03 08:33:29 +08:00
|
|
|
function PillDownloadIcon() {
|
|
|
|
|
return (
|
|
|
|
|
<svg
|
|
|
|
|
width="12"
|
|
|
|
|
height="10"
|
|
|
|
|
viewBox="0 0 12 10"
|
|
|
|
|
fill="none"
|
|
|
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
|
|
|
aria-hidden="true"
|
|
|
|
|
>
|
|
|
|
|
<path
|
|
|
|
|
d="M9.84528 3.61663C9.68928 1.5958 8.02426 0 6.00008 0C4.26863 0 2.78232 1.14503 2.30275 2.81502C0.934727 3.29852 0 4.6181 0 6.10918C0 8.034 1.53816 9.60013 3.42862 9.60013H9.00013C10.6544 9.60013 12.0002 8.22993 12.0002 6.54555C12.0002 5.17142 11.113 3.99191 9.84528 3.61663ZM8.0174 5.98132L6.30309 7.7268C6.21952 7.81189 6.1098 7.85465 6.00008 7.85465C5.89037 7.85465 5.78065 7.81189 5.69708 7.7268L3.98277 5.98132C3.8602 5.85652 3.82334 5.66888 3.88977 5.50568C3.9562 5.34291 4.11263 5.23644 4.28577 5.23644H5.14293V3.49096C5.14293 3.00921 5.52693 2.61822 6.00008 2.61822C6.47323 2.61822 6.85724 3.00921 6.85724 3.49096V5.23644H7.71439C7.88754 5.23644 8.04397 5.34291 8.1104 5.50568C8.17683 5.66888 8.13997 5.85652 8.0174 5.98132Z"
|
|
|
|
|
fill="#A8A9AE"
|
|
|
|
|
/>
|
|
|
|
|
</svg>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function MediaSizeChip({ att }: { att: Attachment }) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="absolute left-3 top-2.5 z-20 flex h-6 w-[72px] items-center overflow-hidden rounded-full bg-black text-white">
|
|
|
|
|
<span className="grid h-6 w-6 shrink-0 place-items-center bg-[#545454]">
|
|
|
|
|
<PillDownloadIcon />
|
|
|
|
|
</span>
|
|
|
|
|
<span className="flex h-4 w-12 items-center justify-center text-[10px] font-medium leading-4">
|
|
|
|
|
{formatBytes(att.sizeBytes)}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function MediaTile({
|
|
|
|
|
att,
|
|
|
|
|
showExtra,
|
|
|
|
|
}: {
|
|
|
|
|
att: Attachment;
|
|
|
|
|
showExtra?: number;
|
|
|
|
|
}) {
|
|
|
|
|
const src = attachmentPreview(att);
|
|
|
|
|
const isVideo = att.kind === "video" || att.mime.startsWith("video/");
|
|
|
|
|
return (
|
|
|
|
|
<div className="relative h-full w-full overflow-hidden bg-black">
|
|
|
|
|
{src ? (
|
|
|
|
|
<img
|
|
|
|
|
src={src}
|
|
|
|
|
alt=""
|
|
|
|
|
className="h-full w-full object-cover"
|
|
|
|
|
loading="lazy"
|
|
|
|
|
decoding="async"
|
|
|
|
|
/>
|
|
|
|
|
) : null}
|
|
|
|
|
<MediaSizeChip att={att} />
|
|
|
|
|
{isVideo ? (
|
|
|
|
|
<div className="absolute inset-0 grid place-items-center">
|
|
|
|
|
<span className="grid h-16 w-16 place-items-center rounded-full bg-black/55 text-white backdrop-blur-sm">
|
|
|
|
|
<Play className="ml-1 h-8 w-8 fill-current" />
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
|
|
|
|
{showExtra ? (
|
|
|
|
|
<div className="absolute inset-0 grid place-items-center bg-black/55 text-[24px] font-bold text-white backdrop-blur-sm">
|
|
|
|
|
+{showExtra}
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-03 08:18:05 +08:00
|
|
|
function MediaGrid({ attachments }: { attachments: Attachment[] }) {
|
|
|
|
|
const visible = attachments.slice(0, 4);
|
|
|
|
|
const extra = Math.max(0, attachments.length - visible.length);
|
2026-06-03 08:33:29 +08:00
|
|
|
if (visible.length === 0) return <div className="h-full w-full bg-black" />;
|
|
|
|
|
if (visible.length === 1) return <MediaTile att={visible[0]} />;
|
|
|
|
|
if (visible.length === 2) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="grid h-full w-full grid-cols-2 gap-0">
|
|
|
|
|
{visible.map((att) => (
|
|
|
|
|
<MediaTile key={att.id} att={att} />
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
if (visible.length === 3) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="grid h-full w-full grid-cols-2 gap-0">
|
|
|
|
|
<MediaTile att={visible[0]} />
|
|
|
|
|
<div className="grid h-full grid-rows-2 gap-0">
|
|
|
|
|
<MediaTile att={visible[1]} />
|
|
|
|
|
<MediaTile att={visible[2]} />
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-06-03 08:18:05 +08:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
return (
|
|
|
|
|
<div className="grid h-full w-full grid-cols-2 grid-rows-2 gap-0">
|
|
|
|
|
{visible.map((att, index) => (
|
2026-06-03 08:33:29 +08:00
|
|
|
<MediaTile
|
|
|
|
|
key={att.id}
|
|
|
|
|
att={att}
|
|
|
|
|
showExtra={extra > 0 && index === visible.length - 1 ? extra : 0}
|
|
|
|
|
/>
|
2026-06-03 08:18:05 +08:00
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function VisualCard({ post }: { post: Post }) {
|
|
|
|
|
const { lang } = useI18n();
|
|
|
|
|
const att = post.attachments[0];
|
|
|
|
|
const isVideo = att?.kind === "video" || att?.mime.startsWith("video/");
|
|
|
|
|
const text = postDisplayText(post, lang);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<article className="relative flex flex-col overflow-hidden rounded-2xl bg-[#272632] text-left">
|
|
|
|
|
<CardLink postId={post.id} />
|
|
|
|
|
<div
|
|
|
|
|
className={`relative overflow-hidden bg-black ${
|
|
|
|
|
isVideo ? "aspect-[416/180]" : "aspect-[416/230]"
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
<MediaGrid attachments={post.attachments} />
|
|
|
|
|
</div>
|
|
|
|
|
{text || post.title ? (
|
|
|
|
|
<div className="message-stream-copyable-text whitespace-pre-wrap break-words px-4 pt-3 text-[15px] font-medium leading-6 text-white">
|
|
|
|
|
{autolink(text || post.title || "")}
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
2026-06-03 08:33:29 +08:00
|
|
|
<Footer post={post} />
|
2026-06-03 08:18:05 +08:00
|
|
|
</article>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function CardLink({ postId }: { postId: string }) {
|
|
|
|
|
const lp = useLocalizedPath();
|
|
|
|
|
return (
|
|
|
|
|
<Link
|
|
|
|
|
to={lp(`/browse?post=${encodeURIComponent(postId)}`)}
|
|
|
|
|
aria-label="Open post"
|
|
|
|
|
className="absolute inset-0 z-10 rounded-2xl outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/70"
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function LatestUpdateCard({ post }: { post: Post }) {
|
|
|
|
|
const first = post.attachments[0];
|
|
|
|
|
const isFile =
|
|
|
|
|
!!first &&
|
|
|
|
|
!(
|
|
|
|
|
first.kind === "image" ||
|
|
|
|
|
first.kind === "video" ||
|
|
|
|
|
first.mime.startsWith("image/") ||
|
|
|
|
|
first.mime.startsWith("video/")
|
|
|
|
|
);
|
|
|
|
|
if (isFile) return <FileCard post={post} att={first} />;
|
|
|
|
|
return <VisualCard post={post} />;
|
|
|
|
|
}
|