feat: apply figma browse mobile redesign
This commit is contained in:
15
src/components/icons/DownloadCloudIcon.tsx
Normal file
15
src/components/icons/DownloadCloudIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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")
|
||||
) : (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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} />
|
||||
))}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user