From 699c3ff4b1a6e0c2a8e9699f326f1edbb63a826e Mon Sep 17 00:00:00 2001
From: SleeplessOne1917 <abias1122@gmail.com>
Date: Tue, 4 Apr 2023 08:40:00 -0400
Subject: [PATCH] Multiple image upload (#971)

* feat: Add multiple image upload

* refactor: Slight cleanup

* feat: Add progress bar for multi-image upload

* fix: Fix progress bar

* fix: Messed up fix last time

* refactor: Use await where possible

* Update translation logic

* Did suggested PR changes

* Updating translations

* Fix i18 issue

* Make prettier actually check src in hopes it will fix CI issue
---
 .prettierignore                               |   1 +
 package.json                                  |   2 +-
 src/shared/components/common/emoji-picker.tsx |   5 +-
 .../components/common/language-select.tsx     |  34 +-
 .../components/common/markdown-textarea.tsx   | 304 ++++++++++--------
 src/shared/components/common/progress-bar.tsx |  44 +++
 src/shared/components/home/emojis-form.tsx    |   7 +-
 src/shared/components/post/post-form.tsx      |   7 +-
 src/shared/utils.ts                           |  50 ++-
 9 files changed, 276 insertions(+), 178 deletions(-)
 create mode 100644 .prettierignore
 create mode 100644 src/shared/components/common/progress-bar.tsx

diff --git a/.prettierignore b/.prettierignore
new file mode 100644
index 0000000..a14ae90
--- /dev/null
+++ b/.prettierignore
@@ -0,0 +1 @@
+src/shared/translations
\ No newline at end of file
diff --git a/package.json b/package.json
index 0e0426a..573858b 100644
--- a/package.json
+++ b/package.json
@@ -12,7 +12,7 @@
     "build:prod": "webpack --mode=production",
     "clean": "yarn run rimraf dist",
     "dev": "yarn start",
-    "lint": "node generate_translations.js && tsc --noEmit && eslint --report-unused-disable-directives --ext .js,.ts,.tsx src && prettier --check 'src/**/*.tsx'",
+    "lint": "node generate_translations.js && tsc --noEmit && eslint --report-unused-disable-directives --ext .js,.ts,.tsx src && prettier --check \"src/**/*.{ts,tsx}\"",
     "prepare": "husky install",
     "start": "yarn build:dev --watch"
   },
diff --git a/src/shared/components/common/emoji-picker.tsx b/src/shared/components/common/emoji-picker.tsx
index 1149583..aea986a 100644
--- a/src/shared/components/common/emoji-picker.tsx
+++ b/src/shared/components/common/emoji-picker.tsx
@@ -5,6 +5,7 @@ import { Icon } from "./icon";
 
 interface EmojiPickerProps {
   onEmojiClick?(val: any): any;
+  disabled?: boolean;
 }
 
 interface EmojiPickerState {
@@ -15,8 +16,9 @@ export class EmojiPicker extends Component<EmojiPickerProps, EmojiPickerState> {
   private emptyState: EmojiPickerState = {
     showPicker: false,
   };
+
   state: EmojiPickerState;
-  constructor(props: any, context: any) {
+  constructor(props: EmojiPickerProps, context: any) {
     super(props, context);
     this.state = this.emptyState;
     this.handleEmojiClick = this.handleEmojiClick.bind(this);
@@ -28,6 +30,7 @@ export class EmojiPicker extends Component<EmojiPickerProps, EmojiPickerState> {
           className="btn btn-sm text-muted"
           data-tippy-content={i18n.t("emoji")}
           aria-label={i18n.t("emoji")}
+          disabled={this.props.disabled}
           onClick={linkEvent(this, this.togglePicker)}
         >
           <Icon icon="smile" classes="icon-inline" />
diff --git a/src/shared/components/common/language-select.tsx b/src/shared/components/common/language-select.tsx
index 64cbac4..feada32 100644
--- a/src/shared/components/common/language-select.tsx
+++ b/src/shared/components/common/language-select.tsx
@@ -10,11 +10,12 @@ interface LanguageSelectProps {
   allLanguages: Language[];
   siteLanguages: number[];
   selectedLanguageIds?: number[];
-  multiple: boolean;
+  multiple?: boolean;
   onChange(val: number[]): any;
   showAll?: boolean;
   showSite?: boolean;
   iconVersion?: boolean;
+  disabled?: boolean;
 }
 
 export class LanguageSelect extends Component<LanguageSelectProps, any> {
@@ -55,19 +56,19 @@ export class LanguageSelect extends Component<LanguageSelectProps, any> {
         )}
         <div className="form-group row">
           <label
-            className={classNames("col-form-label", {
-              "col-sm-3": this.props.multiple,
-              "col-sm-2": !this.props.multiple,
-            })}
+            className={classNames(
+              "col-form-label",
+              `col-sm-${this.props.multiple ? 3 : 2}`
+            )}
             htmlFor={this.id}
           >
             {i18n.t(this.props.multiple ? "language_plural" : "language")}
           </label>
           <div
-            className={classNames("input-group", {
-              "col-sm-9": this.props.multiple,
-              "col-sm-10": !this.props.multiple,
-            })}
+            className={classNames(
+              "input-group",
+              `col-sm-${this.props.multiple ? 9 : 10}`
+            )}
           >
             {this.selectBtn}
             {this.props.multiple && (
@@ -87,8 +88,8 @@ export class LanguageSelect extends Component<LanguageSelectProps, any> {
   }
 
   get selectBtn() {
-    let selectedLangs = this.props.selectedLanguageIds;
-    let filteredLangs = selectableLanguages(
+    const selectedLangs = this.props.selectedLanguageIds;
+    const filteredLangs = selectableLanguages(
       this.props.allLanguages,
       this.props.siteLanguages,
       this.props.showAll,
@@ -98,14 +99,17 @@ export class LanguageSelect extends Component<LanguageSelectProps, any> {
 
     return (
       <select
-        className={classNames("lang-select-action", {
-          "form-control custom-select": !this.props.iconVersion,
-          "btn btn-sm text-muted": this.props.iconVersion,
-        })}
+        className={classNames(
+          "lang-select-action",
+          this.props.iconVersion
+            ? "btn btn-sm text-muted"
+            : "form-control custom-select"
+        )}
         id={this.id}
         onChange={linkEvent(this, this.handleLanguageChange)}
         aria-label="action"
         multiple={this.props.multiple}
+        disabled={this.props.disabled}
       >
         {filteredLangs.map(l => (
           <option
diff --git a/src/shared/components/common/markdown-textarea.tsx b/src/shared/components/common/markdown-textarea.tsx
index d7bb4c5..5aaa127 100644
--- a/src/shared/components/common/markdown-textarea.tsx
+++ b/src/shared/components/common/markdown-textarea.tsx
@@ -1,15 +1,19 @@
 import autosize from "autosize";
+import { NoOptionI18nKeys } from "i18next";
 import { Component, linkEvent } from "inferno";
 import { Prompt } from "inferno-router";
 import { Language } from "lemmy-js-client";
 import { i18n } from "../../i18next";
 import { UserService } from "../../services";
 import {
+  concurrentImageUpload,
   customEmojisLookup,
   isBrowser,
   markdownFieldCharacterLimit,
   markdownHelpUrl,
+  maxUploadImages,
   mdToHtml,
+  numToSI,
   pictrsDeleteToast,
   randomStr,
   relTags,
@@ -21,6 +25,7 @@ import {
 import { EmojiPicker } from "./emoji-picker";
 import { Icon, Spinner } from "./icon";
 import { LanguageSelect } from "./language-select";
+import ProgressBar from "./progress-bar";
 
 interface MarkdownTextAreaProps {
   initialContent?: string;
@@ -41,12 +46,17 @@ interface MarkdownTextAreaProps {
   siteLanguages: number[]; // TODO same
 }
 
+interface ImageUploadStatus {
+  total: number;
+  uploaded: number;
+}
+
 interface MarkdownTextAreaState {
   content?: string;
   languageId?: number;
   previewMode: boolean;
   loading: boolean;
-  imageLoading: boolean;
+  imageUploadStatus?: ImageUploadStatus;
 }
 
 export class MarkdownTextArea extends Component<
@@ -56,12 +66,12 @@ export class MarkdownTextArea extends Component<
   private id = `comment-textarea-${randomStr()}`;
   private formId = `comment-form-${randomStr()}`;
   private tribute: any;
+
   state: MarkdownTextAreaState = {
     content: this.props.initialContent,
     languageId: this.props.initialLanguageId,
     previewMode: false,
     loading: false,
-    imageLoading: false,
   };
 
   constructor(props: any, context: any) {
@@ -110,8 +120,8 @@ export class MarkdownTextArea extends Component<
         this.props.onReplyCancel?.();
       }
 
-      let textarea: any = document.getElementById(this.id);
-      let form: any = document.getElementById(this.formId);
+      const textarea: any = document.getElementById(this.id);
+      const form: any = document.getElementById(this.formId);
       form.reset();
       setTimeout(() => autosize.update(textarea), 10);
     }
@@ -139,7 +149,7 @@ export class MarkdownTextArea extends Component<
               onInput={linkEvent(this, this.handleContentChange)}
               onPaste={linkEvent(this, this.handleImageUploadPaste)}
               required
-              disabled={this.props.disabled}
+              disabled={this.isDisabled}
               rows={2}
               maxLength={this.props.maxLength ?? markdownFieldCharacterLimit}
               placeholder={this.props.placeholder}
@@ -150,6 +160,20 @@ export class MarkdownTextArea extends Component<
                 dangerouslySetInnerHTML={mdToHtml(this.state.content)}
               />
             )}
+            {this.state.imageUploadStatus &&
+              this.state.imageUploadStatus.total > 1 && (
+                <ProgressBar
+                  className="mt-2"
+                  striped
+                  animated
+                  value={this.state.imageUploadStatus.uploaded}
+                  max={this.state.imageUploadStatus.total}
+                  text={i18n.t("pictures_uploded_progess", {
+                    uploaded: this.state.imageUploadStatus.uploaded,
+                    total: this.state.imageUploadStatus.total,
+                  })}
+                />
+              )}
           </div>
           <label className="sr-only" htmlFor={this.id}>
             {i18n.t("body")}
@@ -161,7 +185,7 @@ export class MarkdownTextArea extends Component<
               <button
                 type="submit"
                 className="btn btn-sm btn-secondary mr-2"
-                disabled={this.props.disabled || this.state.loading}
+                disabled={this.isDisabled}
               >
                 {this.state.loading ? (
                   <Spinner />
@@ -200,36 +224,16 @@ export class MarkdownTextArea extends Component<
                   languageId ? Array.of(languageId) : undefined
                 }
                 siteLanguages={this.props.siteLanguages}
-                multiple={false}
                 onChange={this.handleLanguageChange}
+                disabled={this.isDisabled}
               />
             )}
-            <button
-              className="btn btn-sm text-muted"
-              data-tippy-content={i18n.t("bold")}
-              aria-label={i18n.t("bold")}
-              onClick={linkEvent(this, this.handleInsertBold)}
-            >
-              <Icon icon="bold" classes="icon-inline" />
-            </button>
-            <button
-              className="btn btn-sm text-muted"
-              data-tippy-content={i18n.t("italic")}
-              aria-label={i18n.t("italic")}
-              onClick={linkEvent(this, this.handleInsertItalic)}
-            >
-              <Icon icon="italic" classes="icon-inline" />
-            </button>
-            <button
-              className="btn btn-sm text-muted"
-              data-tippy-content={i18n.t("link")}
-              aria-label={i18n.t("link")}
-              onClick={linkEvent(this, this.handleInsertLink)}
-            >
-              <Icon icon="link" classes="icon-inline" />
-            </button>
+            {this.getFormatButton("bold", this.handleInsertBold)}
+            {this.getFormatButton("italic", this.handleInsertItalic)}
+            {this.getFormatButton("link", this.handleInsertLink)}
             <EmojiPicker
               onEmojiClick={e => this.handleEmoji(this, e)}
+              disabled={this.isDisabled}
             ></EmojiPicker>
             <form className="btn btn-sm text-muted font-weight-bold">
               <label
@@ -239,7 +243,7 @@ export class MarkdownTextArea extends Component<
                 }`}
                 data-tippy-content={i18n.t("upload_image")}
               >
-                {this.state.imageLoading ? (
+                {this.state.imageUploadStatus ? (
                   <Spinner />
                 ) : (
                   <Icon icon="image" classes="icon-inline" />
@@ -251,74 +255,22 @@ export class MarkdownTextArea extends Component<
                 accept="image/*,video/*"
                 name="file"
                 className="d-none"
-                disabled={!UserService.Instance.myUserInfo}
+                multiple
+                disabled={!UserService.Instance.myUserInfo || this.isDisabled}
                 onChange={linkEvent(this, this.handleImageUpload)}
               />
             </form>
-            <button
-              className="btn btn-sm text-muted"
-              data-tippy-content={i18n.t("header")}
-              aria-label={i18n.t("header")}
-              onClick={linkEvent(this, this.handleInsertHeader)}
-            >
-              <Icon icon="header" classes="icon-inline" />
-            </button>
-            <button
-              className="btn btn-sm text-muted"
-              data-tippy-content={i18n.t("strikethrough")}
-              aria-label={i18n.t("strikethrough")}
-              onClick={linkEvent(this, this.handleInsertStrikethrough)}
-            >
-              <Icon icon="strikethrough" classes="icon-inline" />
-            </button>
-            <button
-              className="btn btn-sm text-muted"
-              data-tippy-content={i18n.t("quote")}
-              aria-label={i18n.t("quote")}
-              onClick={linkEvent(this, this.handleInsertQuote)}
-            >
-              <Icon icon="format_quote" classes="icon-inline" />
-            </button>
-            <button
-              className="btn btn-sm text-muted"
-              data-tippy-content={i18n.t("list")}
-              aria-label={i18n.t("list")}
-              onClick={linkEvent(this, this.handleInsertList)}
-            >
-              <Icon icon="list" classes="icon-inline" />
-            </button>
-            <button
-              className="btn btn-sm text-muted"
-              data-tippy-content={i18n.t("code")}
-              aria-label={i18n.t("code")}
-              onClick={linkEvent(this, this.handleInsertCode)}
-            >
-              <Icon icon="code" classes="icon-inline" />
-            </button>
-            <button
-              className="btn btn-sm text-muted"
-              data-tippy-content={i18n.t("subscript")}
-              aria-label={i18n.t("subscript")}
-              onClick={linkEvent(this, this.handleInsertSubscript)}
-            >
-              <Icon icon="subscript" classes="icon-inline" />
-            </button>
-            <button
-              className="btn btn-sm text-muted"
-              data-tippy-content={i18n.t("superscript")}
-              aria-label={i18n.t("superscript")}
-              onClick={linkEvent(this, this.handleInsertSuperscript)}
-            >
-              <Icon icon="superscript" classes="icon-inline" />
-            </button>
-            <button
-              className="btn btn-sm text-muted"
-              data-tippy-content={i18n.t("spoiler")}
-              aria-label={i18n.t("spoiler")}
-              onClick={linkEvent(this, this.handleInsertSpoiler)}
-            >
-              <Icon icon="alert-triangle" classes="icon-inline" />
-            </button>
+            {this.getFormatButton("header", this.handleInsertHeader)}
+            {this.getFormatButton(
+              "strikethrough",
+              this.handleInsertStrikethrough
+            )}
+            {this.getFormatButton("quote", this.handleInsertQuote)}
+            {this.getFormatButton("list", this.handleInsertList)}
+            {this.getFormatButton("code", this.handleInsertCode)}
+            {this.getFormatButton("subscript", this.handleInsertSubscript)}
+            {this.getFormatButton("superscript", this.handleInsertSuperscript)}
+            {this.getFormatButton("spoiler", this.handleInsertSpoiler)}
             <a
               href={markdownHelpUrl}
               className="btn btn-sm text-muted font-weight-bold"
@@ -333,6 +285,39 @@ export class MarkdownTextArea extends Component<
     );
   }
 
+  getFormatButton(
+    type: NoOptionI18nKeys,
+    handleClick: (i: MarkdownTextArea, event: any) => void
+  ) {
+    let iconType: string;
+
+    switch (type) {
+      case "spoiler": {
+        iconType = "alert-triangle";
+        break;
+      }
+      case "quote": {
+        iconType = "format_quote";
+        break;
+      }
+      default: {
+        iconType = type;
+      }
+    }
+
+    return (
+      <button
+        className="btn btn-sm text-muted"
+        data-tippy-content={i18n.t(type)}
+        aria-label={i18n.t(type)}
+        onClick={linkEvent(this, handleClick)}
+        disabled={this.isDisabled}
+      >
+        <Icon icon={iconType} classes="icon-inline" />
+      </button>
+    );
+  }
+
   handleEmoji(i: MarkdownTextArea, e: any) {
     let value = e.native;
     if (value == null) {
@@ -350,53 +335,87 @@ export class MarkdownTextArea extends Component<
   }
 
   handleImageUploadPaste(i: MarkdownTextArea, event: any) {
-    let image = event.clipboardData.files[0];
+    const image = event.clipboardData.files[0];
     if (image) {
       i.handleImageUpload(i, image);
     }
   }
 
   handleImageUpload(i: MarkdownTextArea, event: any) {
-    let file: any;
+    const files: File[] = [];
     if (event.target) {
       event.preventDefault();
-      file = event.target.files[0];
+      files.push(...event.target.files);
     } else {
-      file = event;
+      files.push(event);
     }
 
-    i.setState({ imageLoading: true });
-
-    uploadImage(file)
-      .then(res => {
-        console.log("pictrs upload:");
-        console.log(res);
-        if (res.msg === "ok") {
-          const imageMarkdown = `![](${res.url})`;
-          const content = i.state.content;
-          i.setState({
-            content: content ? `${content}\n${imageMarkdown}` : imageMarkdown,
-            imageLoading: false,
-          });
-          i.contentChange();
-          const textarea: any = document.getElementById(i.id);
-          autosize.update(textarea);
-          pictrsDeleteToast(
-            `${i18n.t("click_to_delete_picture")}: ${file.name}`,
-            `${i18n.t("picture_deleted")}: ${file.name}`,
-            `${i18n.t("failed_to_delete_picture")}: ${file.name}`,
-            res.delete_url as string
-          );
-        } else {
-          i.setState({ imageLoading: false });
-          toast(JSON.stringify(res), "danger");
-        }
-      })
-      .catch(error => {
-        i.setState({ imageLoading: false });
-        console.error(error);
-        toast(error, "danger");
+    if (files.length > maxUploadImages) {
+      toast(
+        i18n.t("too_many_images_upload", {
+          count: maxUploadImages,
+          formattedCount: numToSI(maxUploadImages),
+        }),
+        "danger"
+      );
+    } else {
+      i.setState({
+        imageUploadStatus: { total: files.length, uploaded: 0 },
       });
+
+      i.uploadImages(i, files).then(() => {
+        i.setState({ imageUploadStatus: undefined });
+      });
+    }
+  }
+
+  async uploadImages(i: MarkdownTextArea, files: File[]) {
+    let errorOccurred = false;
+    const filesCopy = [...files];
+    while (filesCopy.length > 0 && !errorOccurred) {
+      try {
+        await Promise.all(
+          filesCopy.splice(0, concurrentImageUpload).map(async file => {
+            await i.uploadSingleImage(i, file);
+
+            this.setState(({ imageUploadStatus }) => ({
+              imageUploadStatus: {
+                ...(imageUploadStatus as Required<ImageUploadStatus>),
+                uploaded: (imageUploadStatus?.uploaded ?? 0) + 1,
+              },
+            }));
+          })
+        );
+      } catch (e) {
+        errorOccurred = true;
+      }
+    }
+  }
+
+  async uploadSingleImage(i: MarkdownTextArea, file: File) {
+    try {
+      const res = await uploadImage(file);
+      console.log("pictrs upload:");
+      console.log(res);
+      if (res.msg === "ok") {
+        const imageMarkdown = `![](${res.url})`;
+        i.setState(({ content }) => ({
+          content: content ? `${content}\n${imageMarkdown}` : imageMarkdown,
+        }));
+        i.contentChange();
+        const textarea: any = document.getElementById(i.id);
+        autosize.update(textarea);
+        pictrsDeleteToast(file.name, res.delete_url as string);
+      } else {
+        throw JSON.stringify(res);
+      }
+    } catch (error) {
+      i.setState({ imageUploadStatus: undefined });
+      console.error(error);
+      toast(error, "danger");
+
+      throw error;
+    }
   }
 
   contentChange() {
@@ -595,11 +614,11 @@ export class MarkdownTextArea extends Component<
   }
 
   quoteInsert() {
-    let textarea: any = document.getElementById(this.id);
-    let selectedText = window.getSelection()?.toString();
-    let content = this.state.content;
+    const textarea: any = document.getElementById(this.id);
+    const selectedText = window.getSelection()?.toString();
+    const { content } = this.state;
     if (selectedText) {
-      let quotedText =
+      const quotedText =
         selectedText
           .split("\n")
           .map(t => `> ${t}`)
@@ -619,9 +638,16 @@ export class MarkdownTextArea extends Component<
   }
 
   getSelectedText(): string {
-    let textarea: any = document.getElementById(this.id);
-    let start: number = textarea.selectionStart;
-    let end: number = textarea.selectionEnd;
+    const { selectionStart: start, selectionEnd: end } =
+      document.getElementById(this.id) as any;
     return start !== end ? this.state.content?.substring(start, end) ?? "" : "";
   }
+
+  get isDisabled() {
+    return (
+      this.state.loading ||
+      this.props.disabled ||
+      !!this.state.imageUploadStatus
+    );
+  }
 }
diff --git a/src/shared/components/common/progress-bar.tsx b/src/shared/components/common/progress-bar.tsx
new file mode 100644
index 0000000..5ddc5ca
--- /dev/null
+++ b/src/shared/components/common/progress-bar.tsx
@@ -0,0 +1,44 @@
+import classNames from "classnames";
+import { ThemeColor } from "shared/utils";
+
+interface ProgressBarProps {
+  className?: string;
+  backgroundColor?: ThemeColor;
+  barColor?: ThemeColor;
+  striped?: boolean;
+  animated?: boolean;
+  min?: number;
+  max?: number;
+  value: number;
+  text?: string;
+}
+
+const ProgressBar = ({
+  value,
+  animated = false,
+  backgroundColor = "secondary",
+  barColor = "primary",
+  className,
+  max = 100,
+  min = 0,
+  striped = false,
+  text,
+}: ProgressBarProps) => (
+  <div className={classNames("progress", `bg-${backgroundColor}`, className)}>
+    <div
+      className={classNames("progress-bar", `bg-${barColor}`, {
+        "progress-bar-striped": striped,
+        "progress-bar-animated": animated,
+      })}
+      role="progressbar"
+      aria-valuemin={min}
+      aria-valuemax={max}
+      aria-valuenow={value}
+      style={`width: ${((value - min) / max) * 100}%;`}
+    >
+      {text}
+    </div>
+  </div>
+);
+
+export default ProgressBar;
diff --git a/src/shared/components/home/emojis-form.tsx b/src/shared/components/home/emojis-form.tsx
index 21634e4..62ac661 100644
--- a/src/shared/components/home/emojis-form.tsx
+++ b/src/shared/components/home/emojis-form.tsx
@@ -481,12 +481,7 @@ export class EmojiForm extends Component<any, EmojiFormState> {
         console.log("pictrs upload:");
         console.log(res);
         if (res.msg === "ok") {
-          pictrsDeleteToast(
-            `${i18n.t("click_to_delete_picture")}: ${file.name}`,
-            `${i18n.t("picture_deleted")}: ${file.name}`,
-            `${i18n.t("failed_to_delete_picture")}: ${file.name}`,
-            res.delete_url as string
-          );
+          pictrsDeleteToast(file.name, res.delete_url as string);
         } else {
           toast(JSON.stringify(res), "danger");
           let hash = res.files?.at(0)?.file;
diff --git a/src/shared/components/post/post-form.tsx b/src/shared/components/post/post-form.tsx
index a9b61a3..db99558 100644
--- a/src/shared/components/post/post-form.tsx
+++ b/src/shared/components/post/post-form.tsx
@@ -596,12 +596,7 @@ export class PostForm extends Component<PostFormProps, PostFormState> {
         if (res.msg === "ok") {
           i.state.form.url = res.url;
           i.setState({ imageLoading: false });
-          pictrsDeleteToast(
-            `${i18n.t("click_to_delete_picture")}: ${file.name}`,
-            `${i18n.t("picture_deleted")}: ${file.name}`,
-            `${i18n.t("failed_to_delete_picture")}: ${file.name}`,
-            res.delete_url as string
-          );
+          pictrsDeleteToast(file.name, res.delete_url as string);
         } else {
           i.setState({ imageLoading: false });
           toast(JSON.stringify(res), "danger");
diff --git a/src/shared/utils.ts b/src/shared/utils.ts
index 3b73389..b0dca6a 100644
--- a/src/shared/utils.ts
+++ b/src/shared/utils.ts
@@ -77,9 +77,34 @@ export const trendingFetchLimit = 6;
 export const mentionDropdownFetchLimit = 10;
 export const commentTreeMaxDepth = 8;
 export const markdownFieldCharacterLimit = 50000;
+export const maxUploadImages = 20;
+export const concurrentImageUpload = 4;
 
 export const relTags = "noopener nofollow";
 
+export type ThemeColor =
+  | "primary"
+  | "secondary"
+  | "light"
+  | "dark"
+  | "success"
+  | "danger"
+  | "warning"
+  | "info"
+  | "blue"
+  | "indigo"
+  | "purple"
+  | "pink"
+  | "red"
+  | "orange"
+  | "yellow"
+  | "green"
+  | "teal"
+  | "cyan"
+  | "white"
+  | "gray"
+  | "gray-dark";
+
 let customEmojis: EmojiMartCategory[] = [];
 export let customEmojisLookup: Map<string, CustomEmojiView> = new Map<
   string,
@@ -487,9 +512,9 @@ export function isCakeDay(published: string): boolean {
   );
 }
 
-export function toast(text: string, background = "success") {
+export function toast(text: string, background: ThemeColor = "success") {
   if (isBrowser()) {
-    let backgroundColor = `var(--${background})`;
+    const backgroundColor = `var(--${background})`;
     Toastify({
       text: text,
       backgroundColor: backgroundColor,
@@ -500,15 +525,19 @@ export function toast(text: string, background = "success") {
   }
 }
 
-export function pictrsDeleteToast(
-  clickToDeleteText: string,
-  deletePictureText: string,
-  failedDeletePictureText: string,
-  deleteUrl: string
-) {
+export function pictrsDeleteToast(filename: string, deleteUrl: string) {
   if (isBrowser()) {
-    let backgroundColor = `var(--light)`;
-    let toast = Toastify({
+    const clickToDeleteText = i18n.t("click_to_delete_picture", { filename });
+    const deletePictureText = i18n.t("picture_deleted", {
+      filename,
+    });
+    const failedDeletePictureText = i18n.t("failed_to_delete_picture", {
+      filename,
+    });
+
+    const backgroundColor = `var(--light)`;
+
+    const toast = Toastify({
       text: clickToDeleteText,
       backgroundColor: backgroundColor,
       gravity: "top",
@@ -528,6 +557,7 @@ export function pictrsDeleteToast(
       },
       close: true,
     });
+
     toast.showToast();
   }
 }
-- 
2.44.1