terry-staging #16

Merged
terry merged 96 commits from terry-staging into main 2026-06-05 16:33:12 +00:00
8 changed files with 464 additions and 65 deletions
Showing only changes of commit be638e32c9 - Show all commits

View File

@@ -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 (
<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>
);
}
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 ? (
<img
src={src}
alt=""
className="h-full w-full object-cover"
loading="lazy"
decoding="async"
/>
) : (
<div className="h-full w-full bg-black" />
);
}
return (
<div className="grid h-full w-full grid-cols-2 grid-rows-2 gap-0">
{visible.map((att, index) => (
<div key={att.id} className="relative overflow-hidden bg-black">
<img
src={attachmentPreview(att)}
alt=""
className="h-full w-full object-cover"
loading="lazy"
decoding="async"
/>
{extra > 0 && index === visible.length - 1 ? (
<div className="absolute inset-0 grid place-items-center bg-black/55 text-[24px] font-bold text-white backdrop-blur-sm">
+{extra}
</div>
) : null}
</div>
))}
</div>
);
}
function MediaBadge({ att }: { att?: Attachment }) {
if (!att) return null;
return (
<div className="absolute left-3 top-3 z-20 inline-flex h-8 items-center overflow-hidden rounded-full bg-black/75 text-[12px] font-medium text-white shadow-lg backdrop-blur">
<span className="grid h-8 w-8 place-items-center rounded-full bg-[#3a3a40]">
<DownloadCloudIcon />
</span>
<span className="px-3">{formatBytes(att.sizeBytes)}</span>
</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} />
<MediaBadge att={att} />
{isVideo ? (
<div className="absolute inset-0 grid place-items-center">
<span className="grid h-14 w-14 place-items-center rounded-full bg-black/55 text-white backdrop-blur-sm">
<Play className="ml-1 h-7 w-7 fill-current" />
</span>
</div>
) : null}
</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}
<Footer post={post} attachment={att} />
</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} />;
}

View File

@@ -3,13 +3,18 @@ import type { SVGProps } from "react";
export function DownloadCloudIcon(props: SVGProps<SVGSVGElement>) { export function DownloadCloudIcon(props: SVGProps<SVGSVGElement>) {
return ( return (
<svg <svg
viewBox="0 0 14 14" width="24"
fill="currentColor" height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
aria-hidden aria-hidden
{...props} {...props}
> >
<path d="M10.7387 5.85011C10.587 3.88544 8.96824 2.33398 7.00033 2.33398C5.31699 2.33398 3.87199 3.4472 3.40574 5.07077C2.07574 5.54083 1.16699 6.82374 1.16699 8.27338C1.16699 10.1447 2.66241 11.6673 4.50033 11.6673H9.91699C11.5253 11.6673 12.8337 10.3352 12.8337 8.69762C12.8337 7.36168 11.9712 6.21495 10.7387 5.85011ZM8.96158 8.14908L7.29491 9.84605C7.21366 9.92877 7.10699 9.97035 7.00033 9.97035C6.89366 9.97035 6.78699 9.92877 6.70574 9.84605L5.03908 8.14908C4.91991 8.02774 4.88408 7.84532 4.94866 7.68665C5.01324 7.52841 5.16533 7.42489 5.33366 7.42489H6.16699V5.72792C6.16699 5.25956 6.54033 4.87944 7.00033 4.87944C7.46033 4.87944 7.83366 5.25956 7.83366 5.72792V7.42489H8.66699C8.83533 7.42489 8.98741 7.52841 9.05199 7.68665C9.11658 7.84532 9.08074 8.02774 8.96158 8.14908Z" /> <path
d="M18.4086 10.0276C18.1486 6.65964 15.3736 4 12 4C9.11429 4 6.63714 5.90836 5.83786 8.69164C3.55786 9.49746 2 11.6967 2 14.1818C2 17.3898 4.56357 20 7.71429 20H17C19.7571 20 22 17.7164 22 14.9091C22 12.6189 20.5214 10.6531 18.4086 10.0276ZM15.3621 13.9687L12.505 16.8778C12.3657 17.0196 12.1829 17.0909 12 17.0909C11.8171 17.0909 11.6343 17.0196 11.495 16.8778L8.63786 13.9687C8.43357 13.7607 8.37214 13.448 8.48286 13.176C8.59357 12.9047 8.85429 12.7273 9.14286 12.7273H10.5714V9.81818C10.5714 9.01527 11.2114 8.36364 12 8.36364C12.7886 8.36364 13.4286 9.01527 13.4286 9.81818V12.7273H14.8571C15.1457 12.7273 15.4064 12.9047 15.5171 13.176C15.6279 13.448 15.5664 13.7607 15.3621 13.9687Z"
fill="#A8A9AE"
/>
</svg> </svg>
); );
} }

View File

