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