feat: add file viewer and downloads
This commit is contained in:
48
apps/web/src/pages/api/download/attatchment.ts
Normal file
48
apps/web/src/pages/api/download/attatchment.ts
Normal 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" });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 };
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
Reference in New Issue
Block a user