refactor(attachments): extract visual gallery layout and tile style tokens
This commit is contained in:
parent
9ca71229a6
commit
8cdcd7b284
3 changed files with 134 additions and 48 deletions
|
|
@ -10,6 +10,18 @@ import type { AttachmentVisualItem, PreviewMediaItem } from "@/utils/media-item"
|
|||
import { buildAttachmentVisualItems } from "@/utils/media-item";
|
||||
import AudioAttachmentItem from "./AudioAttachmentItem";
|
||||
import { getAttachmentMetadata, isAudioAttachment, separateAttachments } from "./attachmentHelpers";
|
||||
import {
|
||||
COLLAGE_VIDEO_PLAY_BADGE_CLASS,
|
||||
COVER_MEDIA_CLASS,
|
||||
MEDIA_HOVER_GRADIENT_CLASS,
|
||||
MEDIA_HOVER_SURFACE_CLASS,
|
||||
NATURAL_MEDIA_CLASS,
|
||||
OVERFLOW_TILE_OVERLAY_CLASS,
|
||||
SINGLE_MOTION_VIDEO_CLASS,
|
||||
SINGLE_VIDEO_CARD_WIDTH_CLASS,
|
||||
VISUAL_TILE_BUTTON_CLASS,
|
||||
} from "./attachmentVisualClasses";
|
||||
import { resolveVisualGalleryLayout } from "./visualGalleryLayout";
|
||||
|
||||
interface AttachmentListViewProps {
|
||||
attachments: Attachment[];
|
||||
|
|
@ -18,15 +30,6 @@ interface AttachmentListViewProps {
|
|||
|
||||
type VisualItem = AttachmentVisualItem;
|
||||
|
||||
const VISUAL_TILE_CLASS =
|
||||
"group relative overflow-hidden rounded-xl border border-border/70 bg-muted/30 text-left transition-colors hover:border-accent/40";
|
||||
const COVER_MEDIA_CLASS = "h-full w-full rounded-none object-cover transition-transform duration-300 group-hover:scale-[1.02]";
|
||||
const NATURAL_MEDIA_CLASS =
|
||||
"block h-auto max-h-[20rem] w-auto max-w-full rounded-none transition-transform duration-300 group-hover:scale-[1.02]";
|
||||
const SINGLE_VIDEO_CARD_WIDTH_CLASS = "w-full max-w-[30rem]";
|
||||
const TWO_ITEM_GRID_HEIGHT_CLASS = "h-[11rem] sm:h-[13rem] md:h-[15rem]";
|
||||
const MOSAIC_GRID_HEIGHT_CLASS = "h-[13rem] sm:h-[16rem] md:h-[18rem]";
|
||||
|
||||
const AttachmentMeta = ({ attachment }: { attachment: Attachment }) => {
|
||||
const { fileTypeLabel, fileSizeLabel } = getAttachmentMetadata(attachment);
|
||||
|
||||
|
|
@ -74,14 +77,12 @@ const VisualTile = ({
|
|||
children,
|
||||
}: PropsWithChildren<{ className?: string; onPreview?: () => void; overlayLabel?: string }>) => {
|
||||
return (
|
||||
<button type="button" className={cn(VISUAL_TILE_CLASS, className)} onClick={onPreview}>
|
||||
{children}
|
||||
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-foreground/15 via-transparent to-transparent opacity-0 transition-opacity group-hover:opacity-100" />
|
||||
{overlayLabel && (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-black/45 text-2xl font-semibold text-white backdrop-blur-[2px]">
|
||||
{overlayLabel}
|
||||
</div>
|
||||
)}
|
||||
<button type="button" className={cn(VISUAL_TILE_BUTTON_CLASS, className)} onClick={onPreview}>
|
||||
<div className={MEDIA_HOVER_SURFACE_CLASS}>
|
||||
{children}
|
||||
<div className={MEDIA_HOVER_GRADIENT_CLASS} aria-hidden />
|
||||
</div>
|
||||
{overlayLabel && <div className={OVERFLOW_TILE_OVERLAY_CLASS}>{overlayLabel}</div>}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
|
@ -116,7 +117,7 @@ const CollageVisualItem = ({
|
|||
<>
|
||||
<video src={item.sourceUrl} className={COVER_MEDIA_CLASS} preload="metadata" />
|
||||
{!overlayLabel && (
|
||||
<VideoPlayBadge className="bottom-2 right-2 h-7 w-7 bg-background/80 text-foreground/70">
|
||||
<VideoPlayBadge className={COLLAGE_VIDEO_PLAY_BADGE_CLASS}>
|
||||
<PlayIcon className="h-3.5 w-3.5 fill-current" />
|
||||
</VideoPlayBadge>
|
||||
)}
|
||||
|
|
@ -159,7 +160,7 @@ const SingleVisualItem = ({ item, onPreview }: { item: VisualItem; onPreview?: (
|
|||
presentationTimestampUs={motionPreviewProps.presentationTimestampUs}
|
||||
containerClassName="max-w-full"
|
||||
posterClassName={cn(NATURAL_MEDIA_CLASS, "object-contain")}
|
||||
videoClassName="absolute inset-0 h-full w-full rounded-none object-contain transition-transform duration-300 group-hover:scale-[1.02]"
|
||||
videoClassName={SINGLE_MOTION_VIDEO_CLASS}
|
||||
badgeClassName="left-2 top-2 px-2 py-0.5 text-[10px]"
|
||||
/>
|
||||
</VisualTile>
|
||||
|
|
@ -180,48 +181,28 @@ const SingleVisualItem = ({ item, onPreview }: { item: VisualItem; onPreview?: (
|
|||
};
|
||||
|
||||
const VisualGallery = ({ items, onPreview }: { items: VisualItem[]; onPreview?: (itemId: string) => void }) => {
|
||||
if (items.length === 0) {
|
||||
const layout = resolveVisualGalleryLayout(items);
|
||||
|
||||
if (!layout) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (items.length === 1) {
|
||||
if (layout.mode === "single") {
|
||||
return (
|
||||
<div className="w-full">
|
||||
<SingleVisualItem item={items[0]} onPreview={() => onPreview?.(items[0].id)} />
|
||||
<SingleVisualItem item={layout.item} onPreview={() => onPreview?.(layout.item.id)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (items.length === 2) {
|
||||
return (
|
||||
<div className={cn("grid grid-cols-2 gap-2", TWO_ITEM_GRID_HEIGHT_CLASS)}>
|
||||
{items.map((item) => (
|
||||
<CollageVisualItem key={item.id} item={item} onPreview={() => onPreview?.(item.id)} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (items.length === 3) {
|
||||
return (
|
||||
<div className={cn("grid grid-cols-2 grid-rows-2 gap-2", MOSAIC_GRID_HEIGHT_CLASS)}>
|
||||
<CollageVisualItem item={items[0]} className="row-span-2" onPreview={() => onPreview?.(items[0].id)} />
|
||||
<CollageVisualItem item={items[1]} onPreview={() => onPreview?.(items[1].id)} />
|
||||
<CollageVisualItem item={items[2]} onPreview={() => onPreview?.(items[2].id)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const visibleItems = items.slice(0, 4);
|
||||
const remainingCount = items.length - visibleItems.length;
|
||||
|
||||
return (
|
||||
<div className={cn("grid grid-cols-2 grid-rows-2 gap-2", MOSAIC_GRID_HEIGHT_CLASS)}>
|
||||
{visibleItems.map((item, index) => (
|
||||
<div className={layout.containerClassName}>
|
||||
{layout.cells.map(({ item, className, overlayLabel }) => (
|
||||
<CollageVisualItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
overlayLabel={index === visibleItems.length - 1 && remainingCount > 0 ? `+${remainingCount}` : undefined}
|
||||
className={className}
|
||||
overlayLabel={overlayLabel}
|
||||
onPreview={() => onPreview?.(item.id)}
|
||||
/>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,33 @@
|
|||
/**
|
||||
* Tailwind class bundles for attachment visual tiles (`VisualTile`, collage, single image/video).
|
||||
* Hover uses `group/media` on {@link MEDIA_HOVER_SURFACE_CLASS} so scale/gradient track the media surface, not the outer button chrome.
|
||||
*/
|
||||
|
||||
export const VISUAL_TILE_BUTTON_CLASS =
|
||||
"relative block overflow-hidden rounded-xl border border-border/70 bg-muted/30 p-0 text-left outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring/50";
|
||||
|
||||
export const MEDIA_HOVER_SURFACE_CLASS = "group/media relative h-full min-h-0 w-full overflow-hidden";
|
||||
|
||||
export const COVER_MEDIA_CLASS = "h-full w-full rounded-none object-cover transition-transform duration-300 group-hover/media:scale-[1.02]";
|
||||
|
||||
export const NATURAL_MEDIA_CLASS =
|
||||
"block h-auto max-h-[20rem] w-auto max-w-full rounded-none transition-transform duration-300 group-hover/media:scale-[1.02]";
|
||||
|
||||
/** Motion overlay video in single-tile layout (pairs with {@link NATURAL_MEDIA_CLASS} poster). */
|
||||
export const SINGLE_MOTION_VIDEO_CLASS =
|
||||
"absolute inset-0 h-full w-full rounded-none object-contain transition-transform duration-300 group-hover/media:scale-[1.02]";
|
||||
|
||||
export const SINGLE_VIDEO_CARD_WIDTH_CLASS = "w-full max-w-[30rem]";
|
||||
|
||||
/** Stacking inside {@link MEDIA_HOVER_SURFACE_CLASS}: gradient < badge < overflow mask. */
|
||||
export const VISUAL_Z = {
|
||||
gradient: "z-[1]",
|
||||
badge: "z-[2]",
|
||||
overflowMask: "z-[3]",
|
||||
} as const;
|
||||
|
||||
export const MEDIA_HOVER_GRADIENT_CLASS = `pointer-events-none absolute inset-0 ${VISUAL_Z.gradient} bg-gradient-to-t from-foreground/15 via-transparent to-transparent opacity-0 transition-opacity group-hover/media:opacity-100`;
|
||||
|
||||
export const COLLAGE_VIDEO_PLAY_BADGE_CLASS = `bottom-2 right-2 ${VISUAL_Z.badge} h-7 w-7 bg-background/80 text-foreground/70`;
|
||||
|
||||
export const OVERFLOW_TILE_OVERLAY_CLASS = `pointer-events-none absolute inset-0 ${VISUAL_Z.overflowMask} flex items-center justify-center bg-black/45 text-2xl font-semibold text-white backdrop-blur-[2px]`;
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
import { cn } from "@/lib/utils";
|
||||
import type { AttachmentVisualItem } from "@/utils/media-item";
|
||||
|
||||
export type VisualGalleryCell = {
|
||||
item: AttachmentVisualItem;
|
||||
className?: string;
|
||||
overlayLabel?: string;
|
||||
};
|
||||
|
||||
/** Resolved layout for attachment visual previews — keeps grid rules in one place. */
|
||||
export type VisualGalleryLayout =
|
||||
| { mode: "single"; item: AttachmentVisualItem }
|
||||
| { mode: "collage"; containerClassName: string; cells: VisualGalleryCell[] };
|
||||
|
||||
const TWO_ITEM_GRID_HEIGHT_CLASS = "h-[11rem] sm:h-[13rem] md:h-[15rem]";
|
||||
const MOSAIC_GRID_HEIGHT_CLASS = "h-[13rem] sm:h-[16rem] md:h-[18rem]";
|
||||
/** 2 rows × 3 columns */
|
||||
const SIX_UP_GRID_HEIGHT_CLASS = "h-[14rem] sm:h-[17rem] md:h-[20rem]";
|
||||
|
||||
/** Max thumbnails shown in the 2×3 collage before `+N` on the last cell. */
|
||||
export const COLLAGE_MAX_VISIBLE_CELLS = 6;
|
||||
|
||||
/**
|
||||
* Maps N visual items to a gallery layout (single, 2-up, 3-mosaic, 2×2, or 2×3 with optional +N).
|
||||
*/
|
||||
export const resolveVisualGalleryLayout = (items: AttachmentVisualItem[]): VisualGalleryLayout | null => {
|
||||
const count = items.length;
|
||||
|
||||
if (count === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (count === 1) {
|
||||
return { mode: "single", item: items[0] };
|
||||
}
|
||||
|
||||
if (count === 2) {
|
||||
return {
|
||||
mode: "collage",
|
||||
containerClassName: cn("grid grid-cols-2 gap-2", TWO_ITEM_GRID_HEIGHT_CLASS),
|
||||
cells: items.map((item) => ({ item })),
|
||||
};
|
||||
}
|
||||
|
||||
if (count === 3) {
|
||||
return {
|
||||
mode: "collage",
|
||||
containerClassName: cn("grid grid-cols-2 grid-rows-2 gap-2", MOSAIC_GRID_HEIGHT_CLASS),
|
||||
cells: [{ item: items[0], className: "row-span-2" }, { item: items[1] }, { item: items[2] }],
|
||||
};
|
||||
}
|
||||
|
||||
if (count === 4) {
|
||||
return {
|
||||
mode: "collage",
|
||||
containerClassName: cn("grid grid-cols-2 grid-rows-2 gap-2", MOSAIC_GRID_HEIGHT_CLASS),
|
||||
cells: items.map((item) => ({ item })),
|
||||
};
|
||||
}
|
||||
|
||||
const visible = items.slice(0, COLLAGE_MAX_VISIBLE_CELLS);
|
||||
const overflowCount = items.length - visible.length;
|
||||
|
||||
return {
|
||||
mode: "collage",
|
||||
containerClassName: cn("grid grid-cols-3 grid-rows-2 gap-2", SIX_UP_GRID_HEIGHT_CLASS),
|
||||
cells: visible.map((item, index) => ({
|
||||
item,
|
||||
overlayLabel: index === visible.length - 1 && overflowCount > 0 ? `+${overflowCount}` : undefined,
|
||||
})),
|
||||
};
|
||||
};
|
||||
Loading…
Reference in a new issue