From 313f913ba418d4777159aa55b24044fcecbab199 Mon Sep 17 00:00:00 2001 From: Henry Date: Thu, 13 Nov 2025 21:25:29 +0000 Subject: [PATCH] feat: add thumbnails and attachment viewer --- apps/web/next.config.js | 61 +++-- .../card/components/AttachmentThumbnails.tsx | 227 ++++++++++++++++++ apps/web/src/views/card/index.tsx | 16 +- packages/api/src/routers/card.ts | 55 ++++- packages/db/src/repository/card.repo.ts | 8 + 5 files changed, 345 insertions(+), 22 deletions(-) create mode 100644 apps/web/src/views/card/components/AttachmentThumbnails.tsx diff --git a/apps/web/next.config.js b/apps/web/next.config.js index c8bfa424..8b81dfcd 100644 --- a/apps/web/next.config.js +++ b/apps/web/next.config.js @@ -30,23 +30,50 @@ const config = { typescript: { ignoreBuildErrors: true }, images: { - remotePatterns: [ - { - protocol: "https", - hostname: - env("S3_FORCE_PATH_STYLE") === "true" - ? `${env("NEXT_PUBLIC_STORAGE_DOMAIN")}` - : `*.${env("NEXT_PUBLIC_STORAGE_DOMAIN")}`, - }, - { - protocol: "http", - hostname: "localhost", - }, - { - protocol: "https", - hostname: "*.googleusercontent.com", - }, - ], + remotePatterns: (() => { + /** @type {Array<{protocol: "http" | "https", hostname: string}>} */ + const patterns = [ + { + protocol: "https", + hostname: + env("S3_FORCE_PATH_STYLE") === "true" + ? `${env("NEXT_PUBLIC_STORAGE_DOMAIN")}` + : `*.${env("NEXT_PUBLIC_STORAGE_DOMAIN")}`, + }, + { + protocol: "http", + hostname: "localhost", + }, + { + protocol: "https", + hostname: "*.googleusercontent.com", + }, + ]; + + // Extract root domain from S3_ENDPOINT and add wildcard pattern + const s3Endpoint = env("S3_ENDPOINT"); + if (s3Endpoint) { + try { + const url = new URL(s3Endpoint); + const hostname = url.hostname; + const protocol = url.protocol.replace(":", ""); + + // Extract root domain (last 2 parts: e.g. cloudflarestorage.com) + const parts = hostname.split("."); + if (parts.length >= 2) { + const rootDomain = parts.slice(-2).join("."); + patterns.push({ + protocol: protocol === "http" ? "http" : "https", + hostname: `*.${rootDomain}`, + }); + } + } catch { + // If S3_ENDPOINT is not a valid URL, ignore it + } + } + + return patterns; + })(), }, webpack(config) { config.module.rules.push({ diff --git a/apps/web/src/views/card/components/AttachmentThumbnails.tsx b/apps/web/src/views/card/components/AttachmentThumbnails.tsx new file mode 100644 index 00000000..34ae1dbf --- /dev/null +++ b/apps/web/src/views/card/components/AttachmentThumbnails.tsx @@ -0,0 +1,227 @@ +import Image from "next/image"; +import { Dialog, Transition } from "@headlessui/react"; +import { Fragment, useEffect, useState } from "react"; +import { HiChevronLeft, HiChevronRight, HiXMark } from "react-icons/hi2"; + +interface Attachment { + publicId: string; + contentType: string; + url: string | null; + originalFilename: string | null; + s3Key: string; +} + +export function AttachmentThumbnails({ + attachments, +}: { + attachments?: Attachment[]; +}) { + const imageAttachments = + attachments?.filter( + (attachment) => + attachment.contentType.startsWith("image/") && attachment.url, + ) ?? []; + + const [selectedIndex, setSelectedIndex] = useState(null); + + // 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) { + 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 selectedAttachment = + selectedIndex !== null ? imageAttachments[selectedIndex] : null; + + return ( + <> +
+ {imageAttachments.map((attachment, index) => { + if (!attachment.url) return null; + return ( + openViewer(index)} + /> + ); + })} +
+ + + {}} static> + +
+ + +
+
+ {imageAttachments.length > 1 && selectedIndex !== null && ( + + )} + + {imageAttachments.length > 1 && selectedIndex !== null && ( + + )} + + {selectedIndex !== null && ( + + )} +
+ +
+ + e.stopPropagation()} + > + {selectedAttachment?.url && ( +
+
+ { +
+ + {imageAttachments.length > 1 && ( +
+ {selectedIndex !== null && selectedIndex + 1} /{" "} + {imageAttachments.length} +
+ )} +
+ )} +
+
+
+
+
+
+ + ); +} + +function AttachmentThumbnail({ + attachment, + onClick, +}: { + attachment: { publicId: string; url: string; originalFilename: string }; + onClick: () => void; +}) { + return ( + + ); +} diff --git a/apps/web/src/views/card/index.tsx b/apps/web/src/views/card/index.tsx index eecb88ff..5445d1a9 100644 --- a/apps/web/src/views/card/index.tsx +++ b/apps/web/src/views/card/index.tsx @@ -20,6 +20,7 @@ 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"; @@ -288,9 +289,18 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) { setActiveChecklistForm={setActiveChecklistForm} /> {!isTemplate && ( -
- -
+ <> + {card?.attachments.length > 0 && ( +
+ +
+ )} +
+ +
+ )}

diff --git a/packages/api/src/routers/card.ts b/packages/api/src/routers/card.ts index 443aabc9..418457b1 100644 --- a/packages/api/src/routers/card.ts +++ b/packages/api/src/routers/card.ts @@ -10,6 +10,7 @@ import * as workspaceRepo from "@kan/db/repository/workspace.repo"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc"; import { assertUserInWorkspace } from "../utils/auth"; +import { generateDownloadUrl } from "../utils/s3"; export const cardRouter = createTRPCRouter({ create: protectedProcedure @@ -574,7 +575,20 @@ export const cardRouter = createTRPCRouter({ .input(z.object({ cardPublicId: z.string().min(12) })) .output( z.custom< - Awaited> + Omit< + NonNullable< + Awaited> + >, + "attachments" + > & { + attachments: { + publicId: string; + contentType: string; + s3Key: string; + originalFilename: string | null; + url: string | null; + }[]; + } >(), ) .query(async ({ ctx, input }) => { @@ -612,7 +626,44 @@ export const cardRouter = createTRPCRouter({ code: "NOT_FOUND", }); - return result; + // Generate URLs for all attachments + const bucket = process.env.NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME; + if (result.attachments && Array.isArray(result.attachments)) { + const attachments = result.attachments as { + publicId: string; + contentType: string; + s3Key: string; + originalFilename: string | null; + }[]; + + const attachmentsWithUrls = await Promise.all( + attachments.map(async (attachment) => { + const base = { + publicId: attachment.publicId, + contentType: attachment.contentType, + s3Key: attachment.s3Key, + originalFilename: attachment.originalFilename, + }; + if (!bucket || !attachment.s3Key) { + return { ...base, url: null }; + } + try { + const url = await generateDownloadUrl( + bucket, + attachment.s3Key, + 3600, // 1 hour expiration + ); + return { ...base, url }; + } catch { + // If URL generation fails, return attachment with url: null + return { ...base, url: null }; + } + }), + ); + return { ...result, attachments: attachmentsWithUrls }; + } + + return { ...result, attachments: [] }; }), update: protectedProcedure .meta({ diff --git a/packages/db/src/repository/card.repo.ts b/packages/db/src/repository/card.repo.ts index ffe9f054..3293ffa9 100644 --- a/packages/db/src/repository/card.repo.ts +++ b/packages/db/src/repository/card.repo.ts @@ -409,6 +409,14 @@ export const getWithListAndMembersByPublicId = async ( }, }, }, + attachments: { + columns: { + publicId: true, + contentType: true, + s3Key: true, + originalFilename: true, + }, + }, checklists: { columns: { publicId: true,