|
|
|
|
@ -1,11 +1,12 @@
|
|
|
|
|
import { DownloadIcon, FileIcon, PaperclipIcon, PlayIcon } from "lucide-react";
|
|
|
|
|
import type { PropsWithChildren } from "react";
|
|
|
|
|
import { useMemo } from "react";
|
|
|
|
|
import MetadataSection from "@/components/MemoMetadata/MetadataSection";
|
|
|
|
|
import MotionPhotoPreview from "@/components/MotionPhotoPreview";
|
|
|
|
|
import { cn } from "@/lib/utils";
|
|
|
|
|
import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb";
|
|
|
|
|
import { getAttachmentUrl } from "@/utils/attachment";
|
|
|
|
|
import type { PreviewMediaItem } from "@/utils/media-item";
|
|
|
|
|
import type { AttachmentVisualItem, PreviewMediaItem } from "@/utils/media-item";
|
|
|
|
|
import { buildAttachmentVisualItems } from "@/utils/media-item";
|
|
|
|
|
import AudioAttachmentItem from "./AudioAttachmentItem";
|
|
|
|
|
import { getAttachmentMetadata, isAudioAttachment, separateAttachments } from "./attachmentHelpers";
|
|
|
|
|
@ -15,6 +16,17 @@ interface AttachmentListViewProps {
|
|
|
|
|
onImagePreview?: (items: PreviewMediaItem[], index: number) => void;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
|
|
|
|
@ -50,89 +62,175 @@ const DocumentItem = ({ attachment }: { attachment: Attachment }) => {
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const MotionItem = ({
|
|
|
|
|
const getMotionPreviewProps = (item: VisualItem) => ({
|
|
|
|
|
motionUrl: item.previewItem.kind === "motion" ? item.previewItem.motionUrl : item.sourceUrl,
|
|
|
|
|
presentationTimestampUs: item.previewItem.kind === "motion" ? item.previewItem.presentationTimestampUs : undefined,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const VisualTile = ({
|
|
|
|
|
className,
|
|
|
|
|
onPreview,
|
|
|
|
|
overlayLabel,
|
|
|
|
|
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>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const VideoPlayBadge = ({ className, children }: PropsWithChildren<{ className?: string }>) => (
|
|
|
|
|
<span
|
|
|
|
|
className={cn(
|
|
|
|
|
"pointer-events-none absolute inline-flex items-center justify-center rounded-full bg-background/85 text-foreground shadow-sm backdrop-blur-sm",
|
|
|
|
|
className,
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
{children}
|
|
|
|
|
</span>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const CollageVisualItem = ({
|
|
|
|
|
item,
|
|
|
|
|
featured = false,
|
|
|
|
|
onPreview,
|
|
|
|
|
className,
|
|
|
|
|
overlayLabel,
|
|
|
|
|
}: {
|
|
|
|
|
item: ReturnType<typeof buildAttachmentVisualItems>[number];
|
|
|
|
|
featured?: boolean;
|
|
|
|
|
item: VisualItem;
|
|
|
|
|
onPreview?: () => void;
|
|
|
|
|
className?: string;
|
|
|
|
|
overlayLabel?: string;
|
|
|
|
|
}) => {
|
|
|
|
|
const motionPreviewProps = item.kind === "motion" ? getMotionPreviewProps(item) : undefined;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className={cn("group block w-full text-left", featured ? "max-w-[18rem] sm:max-w-[20rem]" : "")}
|
|
|
|
|
onClick={onPreview}
|
|
|
|
|
>
|
|
|
|
|
<div
|
|
|
|
|
className={cn(
|
|
|
|
|
"relative overflow-hidden rounded-xl border border-border/70 bg-muted/30 transition-colors hover:border-accent/40",
|
|
|
|
|
featured ? "aspect-[4/3]" : "aspect-square",
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
{item.kind === "video" ? (
|
|
|
|
|
<video
|
|
|
|
|
src={item.sourceUrl}
|
|
|
|
|
className="h-full w-full rounded-none object-cover transition-transform duration-300 group-hover:scale-[1.02]"
|
|
|
|
|
preload="metadata"
|
|
|
|
|
/>
|
|
|
|
|
) : item.kind === "motion" ? (
|
|
|
|
|
<MotionPhotoPreview
|
|
|
|
|
posterUrl={item.posterUrl}
|
|
|
|
|
motionUrl={item.previewItem.kind === "motion" ? item.previewItem.motionUrl : item.sourceUrl}
|
|
|
|
|
alt={item.filename}
|
|
|
|
|
presentationTimestampUs={item.previewItem.kind === "motion" ? item.previewItem.presentationTimestampUs : undefined}
|
|
|
|
|
containerClassName="h-full w-full"
|
|
|
|
|
badgeClassName="left-2 top-2 px-2 py-0.5 text-[10px]"
|
|
|
|
|
mediaClassName="h-full w-full rounded-none object-cover transition-transform duration-300 group-hover:scale-[1.02]"
|
|
|
|
|
/>
|
|
|
|
|
) : (
|
|
|
|
|
<img
|
|
|
|
|
src={item.posterUrl}
|
|
|
|
|
alt={item.filename}
|
|
|
|
|
className="h-full w-full rounded-none object-cover transition-transform duration-300 group-hover:scale-[1.02]"
|
|
|
|
|
loading="lazy"
|
|
|
|
|
decoding="async"
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
<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" />
|
|
|
|
|
{item.kind === "video" && (
|
|
|
|
|
<span className="pointer-events-none absolute bottom-2 right-2 inline-flex h-7 w-7 items-center justify-center rounded-full bg-background/80 text-foreground/70 backdrop-blur-sm">
|
|
|
|
|
<PlayIcon className="h-3.5 w-3.5 fill-current" />
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
<VisualTile className={cn("block h-full w-full", className)} onPreview={onPreview} overlayLabel={overlayLabel}>
|
|
|
|
|
{item.kind === "video" ? (
|
|
|
|
|
<>
|
|
|
|
|
<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">
|
|
|
|
|
<PlayIcon className="h-3.5 w-3.5 fill-current" />
|
|
|
|
|
</VideoPlayBadge>
|
|
|
|
|
)}
|
|
|
|
|
</>
|
|
|
|
|
) : item.kind === "motion" && motionPreviewProps ? (
|
|
|
|
|
<MotionPhotoPreview
|
|
|
|
|
posterUrl={item.posterUrl}
|
|
|
|
|
motionUrl={motionPreviewProps.motionUrl}
|
|
|
|
|
alt={item.filename}
|
|
|
|
|
presentationTimestampUs={motionPreviewProps.presentationTimestampUs}
|
|
|
|
|
containerClassName="h-full w-full"
|
|
|
|
|
badgeClassName="left-2 top-2 px-2 py-0.5 text-[10px]"
|
|
|
|
|
mediaClassName={COVER_MEDIA_CLASS}
|
|
|
|
|
/>
|
|
|
|
|
) : (
|
|
|
|
|
<img src={item.posterUrl} alt={item.filename} className={COVER_MEDIA_CLASS} loading="lazy" decoding="async" />
|
|
|
|
|
)}
|
|
|
|
|
</VisualTile>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const SingleVisualItem = ({ item, onPreview }: { item: VisualItem; onPreview?: () => void }) => {
|
|
|
|
|
const motionPreviewProps = item.kind === "motion" ? getMotionPreviewProps(item) : undefined;
|
|
|
|
|
|
|
|
|
|
if (item.kind === "image") {
|
|
|
|
|
return (
|
|
|
|
|
<VisualTile className="inline-block max-w-full" onPreview={onPreview}>
|
|
|
|
|
<img src={item.posterUrl} alt={item.filename} className={NATURAL_MEDIA_CLASS} loading="lazy" decoding="async" />
|
|
|
|
|
</VisualTile>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (item.kind === "motion" && motionPreviewProps) {
|
|
|
|
|
return (
|
|
|
|
|
<VisualTile className="inline-block max-w-full" onPreview={onPreview}>
|
|
|
|
|
<MotionPhotoPreview
|
|
|
|
|
posterUrl={item.posterUrl}
|
|
|
|
|
motionUrl={motionPreviewProps.motionUrl}
|
|
|
|
|
alt={item.filename}
|
|
|
|
|
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]"
|
|
|
|
|
badgeClassName="left-2 top-2 px-2 py-0.5 text-[10px]"
|
|
|
|
|
/>
|
|
|
|
|
</VisualTile>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<VisualTile className={cn("block", SINGLE_VIDEO_CARD_WIDTH_CLASS)} onPreview={onPreview}>
|
|
|
|
|
<div className="relative aspect-video bg-black/5">
|
|
|
|
|
<video src={item.sourceUrl} poster={item.posterUrl} className={COVER_MEDIA_CLASS} preload="metadata" />
|
|
|
|
|
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/35 via-black/5 to-transparent" />
|
|
|
|
|
<VideoPlayBadge className="bottom-3 right-3 h-9 w-9">
|
|
|
|
|
<PlayIcon className="h-4 w-4 fill-current" />
|
|
|
|
|
</VideoPlayBadge>
|
|
|
|
|
</div>
|
|
|
|
|
</button>
|
|
|
|
|
</VisualTile>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const VisualGallery = ({
|
|
|
|
|
items,
|
|
|
|
|
onPreview,
|
|
|
|
|
}: {
|
|
|
|
|
items: ReturnType<typeof buildAttachmentVisualItems>;
|
|
|
|
|
onPreview?: (itemId: string) => void;
|
|
|
|
|
}) => {
|
|
|
|
|
const VisualGallery = ({ items, onPreview }: { items: VisualItem[]; onPreview?: (itemId: string) => void }) => {
|
|
|
|
|
if (items.length === 0) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (items.length === 1) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex">
|
|
|
|
|
<MotionItem item={items[0]} featured onPreview={() => onPreview?.(items[0].id)} />
|
|
|
|
|
<div className="w-full">
|
|
|
|
|
<SingleVisualItem item={items[0]} onPreview={() => onPreview?.(items[0].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="grid max-w-[22rem] grid-cols-2 gap-1.5 sm:max-w-[24rem]">
|
|
|
|
|
{items.map((item) => (
|
|
|
|
|
<MotionItem key={item.id} item={item} onPreview={() => onPreview?.(item.id)} />
|
|
|
|
|
<div className={cn("grid grid-cols-2 grid-rows-2 gap-2", MOSAIC_GRID_HEIGHT_CLASS)}>
|
|
|
|
|
{visibleItems.map((item, index) => (
|
|
|
|
|
<CollageVisualItem
|
|
|
|
|
key={item.id}
|
|
|
|
|
item={item}
|
|
|
|
|
overlayLabel={index === visibleItems.length - 1 && remainingCount > 0 ? `+${remainingCount}` : undefined}
|
|
|
|
|
onPreview={() => onPreview?.(item.id)}
|
|
|
|
|
/>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const AudioList = ({ attachments }: { attachments: Attachment[] }) => (
|
|
|
|
|
<div className="flex flex-col gap-2">
|
|
|
|
|
const AudioList = ({ attachments, compact = false }: { attachments: Attachment[]; compact?: boolean }) => (
|
|
|
|
|
<div className={cn("gap-2", compact ? "grid grid-cols-1 sm:grid-cols-2" : "flex flex-col")}>
|
|
|
|
|
{attachments.map((attachment) => (
|
|
|
|
|
<AudioAttachmentItem
|
|
|
|
|
key={attachment.name}
|
|
|
|
|
@ -140,6 +238,7 @@ const AudioList = ({ attachments }: { attachments: Attachment[] }) => (
|
|
|
|
|
sourceUrl={getAttachmentUrl(attachment)}
|
|
|
|
|
mimeType={attachment.type}
|
|
|
|
|
size={Number(attachment.size)}
|
|
|
|
|
compact={compact}
|
|
|
|
|
/>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
@ -164,7 +263,7 @@ const AttachmentListView = ({ attachments, onImagePreview }: AttachmentListViewP
|
|
|
|
|
const hasVisual = visualItems.length > 0;
|
|
|
|
|
const hasAudio = audio.length > 0;
|
|
|
|
|
const hasDocs = docs.length > 0;
|
|
|
|
|
const sectionCount = [hasVisual, hasAudio, hasDocs].filter(Boolean).length;
|
|
|
|
|
const hasMedia = hasVisual || hasAudio;
|
|
|
|
|
|
|
|
|
|
if (attachments.length === 0) {
|
|
|
|
|
return null;
|
|
|
|
|
@ -182,10 +281,13 @@ const AttachmentListView = ({ attachments, onImagePreview }: AttachmentListViewP
|
|
|
|
|
count={visualItems.length + audio.length + docs.length}
|
|
|
|
|
contentClassName="flex flex-col gap-2 p-2"
|
|
|
|
|
>
|
|
|
|
|
{hasVisual && <VisualGallery items={visualItems} onPreview={handlePreview} />}
|
|
|
|
|
{hasVisual && sectionCount > 1 && <Divider />}
|
|
|
|
|
{hasAudio && <AudioList attachments={audio.filter(isAudioAttachment)} />}
|
|
|
|
|
{hasAudio && hasDocs && <Divider />}
|
|
|
|
|
{hasMedia && (
|
|
|
|
|
<div className="flex flex-col gap-2">
|
|
|
|
|
{hasVisual && <VisualGallery items={visualItems} onPreview={handlePreview} />}
|
|
|
|
|
{hasAudio && <AudioList attachments={audio.filter(isAudioAttachment)} compact />}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
{hasMedia && hasDocs && <Divider />}
|
|
|
|
|
{hasDocs && <DocsList attachments={docs} />}
|
|
|
|
|
</MetadataSection>
|
|
|
|
|
);
|
|
|
|
|
|