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

@@ -0,0 +1,15 @@
import type { SVGProps } from "react";
export function DownloadCloudIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
viewBox="0 0 14 14"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
aria-hidden
{...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" />
</svg>
);
}

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}