From ee6c47924d1661ba564a7de33b6addabd080b1f2 Mon Sep 17 00:00:00 2001
From: sunaurus <sander@saarend.com>
Date: Sun, 14 Apr 2024 19:18:37 +0300
Subject: [PATCH] feat: implement image uploads in markdown editor

Both uploading from clipboard and from the toolbar button are supported
---
 src/app/(ui)/markdown/MarkdownTextArea.tsx | 112 +++++++++++++++++++--
 src/app/(ui)/markdown/imageActions.ts      |  13 +++
 src/app/comment/Comment.tsx                |   2 +
 src/app/comment/CommentEditor.tsx          |   5 +-
 src/app/post/[id]/CommentsSection.tsx      |   1 +
 5 files changed, 123 insertions(+), 10 deletions(-)
 create mode 100644 src/app/(ui)/markdown/imageActions.ts

diff --git a/src/app/(ui)/markdown/MarkdownTextArea.tsx b/src/app/(ui)/markdown/MarkdownTextArea.tsx
index 48ef358..38bf9bb 100644
--- a/src/app/(ui)/markdown/MarkdownTextArea.tsx
+++ b/src/app/(ui)/markdown/MarkdownTextArea.tsx
@@ -3,6 +3,7 @@
 import { TextArea, TextAreaProps } from "@/app/(ui)/form/TextArea";
 import {
   ChangeEvent,
+  ClipboardEvent,
   KeyboardEvent,
   ReactNode,
   useEffect,
@@ -10,8 +11,10 @@ import {
 } from "react";
 import classNames from "classnames";
 import { CodeBracketIcon, LinkIcon } from "@heroicons/react/16/solid";
-import { Prose } from "@/app/(ui)/markdown/Prose";
 import { Markdown } from "@/app/(ui)/markdown/Markdown";
+import { uploadImageAction } from "@/app/(ui)/markdown/imageActions";
+import { Spinner } from "@/app/(ui)/Spinner";
+import { PhotoIcon } from "@heroicons/react/24/outline";
 
 export const MarkdownTextArea = (
   props: TextAreaProps & {
@@ -26,6 +29,9 @@ export const MarkdownTextArea = (
     typeof defaultValue === "string" ? defaultValue : "",
   );
 
+  const [uploading, setUploading] = useState(false);
+  const [uploadError, setUploadError] = useState<string | null>(null);
+
   const [previewActive, setPreviewActive] = useState(false);
 
   const [selection, setSelection] = useState<{
@@ -76,6 +82,58 @@ export const MarkdownTextArea = (
     }
   };
 
+  const insertImageFromClipboard = async (
+    e: ClipboardEvent<HTMLTextAreaElement>,
+  ) => {
+    const image = e.clipboardData.files[0];
+    if (image) {
+      await uploadImage(image);
+    }
+  };
+
+  const handleImageUpload = async (e: ChangeEvent<HTMLInputElement>) => {
+    const file = e.currentTarget.files?.[0];
+    if (file) {
+      await uploadImage(file);
+    }
+  };
+
+  const uploadImage = async (image: File) => {
+    setUploading(true);
+    const formData = new FormData();
+    formData.set("image", image);
+
+    const res = await uploadImageAction(formData);
+
+    if (res.url) {
+      const textarea = document.getElementById(
+        props.id,
+      )! as HTMLTextAreaElement;
+      const selectionStart = textarea.selectionStart;
+
+      const imageMarkdown = `![alt text](${res.url})`;
+
+      setValue(
+        (prev) =>
+          `${prev.substring(
+            0,
+            selectionStart,
+          )}${imageMarkdown}${prev.substring(selectionStart)}`,
+      );
+
+      setSelection({
+        start: selectionStart + 2,
+        end: selectionStart + 10,
+      });
+    } else {
+      setUploadError(`Error: ${res.msg}` ?? "Error!");
+      setTimeout(() => {
+        setUploadError(null);
+      }, 2000);
+    }
+    setUploading(false);
+  };
+
   const makeSelectionLink = () => {
     surroundSelection("[", "](<enter url>)");
   };
@@ -144,7 +202,7 @@ export const MarkdownTextArea = (
   };
 
   return (
-    <div className={classNames("", className)}>
+    <div className={classNames("relative", className)}>
       <div
         className={`flex items-center justify-end gap-1.5 rounded-t border border-b-0
           border-neutral-600 bg-neutral-800 p-0.5 px-4`}
@@ -153,6 +211,7 @@ export const MarkdownTextArea = (
           className={classNames("text-sm", {
             "bg-neutral-500 font-bold": !previewActive,
           })}
+          disabled={uploading}
           onClick={() => setPreviewActive(false)}
           title={"Write"}
         >
@@ -162,6 +221,7 @@ export const MarkdownTextArea = (
           className={classNames("mr-auto text-sm", {
             "bg-neutral-500 font-bold": previewActive,
           })}
+          disabled={uploading}
           onClick={() => setPreviewActive(true)}
           title={"Preview"}
         >
@@ -169,7 +229,7 @@ export const MarkdownTextArea = (
         </ToolbarButton>
         <ToolbarButton
           className={"font-bold"}
-          disabled={previewActive}
+          disabled={previewActive || uploading}
           onClick={makeSelectionBold}
           title={"Bold"}
         >
@@ -177,7 +237,7 @@ export const MarkdownTextArea = (
         </ToolbarButton>
         <ToolbarButton
           className={"italic"}
-          disabled={previewActive}
+          disabled={previewActive || uploading}
           onClick={makeSelectionItalic}
           title={"Italic"}
         >
@@ -185,7 +245,7 @@ export const MarkdownTextArea = (
         </ToolbarButton>
         <ToolbarButton
           className={"font-bold"}
-          disabled={previewActive}
+          disabled={previewActive || uploading}
           onClick={makeSelectionLink}
           title={"Link"}
         >
@@ -193,12 +253,28 @@ export const MarkdownTextArea = (
         </ToolbarButton>
         <ToolbarButton
           className={"font-bold"}
-          disabled={previewActive}
+          disabled={previewActive || uploading}
           onClick={makeSelectionCode}
           title={"Code"}
         >
           <CodeBracketIcon className={"h-4"} />
         </ToolbarButton>
+        <label
+          className={`cursor-pointer rounded bg-neutral-800 p-1 hover:brightness-125
+            disabled:cursor-default disabled:hover:brightness-100`}
+          htmlFor={`${props.id}-upload`}
+        >
+          <PhotoIcon className={"h-4"} />
+          <input
+            accept={"image/png, image/jpeg, image/webp"}
+            className={"hidden"}
+            disabled={uploading || previewActive}
+            id={`${props.id}-upload`}
+            name={"image"}
+            onChange={handleImageUpload}
+            type={"file"}
+          />
+        </label>
       </div>
       {!previewActive && (
         <TextArea
@@ -207,21 +283,40 @@ export const MarkdownTextArea = (
             "mt-0 rounded-none rounded-b pt-0",
             textAreaClassName,
           )}
+          disabled={uploading}
           onChange={onChange}
           onKeyDown={onKeyDown}
+          onPaste={insertImageFromClipboard}
           value={value}
         />
       )}
       {previewActive && (
         <div
           className={classNames(
-            "rounded-b border border-neutral-600 p-4",
+            "block rounded-b border border-neutral-600 p-4",
             props.textAreaClassName,
           )}
         >
           <Markdown content={value} />
         </div>
       )}
+      {uploading && (
+        <div
+          className={`absolute bottom-0 left-0 flex items-center gap-1 rounded-bl rounded-tr border-b
+            border-l border-neutral-600 bg-neutral-800 px-4 py-1`}
+        >
+          <Spinner />
+          {"Uploading..."}
+        </div>
+      )}
+      {uploadError && (
+        <div
+          className={`absolute bottom-0 left-0 flex items-center gap-1 rounded-bl rounded-tr border-b
+            border-l border-neutral-600 bg-rose-600 px-4 py-1`}
+        >
+          {uploadError}
+        </div>
+      )}
     </div>
   );
 };
@@ -236,7 +331,8 @@ const ToolbarButton = (props: {
   return (
     <button
       className={classNames(
-        "rounded bg-neutral-800 p-1 hover:brightness-125",
+        `rounded bg-neutral-800 p-1 hover:brightness-125 disabled:cursor-default
+        disabled:hover:brightness-100`,
         props.className,
       )}
       disabled={props.disabled}
diff --git a/src/app/(ui)/markdown/imageActions.ts b/src/app/(ui)/markdown/imageActions.ts
new file mode 100644
index 0000000..0a0f488
--- /dev/null
+++ b/src/app/(ui)/markdown/imageActions.ts
@@ -0,0 +1,13 @@
+"use server";
+
+import { apiClient } from "@/app/apiClient";
+
+export const uploadImageAction = async (form: FormData) => {
+  const image = form.get("image") as File | null;
+
+  if (!image) {
+    throw new Error("Image missing!");
+  }
+
+  return apiClient.uploadImage({ image });
+};
diff --git a/src/app/comment/Comment.tsx b/src/app/comment/Comment.tsx
index 0e49773..3a3762a 100644
--- a/src/app/comment/Comment.tsx
+++ b/src/app/comment/Comment.tsx
@@ -75,6 +75,7 @@ export const Comment = (props: {
       <CommentEditor
         className={"w-full"}
         commentId={props.commentView.comment.id}
+        id={`edit-${props.commentView.comment.id}`}
         initialContent={editedContent ?? props.commentView.comment.content}
         onCancel={() => setIsEditing(false)}
         onSubmit={(newContent: string) => {
@@ -272,6 +273,7 @@ export const Comment = (props: {
         </div>
         {isReplying && (
           <CommentEditor
+            id={`reply-${props.commentView.comment.id}`}
             onCancel={() => setIsReplying(false)}
             onSubmit={(newComment: CommentView) => {
               setIsReplying(false);
diff --git a/src/app/comment/CommentEditor.tsx b/src/app/comment/CommentEditor.tsx
index c9af0f5..b37212b 100644
--- a/src/app/comment/CommentEditor.tsx
+++ b/src/app/comment/CommentEditor.tsx
@@ -13,6 +13,7 @@ import { MarkdownTextArea } from "@/app/(ui)/markdown/MarkdownTextArea";
 type BaseProps = {
   className?: string;
   onCancel?(): void;
+  id: string;
 };
 
 export type NewCommentProps = BaseProps & {
@@ -49,10 +50,10 @@ export const CommentEditor = (props: Props) => {
         <MarkdownTextArea
           className={"mb-2 mt-4"}
           defaultValue={isNewComment(props) ? undefined : props.initialContent}
-          id={"content"}
+          id={props.id}
           name={"content"}
           required={true}
-          textAreaClassName={"h-32"}
+          textAreaClassName={"min-h-32"}
         />
         <div className={"flex justify-end gap-2"}>
           {props.onCancel && (
diff --git a/src/app/post/[id]/CommentsSection.tsx b/src/app/post/[id]/CommentsSection.tsx
index b8011bf..74db6b8 100644
--- a/src/app/post/[id]/CommentsSection.tsx
+++ b/src/app/post/[id]/CommentsSection.tsx
@@ -36,6 +36,7 @@ export const CommentsSection = (props: {
       ) : (
         <CommentEditor
           className={"mx-2 max-w-[880px] lg:mx-4"}
+          id={`new-root-${props.postId}`}
           postId={props.postId}
         />
       )}