Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
113 changes: 112 additions & 1 deletion apps/web/src/components/Editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -44,10 +45,42 @@ 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 { FileAttachmentNode } from "./FileAttachment/FileAttachmentNode";
import { YouTubeNode } from "./YouTubeEmbed/YouTubeNode";

async function uploadFile(
file: File,
cardPublicId: string,
): Promise<{ publicId: string } | null> {
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 { publicId };
} catch {
return null;
}
}

declare module "@tiptap/core" {
interface Commands<ReturnType> {
slashSuggestion: {
Expand Down Expand Up @@ -445,6 +478,8 @@ export default function Editor({
enableYouTubeEmbed = true,
placeholder,
disableHeadings = false,
cardPublicId,
onFileUpload,
}: {
content: string | null;
onChange?: (value: string) => void;
Expand All @@ -454,6 +489,8 @@ export default function Editor({
enableYouTubeEmbed?: boolean;
placeholder?: string;
disableHeadings?: boolean;
cardPublicId?: string;
onFileUpload?: () => void;
}) {
const containerRef = useRef<HTMLDivElement>(null);

Expand Down Expand Up @@ -550,6 +587,8 @@ export default function Editor({
superscriptTwo: false,
superscriptThree: false,
}),
Image.configure({ inline: false, allowBase64: false }),
FileAttachmentNode,
...(enableYouTubeEmbed ? [YouTubeNode] : []),
],
content,
Expand All @@ -561,7 +600,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?.();
}
Expand All @@ -570,6 +608,73 @@ 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 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: mediaUrl }),
),
);
} else {
view.dispatch(
view.state.tr.replaceSelectionWith(
view.state.schema.nodes.fileAttachment!.create({
href: mediaUrl,
filename: file.name,
}),
),
);
}
onFileUpload?.();
});
return true;
},
handleDrop: (view, event) => {
if (!cardPublicId || readOnly) return false;
const files = Array.from(event.dataTransfer?.files ?? []);
const file = files[0];
if (!file) return false;
event.preventDefault();
const pos = view.posAtCoords({
left: event.clientX,
top: event.clientY,
});
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/")) {
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,
}),
),
);
}
onFileUpload?.();
});
return true;
},
},
editable: !readOnly,
injectCSS: false,
Expand Down Expand Up @@ -610,6 +715,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;
}
`}</style>
{!readOnly && editor && <EditorBubbleMenu editor={editor} />}
<EditorContent
Expand Down
59 changes: 59 additions & 0 deletions apps/web/src/components/FileAttachment/FileAttachmentNode.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { mergeAttributes, Node } from "@tiptap/core";
import { ReactNodeViewRenderer } from "@tiptap/react";

import FileAttachmentNodeView from "./FileAttachmentNodeView";

export interface FileAttachmentOptions {
HTMLAttributes: Record<string, unknown>;
}

export const FileAttachmentNode = Node.create<FileAttachmentOptions>({
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);
},
});
29 changes: 29 additions & 0 deletions apps/web/src/components/FileAttachment/FileAttachmentNodeView.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<NodeViewWrapper>
<a
href={href}
target="_blank"
rel="noopener noreferrer"
contentEditable={false}
className="my-1 flex items-center gap-2.5 rounded-lg border border-light-300 bg-light-50 px-3 py-2 no-underline transition-colors hover:bg-light-100 dark:border-dark-400 dark:bg-dark-100 dark:hover:bg-dark-200"
style={{ textDecoration: "none" }}
>
<HiOutlineDocumentText className="h-5 w-5 flex-shrink-0 text-blue-500" />
<span className="min-w-0 flex-1 truncate text-sm font-medium text-light-950 dark:text-dark-1000">
{filename}
</span>
<HiOutlineArrowDownTray className="h-4 w-4 flex-shrink-0 text-light-700 dark:text-dark-700" />
</a>
</NodeViewWrapper>
);
}
66 changes: 66 additions & 0 deletions apps/web/src/pages/api/media/[attachmentPublicId].ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
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 * as cardAttachmentRepo from "@kan/db/repository/cardAttachment.repo";
import { generateAttachmentUrl } from "@kan/shared/utils";

export default withRateLimit(
{ points: 200, duration: 60 },
withApiLogging(async (req: NextApiRequest, res: NextApiResponse) => {
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 { user, db } = await createNextApiContext(req);

if (!user) {
return res.status(401).json({ error: "Unauthorized" });
}

const attachment = await cardAttachmentRepo.getByPublicId(
db,
attachmentPublicId,
);

if (!attachment || attachment.deletedAt) {
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
.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" });
}
}),
);
2 changes: 2 additions & 0 deletions apps/web/src/views/card/components/Comment.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,8 @@ const Comment = ({
enableYouTubeEmbed={false}
placeholder={t`Add comment... (type '/' to open commands or '@' to mention)`}
disableHeadings={true}
cardPublicId={cardPublicId}
onFileUpload={() => void invalidateCard(utils, cardPublicId)}
/>
</div>
<div className="flex justify-end space-x-2 mt-2">
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/views/card/components/NewCommentForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ const NewCommentForm = ({
enableYouTubeEmbed={false}
placeholder={t`Add comment... (type '/' to open commands or '@' to mention)`}
disableHeadings={true}
cardPublicId={cardPublicId}
onFileUpload={() => void invalidateCard(utils, cardPublicId)}
/>
<div className="flex justify-end">
<button
Expand Down
4 changes: 4 additions & 0 deletions apps/web/src/views/card/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,10 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
}
workspaceMembers={workspaceMembers ?? []}
readOnly={!canEdit}
cardPublicId={cardId}
onFileUpload={() => {
if (cardId) void invalidateCard(utils, cardId);
}}
/>
</div>
</form>
Expand Down
Loading
Loading