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
This commit is contained in:
SleeplessOne1917 2023-04-04 08:40:00 -04:00 committed by GitHub
parent a8d6df9688
commit 699c3ff4b1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 276 additions and 178 deletions

1
.prettierignore Normal file
View file

@ -0,0 +1 @@
src/shared/translations

View file

@ -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"
},

View file

@ -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" />

View file

@ -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

View file

@ -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
);
}
}

View file

@ -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;

View file

@ -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;

View file

@ -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");

View file

@ -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();
}
}