Commit 8cdcd7b2 authored by boojack's avatar boojack

refactor(attachments): extract visual gallery layout and tile style tokens

parent 9ca71229
...@@ -10,6 +10,18 @@ import type { AttachmentVisualItem, PreviewMediaItem } from "@/utils/media-item" ...@@ -10,6 +10,18 @@ import type { AttachmentVisualItem, PreviewMediaItem } from "@/utils/media-item"
import { buildAttachmentVisualItems } from "@/utils/media-item"; import { buildAttachmentVisualItems } from "@/utils/media-item";
import AudioAttachmentItem from "./AudioAttachmentItem"; import AudioAttachmentItem from "./AudioAttachmentItem";
import { getAttachmentMetadata, isAudioAttachment, separateAttachments } from "./attachmentHelpers"; 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 { interface AttachmentListViewProps {
attachments: Attachment[]; attachments: Attachment[];
...@@ -18,15 +30,6 @@ interface AttachmentListViewProps { ...@@ -18,15 +30,6 @@ interface AttachmentListViewProps {
type VisualItem = AttachmentVisualItem; 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 AttachmentMeta = ({ attachment }: { attachment: Attachment }) => {
const { fileTypeLabel, fileSizeLabel } = getAttachmentMetadata(attachment); const { fileTypeLabel, fileSizeLabel } = getAttachmentMetadata(attachment);
...@@ -74,14 +77,12 @@ const VisualTile = ({ ...@@ -74,14 +77,12 @@ const VisualTile = ({
children, children,
}: PropsWithChildren<{ className?: string; onPreview?: () => void; overlayLabel?: string }>) => { }: PropsWithChildren<{ className?: string; onPreview?: () => void; overlayLabel?: string }>) => {
return ( return (
<button type="button" className={cn(VISUAL_TILE_CLASS, className)} onClick={onPreview}> <button type="button" className={cn(VISUAL_TILE_BUTTON_CLASS, className)} onClick={onPreview}>
{children} <div className={MEDIA_HOVER_SURFACE_CLASS}>
<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" /> {children}
{overlayLabel && ( <div className={MEDIA_HOVER_GRADIENT_CLASS} aria-hidden />
<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]"> </div>
{overlayLabel} {overlayLabel && <div className={OVERFLOW_TILE_OVERLAY_CLASS}>{overlayLabel}</div>}
</div>
)}
</button> </button>
); );
}; };
...@@ -116,7 +117,7 @@ const CollageVisualItem = ({ ...@@ -116,7 +117,7 @@ const CollageVisualItem = ({
<> <>
<video src={item.sourceUrl} className={COVER_MEDIA_CLASS} preload="metadata" /> <video src={item.sourceUrl} className={COVER_MEDIA_CLASS} preload="metadata" />
{!overlayLabel && ( {!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" /> <PlayIcon className="h-3.5 w-3.5 fill-current" />
</VideoPlayBadge> </VideoPlayBadge>
)} )}
...@@ -159,7 +160,7 @@ const SingleVisualItem = ({ item, onPreview }: { item: VisualItem; onPreview?: ( ...@@ -159,7 +160,7 @@ const SingleVisualItem = ({ item, onPreview }: { item: VisualItem; onPreview?: (
presentationTimestampUs={motionPreviewProps.presentationTimestampUs} presentationTimestampUs={motionPreviewProps.presentationTimestampUs}
containerClassName="max-w-full" containerClassName="max-w-full"
posterClassName={cn(NATURAL_MEDIA_CLASS, "object-contain")} 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]" badgeClassName="left-2 top-2 px-2 py-0.5 text-[10px]"
/> />
</VisualTile> </VisualTile>
...@@ -180,48 +181,28 @@ const SingleVisualItem = ({ item, onPreview }: { item: VisualItem; onPreview?: ( ...@@ -180,48 +181,28 @@ const SingleVisualItem = ({ item, onPreview }: { item: VisualItem; onPreview?: (
}; };
const VisualGallery = ({ items, onPreview }: { items: VisualItem[]; onPreview?: (itemId: string) => void }) => { const VisualGallery = ({ items, onPreview }: { items: VisualItem[]; onPreview?: (itemId: string) => void }) => {
if (items.length === 0) { const layout = resolveVisualGalleryLayout(items);
if (!layout) {
return null; return null;
} }
if (items.length === 1) { if (layout.mode === "single") {
return ( return (
<div className="w-full"> <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> </div>
); );
} }
const visibleItems = items.slice(0, 4);
const remainingCount = items.length - visibleItems.length;
return ( return (
<div className={cn("grid grid-cols-2 grid-rows-2 gap-2", MOSAIC_GRID_HEIGHT_CLASS)}> <div className={layout.containerClassName}>
{visibleItems.map((item, index) => ( {layout.cells.map(({ item, className, overlayLabel }) => (
<CollageVisualItem <CollageVisualItem
key={item.id} key={item.id}
item={item} item={item}
overlayLabel={index === visibleItems.length - 1 && remainingCount > 0 ? `+${remainingCount}` : undefined} className={className}
overlayLabel={overlayLabel}
onPreview={() => onPreview?.(item.id)} onPreview={() => onPreview?.(item.id)}
/> />
))} ))}
......
/**
* 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]`;
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,
})),
};
};
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment