From 5345eeb033466e7ab1e979b8c8fb7ec9e5bb7c2a Mon Sep 17 00:00:00 2001 From: Florent Azonnoudo Date: Wed, 29 Apr 2026 17:31:23 +0000 Subject: [PATCH 1/4] feat: enable image uploads in comments with card public ID --- apps/web/package.json | 1 + apps/web/src/components/Editor.tsx | 82 ++++++++++++++++++- .../pages/api/media/[attachmentPublicId].ts | 50 +++++++++++ .../web/src/views/card/components/Comment.tsx | 1 + .../views/card/components/NewCommentForm.tsx | 1 + apps/web/src/views/card/index.tsx | 1 + pnpm-lock.yaml | 39 ++++++++- 7 files changed, 173 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/pages/api/media/[attachmentPublicId].ts diff --git a/apps/web/package.json b/apps/web/package.json index 14889ab27..8f9a30483 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -37,6 +37,7 @@ "@t3-oss/env-nextjs": "^0.11.1", "@tailwindcss/typography": "^0.5.16", "@tanstack/react-query": "catalog:", + "@tiptap/extension-image": "^2.14.0", "@tiptap/extension-link": "^2.22.2", "@tiptap/extension-mention": "^3.0.9", "@tiptap/extension-placeholder": "^2.14.0", diff --git a/apps/web/src/components/Editor.tsx b/apps/web/src/components/Editor.tsx index 0f576446f..77892bb93 100644 --- a/apps/web/src/components/Editor.tsx +++ b/apps/web/src/components/Editor.tsx @@ -7,6 +7,7 @@ import type { import type { Instance as TippyInstance } from "tippy.js"; import { Button } from "@headlessui/react"; import { t } from "@lingui/core/macro"; +import Image from "@tiptap/extension-image"; import Link from "@tiptap/extension-link"; import Mention from "@tiptap/extension-mention"; import Placeholder from "@tiptap/extension-placeholder"; @@ -44,10 +45,41 @@ import { twMerge } from "tailwind-merge"; import tippy from "tippy.js"; import { Markdown } from "tiptap-markdown"; +import { env } from "next-runtime-env"; + import { getAvatarUrl } from "~/utils/helpers"; import Avatar from "./Avatar"; import { YouTubeNode } from "./YouTubeEmbed/YouTubeNode"; +async function uploadImageFile( + file: File, + cardPublicId: string, +): Promise { + const baseUrl = env("NEXT_PUBLIC_BASE_URL") ?? ""; + try { + const response = await fetch( + `${baseUrl}/api/upload/attachment?cardPublicId=${encodeURIComponent(cardPublicId)}`, + { + method: "POST", + headers: { + "Content-Type": file.type, + "x-original-filename": file.name, + }, + body: file, + }, + ); + if (!response.ok) return null; + const data = (await response.json()) as { + attachment?: { publicId?: string }; + }; + const publicId = data.attachment?.publicId; + if (!publicId) return null; + return `/api/media/${publicId}`; + } catch { + return null; + } +} + declare module "@tiptap/core" { interface Commands { slashSuggestion: { @@ -445,6 +477,7 @@ export default function Editor({ enableYouTubeEmbed = true, placeholder, disableHeadings = false, + cardPublicId, }: { content: string | null; onChange?: (value: string) => void; @@ -454,6 +487,7 @@ export default function Editor({ enableYouTubeEmbed?: boolean; placeholder?: string; disableHeadings?: boolean; + cardPublicId?: string; }) { const containerRef = useRef(null); @@ -550,6 +584,7 @@ export default function Editor({ superscriptTwo: false, superscriptThree: false, }), + Image.configure({ inline: false, allowBase64: false }), ...(enableYouTubeEmbed ? [YouTubeNode] : []), ], content, @@ -561,7 +596,6 @@ export default function Editor({ ?.contains(event.relatedTarget as Node) ) return; - // Only trigger onBlur if the click was outside both the editor and menu if (!containerRef.current?.contains(event.relatedTarget as Node)) { onBlur?.(); } @@ -570,6 +604,46 @@ export default function Editor({ attributes: { class: "outline-none focus:outline-none focus-visible:ring-0", }, + handlePaste: (view, event) => { + if (!cardPublicId || readOnly) return false; + const items = Array.from(event.clipboardData?.items ?? []); + const imageItem = items.find((item) => + item.type.startsWith("image/"), + ); + if (!imageItem) return false; + const file = imageItem.getAsFile(); + if (!file) return false; + event.preventDefault(); + void uploadImageFile(file, cardPublicId).then((src) => { + if (src) { + view.dispatch( + view.state.tr.replaceSelectionWith( + view.state.schema.nodes.image!.create({ src }), + ), + ); + } + }); + return true; + }, + handleDrop: (view, event) => { + if (!cardPublicId || readOnly) return false; + const files = Array.from(event.dataTransfer?.files ?? []); + const imageFile = files.find((f) => f.type.startsWith("image/")); + if (!imageFile) return false; + event.preventDefault(); + const pos = view.posAtCoords({ + left: event.clientX, + top: event.clientY, + }); + void uploadImageFile(imageFile, cardPublicId).then((src) => { + if (src) { + const node = view.state.schema.nodes.image!.create({ src }); + const tr = view.state.tr.insert(pos?.pos ?? view.state.tr.doc.content.size, node); + view.dispatch(tr); + } + }); + return true; + }, }, editable: !readOnly, injectCSS: false, @@ -610,6 +684,12 @@ export default function Editor({ .tiptap [data-youtube] { margin: 1rem 0; } + .tiptap img { + max-width: 100%; + height: auto; + border-radius: 0.5rem; + margin: 0.5rem 0; + } `} {!readOnly && editor && } { + if (req.method !== "GET") { + return res.status(405).json({ error: "Method not allowed" }); + } + + const { attachmentPublicId } = req.query; + if ( + typeof attachmentPublicId !== "string" || + attachmentPublicId.length < 12 + ) { + return res.status(400).json({ error: "Invalid attachment ID" }); + } + + try { + const attachment = await cardAttachmentRepo.getByPublicId( + db, + attachmentPublicId, + ); + + if (!attachment || attachment.deletedAt) { + return res.status(404).json({ error: "Attachment not found" }); + } + + const url = await generateAttachmentUrl(attachment.s3Key); + if (!url) { + return res + .status(500) + .json({ error: "Unable to generate download URL" }); + } + + res.setHeader("Cache-Control", "private, max-age=3600, immutable"); + res.setHeader("Location", url); + return res.status(302).end(); + } catch { + return res.status(500).json({ error: "Internal server error" }); + } + }), +); diff --git a/apps/web/src/views/card/components/Comment.tsx b/apps/web/src/views/card/components/Comment.tsx index 6653c563e..3b46961a2 100644 --- a/apps/web/src/views/card/components/Comment.tsx +++ b/apps/web/src/views/card/components/Comment.tsx @@ -184,6 +184,7 @@ const Comment = ({ enableYouTubeEmbed={false} placeholder={t`Add comment... (type '/' to open commands or '@' to mention)`} disableHeadings={true} + cardPublicId={cardPublicId} />
diff --git a/apps/web/src/views/card/components/NewCommentForm.tsx b/apps/web/src/views/card/components/NewCommentForm.tsx index c1a9aaded..c4c8bc13e 100644 --- a/apps/web/src/views/card/components/NewCommentForm.tsx +++ b/apps/web/src/views/card/components/NewCommentForm.tsx @@ -67,6 +67,7 @@ const NewCommentForm = ({ enableYouTubeEmbed={false} placeholder={t`Add comment... (type '/' to open commands or '@' to mention)`} disableHeadings={true} + cardPublicId={cardPublicId} />
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6a5dc9e56..14fb21d98 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -133,6 +133,9 @@ importers: '@tanstack/react-query': specifier: 'catalog:' version: 5.85.6(react@18.3.1) + '@tiptap/extension-image': + specifier: ^2.14.0 + version: 2.27.2(@tiptap/core@2.26.1(@tiptap/pm@2.26.1)) '@tiptap/extension-link': specifier: ^2.22.2 version: 2.26.1(@tiptap/core@2.26.1(@tiptap/pm@2.26.1))(@tiptap/pm@2.26.1) @@ -3065,95 +3068,111 @@ packages: '@react-email/body@0.2.0': resolution: {integrity: sha512-9GCWmVmKUAoRfloboCd+RKm6X17xn7eGL7HnpAZUnjBXBilWCxsKnLMTC/ixSHDKS/A/057M1Tx6ZUXd89sVBw==} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/button@0.2.0': resolution: {integrity: sha512-8i+v6cMxr2emz4ihCrRiYJPp2/sdYsNNsBzXStlcA+/B9Umpm5Jj3WJKYpgTPM+aeyiqlG/MMI1AucnBm4f1oQ==} engines: {node: '>=18.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/code-block@0.2.0': resolution: {integrity: sha512-eIrPW9PIFgDopQU0e/OPpwCW2QWQDtNZDSsiN4sJO8KdMnWWnXJicnRfzrit5rHwFo+Y98i+w/Y5ScnBAFr1dQ==} engines: {node: '>=22.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/code-inline@0.0.5': resolution: {integrity: sha512-MmAsOzdJpzsnY2cZoPHFPk6uDO/Ncpb4Kh1hAt9UZc1xOW3fIzpe1Pi9y9p6wwUmpaeeDalJxAxH6/fnTquinA==} engines: {node: '>=18.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/column@0.0.13': resolution: {integrity: sha512-Lqq17l7ShzJG/d3b1w/+lVO+gp2FM05ZUo/nW0rjxB8xBICXOVv6PqjDnn3FXKssvhO5qAV20lHM6S+spRhEwQ==} engines: {node: '>=18.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/components@1.0.1': resolution: {integrity: sha512-HnL0Y/up61sOBQT2cQg9N/kCoW0bP727gDs2MkFWQYELg6+iIHidMDvENXFC0f1ZE6hTB+4t7sszptvTcJWsDA==} engines: {node: '>=22.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/container@0.0.15': resolution: {integrity: sha512-Qo2IQo0ru2kZq47REmHW3iXjAQaKu4tpeq/M8m1zHIVwKduL2vYOBQWbC2oDnMtWPmkBjej6XxgtZByxM6cCFg==} engines: {node: '>=18.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/font@0.0.9': resolution: {integrity: sha512-4zjq23oT9APXkerqeslPH3OZWuh5X4crHK6nx82mVHV2SrLba8+8dPEnWbaACWTNjOCbcLIzaC9unk7Wq2MIXw==} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/head@0.0.12': resolution: {integrity: sha512-X2Ii6dDFMF+D4niNwMAHbTkeCjlYYnMsd7edXOsi0JByxt9wNyZ9EnhFiBoQdqkE+SMDcu8TlNNttMrf5sJeMA==} engines: {node: '>=18.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/heading@0.0.15': resolution: {integrity: sha512-xF2GqsvBrp/HbRHWEfOgSfRFX+Q8I5KBEIG5+Lv3Vb2R/NYr0s8A5JhHHGf2pWBMJdbP4B2WHgj/VUrhy8dkIg==} engines: {node: '>=18.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/hr@0.0.11': resolution: {integrity: sha512-S1gZHVhwOsd1Iad5IFhpfICwNPMGPJidG/Uysy1AwmspyoAP5a4Iw3OWEpINFdgh9MHladbxcLKO2AJO+cA9Lw==} engines: {node: '>=18.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/html@0.0.11': resolution: {integrity: sha512-qJhbOQy5VW5qzU74AimjAR9FRFQfrMa7dn4gkEXKMB/S9xZN8e1yC1uA9C15jkXI/PzmJ0muDIWmFwatm5/+VA==} engines: {node: '>=18.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/img@0.0.11': resolution: {integrity: sha512-aGc8Y6U5C3igoMaqAJKsCpkbm1XjguQ09Acd+YcTKwjnC2+0w3yGUJkjWB2vTx4tN8dCqQCXO8FmdJpMfOA9EQ==} engines: {node: '>=18.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/link@0.0.12': resolution: {integrity: sha512-vF+xxQk2fGS1CN7UPQDbzvcBGfffr+GjTPNiWM38fhBfsLv6A/YUfaqxWlmL7zLzVmo0K2cvvV9wxlSyNba1aQ==} engines: {node: '>=18.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/markdown@0.0.17': resolution: {integrity: sha512-6op3AfsBC9BJKkhG+eoMFRFWlr0/f3FYbtQrK+VhGzJocEAY0WINIFN+W8xzXr//3IL0K/aKtnH3FtpIuescQQ==} engines: {node: '>=22.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/preview@0.0.13': resolution: {integrity: sha512-F7j9FJ0JN/A4d7yr+aw28p4uX7VLWs7hTHtLo7WRyw4G+Lit6Zucq4UWKRxJC8lpsUdzVmG7aBJnKOT+urqs/w==} engines: {node: '>=18.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc @@ -3167,18 +3186,21 @@ packages: '@react-email/row@0.0.12': resolution: {integrity: sha512-HkCdnEjvK3o+n0y0tZKXYhIXUNPDx+2vq1dJTmqappVHXS5tXS6W5JOPZr5j+eoZ8gY3PShI2LWj5rWF7ZEtIQ==} engines: {node: '>=18.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/section@0.0.16': resolution: {integrity: sha512-FjqF9xQ8FoeUZYKSdt8sMIKvoT9XF8BrzhT3xiFKdEMwYNbsDflcjfErJe3jb7Wj/es/lKTbV5QR1dnLzGpL3w==} engines: {node: '>=18.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/tailwind@2.0.1': resolution: {integrity: sha512-/xq0IDYVY7863xPY7cdI45Xoz7M6CnIQBJcQvbqN7MNVpopfH9f+mhjayV1JGfKaxlGWuxfLKhgi9T2shsnEFg==} engines: {node: '>=22.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: '@react-email/body': 0.2.0 '@react-email/button': 0.2.0 @@ -3217,6 +3239,7 @@ packages: '@react-email/text@0.1.5': resolution: {integrity: sha512-o5PNHFSE085VMXayxH+SJ1LSOtGsTv+RpNKnTiJDrJUwoBu77G3PlKOsZZQHCNyD28WsQpl9v2WcJLbQudqwPg==} engines: {node: '>=18.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc @@ -3908,6 +3931,11 @@ packages: '@tiptap/core': ^2.7.0 '@tiptap/pm': ^2.7.0 + '@tiptap/extension-image@2.27.2': + resolution: {integrity: sha512-5zL/BY41FIt72azVrCrv3n+2YJ/JyO8wxCcA4Dk1eXIobcgVyIdo4rG39gCqIOiqziAsqnqoj12QHTBtHsJ6mQ==} + peerDependencies: + '@tiptap/core': ^2.7.0 + '@tiptap/extension-italic@2.26.1': resolution: {integrity: sha512-pOs6oU4LyGO89IrYE4jbE8ZYsPwMMIiKkYfXcfeD9NtpGNBnjeVXXF5I9ndY2ANrCAgC8k58C3/powDRf0T2yA==} peerDependencies: @@ -4582,6 +4610,7 @@ packages: basic-ftp@5.0.5: resolution: {integrity: sha512-4Bcg1P8xhUuqcii/S0Z9wiHIrQVPMermM1any+MX5GeGD7faD3/msQUDGLol9wOcz4/jbg/WJnGqoJF6LiBdtg==} engines: {node: '>=10.0.0'} + deprecated: Security vulnerability fixed in 5.2.1, please upgrade before-after-hook@2.2.3: resolution: {integrity: sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ==} @@ -5786,25 +5815,29 @@ packages: glob@10.3.10: resolution: {integrity: sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==} engines: {node: '>=16 || 14 >=14.17'} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true glob@10.4.5: resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true glob@11.0.3: resolution: {integrity: sha512-2Nim7dha1KVkaiF4q6Dj+ngPPMdfvLJEOpZk/jKiUAkqKebpGAWQXAq9z1xu9HKu5lWfqw/FASuccEjyznjPaA==} engines: {node: 20 || >=22} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true glob@11.1.0: resolution: {integrity: sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==} engines: {node: 20 || >=22} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me globals@14.0.0: resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} @@ -12333,6 +12366,10 @@ snapshots: '@tiptap/core': 2.26.1(@tiptap/pm@2.26.1) '@tiptap/pm': 2.26.1 + '@tiptap/extension-image@2.27.2(@tiptap/core@2.26.1(@tiptap/pm@2.26.1))': + dependencies: + '@tiptap/core': 2.26.1(@tiptap/pm@2.26.1) + '@tiptap/extension-italic@2.26.1(@tiptap/core@2.26.1(@tiptap/pm@2.26.1))': dependencies: '@tiptap/core': 2.26.1(@tiptap/pm@2.26.1) From 15588f1b67fe594651e534954320115142a61d59 Mon Sep 17 00:00:00 2001 From: Florent Azonnoudo Date: Wed, 29 Apr 2026 17:38:44 +0000 Subject: [PATCH 2/4] feat: enhance file upload functionality in comments and editor --- apps/web/src/components/Editor.tsx | 45 ++++++++++++------- .../web/src/views/card/components/Comment.tsx | 1 + .../views/card/components/NewCommentForm.tsx | 1 + apps/web/src/views/card/index.tsx | 3 ++ 4 files changed, 34 insertions(+), 16 deletions(-) diff --git a/apps/web/src/components/Editor.tsx b/apps/web/src/components/Editor.tsx index 77892bb93..8362b8658 100644 --- a/apps/web/src/components/Editor.tsx +++ b/apps/web/src/components/Editor.tsx @@ -51,10 +51,10 @@ import { getAvatarUrl } from "~/utils/helpers"; import Avatar from "./Avatar"; import { YouTubeNode } from "./YouTubeEmbed/YouTubeNode"; -async function uploadImageFile( +async function uploadFile( file: File, cardPublicId: string, -): Promise { +): Promise<{ publicId: string } | null> { const baseUrl = env("NEXT_PUBLIC_BASE_URL") ?? ""; try { const response = await fetch( @@ -74,7 +74,7 @@ async function uploadImageFile( }; const publicId = data.attachment?.publicId; if (!publicId) return null; - return `/api/media/${publicId}`; + return { publicId }; } catch { return null; } @@ -478,6 +478,7 @@ export default function Editor({ placeholder, disableHeadings = false, cardPublicId, + onFileUpload, }: { content: string | null; onChange?: (value: string) => void; @@ -488,6 +489,7 @@ export default function Editor({ placeholder?: string; disableHeadings?: boolean; cardPublicId?: string; + onFileUpload?: () => void; }) { const containerRef = useRef(null); @@ -607,40 +609,51 @@ export default function Editor({ handlePaste: (view, event) => { if (!cardPublicId || readOnly) return false; const items = Array.from(event.clipboardData?.items ?? []); - const imageItem = items.find((item) => - item.type.startsWith("image/"), + const fileItem = items.find((item) => + item.kind === "file", ); - if (!imageItem) return false; - const file = imageItem.getAsFile(); + if (!fileItem) return false; + const file = fileItem.getAsFile(); if (!file) return false; event.preventDefault(); - void uploadImageFile(file, cardPublicId).then((src) => { - if (src) { + void uploadFile(file, cardPublicId).then((result) => { + if (!result) return; + if (file.type.startsWith("image/")) { view.dispatch( view.state.tr.replaceSelectionWith( - view.state.schema.nodes.image!.create({ src }), + view.state.schema.nodes.image!.create({ + src: `/api/media/${result.publicId}`, + }), ), ); } + onFileUpload?.(); }); return true; }, handleDrop: (view, event) => { if (!cardPublicId || readOnly) return false; const files = Array.from(event.dataTransfer?.files ?? []); - const imageFile = files.find((f) => f.type.startsWith("image/")); - if (!imageFile) return false; + const file = files[0]; + if (!file) return false; event.preventDefault(); const pos = view.posAtCoords({ left: event.clientX, top: event.clientY, }); - void uploadImageFile(imageFile, cardPublicId).then((src) => { - if (src) { - const node = view.state.schema.nodes.image!.create({ src }); - const tr = view.state.tr.insert(pos?.pos ?? view.state.tr.doc.content.size, node); + void uploadFile(file, cardPublicId).then((result) => { + if (!result) return; + if (file.type.startsWith("image/")) { + const node = view.state.schema.nodes.image!.create({ + src: `/api/media/${result.publicId}`, + }); + const tr = view.state.tr.insert( + pos?.pos ?? view.state.tr.doc.content.size, + node, + ); view.dispatch(tr); } + onFileUpload?.(); }); return true; }, diff --git a/apps/web/src/views/card/components/Comment.tsx b/apps/web/src/views/card/components/Comment.tsx index 3b46961a2..827b94c57 100644 --- a/apps/web/src/views/card/components/Comment.tsx +++ b/apps/web/src/views/card/components/Comment.tsx @@ -185,6 +185,7 @@ const Comment = ({ placeholder={t`Add comment... (type '/' to open commands or '@' to mention)`} disableHeadings={true} cardPublicId={cardPublicId} + onFileUpload={() => void invalidateCard(utils, cardPublicId)} />
diff --git a/apps/web/src/views/card/components/NewCommentForm.tsx b/apps/web/src/views/card/components/NewCommentForm.tsx index c4c8bc13e..51f279e85 100644 --- a/apps/web/src/views/card/components/NewCommentForm.tsx +++ b/apps/web/src/views/card/components/NewCommentForm.tsx @@ -68,6 +68,7 @@ const NewCommentForm = ({ placeholder={t`Add comment... (type '/' to open commands or '@' to mention)`} disableHeadings={true} cardPublicId={cardPublicId} + onFileUpload={() => void invalidateCard(utils, cardPublicId)} />
From 2f00f326ea0dceab40033d8bdf6a7b5e375ed5b3 Mon Sep 17 00:00:00 2001 From: Florent Azonnoudo Date: Wed, 29 Apr 2026 17:47:11 +0000 Subject: [PATCH 3/4] feat: add file attachment support in editor with upload functionality --- apps/web/src/components/Editor.tsx | 42 +++++++++---- .../FileAttachment/FileAttachmentNode.tsx | 59 +++++++++++++++++++ .../FileAttachment/FileAttachmentNodeView.tsx | 29 +++++++++ 3 files changed, 118 insertions(+), 12 deletions(-) create mode 100644 apps/web/src/components/FileAttachment/FileAttachmentNode.tsx create mode 100644 apps/web/src/components/FileAttachment/FileAttachmentNodeView.tsx diff --git a/apps/web/src/components/Editor.tsx b/apps/web/src/components/Editor.tsx index 8362b8658..87a172ab4 100644 --- a/apps/web/src/components/Editor.tsx +++ b/apps/web/src/components/Editor.tsx @@ -49,6 +49,7 @@ import { env } from "next-runtime-env"; import { getAvatarUrl } from "~/utils/helpers"; import Avatar from "./Avatar"; +import { FileAttachmentNode } from "./FileAttachment/FileAttachmentNode"; import { YouTubeNode } from "./YouTubeEmbed/YouTubeNode"; async function uploadFile( @@ -587,6 +588,7 @@ export default function Editor({ superscriptThree: false, }), Image.configure({ inline: false, allowBase64: false }), + FileAttachmentNode, ...(enableYouTubeEmbed ? [YouTubeNode] : []), ], content, @@ -609,20 +611,26 @@ export default function Editor({ handlePaste: (view, event) => { if (!cardPublicId || readOnly) return false; const items = Array.from(event.clipboardData?.items ?? []); - const fileItem = items.find((item) => - item.kind === "file", - ); + const fileItem = items.find((item) => item.kind === "file"); if (!fileItem) return false; const file = fileItem.getAsFile(); if (!file) return false; event.preventDefault(); void uploadFile(file, cardPublicId).then((result) => { if (!result) return; + const mediaUrl = `/api/media/${result.publicId}`; if (file.type.startsWith("image/")) { view.dispatch( view.state.tr.replaceSelectionWith( - view.state.schema.nodes.image!.create({ - src: `/api/media/${result.publicId}`, + view.state.schema.nodes.image!.create({ src: mediaUrl }), + ), + ); + } else { + view.dispatch( + view.state.tr.replaceSelectionWith( + view.state.schema.nodes.fileAttachment!.create({ + href: mediaUrl, + filename: file.name, }), ), ); @@ -643,15 +651,25 @@ export default function Editor({ }); void uploadFile(file, cardPublicId).then((result) => { if (!result) return; + const mediaUrl = `/api/media/${result.publicId}`; + const insertPos = pos?.pos ?? view.state.tr.doc.content.size; if (file.type.startsWith("image/")) { - const node = view.state.schema.nodes.image!.create({ - src: `/api/media/${result.publicId}`, - }); - const tr = view.state.tr.insert( - pos?.pos ?? view.state.tr.doc.content.size, - node, + view.dispatch( + view.state.tr.insert( + insertPos, + view.state.schema.nodes.image!.create({ src: mediaUrl }), + ), + ); + } else { + view.dispatch( + view.state.tr.insert( + insertPos, + view.state.schema.nodes.fileAttachment!.create({ + href: mediaUrl, + filename: file.name, + }), + ), ); - view.dispatch(tr); } onFileUpload?.(); }); diff --git a/apps/web/src/components/FileAttachment/FileAttachmentNode.tsx b/apps/web/src/components/FileAttachment/FileAttachmentNode.tsx new file mode 100644 index 000000000..89490508e --- /dev/null +++ b/apps/web/src/components/FileAttachment/FileAttachmentNode.tsx @@ -0,0 +1,59 @@ +import { mergeAttributes, Node } from "@tiptap/core"; +import { ReactNodeViewRenderer } from "@tiptap/react"; + +import FileAttachmentNodeView from "./FileAttachmentNodeView"; + +export interface FileAttachmentOptions { + HTMLAttributes: Record; +} + +export const FileAttachmentNode = Node.create({ + name: "fileAttachment", + group: "block", + atom: true, + + addOptions() { + return { + HTMLAttributes: {}, + }; + }, + + addAttributes() { + return { + href: { + default: null, + parseHTML: (element) => element.getAttribute("data-href"), + renderHTML: (attributes) => { + if (!attributes.href) return {}; + return { "data-href": attributes.href as string }; + }, + }, + filename: { + default: "File", + parseHTML: (element) => element.getAttribute("data-filename"), + renderHTML: (attributes) => { + return { "data-filename": attributes.filename as string }; + }, + }, + }; + }, + + parseHTML() { + return [{ tag: "div[data-file-attachment]" }]; + }, + + renderHTML({ HTMLAttributes }) { + return [ + "div", + mergeAttributes( + { "data-file-attachment": "" }, + this.options.HTMLAttributes, + HTMLAttributes, + ), + ]; + }, + + addNodeView() { + return ReactNodeViewRenderer(FileAttachmentNodeView); + }, +}); diff --git a/apps/web/src/components/FileAttachment/FileAttachmentNodeView.tsx b/apps/web/src/components/FileAttachment/FileAttachmentNodeView.tsx new file mode 100644 index 000000000..973766efc --- /dev/null +++ b/apps/web/src/components/FileAttachment/FileAttachmentNodeView.tsx @@ -0,0 +1,29 @@ +import type { NodeViewProps } from "@tiptap/react"; +import { NodeViewWrapper } from "@tiptap/react"; +import { HiOutlineDocumentText, HiOutlineArrowDownTray } from "react-icons/hi2"; + +export default function FileAttachmentNodeView({ node }: NodeViewProps) { + const { href, filename } = node.attrs as { + href: string; + filename: string; + }; + + return ( + + + + + {filename} + + + + + ); +} From 97749a84e8b436be06f63d7373a49e38b1eeaeff Mon Sep 17 00:00:00 2001 From: Florent Azonnoudo Date: Wed, 29 Apr 2026 17:52:21 +0000 Subject: [PATCH 4/4] feat: implement user authentication and permission checks for attachment access --- .../pages/api/media/[attachmentPublicId].ts | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/apps/web/src/pages/api/media/[attachmentPublicId].ts b/apps/web/src/pages/api/media/[attachmentPublicId].ts index f10e44c9c..c47150d31 100644 --- a/apps/web/src/pages/api/media/[attachmentPublicId].ts +++ b/apps/web/src/pages/api/media/[attachmentPublicId].ts @@ -1,13 +1,12 @@ import type { NextApiRequest, NextApiResponse } from "next"; +import { createNextApiContext } from "@kan/api/trpc"; import { withApiLogging } from "@kan/api/utils/apiLogging"; +import { assertPermission } from "@kan/api/utils/permissions"; import { withRateLimit } from "@kan/api/utils/rateLimit"; -import { createDrizzleClient } from "@kan/db/client"; import * as cardAttachmentRepo from "@kan/db/repository/cardAttachment.repo"; import { generateAttachmentUrl } from "@kan/shared/utils"; -const db = createDrizzleClient(); - export default withRateLimit( { points: 200, duration: 60 }, withApiLogging(async (req: NextApiRequest, res: NextApiResponse) => { @@ -24,6 +23,12 @@ export default withRateLimit( } try { + const { user, db } = await createNextApiContext(req); + + if (!user) { + return res.status(401).json({ error: "Unauthorized" }); + } + const attachment = await cardAttachmentRepo.getByPublicId( db, attachmentPublicId, @@ -33,6 +38,17 @@ export default withRateLimit( return res.status(404).json({ error: "Attachment not found" }); } + const workspaceId = attachment.card?.list?.board?.workspaceId; + if (!workspaceId) { + return res.status(404).json({ error: "Attachment not found" }); + } + + try { + await assertPermission(db, user.id, workspaceId, "card:view"); + } catch { + return res.status(403).json({ error: "Permission denied" }); + } + const url = await generateAttachmentUrl(attachment.s3Key); if (!url) { return res