feat: card attachments (#247)

* feat: add card attachment schema

* feat: setup s3 util funcs

* feat: add attachments router and repo funcs

* feat: add upload button

* feat: add thumbnails and attachment viewer

* feat: add download and delete button

* feat: add file viewer and downloads

* feat: update compose and readme

* chore: build lang

* feat: display attachments on public boards

* chore: update compiled translations
This commit is contained in:
Henry
2025-11-19 21:34:43 +00:00
committed by GitHub
parent af6490896a
commit b472c5ce93
45 changed files with 5120 additions and 581 deletions

View File

@@ -1,3 +1,4 @@
import { HiOutlinePaperClip } from "react-icons/hi";
import { HiBars3BottomLeft, HiChatBubbleLeft } from "react-icons/hi2";
import Avatar from "~/components/Avatar";
@@ -13,6 +14,7 @@ const Card = ({
checklists,
description,
comments,
attachments,
}: {
title: string;
labels: { name: string; colourCode: string | null }[];
@@ -33,6 +35,7 @@ const Card = ({
}[];
description: string | null;
comments: { publicId: string }[];
attachments?: { publicId: string }[];
}) => {
const completedItems = checklists.reduce((acc, checklist) => {
return acc + checklist.items.filter((item) => item.completed).length;
@@ -47,6 +50,7 @@ const Card = ({
const hasDescription =
description && description.replace(/<[^>]*>/g, "").trim().length > 0;
const hasAttachments = attachments && attachments.length > 0;
return (
<div className="flex flex-col rounded-md border border-light-200 bg-light-50 px-3 py-2 text-sm text-neutral-900 dark:border-dark-200 dark:bg-dark-200 dark:text-dark-1000 dark:hover:bg-dark-300">
@@ -55,7 +59,8 @@ const Card = ({
members.length ||
checklists.length > 0 ||
hasDescription ||
comments.length > 0 ? (
comments.length > 0 ||
hasAttachments ? (
<div className="mt-2 flex flex-col justify-end">
<div className="space-x-0.5">
{labels.map((label) => (
@@ -77,6 +82,11 @@ const Card = ({
<HiChatBubbleLeft className="h-4 w-4" />
</div>
)}
{hasAttachments && (
<div className="flex items-center gap-1 text-light-700 dark:text-dark-800">
<HiOutlinePaperClip className="h-4 w-4" />
</div>
)}
</div>
<div className="flex items-center justify-end gap-1">
{checklists.length > 0 && (

View File

@@ -553,6 +553,7 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
card.description ?? null
}
comments={card.comments ?? []}
attachments={card.attachments}
/>
</Link>
)}

View File

@@ -0,0 +1,462 @@
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<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 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 (
<>
<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>
);
}

View File

@@ -0,0 +1,174 @@
import { t } from "@lingui/core/macro";
import { useRef, useState } from "react";
import { HiOutlinePaperClip } from "react-icons/hi";
import { HiCheckBadge } from "react-icons/hi2";
import { twMerge } from "tailwind-merge";
import Button from "~/components/Button";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
export function AttachmentUpload({ cardPublicId }: { cardPublicId: string }) {
const { openModal } = useModal();
const { showPopup } = usePopup();
const utils = api.useUtils();
const [uploading, setUploading] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const inputRef = useRef<HTMLInputElement | null>(null);
const generateUploadUrl = api.attachment.generateUploadUrl.useMutation();
const confirmAttachment = api.attachment.confirm.useMutation({
onSuccess: async () => {
await utils.card.byId.invalidate({ cardPublicId });
showPopup({
header: t`Attachment uploaded`,
message: t`Your file has been uploaded successfully.`,
icon: "success",
});
},
onError: () => {
showPopup({
header: t`Upload failed`,
message: t`Failed to upload attachment. Please try again.`,
icon: "error",
});
},
onSettled: () => {
setUploading(false);
},
});
const uploadFile = async (file: File) => {
setUploading(true);
try {
// Generate presigned URL
const { url, key } = await generateUploadUrl.mutateAsync({
cardPublicId,
filename: file.name,
contentType: file.type,
size: file.size,
});
// Upload file to S3
const uploadResponse = await fetch(url, {
method: "PUT",
body: file,
headers: {
"Content-Type": file.type,
},
});
if (!uploadResponse.ok) {
throw new Error("Upload failed");
}
// Confirm attachment in database
await confirmAttachment.mutateAsync({
cardPublicId,
s3Key: key,
filename: file.name,
originalFilename: file.name,
contentType: file.type,
size: file.size,
});
} catch {
showPopup({
header: t`Upload failed`,
message: t`Failed to upload attachment. Please try again.`,
icon: "error",
});
setUploading(false);
}
};
const handleFileSelect = async (
event: React.ChangeEvent<HTMLInputElement>,
) => {
const file = event.target.files?.[0];
if (!file) return;
// Reset input
event.target.value = "";
await uploadFile(file);
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (!uploading) {
setIsDragging(true);
}
};
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
};
const handleDrop = async (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
if (uploading) return;
const files = Array.from(e.dataTransfer.files);
if (files.length === 0) return;
// Upload the first file (or could upload all files)
await uploadFile(files[0] ?? new File([], ""));
};
return (
<div className="mb-6">
<input
ref={inputRef}
type="file"
id="attachment-upload"
className="hidden"
onChange={handleFileSelect}
disabled={uploading}
/>
<div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={twMerge(
"rounded-lg border-2 border-dashed transition-colors",
isDragging
? "border-light-300 bg-light-100 dark:border-dark-300 dark:bg-dark-100"
: "border-transparent",
)}
>
<div className="flex items-center justify-between p-2">
<Button
type="button"
variant="ghost"
iconLeft={
<HiCheckBadge className="h-4 w-4 text-light-950 dark:text-dark-950" />
}
iconOnly
size="sm"
onClick={() => openModal("ADD_CHECKLIST")}
/>
<Button
type="button"
variant="ghost"
iconLeft={
<HiOutlinePaperClip className="h-4 w-4 text-light-950 dark:text-dark-950" />
}
isLoading={uploading}
disabled={uploading}
iconOnly
size="sm"
onClick={() => inputRef.current?.click()}
/>
</div>
</div>
</div>
);
}

View File

@@ -20,6 +20,8 @@ import { api } from "~/utils/api";
import { formatMemberDisplayName, getAvatarUrl } from "~/utils/helpers";
import { DeleteLabelConfirmation } from "../../components/DeleteLabelConfirmation";
import ActivityList from "./components/ActivityList";
import { AttachmentThumbnails } from "./components/AttachmentThumbnails";
import { AttachmentUpload } from "./components/AttachmentUpload";
import Checklists from "./components/Checklists";
import { DeleteCardConfirmation } from "./components/DeleteCardConfirmation";
import { DeleteChecklistConfirmation } from "./components/DeleteChecklistConfirmation";
@@ -286,6 +288,21 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
activeChecklistForm={activeChecklistForm}
setActiveChecklistForm={setActiveChecklistForm}
/>
{!isTemplate && (
<>
{card?.attachments.length > 0 && (
<div className="mt-6">
<AttachmentThumbnails
attachments={card.attachments}
cardPublicId={cardId ?? ""}
/>
</div>
)}
<div className="mt-6">
<AttachmentUpload cardPublicId={cardId} />
</div>
</>
)}
<div className="border-t-[1px] border-light-300 pt-12 dark:border-dark-300">
<h2 className="text-md pb-4 font-medium text-light-1000 dark:text-dark-1000">
{t`Activity`}

View File

@@ -9,6 +9,7 @@ import LabelIcon from "~/components/LabelIcon";
import { useModal } from "~/providers/modal";
import { api } from "~/utils/api";
import ActivityList from "~/views/card/components/ActivityList";
import { AttachmentThumbnails } from "~/views/card/components/AttachmentThumbnails";
import Checklists from "~/views/card/components/Checklists";
export function CardModal({
@@ -126,11 +127,24 @@ export function CardModal({
<Editor
content={data.description}
readOnly
workspaceMembers={data?.list.board.workspace.members ?? []}
workspaceMembers={
data?.list.board.workspace.members ?? []
}
/>
</div>
</div>
)}
{data?.attachments &&
data.attachments.length > 0 &&
cardPublicId && (
<div className="mb-10 max-w-2xl">
<AttachmentThumbnails
attachments={data.attachments}
cardPublicId={cardPublicId}
isReadOnly
/>
</div>
)}
{data?.checklists && data.checklists.length > 0 && (
<Checklists
checklists={data.checklists}

View File

@@ -189,6 +189,7 @@ export default function PublicBoardView() {
members={[]}
description={card.description}
comments={card.comments ?? []}
attachments={card.attachments}
/>
</Link>
))}