feat: add file viewer and downloads

This commit is contained in:
Henry
2025-11-14 22:15:28 +00:00
parent 070fc373f2
commit f495ca3505
9 changed files with 286 additions and 35 deletions

View File

@@ -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" });
}
}

View File

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

View File

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

View File

@@ -6,6 +6,7 @@ import {
HiArrowDownTray, HiArrowDownTray,
HiChevronLeft, HiChevronLeft,
HiChevronRight, HiChevronRight,
HiDocumentText,
HiOutlineTrash, HiOutlineTrash,
HiXMark, HiXMark,
} from "react-icons/hi2"; } from "react-icons/hi2";
@@ -19,6 +20,7 @@ interface Attachment {
url: string | null; url: string | null;
originalFilename: string | null; originalFilename: string | null;
s3Key: string; s3Key: string;
size?: number | null;
} }
export function AttachmentThumbnails({ export function AttachmentThumbnails({
@@ -36,6 +38,12 @@ export function AttachmentThumbnails({
attachment.contentType.startsWith("image/") && attachment.url, 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 [selectedIndex, setSelectedIndex] = useState<number | null>(null);
const deleteAttachment = api.attachment.delete.useMutation({ const deleteAttachment = api.attachment.delete.useMutation({
@@ -94,7 +102,7 @@ export function AttachmentThumbnails({
return () => window.removeEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown);
}, [selectedIndex, imageAttachments.length]); }, [selectedIndex, imageAttachments.length]);
if (imageAttachments.length === 0) { if (imageAttachments.length === 0 && nonImageAttachments.length === 0) {
return null; return null;
} }
@@ -130,9 +138,13 @@ export function AttachmentThumbnails({
return; return;
} }
// Open the URL directly - the browser will handle the download const downloadUrl = `/api/download/attatchment?url=${encodeURIComponent(attachment.url)}&filename=${encodeURIComponent(attachment.originalFilename ?? "attachment")}`;
// if the S3 object has Content-Disposition: attachment header
window.open(attachment.url, "_blank", "noopener,noreferrer"); const link = document.createElement("a");
link.href = downloadUrl;
link.style.display = "none";
document.body.appendChild(link);
link.click();
}; };
const selectedAttachment = const selectedAttachment =
@@ -150,13 +162,35 @@ export function AttachmentThumbnails({
publicId: attachment.publicId, publicId: attachment.publicId,
url: attachment.url, url: attachment.url,
originalFilename: attachment.originalFilename ?? "", originalFilename: attachment.originalFilename ?? "",
contentType: attachment.contentType,
}} }}
onClick={() => openViewer(index)} onClick={() => openViewer(index)}
isImage={true}
/> />
); );
})} })}
</div> </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={() => {
deleteAttachment.mutate({
attachmentPublicId: attachment.publicId,
});
}}
/>
);
})}
</div>
)}
<Transition.Root show={selectedIndex !== null} as={Fragment}> <Transition.Root show={selectedIndex !== null} as={Fragment}>
<Dialog <Dialog
as="div" as="div"
@@ -322,9 +356,16 @@ export function AttachmentThumbnails({
function AttachmentThumbnail({ function AttachmentThumbnail({
attachment, attachment,
onClick, onClick,
isImage,
}: { }: {
attachment: { publicId: string; url: string; originalFilename: string }; attachment: {
publicId: string;
url: string;
originalFilename: string;
contentType: string;
};
onClick: () => void; onClick: () => void;
isImage: boolean;
}) { }) {
return ( return (
<button <button
@@ -332,13 +373,77 @@ function AttachmentThumbnail({
className="relative h-16 w-16 overflow-hidden rounded-xl border border-light-300 transition-transform hover:scale-105 dark:border-dark-300" 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}`} aria-label={`View ${attachment.originalFilename}`}
> >
<Image {isImage ? (
src={attachment.url} <Image
alt={attachment.originalFilename} src={attachment.url}
fill alt={attachment.originalFilename}
className="object-cover" fill
sizes="64px" 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> </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>
<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

@@ -1,15 +1,20 @@
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import { HiOutlinePaperClip } from "react-icons/hi"; import { HiOutlinePaperClip } from "react-icons/hi";
import { HiCheckBadge } from "react-icons/hi2";
import { twMerge } from "tailwind-merge";
import Button from "~/components/Button"; import Button from "~/components/Button";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup"; import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api"; import { api } from "~/utils/api";
export function AttachmentUpload({ cardPublicId }: { cardPublicId: string }) { export function AttachmentUpload({ cardPublicId }: { cardPublicId: string }) {
const { openModal } = useModal();
const { showPopup } = usePopup(); const { showPopup } = usePopup();
const utils = api.useUtils(); const utils = api.useUtils();
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const inputRef = useRef<HTMLInputElement | null>(null); const inputRef = useRef<HTMLInputElement | null>(null);
const generateUploadUrl = api.attachment.generateUploadUrl.useMutation(); const generateUploadUrl = api.attachment.generateUploadUrl.useMutation();
@@ -34,15 +39,7 @@ export function AttachmentUpload({ cardPublicId }: { cardPublicId: string }) {
}, },
}); });
const handleFileSelect = async ( const uploadFile = async (file: File) => {
event: React.ChangeEvent<HTMLInputElement>,
) => {
const file = event.target.files?.[0];
if (!file) return;
// Reset input
event.target.value = "";
setUploading(true); setUploading(true);
try { try {
@@ -86,6 +83,46 @@ export function AttachmentUpload({ cardPublicId }: { cardPublicId: string }) {
} }
}; };
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 ( return (
<div className="mb-6"> <div className="mb-6">
<input <input
@@ -96,17 +133,41 @@ export function AttachmentUpload({ cardPublicId }: { cardPublicId: string }) {
onChange={handleFileSelect} onChange={handleFileSelect}
disabled={uploading} disabled={uploading}
/> />
<div> <div
<Button onDragOver={handleDragOver}
type="button" onDragLeave={handleDragLeave}
variant="ghost" onDrop={handleDrop}
iconLeft={<HiOutlinePaperClip className="h-4 w-4" />} className={twMerge(
isLoading={uploading} "rounded-lg border-2 border-dashed transition-colors",
disabled={uploading} isDragging
iconOnly ? "border-light-300 bg-light-100 dark:border-dark-300 dark:bg-dark-100"
size="sm" : "border-transparent",
onClick={() => inputRef.current?.click()} )}
/> >
<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>
</div> </div>
); );

View File

@@ -197,7 +197,11 @@ export const attachmentRouter = createTRPCRouter({
code: "INTERNAL_SERVER_ERROR", 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 }; return { url, filename: attachment.originalFilename };
}), }),

View File

@@ -586,6 +586,7 @@ export const cardRouter = createTRPCRouter({
contentType: string; contentType: string;
s3Key: string; s3Key: string;
originalFilename: string | null; originalFilename: string | null;
size?: number | null;
url: string | null; url: string | null;
}[]; }[];
} }
@@ -634,6 +635,7 @@ export const cardRouter = createTRPCRouter({
contentType: string; contentType: string;
s3Key: string; s3Key: string;
originalFilename: string | null; originalFilename: string | null;
size?: number | null;
}[]; }[];
const attachmentsWithUrls = await Promise.all( const attachmentsWithUrls = await Promise.all(
@@ -643,6 +645,7 @@ export const cardRouter = createTRPCRouter({
contentType: attachment.contentType, contentType: attachment.contentType,
s3Key: attachment.s3Key, s3Key: attachment.s3Key,
originalFilename: attachment.originalFilename, originalFilename: attachment.originalFilename,
size: attachment.size,
}; };
if (!bucket || !attachment.s3Key) { if (!bucket || !attachment.s3Key) {
return { ...base, url: null }; return { ...base, url: null };
@@ -651,7 +654,7 @@ export const cardRouter = createTRPCRouter({
const url = await generateDownloadUrl( const url = await generateDownloadUrl(
bucket, bucket,
attachment.s3Key, attachment.s3Key,
3600, // 1 hour expiration 86400, // 24 hours expiration
); );
return { ...base, url }; return { ...base, url };
} catch { } catch {

View File

@@ -5,6 +5,7 @@ import type { BoardVisibilityStatus } from "@kan/db/schema";
import { import {
boards, boards,
cardActivities, cardActivities,
cardAttachments,
cards, cards,
cardsToLabels, cardsToLabels,
cardToWorkspaceMembers, cardToWorkspaceMembers,
@@ -196,6 +197,13 @@ export const getByPublicId = async (
}, },
}, },
}, },
attachments: {
columns: {
publicId: true,
},
where: isNull(cardAttachments.deletedAt),
orderBy: asc(cardAttachments.createdAt),
},
checklists: { checklists: {
columns: { columns: {
publicId: true, publicId: true,
@@ -358,6 +366,13 @@ export const getBySlug = async (
}, },
}, },
}, },
attachments: {
columns: {
publicId: true,
},
where: isNull(cardAttachments.deletedAt),
orderBy: asc(cardAttachments.createdAt),
},
comments: { comments: {
columns: { columns: {
publicId: true, publicId: true,

View File

@@ -3,6 +3,7 @@ import { and, asc, desc, eq, gt, inArray, isNull, sql } from "drizzle-orm";
import type { dbClient } from "@kan/db/client"; import type { dbClient } from "@kan/db/client";
import { import {
cardActivities, cardActivities,
cardAttachments,
cards, cards,
cardsToLabels, cardsToLabels,
cardToWorkspaceMembers, cardToWorkspaceMembers,
@@ -415,7 +416,10 @@ export const getWithListAndMembersByPublicId = async (
contentType: true, contentType: true,
s3Key: true, s3Key: true,
originalFilename: true, originalFilename: true,
size: true,
}, },
where: isNull(cardAttachments.deletedAt),
orderBy: asc(cardAttachments.createdAt),
}, },
checklists: { checklists: {
columns: { columns: {