From f495ca3505bdc780ef8f36c929b5e525e75e1d0a Mon Sep 17 00:00:00 2001 From: Henry Date: Fri, 14 Nov 2025 22:15:28 +0000 Subject: [PATCH] feat: add file viewer and downloads --- .../web/src/pages/api/download/attatchment.ts | 48 +++++++ apps/web/src/views/board/components/Card.tsx | 12 +- apps/web/src/views/board/index.tsx | 1 + .../card/components/AttachmentThumbnails.tsx | 129 ++++++++++++++++-- .../card/components/AttachmentUpload.tsx | 101 +++++++++++--- packages/api/src/routers/attachment.ts | 6 +- packages/api/src/routers/card.ts | 5 +- packages/db/src/repository/board.repo.ts | 15 ++ packages/db/src/repository/card.repo.ts | 4 + 9 files changed, 286 insertions(+), 35 deletions(-) create mode 100644 apps/web/src/pages/api/download/attatchment.ts diff --git a/apps/web/src/pages/api/download/attatchment.ts b/apps/web/src/pages/api/download/attatchment.ts new file mode 100644 index 00000000..2c82e54f --- /dev/null +++ b/apps/web/src/pages/api/download/attatchment.ts @@ -0,0 +1,48 @@ +import type { NextApiRequest, NextApiResponse } from "next"; + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse, +) { + if (req.method !== "GET") { + return res.status(405).json({ message: "Method not allowed" }); + } + + const { url, filename } = req.query; + + if (!url || typeof url !== "string") { + return res.status(400).json({ + message: "url parameter is required", + }); + } + + try { + const downloadUrl = decodeURIComponent(url); + const downloadFilename = + (typeof filename === "string" ? decodeURIComponent(filename) : null) ?? + "attachment"; + + const upstream = await fetch(downloadUrl); + + if (!upstream.ok) { + return res.status(upstream.status).json({ + message: "Failed to fetch attachment", + }); + } + + const contentType = + upstream.headers.get("Content-Type") ?? "application/octet-stream"; + + res.setHeader("Content-Type", contentType); + res.setHeader( + "Content-Disposition", + `attachment; filename="${downloadFilename}"`, + ); + + const buffer = await upstream.arrayBuffer(); + return res.send(Buffer.from(buffer)); + } catch (error) { + console.error("Error downloading attachment:", error); + return res.status(500).json({ message: "Failed to download attachment" }); + } +} diff --git a/apps/web/src/views/board/components/Card.tsx b/apps/web/src/views/board/components/Card.tsx index f09886d8..3577288b 100644 --- a/apps/web/src/views/board/components/Card.tsx +++ b/apps/web/src/views/board/components/Card.tsx @@ -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 (
@@ -55,7 +59,8 @@ const Card = ({ members.length || checklists.length > 0 || hasDescription || - comments.length > 0 ? ( + comments.length > 0 || + hasAttachments ? (
{labels.map((label) => ( @@ -77,6 +82,11 @@ const Card = ({
)} + {hasAttachments && ( +
+ +
+ )}
{checklists.length > 0 && ( diff --git a/apps/web/src/views/board/index.tsx b/apps/web/src/views/board/index.tsx index de529f30..1c471105 100644 --- a/apps/web/src/views/board/index.tsx +++ b/apps/web/src/views/board/index.tsx @@ -553,6 +553,7 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) { card.description ?? null } comments={card.comments ?? []} + attachments={card.attachments} /> )} diff --git a/apps/web/src/views/card/components/AttachmentThumbnails.tsx b/apps/web/src/views/card/components/AttachmentThumbnails.tsx index f7608a5f..2ee0afad 100644 --- a/apps/web/src/views/card/components/AttachmentThumbnails.tsx +++ b/apps/web/src/views/card/components/AttachmentThumbnails.tsx @@ -6,6 +6,7 @@ import { HiArrowDownTray, HiChevronLeft, HiChevronRight, + HiDocumentText, HiOutlineTrash, HiXMark, } from "react-icons/hi2"; @@ -19,6 +20,7 @@ interface Attachment { url: string | null; originalFilename: string | null; s3Key: string; + size?: number | null; } export function AttachmentThumbnails({ @@ -36,6 +38,12 @@ export function AttachmentThumbnails({ 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({ @@ -94,7 +102,7 @@ export function AttachmentThumbnails({ return () => window.removeEventListener("keydown", handleKeyDown); }, [selectedIndex, imageAttachments.length]); - if (imageAttachments.length === 0) { + if (imageAttachments.length === 0 && nonImageAttachments.length === 0) { return null; } @@ -130,9 +138,13 @@ export function AttachmentThumbnails({ return; } - // Open the URL directly - the browser will handle the download - // if the S3 object has Content-Disposition: attachment header - window.open(attachment.url, "_blank", "noopener,noreferrer"); + 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 = @@ -150,13 +162,35 @@ export function AttachmentThumbnails({ publicId: attachment.publicId, url: attachment.url, originalFilename: attachment.originalFilename ?? "", + contentType: attachment.contentType, }} onClick={() => openViewer(index)} + isImage={true} /> ); })}
+ {nonImageAttachments.length > 0 && ( +
+ {nonImageAttachments.map((attachment) => { + if (!attachment.url) return null; + return ( + handleDownload(attachment)} + onDelete={() => { + deleteAttachment.mutate({ + attachmentPublicId: attachment.publicId, + }); + }} + /> + ); + })} +
+ )} + 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)}`} +
+
+ + +
+
+
+ ); +} diff --git a/apps/web/src/views/card/components/AttachmentUpload.tsx b/apps/web/src/views/card/components/AttachmentUpload.tsx index ac76a81e..f073a31d 100644 --- a/apps/web/src/views/card/components/AttachmentUpload.tsx +++ b/apps/web/src/views/card/components/AttachmentUpload.tsx @@ -1,15 +1,20 @@ 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(null); const generateUploadUrl = api.attachment.generateUploadUrl.useMutation(); @@ -34,15 +39,7 @@ export function AttachmentUpload({ cardPublicId }: { cardPublicId: string }) { }, }); - const handleFileSelect = async ( - event: React.ChangeEvent, - ) => { - const file = event.target.files?.[0]; - if (!file) return; - - // Reset input - event.target.value = ""; - + const uploadFile = async (file: File) => { setUploading(true); try { @@ -86,6 +83,46 @@ export function AttachmentUpload({ cardPublicId }: { cardPublicId: string }) { } }; + const handleFileSelect = async ( + event: React.ChangeEvent, + ) => { + 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 (
-
-
); diff --git a/packages/api/src/routers/attachment.ts b/packages/api/src/routers/attachment.ts index 9fe27d23..6231119d 100644 --- a/packages/api/src/routers/attachment.ts +++ b/packages/api/src/routers/attachment.ts @@ -197,7 +197,11 @@ export const attachmentRouter = createTRPCRouter({ code: "INTERNAL_SERVER_ERROR", }); - const url = await generateDownloadUrl(bucket, attachment.s3Key, 3600); + const url = await generateDownloadUrl( + bucket, + attachment.s3Key, + 86400, // 24 hours expiration + ); return { url, filename: attachment.originalFilename }; }), diff --git a/packages/api/src/routers/card.ts b/packages/api/src/routers/card.ts index 418457b1..93d27f84 100644 --- a/packages/api/src/routers/card.ts +++ b/packages/api/src/routers/card.ts @@ -586,6 +586,7 @@ export const cardRouter = createTRPCRouter({ contentType: string; s3Key: string; originalFilename: string | null; + size?: number | null; url: string | null; }[]; } @@ -634,6 +635,7 @@ export const cardRouter = createTRPCRouter({ contentType: string; s3Key: string; originalFilename: string | null; + size?: number | null; }[]; const attachmentsWithUrls = await Promise.all( @@ -643,6 +645,7 @@ export const cardRouter = createTRPCRouter({ contentType: attachment.contentType, s3Key: attachment.s3Key, originalFilename: attachment.originalFilename, + size: attachment.size, }; if (!bucket || !attachment.s3Key) { return { ...base, url: null }; @@ -651,7 +654,7 @@ export const cardRouter = createTRPCRouter({ const url = await generateDownloadUrl( bucket, attachment.s3Key, - 3600, // 1 hour expiration + 86400, // 24 hours expiration ); return { ...base, url }; } catch { diff --git a/packages/db/src/repository/board.repo.ts b/packages/db/src/repository/board.repo.ts index 1fe80ae7..8507cb4c 100644 --- a/packages/db/src/repository/board.repo.ts +++ b/packages/db/src/repository/board.repo.ts @@ -5,6 +5,7 @@ import type { BoardVisibilityStatus } from "@kan/db/schema"; import { boards, cardActivities, + cardAttachments, cards, cardsToLabels, cardToWorkspaceMembers, @@ -196,6 +197,13 @@ export const getByPublicId = async ( }, }, }, + attachments: { + columns: { + publicId: true, + }, + where: isNull(cardAttachments.deletedAt), + orderBy: asc(cardAttachments.createdAt), + }, checklists: { columns: { publicId: true, @@ -358,6 +366,13 @@ export const getBySlug = async ( }, }, }, + attachments: { + columns: { + publicId: true, + }, + where: isNull(cardAttachments.deletedAt), + orderBy: asc(cardAttachments.createdAt), + }, comments: { columns: { publicId: true, diff --git a/packages/db/src/repository/card.repo.ts b/packages/db/src/repository/card.repo.ts index 3293ffa9..075687d2 100644 --- a/packages/db/src/repository/card.repo.ts +++ b/packages/db/src/repository/card.repo.ts @@ -3,6 +3,7 @@ import { and, asc, desc, eq, gt, inArray, isNull, sql } from "drizzle-orm"; import type { dbClient } from "@kan/db/client"; import { cardActivities, + cardAttachments, cards, cardsToLabels, cardToWorkspaceMembers, @@ -415,7 +416,10 @@ export const getWithListAndMembersByPublicId = async ( contentType: true, s3Key: true, originalFilename: true, + size: true, }, + where: isNull(cardAttachments.deletedAt), + orderBy: asc(cardAttachments.createdAt), }, checklists: { columns: {