@@ -10,7 +10,12 @@ import { LinkPreviewCard } from "./LinkPreviewCard";
import { formatDateTime } from "./utils/formatTime"; import { formatDateTime } from "./utils/formatTime";
import { FavoriteButton } from "../../favorites/FavoriteButton"; 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 { export function pickBubble(post: Post): BubbleComponent {
const a = post.attachments; const a = post.attachments;
@@ -27,11 +32,14 @@ export function pickBubble(post: Post): BubbleComponent {
export function MessageBubble({ export function MessageBubble({
post, post,
fluid = false, fluid = false,
variant = "default",
}: { }: {
post: Post; post: Post;
/** When true, fill the parent container instead of applying the standalone /** 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. */ * feed max-widths. Used by the desktop 3-column masonry on the home page. */
fluid?: boolean; fluid?: boolean;
/** Desktop latest-updates cards follow the dedicated Figma masonry design. */
variant?: MessageBubbleVariant;
}) { }) {
const Bubble = pickBubble(post); const Bubble = pickBubble(post);
const isVisual = const isVisual =
@@ -39,6 +47,7 @@ export function MessageBubble({
Bubble === VideoBubble || Bubble === VideoBubble ||
Bubble === ImageBubble || Bubble === ImageBubble ||
Bubble === ImageWithTextBubble; Bubble === ImageWithTextBubble;
const isLatestFileCard = variant === "latest" && Bubble === FileDocBubble;
return ( return (
<div <div
@@ -51,20 +60,25 @@ export function MessageBubble({
> >
<article <article
className={`relative w-full overflow-hidden rounded-2xl bg-[#272632] text-left shadow-sm ${ className={`relative w-full overflow-hidden rounded-2xl bg-[#272632] text-left shadow-sm ${
isVisual ? "p-0" : "px-4 py-3" isVisual || isLatestFileCard ? "p-0" : "px-4 py-3"
}`} }`}
> >
{!isLatestFileCard ? (
<FavoriteButton <FavoriteButton
resourceId={post.id} resourceId={post.id}
size="sm" size="sm"
className="absolute right-3 top-3 z-20 shadow-lg shadow-black/30" className={`absolute z-20 shadow-lg shadow-black/30 ${
variant === "latest" ? "bottom-4 right-4" : "right-3 top-3"
}`}
/> />
<Bubble post={post} /> ) : null}
<Bubble post={post} variant={variant} />
{post.linkPreview ? ( {post.linkPreview ? (
<div className={isVisual ? "px-4 pt-3" : "mt-3"}> <div className={isVisual ? "px-4 pt-3" : "mt-3"}>
<LinkPreviewCard preview={post.linkPreview} /> <LinkPreviewCard preview={post.linkPreview} />
</div> </div>
) : null} ) : null}
{!isLatestFileCard ? (
<time <time
dateTime={post.publishedAt} dateTime={post.publishedAt}
className={`block text-right text-[12px] leading-[19px] text-[#A8A9AE] ${ className={`block text-right text-[12px] leading-[19px] text-[#A8A9AE] ${
@@ -73,6 +87,7 @@ export function MessageBubble({
> >
{formatDateTime(post.publishedAt)} {formatDateTime(post.publishedAt)}
</time> </time>
) : null}
</article> </article>
</div> </div>
); );

View File

@@ -7,6 +7,7 @@ import { downloadAttachment } from "../utils/downloadFile";
import { fileIcon } from "../utils/fileIcon"; import { fileIcon } from "../utils/fileIcon";
import { filenameWithExtension, splitFilename } from "../utils/filenameDisplay"; import { filenameWithExtension, splitFilename } from "../utils/filenameDisplay";
import { formatBytes } from "../utils/formatBytes"; import { formatBytes } from "../utils/formatBytes";
import { formatDateTime } from "../utils/formatTime";
import { postDisplayText } from "../utils/postText"; import { postDisplayText } from "../utils/postText";
import { CollapsibleText } from "../CollapsibleText"; import { CollapsibleText } from "../CollapsibleText";
import { import {
@@ -14,6 +15,8 @@ import {
useSaveToAlbumGuide, useSaveToAlbumGuide,
} from "../../SaveToAlbumGuide"; } from "../../SaveToAlbumGuide";
import { useToast } from "../../Toast"; import { useToast } from "../../Toast";
import { FavoriteButton } from "../../../favorites/FavoriteButton";
import type { MessageBubbleVariant } from "../MessageBubble";
function AttachmentRow({ postId, att }: { postId: string; att: Attachment }) { function AttachmentRow({ postId, att }: { postId: string; att: Attachment }) {
const { t } = useI18n(); 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 (
<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 { lang } = useI18n();
const text = postDisplayText(post, lang); const text = postDisplayText(post, lang);
if (variant === "latest") {
return <LatestFileCard post={post} />;
}
return ( return (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
{post.attachments.map((att) => ( {post.attachments.map((att) => (

View File

@@ -1,4 +1,4 @@
import { Heart, LoaderCircle } from "lucide-react"; import { LoaderCircle } from "lucide-react";
import { useEffect } from "react"; import { useEffect } from "react";
import { useI18n } from "../i18n"; import { useI18n } from "../i18n";
import { useFavorites } from "./FavoritesProvider"; import { useFavorites } from "./FavoritesProvider";
@@ -9,6 +9,24 @@ type FavoriteButtonProps = {
size?: "sm" | "md"; size?: "sm" | "md";
}; };
function FigmaBookmarkIcon() {
return (
<svg
width="14"
height="16"
viewBox="0 0 14 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path
d="M12.8146 0H1.20137C0.882749 0 0.577175 0.123344 0.351874 0.342899C0.126573 0.562454 0 0.860233 0 1.17073V14.6341C0.000313518 14.8904 0.0758979 15.1412 0.217822 15.357C0.359747 15.5728 0.56208 15.7444 0.800915 15.8517C0.993567 15.9502 1.20808 16.0011 1.42563 16C1.71496 15.9908 1.99446 15.8954 2.22654 15.7268L6.51144 12.6049C6.65008 12.5035 6.8187 12.4488 6.99199 12.4488C7.16529 12.4488 7.3339 12.5035 7.47254 12.6049L11.7574 15.7268C11.9657 15.879 12.2133 15.9717 12.4725 15.9945C12.7318 16.0172 12.9924 15.9692 13.2252 15.8558C13.458 15.7423 13.6538 15.568 13.7907 15.3522C13.9275 15.1364 14 14.8878 14 14.6341V1.17073C14 0.862919 13.8757 0.567481 13.6538 0.348369C13.432 0.129258 13.1305 0.00410415 12.8146 0Z"
fill="currentColor"
/>
</svg>
);
}
export function FavoriteButton({ export function FavoriteButton({
resourceId, resourceId,
className = "", className = "",
@@ -43,18 +61,14 @@ export function FavoriteButton({
dimension, dimension,
isFavorite isFavorite
? "border-ark-gold/60 bg-ark-gold text-black hover:bg-ark-gold2" ? "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, className,
].join(" ")} ].join(" ")}
> >
{pending ? ( {pending ? (
<LoaderCircle className="h-5 w-5 animate-spin" strokeWidth={2.2} /> <LoaderCircle className="h-5 w-5 animate-spin" strokeWidth={2.2} />
) : ( ) : (
<Heart <FigmaBookmarkIcon />
className="h-5 w-5"
strokeWidth={2.2}
fill={isFavorite ? "currentColor" : "none"}
/>
)} )}
</button> </button>
); );

View File

@@ -645,19 +645,6 @@ export function PublicLayout() {
</nav> </nav>
<div className="flex min-w-0 flex-1 items-center justify-end gap-2 min-[1000px]:flex-none"> <div className="flex min-w-0 flex-1 items-center justify-end gap-2 min-[1000px]:flex-none">
<Link
to={lp("/favorites")}
aria-label={t("favorites")}
title={t("favorites")}
aria-current={na("favorites") ? "page" : undefined}
className={`hidden h-10 w-10 shrink-0 items-center justify-center rounded-full border bg-[#1a1b20] outline-none transition hover:border-ark-gold/50 hover:text-ark-gold focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg md:inline-flex ${
na("favorites")
? "border-ark-gold/70 text-ark-gold"
: "border-ark-line text-neutral-200"
}`}
>
<Heart size={18} strokeWidth={2} />
</Link>
<div ref={desktopSearchRef} className="hidden md:block"> <div ref={desktopSearchRef} className="hidden md:block">
<button <button
type="button" type="button"

View File

@@ -7,6 +7,7 @@ import { FigmaBanner } from "../../components/FigmaBanner";
import { PopularRankList } from "../../components/PopularRankList"; import { PopularRankList } from "../../components/PopularRankList";
import { RecommendedCard } from "../../components/RecommendedCard"; import { RecommendedCard } from "../../components/RecommendedCard";
import { SectionHeader } from "../../components/SectionHeader"; import { SectionHeader } from "../../components/SectionHeader";
import { LatestUpdateCard } from "../../components/LatestUpdateCard";
import { MessageBubble } from "../../components/messageStream/MessageBubble"; import { MessageBubble } from "../../components/messageStream/MessageBubble";
import { langQuery, useI18n } from "../../i18n"; import { langQuery, useI18n } from "../../i18n";
import { useLocalizedPath } from "../../useLocalizedPath"; import { useLocalizedPath } from "../../useLocalizedPath";
@@ -47,28 +48,27 @@ type LatestPostColumnItem = {
}; };
function estimateLatestPostHeight(post: Post): number { function estimateLatestPostHeight(post: Post): number {
const textLength = (post.text ?? post.title ?? "").length;
const textRows = Math.ceil(textLength / 72);
const textHeight = Math.min(180, Math.max(0, textRows * 22));
const previewHeight = post.linkPreview ? 132 : 0;
const [firstAttachment] = post.attachments; const [firstAttachment] = post.attachments;
const textLength = (post.text ?? post.title ?? "").length;
const textRows = Math.max(1, Math.ceil(textLength / 26));
const textHeight = textRows * 24;
const footerHeight = 64;
if (!firstAttachment) return 72 + textHeight + previewHeight; if (!firstAttachment) return textHeight + footerHeight + 24;
if (post.attachments.length >= 2) { const isVisual =
const mediaHeight = firstAttachment.kind === "video" ? 340 : 300; firstAttachment.kind === "image" ||
return mediaHeight + textHeight + previewHeight + 42; firstAttachment.kind === "video" ||
} firstAttachment.mime.startsWith("image/") ||
firstAttachment.mime.startsWith("video/");
if (!isVisual) return 52 + textHeight + footerHeight + 28;
if (firstAttachment.kind === "video") { const mediaHeight =
return 300 + textHeight + previewHeight + 42; firstAttachment.kind === "video" ||
} firstAttachment.mime.startsWith("video/")
? 180
if (firstAttachment.kind === "image") { : 230;
return (post.text ? 300 : 260) + textHeight + previewHeight + 42; return mediaHeight + textHeight + footerHeight + 12;
}
return 96 + post.attachments.length * 72 + textHeight + previewHeight;
} }
function splitLatestPostsIntoColumns( function splitLatestPostsIntoColumns(
@@ -545,7 +545,7 @@ export function Home() {
<Reveal> <Reveal>
<section id="latest" className="scroll-mt-16 md:scroll-mt-24"> <section id="latest" className="scroll-mt-16 md:scroll-mt-24">
<div className="mx-auto max-w-full md:max-w-[820px] lg:max-w-[1080px] xl:max-w-[1180px]"> <div className="mx-auto max-w-full md:max-w-[820px] lg:max-w-[1080px] xl:max-w-[1280px]">
<div className="px-4 md:px-0"> <div className="px-4 md:px-0">
<SectionHeader <SectionHeader
title={t("latestSection")} title={t("latestSection")}
@@ -562,7 +562,7 @@ export function Home() {
</div> </div>
{/* Desktop: explicit balanced columns avoid the uneven gaps that {/* Desktop: explicit balanced columns avoid the uneven gaps that
CSS multi-column masonry can create with variable-height cards. */} CSS multi-column masonry can create with variable-height cards. */}
<div className="mt-7 hidden gap-4 px-4 md:grid md:grid-cols-2 lg:grid-cols-3 lg:px-0"> <div className="mt-7 hidden gap-4 px-4 md:grid md:grid-cols-2 lg:grid-cols-3 lg:px-0 xl:grid-cols-[repeat(3,416px)] xl:justify-center">
{latestDesktopColumns.map((column, columnIndex) => ( {latestDesktopColumns.map((column, columnIndex) => (
<div <div
key={`latest-desktop-column-${columnIndex}`} key={`latest-desktop-column-${columnIndex}`}
@@ -573,7 +573,7 @@ export function Home() {
key={post.id} key={post.id}
delay={Math.min(originalIndex, 8) * 0.05} delay={Math.min(originalIndex, 8) * 0.05}
> >
<MessageBubble post={post} fluid /> <LatestUpdateCard post={post} />
</Reveal> </Reveal>
))} ))}
</div> </div>

View File

@@ -1,4 +1,4 @@
import { Heart } from "lucide-react"; import { Heart, Wallet } from "lucide-react";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { useI18n } from "../i18n"; import { useI18n } from "../i18n";
@@ -107,11 +107,14 @@ export function WalletButton({
wallet.openLoginModal(); wallet.openLoginModal();
}} }}
className={[ className={[
"inline-flex h-10 items-center justify-center rounded-full border border-ark-gold/50 bg-ark-gold px-4 text-sm font-bold text-black outline-none transition hover:bg-ark-gold2 focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg", "inline-flex h-10 items-center justify-center gap-2 rounded-full border border-ark-gold bg-ark-gold px-4 text-sm font-bold text-black outline-none transition hover:bg-ark-gold2 focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg",
compact ? "w-full" : "", compact ? "w-full" : "min-w-[124px] shrink-0 whitespace-nowrap",
].join(" ")} ].join(" ")}
> >
<Wallet className="h-4 w-4" strokeWidth={2.5} aria-hidden />
<span>
{wallet.status === "loading" ? t("loading") : t("walletConnect")} {wallet.status === "loading" ? t("loading") : t("walletConnect")}
</span>
</button> </button>
); );
} }