feat: apply figma browse mobile redesign

This commit is contained in:
TerryM
2026-05-28 10:36:38 +08:00
parent 3825c4ec2f
commit 49f61b89f1
26 changed files with 401 additions and 264 deletions

View File

@@ -1,4 +1,5 @@
import { ArrowDownToLine, LoaderCircle } from "lucide-react";
import { LoaderCircle } from "lucide-react";
import { DownloadCloudIcon } from "../icons/DownloadCloudIcon";
import { useState, type MouseEvent } from "react";
import { useI18n } from "../../i18n";
import type { Attachment } from "../../types/post";
@@ -35,20 +36,23 @@ export function AttachmentDownloadPill({
type="button"
onClick={handleDownload}
disabled={isDownloading}
className={`group z-10 inline-flex overflow-hidden rounded-full bg-black/45 text-[10px] text-white shadow-lg ring-1 ring-white/15 backdrop-blur-md transition hover:bg-black/60 disabled:cursor-wait ${className}`}
className={`group z-10 inline-flex overflow-hidden rounded-full bg-black/80 text-[11px] text-white shadow-lg ring-1 ring-inset ring-white/20 backdrop-blur-md transition hover:bg-black/90 disabled:cursor-wait ${className}`}
aria-label={
isDownloading ? t("downloading") : `Download ${attachment.filename}`
}
aria-busy={isDownloading}
>
<span className="flex h-6 w-6 items-center justify-center bg-white/10 transition group-hover:bg-white/15">
<span className="flex h-6 w-6 items-center justify-center bg-[#545454]/50 transition group-hover:bg-[#545454]/70">
{isDownloading ? (
<LoaderCircle className="h-3 w-3 animate-spin" strokeWidth={2.3} />
<LoaderCircle
className="h-3.5 w-3.5 animate-spin"
strokeWidth={2.3}
/>
) : (
<ArrowDownToLine className="h-3 w-3" strokeWidth={2.3} />
<DownloadCloudIcon className="h-3.5 w-3.5" />
)}
</span>
<span className="flex h-6 items-center gap-0.5 px-1.5">
<span className="flex h-6 items-center gap-0.5 px-2">
{isDownloading ? (
t("downloading")
) : (

View File

@@ -1,5 +1,3 @@
import { SlidersHorizontal } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useI18n } from "../../i18n";
import { typeFilterLabel } from "../../resourceTypeLabels";
@@ -22,95 +20,37 @@ export type FilterChipsProps = {
export function FilterChips({ type, onTypeChange }: FilterChipsProps) {
const { t } = useI18n();
const containerRef = useRef<HTMLDivElement>(null);
const measureRef = useRef<HTMLDivElement>(null);
const [expanded, setExpanded] = useState(false);
const [hasOverflow, setHasOverflow] = useState(false);
const labelsKey = useMemo(
() => TYPE_FILTERS.map((tp) => typeFilterLabel(t, tp)).join("|"),
[t],
);
useEffect(() => {
const checkOverflow = () => {
const container = containerRef.current;
const measure = measureRef.current;
if (!container || !measure) return;
const nextHasOverflow = measure.scrollWidth > container.clientWidth + 1;
setHasOverflow(nextHasOverflow);
if (!nextHasOverflow) setExpanded(false);
};
checkOverflow();
const resizeObserver = new ResizeObserver(checkOverflow);
if (containerRef.current) resizeObserver.observe(containerRef.current);
if (measureRef.current) resizeObserver.observe(measureRef.current);
return () => resizeObserver.disconnect();
}, [labelsKey]);
const chipClass = (active: boolean) =>
`inline-flex h-8 min-w-[72px] shrink-0 items-center justify-center rounded-full border px-3 text-xs leading-none transition ${
const tabClass = (active: boolean) =>
[
"relative shrink-0 whitespace-nowrap px-1 py-3 text-[15px] leading-none outline-none transition-colors",
"border-b-2",
active
? "border-ark-gold bg-ark-gold/10 text-ark-gold2"
: "border-ark-line text-neutral-300 hover:border-ark-gold/50"
}`;
? "border-ark-gold text-ark-gold font-medium"
: "border-transparent text-neutral-400 hover:text-ark-gold/80",
].join(" ");
return (
<div
ref={containerRef}
className="sticky top-0 z-10 overflow-hidden border-b border-ark-line bg-ark-bg/90 py-2 backdrop-blur md:rounded-t-xl"
>
<div className="flex items-start gap-1.5">
<div
className={`flex min-w-0 flex-1 gap-1.5 ${
expanded
? "flex-wrap whitespace-normal"
: "overflow-hidden whitespace-nowrap"
}`}
>
{TYPE_FILTERS.map((tp) => {
const active = type === tp;
return (
<button
key={tp}
type="button"
onClick={() => onTypeChange(tp)}
className={chipClass(active)}
>
{typeFilterLabel(t, tp)}
</button>
);
})}
</div>
{hasOverflow ? (
<button
type="button"
onClick={() => setExpanded((value) => !value)}
className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-ark-line bg-[#1a1b20] text-neutral-200 transition hover:border-ark-gold/60 hover:text-ark-gold2 min-[440px]:w-9 md:w-10"
aria-label={expanded ? "Collapse filters" : "Expand filters"}
aria-expanded={expanded}
>
<SlidersHorizontal
className="h-3.5 w-3.5 md:h-4 md:w-4"
strokeWidth={2.2}
/>
</button>
) : null}
</div>
<div className="sticky top-0 z-10 border-b border-ark-line bg-ark-bg/95 backdrop-blur md:rounded-t-xl">
<div
ref={measureRef}
aria-hidden="true"
className="pointer-events-none invisible absolute left-0 top-0 -z-10 flex h-0 max-w-none gap-1.5 overflow-hidden whitespace-nowrap"
className="flex items-end gap-5 overflow-x-auto overflow-y-hidden px-1 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
role="tablist"
>
{TYPE_FILTERS.map((tp) => (
<span key={tp} className={chipClass(type === tp)}>
{typeFilterLabel(t, tp)}
</span>
))}
{TYPE_FILTERS.map((tp) => {
const active = type === tp;
return (
<button
key={tp}
type="button"
role="tab"
aria-selected={active}
onClick={() => onTypeChange(tp)}
className={tabClass(active)}
>
{typeFilterLabel(t, tp)}
</button>
);
})}
</div>
</div>
);

View File

@@ -26,29 +26,31 @@ export function pickBubble(post: Post): BubbleComponent {
export function MessageBubble({ post }: { post: Post }) {
const { lang } = useI18n();
const Bubble = pickBubble(post);
const isTextOnly = post.attachments.length === 0;
const isVisual = post.attachments.some(
(a) => a.kind === "image" || a.kind === "video",
);
const isVisual =
Bubble === AlbumBubble ||
Bubble === VideoBubble ||
Bubble === ImageBubble ||
Bubble === ImageWithTextBubble;
return (
<div
id={`post-${post.id}`}
className="mx-auto w-full max-w-[380px] md:max-w-[680px] lg:max-w-[900px] xl:max-w-[1120px]"
className="mx-auto w-full max-w-[358px] md:max-w-[680px] lg:max-w-[900px] xl:max-w-[1120px]"
>
<article
className={`relative rounded-2xl bg-ark-panel text-left shadow-sm ${
isVisual ? "w-full" : "w-fit max-w-full"
} ${isTextOnly ? "px-3 py-2" : "p-2"}`}
className={`relative w-full overflow-hidden rounded-2xl bg-[#272632] text-left shadow-sm ${
isVisual ? "p-0" : "px-4 py-3"
}`}
>
<Bubble post={post} />
<time
dateTime={post.publishedAt}
className="ml-2 mt-1 inline-block float-right text-[10.5px] leading-none text-neutral-500"
className={`block text-right text-[12px] leading-[19px] text-[#A8A9AE] ${
isVisual ? "px-4 pb-3 pt-3" : "mt-3"
}`}
>
{formatDateTime(post.publishedAt, lang)}
</time>
<span className="block clear-both" />
</article>
</div>
);

View File

@@ -72,12 +72,12 @@ export function MessageStream({ scope }: MessageStreamProps) {
};
return (
<div className="mx-auto max-w-full px-3 md:max-w-[820px] lg:max-w-[1080px] xl:max-w-[1180px]">
<div className="mx-auto max-w-full px-4 md:max-w-[820px] lg:max-w-[1080px] xl:max-w-[1180px]">
<FilterChips type={type} onTypeChange={(v) => updateParam("type", v)} />
<div className="flex flex-col gap-2 pb-10 pt-2">
<div className="flex flex-col gap-3 pt-2">
{groups.map((group) => (
<div key={group.dayKey} className="flex flex-col gap-2">
<div key={group.dayKey} className="flex flex-col gap-3">
{group.items.map((post) => (
<MessageBubble key={post.id} post={post} />
))}

View File

@@ -1,4 +1,5 @@
import { ArrowDownToLine, LoaderCircle, X } from "lucide-react";
import { LoaderCircle, X } from "lucide-react";
import { DownloadCloudIcon } from "../../icons/DownloadCloudIcon";
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { useI18n } from "../../../i18n";
@@ -12,8 +13,15 @@ import { postDisplayText } from "../utils/postText";
const MAX_VISIBLE = 4;
function imageRatio(att: Attachment) {
return att.width && att.height ? `${att.width} / ${att.height}` : "4 / 3";
function albumGridClass(count: number) {
const height = "h-[230px] min-[440px]:h-[250px] md:h-[300px] lg:h-[340px]";
if (count === 2) return `${height} grid grid-cols-1 grid-rows-2`;
return `${height} grid grid-cols-2 grid-rows-2`;
}
function albumItemClass(index: number, count: number) {
if (count === 3 && index === 0) return "row-span-2";
return "";
}
function ImageListDownloadButton({
@@ -51,7 +59,7 @@ function ImageListDownloadButton({
{isDownloading ? (
<LoaderCircle className="h-4 w-4 animate-spin" strokeWidth={2.3} />
) : (
<ArrowDownToLine className="h-4 w-4" strokeWidth={2.3} />
<DownloadCloudIcon className="h-4 w-4" />
)}
</button>
);
@@ -142,54 +150,22 @@ export function AlbumBubble({ post }: { post: Post }) {
const [listOpen, setListOpen] = useState(false);
const images = post.attachments;
const text = postDisplayText(post, lang);
const shouldMerge = images.length > MAX_VISIBLE;
if (!shouldMerge) {
return (
<div className="flex flex-col gap-1.5">
{images.map((att, i) => (
<div
key={att.id}
className="relative max-h-[180px] w-full overflow-hidden rounded-xl min-[440px]:max-h-[200px] md:max-h-[240px] lg:max-h-[280px]"
>
<button
type="button"
onClick={() => openLightbox(images, i, text, post.id)}
className="block w-full"
aria-label={att.filename}
>
<img
src={att.url}
alt={att.filename}
loading="lazy"
className="h-full w-full object-cover"
style={{ aspectRatio: imageRatio(att) }}
/>
</button>
<AttachmentDownloadPill postId={post.id} attachment={att} />
</div>
))}
{text ? (
<div className="message-stream-copyable-text select-text whitespace-pre-wrap break-words text-[14px] leading-snug text-neutral-100">
{autolink(text)}
</div>
) : null}
</div>
);
}
const visible = images.slice(0, MAX_VISIBLE);
const extra = images.length - MAX_VISIBLE;
const layoutCount = Math.min(images.length, MAX_VISIBLE);
return (
<div className="flex flex-col gap-1.5">
<div className="grid h-[220px] grid-cols-2 grid-rows-2 gap-[2px] overflow-hidden rounded-xl min-[440px]:h-[250px] md:h-[300px] lg:h-[340px]">
<div className="flex flex-col">
<div className={`${albumGridClass(layoutCount)} gap-px overflow-hidden`}>
{visible.map((att, i) => {
const isLastSlot = i === MAX_VISIBLE - 1 && extra > 0;
return (
<div
key={att.id}
className="relative h-full w-full overflow-hidden"
className={`relative h-full w-full overflow-hidden ${albumItemClass(
i,
layoutCount,
)}`}
>
<button
type="button"
@@ -204,12 +180,10 @@ export function AlbumBubble({ post }: { post: Post }) {
src={att.thumbnailUrl ?? att.url}
alt={att.filename}
loading="lazy"
className={`h-full w-full object-cover ${
isLastSlot ? "blur-sm scale-105" : ""
}`}
className="h-full w-full object-cover"
/>
{isLastSlot ? (
<div className="absolute inset-0 flex items-center justify-center bg-black/45 text-3xl font-semibold text-white">
<div className="absolute inset-0 flex items-center justify-center bg-black/55 text-4xl font-bold text-white">
+{extra}
</div>
) : null}
@@ -222,7 +196,7 @@ export function AlbumBubble({ post }: { post: Post }) {
})}
</div>
{text ? (
<div className="message-stream-copyable-text select-text whitespace-pre-wrap break-words text-[14px] leading-snug text-neutral-100">
<div className="message-stream-copyable-text select-text whitespace-pre-wrap break-words px-4 pt-3 text-[14px] leading-6 text-neutral-100">
{autolink(text)}
</div>
) : null}

View File

@@ -1,4 +1,5 @@
import { ArrowDownToLine, LoaderCircle } from "lucide-react";
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";
@@ -56,7 +57,7 @@ function AttachmentRow({ postId, att }: { postId: string; att: Attachment }) {
{isDownloading ? (
<LoaderCircle className="h-4 w-4 animate-spin" strokeWidth={2.3} />
) : (
<ArrowDownToLine className="h-4 w-4" strokeWidth={2.3} />
<DownloadCloudIcon className="h-4 w-4" />
)}
</div>
</button>

View File

@@ -6,15 +6,12 @@ export function ImageBubble({ post }: { post: Post }) {
const { openLightbox } = useLightbox();
const att = post.attachments[0];
if (!att) return null;
const ratio =
att.width && att.height ? `${att.width} / ${att.height}` : "4 / 3";
return (
<div className="relative w-full overflow-hidden rounded-xl max-h-[240px] min-[440px]:max-h-[270px] md:max-h-[320px] lg:max-h-[360px]">
<div className="relative h-[180px] w-full overflow-hidden bg-black min-[440px]:h-[210px] md:h-[260px] lg:h-[300px]">
<button
type="button"
onClick={() => openLightbox([att], 0, undefined, post.id)}
className="block w-full"
className="block h-full w-full"
aria-label={att.filename}
>
<img
@@ -22,7 +19,6 @@ export function ImageBubble({ post }: { post: Post }) {
alt={att.filename}
loading="lazy"
className="h-full w-full object-cover"
style={{ aspectRatio: ratio }}
/>
</button>
<AttachmentDownloadPill postId={post.id} attachment={att} />

View File

@@ -11,31 +11,27 @@ export function ImageWithTextBubble({ post }: { post: Post }) {
const att = post.attachments[0];
const text = postDisplayText(post, lang);
if (!att) return null;
const ratio =
att.width && att.height ? `${att.width} / ${att.height}` : "4 / 3";
return (
<div className="relative overflow-hidden rounded-xl bg-black/20">
<button
type="button"
onClick={() => openLightbox([att], 0, text, post.id)}
className="block w-full"
aria-label={att.filename}
>
<img
src={att.url}
alt={att.filename}
loading="lazy"
className="block h-auto w-full"
style={{ aspectRatio: ratio }}
/>
</button>
<AttachmentDownloadPill postId={post.id} attachment={att} />
<div className="flex flex-col">
<div className="relative h-[180px] w-full overflow-hidden bg-black min-[440px]:h-[210px] md:h-[260px] lg:h-[300px]">
<button
type="button"
onClick={() => openLightbox([att], 0, text, post.id)}
className="block h-full w-full"
aria-label={att.filename}
>
<img
src={att.url}
alt={att.filename}
loading="lazy"
className="h-full w-full object-cover"
/>
</button>
<AttachmentDownloadPill postId={post.id} attachment={att} />
</div>
{text ? (
<div className="bg-gradient-to-b from-ark-panel/90 to-ark-panel px-4 py-3 text-[14px] leading-snug text-neutral-100">
<div className="message-stream-copyable-text select-text whitespace-pre-wrap break-words">
{autolink(text)}
</div>
<div className="message-stream-copyable-text select-text whitespace-pre-wrap break-words px-4 pt-3 text-[14px] leading-6 text-neutral-100">
{autolink(text)}
</div>
) : null}
</div>

View File

@@ -1,4 +1,5 @@
import { ArrowDownToLine, LoaderCircle, Play, X } from "lucide-react";
import { LoaderCircle, Play, X } from "lucide-react";
import { DownloadCloudIcon } from "../../icons/DownloadCloudIcon";
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useI18n } from "../../../i18n";
@@ -12,6 +13,17 @@ import { postDisplayText } from "../utils/postText";
const MAX_VISIBLE = 4;
function videoGridClass(count: number) {
const height = "h-[230px] min-[440px]:h-[250px] md:h-[300px] lg:h-[340px]";
if (count === 2) return `${height} grid grid-cols-1 grid-rows-2`;
return `${height} grid grid-cols-2 grid-rows-2`;
}
function videoItemClass(index: number, count: number) {
if (count === 3 && index === 0) return "row-span-2";
return "";
}
function formatDuration(sec: number | undefined): string {
if (!sec || sec <= 0) return "";
const m = Math.floor(sec / 60);
@@ -54,7 +66,7 @@ function VideoAttachmentCard({
className={`relative w-full overflow-hidden bg-black ${
compact
? "h-full"
: "max-h-[220px] rounded-xl min-[440px]:max-h-[250px] md:max-h-[300px] lg:max-h-[340px]"
: "h-[180px] min-[440px]:h-[210px] md:h-[260px] lg:h-[300px]"
}`}
style={compact ? undefined : { aspectRatio: videoRatio(attachment) }}
onClick={() => {
@@ -180,7 +192,7 @@ function AttachmentListDownloadButton({
{isDownloading ? (
<LoaderCircle className="h-4 w-4 animate-spin" strokeWidth={2.3} />
) : (
<ArrowDownToLine className="h-4 w-4" strokeWidth={2.3} />
<DownloadCloudIcon className="h-4 w-4" />
)}
</button>
);
@@ -297,32 +309,38 @@ export function VideoBubble({ post }: { post: Post }) {
const [listOpen, setListOpen] = useState(false);
const videos = post.attachments.filter(isVideoAttachment);
const text = postDisplayText(post, lang);
const shouldMerge = videos.length > MAX_VISIBLE;
if (!videos.length) return null;
if (shouldMerge) {
if (videos.length >= 2) {
const visible = videos.slice(0, MAX_VISIBLE);
const extra = videos.length - MAX_VISIBLE;
const layoutCount = Math.min(videos.length, MAX_VISIBLE);
return (
<div className="flex flex-col gap-1.5">
<div className="grid h-[220px] grid-cols-2 grid-rows-2 gap-[2px] overflow-hidden rounded-xl bg-black min-[440px]:h-[250px] md:h-[300px] lg:h-[340px]">
<div className="flex flex-col">
<div
className={`${videoGridClass(layoutCount)} gap-px overflow-hidden bg-black`}
>
{visible.map((att, i) => {
const isLastSlot = i === MAX_VISIBLE - 1 && extra > 0;
return (
<VideoAttachmentCard
<div
key={att.id}
postId={post.id}
attachment={att}
compact
overlayCount={isLastSlot ? extra : undefined}
onMoreClick={() => setListOpen(true)}
/>
className={`h-full w-full ${videoItemClass(i, layoutCount)}`}
>
<VideoAttachmentCard
postId={post.id}
attachment={att}
compact
overlayCount={isLastSlot ? extra : undefined}
onMoreClick={() => setListOpen(true)}
/>
</div>
);
})}
</div>
{text ? (
<div className="message-stream-copyable-text select-text whitespace-pre-wrap break-words text-[14px] leading-snug text-neutral-100">
<div className="message-stream-copyable-text select-text whitespace-pre-wrap break-words px-4 pt-3 text-[14px] leading-6 text-neutral-100">
{autolink(text)}
</div>
) : null}
@@ -342,12 +360,10 @@ export function VideoBubble({ post }: { post: Post }) {
}
return (
<div className="flex flex-col gap-1.5">
{videos.map((att) => (
<VideoAttachmentCard key={att.id} postId={post.id} attachment={att} />
))}
<div className="flex flex-col">
<VideoAttachmentCard postId={post.id} attachment={videos[0]} />
{text ? (
<div className="message-stream-copyable-text select-text whitespace-pre-wrap break-words text-[14px] leading-snug text-neutral-100">
<div className="message-stream-copyable-text select-text whitespace-pre-wrap break-words px-4 pt-3 text-[14px] leading-6 text-neutral-100">
{autolink(text)}
</div>
) : null}