From 54409ffc669bf7019e960838d4275a2bd908e0c0 Mon Sep 17 00:00:00 2001 From: abias Date: Tue, 28 Mar 2023 18:50:41 -0400 Subject: [PATCH 01/11] feat: Add multiple image upload --- .../components/common/markdown-textarea.tsx | 101 ++++++++++++------ src/shared/utils.ts | 2 + 2 files changed, 69 insertions(+), 34 deletions(-) diff --git a/src/shared/components/common/markdown-textarea.tsx b/src/shared/components/common/markdown-textarea.tsx index d7bb4c522..dd9f4d651 100644 --- a/src/shared/components/common/markdown-textarea.tsx +++ b/src/shared/components/common/markdown-textarea.tsx @@ -5,11 +5,14 @@ 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, @@ -251,6 +254,7 @@ export class MarkdownTextArea extends Component< accept="image/*,video/*" name="file" className="d-none" + multiple disabled={!UserService.Instance.myUserInfo} onChange={linkEvent(this, this.handleImageUpload)} /> @@ -350,53 +354,82 @@ 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 => { + if (files.length > maxUploadImages) { + toast( + i18n.t("too_many_images_upload", { + count: maxUploadImages, + formattedCount: numToSI(maxUploadImages), + }), + "danger" + ); + } else { + i.setState({ imageLoading: true }); + + i.uploadImages(i, files).then(() => { i.setState({ imageLoading: false }); - console.error(error); - toast(error, "danger"); }); + } + } + + async uploadImages(i: MarkdownTextArea, files: File[]) { + let errorOccurred = false; + while (files.length > 0 && !errorOccurred) { + try { + await Promise.all( + files + .splice(0, concurrentImageUpload) + .map(file => i.uploadSingleImage(i, file)) + ); + } 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( + `${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 { + throw JSON.stringify(res); + } + } catch (error) { + i.setState({ imageLoading: false }); + console.error(error); + toast(error, "danger"); + + throw error; + } } contentChange() { diff --git a/src/shared/utils.ts b/src/shared/utils.ts index 3b73389d8..556782810 100644 --- a/src/shared/utils.ts +++ b/src/shared/utils.ts @@ -77,6 +77,8 @@ 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"; From ecd3ae9cc2cf84ec1c455c5329905ae061c94c6c Mon Sep 17 00:00:00 2001 From: abias Date: Tue, 28 Mar 2023 19:21:29 -0400 Subject: [PATCH 02/11] refactor: Slight cleanup --- .../components/common/markdown-textarea.tsx | 10 +++---- src/shared/components/home/emojis-form.tsx | 7 +---- src/shared/components/post/post-form.tsx | 7 +---- src/shared/utils.ts | 27 ++++++++++++------- 4 files changed, 23 insertions(+), 28 deletions(-) diff --git a/src/shared/components/common/markdown-textarea.tsx b/src/shared/components/common/markdown-textarea.tsx index dd9f4d651..c12b176e5 100644 --- a/src/shared/components/common/markdown-textarea.tsx +++ b/src/shared/components/common/markdown-textarea.tsx @@ -386,6 +386,9 @@ export class MarkdownTextArea extends Component< } } + /** + * **NOTE**: Destroys files parameter + */ async uploadImages(i: MarkdownTextArea, files: File[]) { let errorOccurred = false; while (files.length > 0 && !errorOccurred) { @@ -414,12 +417,7 @@ export class MarkdownTextArea extends Component< 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 - ); + pictrsDeleteToast(file.name, res.delete_url as string); } else { throw JSON.stringify(res); } diff --git a/src/shared/components/home/emojis-form.tsx b/src/shared/components/home/emojis-form.tsx index 21634e45c..62ac6614a 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 { 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 a9b61a357..db9955808 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 { 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 556782810..0a014a777 100644 --- a/src/shared/utils.ts +++ b/src/shared/utils.ts @@ -489,9 +489,12 @@ export function isCakeDay(published: string): boolean { ); } -export function toast(text: string, background = "success") { +export function toast( + text: string, + background: "success" | "danger" = "success" +) { if (isBrowser()) { - let backgroundColor = `var(--${background})`; + const backgroundColor = `var(--${background})`; Toastify({ text: text, backgroundColor: backgroundColor, @@ -502,15 +505,18 @@ 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", @@ -530,6 +536,7 @@ export function pictrsDeleteToast( }, close: true, }); + toast.showToast(); } } From 55941b401fb5bb5808c55db81b58065c977d2a29 Mon Sep 17 00:00:00 2001 From: abias Date: Wed, 29 Mar 2023 00:20:16 -0400 Subject: [PATCH 03/11] feat: Add progress bar for multi-image upload --- src/shared/components/common/emoji-picker.tsx | 5 +- .../components/common/language-select.tsx | 34 +-- .../components/common/markdown-textarea.tsx | 223 +++++++++--------- src/shared/components/common/progress-bar.tsx | 44 ++++ src/shared/utils.ts | 28 ++- 5 files changed, 200 insertions(+), 134 deletions(-) create mode 100644 src/shared/components/common/progress-bar.tsx diff --git a/src/shared/components/common/emoji-picker.tsx b/src/shared/components/common/emoji-picker.tsx index 114958335..aea986ad9 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 { 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 { 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)} > diff --git a/src/shared/components/common/language-select.tsx b/src/shared/components/common/language-select.tsx index 64cbac498..feada32a6 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 { @@ -55,19 +56,19 @@ export class LanguageSelect extends Component { )}
{this.selectBtn} {this.props.multiple && ( @@ -87,8 +88,8 @@ export class LanguageSelect extends Component { } 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 { return (