From be638e32c9a6009873d432be1823b38d2e0d3b29 Mon Sep 17 00:00:00 2001 From: TerryM Date: Wed, 3 Jun 2026 08:18:05 +0800 Subject: [PATCH] feat(home): align desktop cards with Figma actions - Update desktop header actions to match Figma: remove the standalone desktop favorites button and style the wallet connect pill with the wallet icon while allowing localized labels to expand. - Replace favorite action with the Figma bookmark SVG and hover state; replace download cloud with the provided Figma SVG. - Align official recommendation cards with the Figma card structure, colors, and bottom action row. - Rework popular rows to the Figma desktop rhythm with 90px rows, wide thumbnails, rank area, and right-side action buttons. - Add a dedicated desktop LatestUpdateCard for Figma-style latest-update masonry cards with flexible text-driven heights instead of fixed card heights. --- src/components/LatestUpdateCard.tsx | 258 ++++++++++++++++++ src/components/icons/DownloadCloudIcon.tsx | 11 +- .../messageStream/MessageBubble.tsx | 47 ++-- .../messageStream/bubbles/FileDocBubble.tsx | 119 +++++++- src/favorites/FavoriteButton.tsx | 28 +- src/layouts/PublicLayout.tsx | 13 - src/pages/Home/index.tsx | 42 +-- src/wallet/WalletButton.tsx | 11 +- 8 files changed, 464 insertions(+), 65 deletions(-) create mode 100644 src/components/LatestUpdateCard.tsx 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() {
- - -
); }