import Image from "next/image"; import { Dialog, Transition } from "@headlessui/react"; import { t } from "@lingui/core/macro"; import { Fragment, useEffect, useState } from "react"; import { HiArrowDownTray, HiChevronLeft, HiChevronRight, HiDocumentText, HiOutlineTrash, HiXMark, } from "react-icons/hi2"; import { usePopup } from "~/providers/popup"; import { api } from "~/utils/api"; interface Attachment { publicId: string; contentType: string; url: string | null; originalFilename: string | null; s3Key: string; size?: number | null; } export function AttachmentThumbnails({ attachments, cardPublicId, isReadOnly = false, }: { attachments?: Attachment[]; cardPublicId: string; isReadOnly?: boolean; }) { const { showPopup } = usePopup(); const utils = api.useUtils(); const imageAttachments = attachments?.filter( (attachment) => attachment.contentType.startsWith("image/") && attachment.url, ) ?? []; const nonImageAttachments = attachments?.filter( (attachment) => !attachment.contentType.startsWith("image/") && attachment.url, ) ?? []; const [selectedIndex, setSelectedIndex] = useState(null); const deleteAttachment = api.attachment.delete.useMutation({ onMutate: async (args) => { if (isReadOnly) return; await utils.card.byId.cancel({ cardPublicId }); const currentState = utils.card.byId.getData({ cardPublicId }); utils.card.byId.setData({ cardPublicId }, (oldCard) => { if (!oldCard) return oldCard; const updatedAttachments = oldCard.attachments.filter( (attachment) => attachment.publicId !== args.attachmentPublicId, ); return { ...oldCard, attachments: updatedAttachments }; }); return { previousState: currentState }; }, onError: (_error, _args, context) => { if (isReadOnly) return; utils.card.byId.setData({ cardPublicId }, context?.previousState); showPopup({ header: t`Unable to delete attachment`, message: t`Please try again later, or contact customer support.`, icon: "error", }); }, onSuccess: () => { if (isReadOnly) return; // Close viewer if the deleted image was being viewed setSelectedIndex(null); }, onSettled: async () => { if (isReadOnly) return; await utils.card.byId.invalidate({ cardPublicId }); }, }); // Keyboard navigation useEffect(() => { if (selectedIndex === null) return; const handleKeyDown = (e: KeyboardEvent) => { if (e.key === "Escape") { setSelectedIndex(null); } else if (e.key === "ArrowLeft") { setSelectedIndex((prev) => { if (prev === null) return null; return prev === 0 ? imageAttachments.length - 1 : prev - 1; }); } else if (e.key === "ArrowRight") { setSelectedIndex((prev) => { if (prev === null) return null; return prev === imageAttachments.length - 1 ? 0 : prev + 1; }); } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [selectedIndex, imageAttachments.length]); if (imageAttachments.length === 0 && nonImageAttachments.length === 0) { return null; } const openViewer = (index: number) => { setSelectedIndex(index); }; const closeViewer = () => { setSelectedIndex(null); }; const goToPrevious = () => { if (selectedIndex === null) return; const newIndex = selectedIndex === 0 ? imageAttachments.length - 1 : selectedIndex - 1; setSelectedIndex(newIndex); }; const goToNext = () => { if (selectedIndex === null) return; const newIndex = selectedIndex === imageAttachments.length - 1 ? 0 : selectedIndex + 1; setSelectedIndex(newIndex); }; const handleDownload = (attachment: Attachment) => { if (!attachment.url) { showPopup({ header: t`Download failed`, message: t`No download URL available for this attachment.`, icon: "error", }); return; } const downloadUrl = `/api/download/attatchment?url=${encodeURIComponent(attachment.url)}&filename=${encodeURIComponent(attachment.originalFilename ?? "attachment")}`; const link = document.createElement("a"); link.href = downloadUrl; link.style.display = "none"; document.body.appendChild(link); link.click(); }; const selectedAttachment = selectedIndex !== null ? imageAttachments[selectedIndex] : null; return ( <>
{imageAttachments.map((attachment, index) => { if (!attachment.url) return null; return ( openViewer(index)} isImage={true} /> ); })}
{nonImageAttachments.length > 0 && (
{nonImageAttachments.map((attachment) => { if (!attachment.url) return null; return ( handleDownload(attachment)} onDelete={ isReadOnly ? undefined : () => { deleteAttachment.mutate({ attachmentPublicId: attachment.publicId, }); } } /> ); })}
)} { // Dialog closing is handled by the background overlay click }} static >
{ // Only close if clicking directly on the background, not on buttons if (e.target === e.currentTarget) { closeViewer(); } }} />
{selectedIndex !== null && selectedAttachment && (
e.stopPropagation()} onClick={(e) => e.stopPropagation()} > {!isReadOnly && ( )}
)}
{imageAttachments.length > 1 && selectedIndex !== null && ( )} {imageAttachments.length > 1 && selectedIndex !== null && ( )} {selectedIndex !== null && ( )}
e.stopPropagation()} > {selectedAttachment?.url && (
{
{imageAttachments.length > 1 && (
{selectedIndex !== null && selectedIndex + 1} /{" "} {imageAttachments.length}
)}
)}
); } function AttachmentThumbnail({ attachment, onClick, isImage, }: { attachment: { publicId: string; url: string; originalFilename: string; contentType: string; }; onClick: () => void; isImage: boolean; }) { return ( ); } function formatFileSize(bytes: number | null | undefined): string { if (!bytes || bytes === 0 || isNaN(bytes)) return "0 B"; const k = 1024; const sizes = ["B", "KB", "MB", "GB"]; const i = Math.floor(Math.log(bytes) / Math.log(k)); return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i] ?? "B"}`; } function FileListItem({ attachment, onDownload, onDelete, }: { attachment: Attachment; onDownload: () => void; onDelete?: () => void; }) { return (
{attachment.originalFilename ?? "File"}
{attachment.size != null && !isNaN(attachment.size) && `${formatFileSize(attachment.size)}`}
{onDelete && ( )}
); }