diff --git a/src/components/LatestUpdateCard.tsx b/src/components/LatestUpdateCard.tsx
new file mode 100644
index 0000000..7043f89
--- /dev/null
+++ b/src/components/LatestUpdateCard.tsx
@@ -0,0 +1,258 @@
+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 (
+
+
+ {attachment ? (
+
+ ) : null}
+
+ );
+}
+
+function Footer({ post, attachment }: { post: Post; attachment?: Attachment }) {
+ return (
+
+
+
+
+ );
+}
+
+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 (
+
+
+
+
+
+
+
+
+ {(() => {
+ 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 (
+ <>
+ {head}
+ {tail}
+ >
+ );
+ })()}
+
+
+ {formatBytes(att.sizeBytes)}
+
+
+
+
+ {text || post.title || displayFilename}
+
+
+
+ );
+}
+
+function MediaGrid({ attachments }: { attachments: Attachment[] }) {
+ const visible = attachments.slice(0, 4);
+ const extra = Math.max(0, attachments.length - visible.length);
+ if (visible.length <= 1) {
+ const src = attachmentPreview(visible[0]);
+ return src ? (
+
+ ) : (
+
+ );
+ }
+ return (
+
+ {visible.map((att, index) => (
+
+
})
+ {extra > 0 && index === visible.length - 1 ? (
+
+ +{extra}
+
+ ) : null}
+
+ ))}
+
+ );
+}
+
+function MediaBadge({ att }: { att?: Attachment }) {
+ if (!att) return null;
+ return (
+
+
+
+
+ {formatBytes(att.sizeBytes)}
+
+ );
+}
+
+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 (
+
+
+
+
+
+ {isVideo ? (
+
+ ) : null}
+
+ {text || post.title ? (
+
+ {autolink(text || post.title || "")}
+
+ ) : null}
+
+
+ );
+}
+
+function CardLink({ postId }: { postId: string }) {
+ const lp = useLocalizedPath();
+ return (
+
+ );
+}
+
+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 ;
+ return ;
+}
diff --git a/src/components/icons/DownloadCloudIcon.tsx b/src/components/icons/DownloadCloudIcon.tsx
index a0fa835..7d4dca1 100644
--- a/src/components/icons/DownloadCloudIcon.tsx
+++ b/src/components/icons/DownloadCloudIcon.tsx
@@ -3,13 +3,18 @@ import type { SVGProps } from "react";
export function DownloadCloudIcon(props: SVGProps) {
return (
);
}
diff --git a/src/components/messageStream/MessageBubble.tsx b/src/components/messageStream/MessageBubble.tsx
index 87268a0..76f9afc 100644
--- a/src/components/messageStream/MessageBubble.tsx
+++ b/src/components/messageStream/MessageBubble.tsx
@@ -10,7 +10,12 @@ import { LinkPreviewCard } from "./LinkPreviewCard";
import { formatDateTime } from "./utils/formatTime";
import { FavoriteButton } from "../../favorites/FavoriteButton";
-type BubbleComponent = ComponentType<{ post: Post }>;
+export type MessageBubbleVariant = "default" | "latest";
+
+type BubbleComponent = ComponentType<{
+ post: Post;
+ variant?: MessageBubbleVariant;
+}>;
export function pickBubble(post: Post): BubbleComponent {
const a = post.attachments;
@@ -27,11 +32,14 @@ export function pickBubble(post: Post): BubbleComponent {
export function MessageBubble({
post,
fluid = false,
+ variant = "default",
}: {
post: Post;
/** When true, fill the parent container instead of applying the standalone
* feed max-widths. Used by the desktop 3-column masonry on the home page. */
fluid?: boolean;
+ /** Desktop latest-updates cards follow the dedicated Figma masonry design. */
+ variant?: MessageBubbleVariant;
}) {
const Bubble = pickBubble(post);
const isVisual =
@@ -39,6 +47,7 @@ export function MessageBubble({
Bubble === VideoBubble ||
Bubble === ImageBubble ||
Bubble === ImageWithTextBubble;
+ const isLatestFileCard = variant === "latest" && Bubble === FileDocBubble;
return (
-
-
+ {!isLatestFileCard ? (
+
+ ) : null}
+
{post.linkPreview ? (
) : null}
-
+ {!isLatestFileCard ? (
+
+ ) : null}
);
diff --git a/src/components/messageStream/bubbles/FileDocBubble.tsx b/src/components/messageStream/bubbles/FileDocBubble.tsx
index a9a3211..2d0ea7c 100644
--- a/src/components/messageStream/bubbles/FileDocBubble.tsx
+++ b/src/components/messageStream/bubbles/FileDocBubble.tsx
@@ -7,6 +7,7 @@ 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 {
@@ -14,6 +15,8 @@ import {
useSaveToAlbumGuide,
} from "../../SaveToAlbumGuide";
import { useToast } from "../../Toast";
+import { FavoriteButton } from "../../../favorites/FavoriteButton";
+import type { MessageBubbleVariant } from "../MessageBubble";
function AttachmentRow({ postId, att }: { postId: string; att: Attachment }) {
const { t } = useI18n();
@@ -104,9 +107,123 @@ function AttachmentRow({ postId, att }: { postId: string; att: Attachment }) {
);
}
-export function FileDocBubble({ post }: { post: Post }) {
+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 (
+
+
+
+
+
+
+
+ {(() => {
+ 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 (
+ <>
+ {head}
+ {tail}
+ >
+ );
+ })()}
+
+
+ {isDownloading ? t("downloading") : formatBytes(att.sizeBytes)}
+
+
+
+
+ {text ? (
+
+ {text}
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export function FileDocBubble({
+ post,
+ variant = "default",
+}: {
+ post: Post;
+ variant?: MessageBubbleVariant;
+}) {
const { lang } = useI18n();
const text = postDisplayText(post, lang);
+
+ if (variant === "latest") {
+ return ;
+ }
+
return (
{post.attachments.map((att) => (
diff --git a/src/favorites/FavoriteButton.tsx b/src/favorites/FavoriteButton.tsx
index 260cd9c..d2824f1 100644
--- a/src/favorites/FavoriteButton.tsx
+++ b/src/favorites/FavoriteButton.tsx
@@ -1,4 +1,4 @@
-import { Heart, LoaderCircle } from "lucide-react";
+import { LoaderCircle } from "lucide-react";
import { useEffect } from "react";
import { useI18n } from "../i18n";
import { useFavorites } from "./FavoritesProvider";
@@ -9,6 +9,24 @@ type FavoriteButtonProps = {
size?: "sm" | "md";
};
+function FigmaBookmarkIcon() {
+ return (
+
+ );
+}
+
export function FavoriteButton({
resourceId,
className = "",
@@ -43,18 +61,14 @@ export function FavoriteButton({
dimension,
isFavorite
? "border-ark-gold/60 bg-ark-gold text-black hover:bg-ark-gold2"
- : "border-white/10 bg-[#191921]/90 text-white hover:border-ark-gold/50 hover:bg-ark-gold/10 hover:text-ark-gold",
+ : "border-white/10 bg-[#191921]/90 text-[#A8A9AE] hover:border-ark-gold hover:bg-[#191921] hover:text-ark-gold",
className,
].join(" ")}
>
{pending ? (
) : (
-
+
)}
);
diff --git a/src/layouts/PublicLayout.tsx b/src/layouts/PublicLayout.tsx
index 39cb841..7e53f8b 100644
--- a/src/layouts/PublicLayout.tsx
+++ b/src/layouts/PublicLayout.tsx
@@ -645,19 +645,6 @@ export function PublicLayout() {
-
-
-