* feat: paginate and merge frequent activities * feat: due date activities * fix: requested changes fixes * refactor: slight reshuffle and improve types * chore: translations --------- Co-authored-by: Henry <henry_ball@hotmail.co.uk>
464 lines
16 KiB
TypeScript
464 lines
16 KiB
TypeScript
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";
|
|
import { invalidateCard } from "~/utils/cardInvalidation";
|
|
|
|
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<number | null>(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 invalidateCard(utils, 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 (
|
|
<>
|
|
<div className="mb-3 flex flex-wrap gap-2 pt-1">
|
|
{imageAttachments.map((attachment, index) => {
|
|
if (!attachment.url) return null;
|
|
return (
|
|
<AttachmentThumbnail
|
|
key={attachment.publicId}
|
|
attachment={{
|
|
publicId: attachment.publicId,
|
|
url: attachment.url,
|
|
originalFilename: attachment.originalFilename ?? "",
|
|
contentType: attachment.contentType,
|
|
}}
|
|
onClick={() => openViewer(index)}
|
|
isImage={true}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{nonImageAttachments.length > 0 && (
|
|
<div className="mb-3 flex flex-col gap-2">
|
|
{nonImageAttachments.map((attachment) => {
|
|
if (!attachment.url) return null;
|
|
return (
|
|
<FileListItem
|
|
key={attachment.publicId}
|
|
attachment={attachment}
|
|
onDownload={() => handleDownload(attachment)}
|
|
onDelete={
|
|
isReadOnly
|
|
? undefined
|
|
: () => {
|
|
deleteAttachment.mutate({
|
|
attachmentPublicId: attachment.publicId,
|
|
});
|
|
}
|
|
}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
<Transition.Root show={selectedIndex !== null} as={Fragment}>
|
|
<Dialog
|
|
as="div"
|
|
className="relative z-50"
|
|
onClose={() => {
|
|
// Dialog closing is handled by the background overlay click
|
|
}}
|
|
static
|
|
>
|
|
<Transition.Child
|
|
as={Fragment}
|
|
enter="ease-out duration-300"
|
|
enterFrom="opacity-0"
|
|
enterTo="opacity-100"
|
|
leave="ease-in duration-200"
|
|
leaveFrom="opacity-100"
|
|
leaveTo="opacity-0"
|
|
>
|
|
<div
|
|
className="fixed inset-0 bg-light-50 transition-opacity dark:bg-dark-50"
|
|
onClick={(e) => {
|
|
// Only close if clicking directly on the background, not on buttons
|
|
if (e.target === e.currentTarget) {
|
|
closeViewer();
|
|
}
|
|
}}
|
|
/>
|
|
</Transition.Child>
|
|
|
|
<div className="fixed inset-0 z-10 overflow-y-auto">
|
|
{selectedIndex !== null && selectedAttachment && (
|
|
<div
|
|
className="fixed left-2 top-2 z-20 flex gap-1"
|
|
onMouseDown={(e) => e.stopPropagation()}
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{!isReadOnly && (
|
|
<button
|
|
onMouseDown={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
}}
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
deleteAttachment.mutate({
|
|
attachmentPublicId: selectedAttachment.publicId,
|
|
});
|
|
}}
|
|
className="rounded-full bg-light-50 p-1.5 text-light-1000 transition-colors hover:bg-light-100 focus:outline-none dark:bg-dark-50 dark:text-dark-1000 dark:hover:bg-dark-100"
|
|
aria-label="Delete image"
|
|
disabled={deleteAttachment.isPending}
|
|
>
|
|
<HiOutlineTrash className="h-4 w-4" />
|
|
</button>
|
|
)}
|
|
<button
|
|
onMouseDown={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
}}
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
handleDownload(selectedAttachment);
|
|
}}
|
|
className="rounded-full bg-light-50 p-1.5 text-light-1000 transition-colors hover:bg-light-100 focus:outline-none dark:bg-dark-50 dark:text-dark-1000 dark:hover:bg-dark-100"
|
|
aria-label="Download image"
|
|
>
|
|
<HiArrowDownTray className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
<div className="fixed right-2 top-2 z-20 flex gap-1">
|
|
{imageAttachments.length > 1 && selectedIndex !== null && (
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
goToPrevious();
|
|
}}
|
|
className="rounded-full bg-light-50 p-1.5 text-light-1000 transition-colors hover:bg-light-100 focus:outline-none dark:bg-dark-50 dark:text-dark-1000 dark:hover:bg-dark-100"
|
|
aria-label="Previous image"
|
|
>
|
|
<HiChevronLeft className="h-4 w-4" />
|
|
</button>
|
|
)}
|
|
|
|
{imageAttachments.length > 1 && selectedIndex !== null && (
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
goToNext();
|
|
}}
|
|
className="rounded-full bg-light-50 p-1.5 text-light-1000 transition-colors hover:bg-light-100 focus:outline-none dark:bg-dark-50 dark:text-dark-1000 dark:hover:bg-dark-100"
|
|
aria-label="Next image"
|
|
>
|
|
<HiChevronRight className="h-4 w-4" />
|
|
</button>
|
|
)}
|
|
|
|
{selectedIndex !== null && (
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
closeViewer();
|
|
}}
|
|
className="rounded-full bg-light-50 p-1.5 text-light-1000 transition-colors hover:bg-light-100 focus:outline-none dark:bg-dark-50 dark:text-dark-1000 dark:hover:bg-dark-100"
|
|
aria-label="Close"
|
|
>
|
|
<HiXMark className="h-4 w-4" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex min-h-full items-center justify-center p-4">
|
|
<Transition.Child
|
|
as={Fragment}
|
|
enter="ease-out duration-300"
|
|
enterFrom="opacity-0 scale-95"
|
|
enterTo="opacity-100 scale-100"
|
|
leave="ease-in duration-200"
|
|
leaveFrom="opacity-100 scale-100"
|
|
leaveTo="opacity-0 scale-95"
|
|
>
|
|
<Dialog.Panel
|
|
className="relative w-full max-w-7xl"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{selectedAttachment?.url && (
|
|
<div className="relative">
|
|
<div className="relative mx-auto max-h-[90vh] w-full">
|
|
<Image
|
|
src={selectedAttachment.url}
|
|
alt={
|
|
selectedAttachment.originalFilename ?? "Attachment"
|
|
}
|
|
width={1920}
|
|
height={1080}
|
|
className="mx-auto max-h-[90vh] w-auto object-contain"
|
|
unoptimized
|
|
/>
|
|
</div>
|
|
|
|
{imageAttachments.length > 1 && (
|
|
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 rounded-full bg-black/50 px-3 py-1.5 text-[10px] text-white">
|
|
{selectedIndex !== null && selectedIndex + 1} /{" "}
|
|
{imageAttachments.length}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</Dialog.Panel>
|
|
</Transition.Child>
|
|
</div>
|
|
</div>
|
|
</Dialog>
|
|
</Transition.Root>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function AttachmentThumbnail({
|
|
attachment,
|
|
onClick,
|
|
isImage,
|
|
}: {
|
|
attachment: {
|
|
publicId: string;
|
|
url: string;
|
|
originalFilename: string;
|
|
contentType: string;
|
|
};
|
|
onClick: () => void;
|
|
isImage: boolean;
|
|
}) {
|
|
return (
|
|
<button
|
|
onClick={onClick}
|
|
className="relative h-16 w-16 overflow-hidden rounded-xl border border-light-300 transition-transform hover:scale-105 dark:border-dark-300"
|
|
aria-label={`View ${attachment.originalFilename}`}
|
|
>
|
|
{isImage ? (
|
|
<Image
|
|
src={attachment.url}
|
|
alt={attachment.originalFilename}
|
|
fill
|
|
className="object-cover"
|
|
sizes="64px"
|
|
/>
|
|
) : (
|
|
<div className="flex h-full w-full items-center justify-center bg-light-100 dark:bg-dark-100">
|
|
<HiDocumentText className="h-6 w-6 text-light-700 dark:text-dark-700" />
|
|
</div>
|
|
)}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div className="group flex w-full items-center gap-3 rounded-lg border border-light-300 bg-light-50 px-3 py-2 dark:border-dark-200 dark:bg-dark-100">
|
|
<div className="flex-shrink-0">
|
|
<HiDocumentText className="h-5 w-5 text-light-700 dark:text-dark-700" />
|
|
</div>
|
|
<div className="min-w-0 flex-1 truncate text-sm text-light-1000 dark:text-dark-1000">
|
|
{attachment.originalFilename ?? "File"}
|
|
</div>
|
|
<div className="flex items-center gap-2 opacity-0 transition-opacity group-hover:opacity-100">
|
|
<div className="text-xs text-light-500 dark:text-dark-900">
|
|
{attachment.size != null &&
|
|
!isNaN(attachment.size) &&
|
|
`${formatFileSize(attachment.size)}`}
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onDownload();
|
|
}}
|
|
className="flex-shrink-0 rounded-full bg-light-100 p-1.5 text-light-1000 transition-colors hover:bg-light-200 focus:outline-none dark:bg-dark-100 dark:text-dark-950 dark:hover:bg-dark-300"
|
|
aria-label={`Download ${attachment.originalFilename}`}
|
|
>
|
|
<HiArrowDownTray className="h-4 w-4" />
|
|
</button>
|
|
{onDelete && (
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onDelete();
|
|
}}
|
|
className="flex-shrink-0 rounded-full bg-light-100 p-1.5 text-light-1000 transition-colors hover:bg-light-200 focus:outline-none dark:bg-dark-100 dark:text-dark-950 dark:hover:bg-dark-300"
|
|
aria-label={`Delete ${attachment.originalFilename}`}
|
|
>
|
|
<HiXMark className="h-4 w-4" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|