mirror of
https://github.com/LemmyNet/lemmy-ui.git
synced 2024-11-25 22:01:13 +00:00
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:
parent
a8d6df9688
commit
699c3ff4b1
9 changed files with 276 additions and 178 deletions
1
.prettierignore
Normal file
1
.prettierignore
Normal file
|
@ -0,0 +1 @@
|
||||||
|
src/shared/translations
|
|
@ -12,7 +12,7 @@
|
||||||
"build:prod": "webpack --mode=production",
|
"build:prod": "webpack --mode=production",
|
||||||
"clean": "yarn run rimraf dist",
|
"clean": "yarn run rimraf dist",
|
||||||
"dev": "yarn start",
|
"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",
|
"prepare": "husky install",
|
||||||
"start": "yarn build:dev --watch"
|
"start": "yarn build:dev --watch"
|
||||||
},
|
},
|
||||||
|
|
|
@ -5,6 +5,7 @@ import { Icon } from "./icon";
|
||||||
|
|
||||||
interface EmojiPickerProps {
|
interface EmojiPickerProps {
|
||||||
onEmojiClick?(val: any): any;
|
onEmojiClick?(val: any): any;
|
||||||
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface EmojiPickerState {
|
interface EmojiPickerState {
|
||||||
|
@ -15,8 +16,9 @@ export class EmojiPicker extends Component<EmojiPickerProps, EmojiPickerState> {
|
||||||
private emptyState: EmojiPickerState = {
|
private emptyState: EmojiPickerState = {
|
||||||
showPicker: false,
|
showPicker: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
state: EmojiPickerState;
|
state: EmojiPickerState;
|
||||||
constructor(props: any, context: any) {
|
constructor(props: EmojiPickerProps, context: any) {
|
||||||
super(props, context);
|
super(props, context);
|
||||||
this.state = this.emptyState;
|
this.state = this.emptyState;
|
||||||
this.handleEmojiClick = this.handleEmojiClick.bind(this);
|
this.handleEmojiClick = this.handleEmojiClick.bind(this);
|
||||||
|
@ -28,6 +30,7 @@ export class EmojiPicker extends Component<EmojiPickerProps, EmojiPickerState> {
|
||||||
className="btn btn-sm text-muted"
|
className="btn btn-sm text-muted"
|
||||||
data-tippy-content={i18n.t("emoji")}
|
data-tippy-content={i18n.t("emoji")}
|
||||||
aria-label={i18n.t("emoji")}
|
aria-label={i18n.t("emoji")}
|
||||||
|
disabled={this.props.disabled}
|
||||||
onClick={linkEvent(this, this.togglePicker)}
|
onClick={linkEvent(this, this.togglePicker)}
|
||||||
>
|
>
|
||||||
<Icon icon="smile" classes="icon-inline" />
|
<Icon icon="smile" classes="icon-inline" />
|
||||||
|
|
|
@ -10,11 +10,12 @@ interface LanguageSelectProps {
|
||||||
allLanguages: Language[];
|
allLanguages: Language[];
|
||||||
siteLanguages: number[];
|
siteLanguages: number[];
|
||||||
selectedLanguageIds?: number[];
|
selectedLanguageIds?: number[];
|
||||||
multiple: boolean;
|
multiple?: boolean;
|
||||||
onChange(val: number[]): any;
|
onChange(val: number[]): any;
|
||||||
showAll?: boolean;
|
showAll?: boolean;
|
||||||
showSite?: boolean;
|
showSite?: boolean;
|
||||||
iconVersion?: boolean;
|
iconVersion?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class LanguageSelect extends Component<LanguageSelectProps, any> {
|
export class LanguageSelect extends Component<LanguageSelectProps, any> {
|
||||||
|
@ -55,19 +56,19 @@ export class LanguageSelect extends Component<LanguageSelectProps, any> {
|
||||||
)}
|
)}
|
||||||
<div className="form-group row">
|
<div className="form-group row">
|
||||||
<label
|
<label
|
||||||
className={classNames("col-form-label", {
|
className={classNames(
|
||||||
"col-sm-3": this.props.multiple,
|
"col-form-label",
|
||||||
"col-sm-2": !this.props.multiple,
|
`col-sm-${this.props.multiple ? 3 : 2}`
|
||||||
})}
|
)}
|
||||||
htmlFor={this.id}
|
htmlFor={this.id}
|
||||||
>
|
>
|
||||||
{i18n.t(this.props.multiple ? "language_plural" : "language")}
|
{i18n.t(this.props.multiple ? "language_plural" : "language")}
|
||||||
</label>
|
</label>
|
||||||
<div
|
<div
|
||||||
className={classNames("input-group", {
|
className={classNames(
|
||||||
"col-sm-9": this.props.multiple,
|
"input-group",
|
||||||
"col-sm-10": !this.props.multiple,
|
`col-sm-${this.props.multiple ? 9 : 10}`
|
||||||
})}
|
)}
|
||||||
>
|
>
|
||||||
{this.selectBtn}
|
{this.selectBtn}
|
||||||
{this.props.multiple && (
|
{this.props.multiple && (
|
||||||
|
@ -87,8 +88,8 @@ export class LanguageSelect extends Component<LanguageSelectProps, any> {
|
||||||
}
|
}
|
||||||
|
|
||||||
get selectBtn() {
|
get selectBtn() {
|
||||||
let selectedLangs = this.props.selectedLanguageIds;
|
const selectedLangs = this.props.selectedLanguageIds;
|
||||||
let filteredLangs = selectableLanguages(
|
const filteredLangs = selectableLanguages(
|
||||||
this.props.allLanguages,
|
this.props.allLanguages,
|
||||||
this.props.siteLanguages,
|
this.props.siteLanguages,
|
||||||
this.props.showAll,
|
this.props.showAll,
|
||||||
|
@ -98,14 +99,17 @@ export class LanguageSelect extends Component<LanguageSelectProps, any> {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<select
|
<select
|
||||||
className={classNames("lang-select-action", {
|
className={classNames(
|
||||||
"form-control custom-select": !this.props.iconVersion,
|
"lang-select-action",
|
||||||
"btn btn-sm text-muted": this.props.iconVersion,
|
this.props.iconVersion
|
||||||
})}
|
? "btn btn-sm text-muted"
|
||||||
|
: "form-control custom-select"
|
||||||
|
)}
|
||||||
id={this.id}
|
id={this.id}
|
||||||
onChange={linkEvent(this, this.handleLanguageChange)}
|
onChange={linkEvent(this, this.handleLanguageChange)}
|
||||||
aria-label="action"
|
aria-label="action"
|
||||||
multiple={this.props.multiple}
|
multiple={this.props.multiple}
|
||||||
|
disabled={this.props.disabled}
|
||||||
>
|
>
|
||||||
{filteredLangs.map(l => (
|
{filteredLangs.map(l => (
|
||||||
<option
|
<option
|
||||||
|
|
|
@ -1,15 +1,19 @@
|
||||||
import autosize from "autosize";
|
import autosize from "autosize";
|
||||||
|
import { NoOptionI18nKeys } from "i18next";
|
||||||
import { Component, linkEvent } from "inferno";
|
import { Component, linkEvent } from "inferno";
|
||||||
import { Prompt } from "inferno-router";
|
import { Prompt } from "inferno-router";
|
||||||
import { Language } from "lemmy-js-client";
|
import { Language } from "lemmy-js-client";
|
||||||
import { i18n } from "../../i18next";
|
import { i18n } from "../../i18next";
|
||||||
import { UserService } from "../../services";
|
import { UserService } from "../../services";
|
||||||
import {
|
import {
|
||||||
|
concurrentImageUpload,
|
||||||
customEmojisLookup,
|
customEmojisLookup,
|
||||||
isBrowser,
|
isBrowser,
|
||||||
markdownFieldCharacterLimit,
|
markdownFieldCharacterLimit,
|
||||||
markdownHelpUrl,
|
markdownHelpUrl,
|
||||||
|
maxUploadImages,
|
||||||
mdToHtml,
|
mdToHtml,
|
||||||
|
numToSI,
|
||||||
pictrsDeleteToast,
|
pictrsDeleteToast,
|
||||||
randomStr,
|
randomStr,
|
||||||
relTags,
|
relTags,
|
||||||
|
@ -21,6 +25,7 @@ import {
|
||||||
import { EmojiPicker } from "./emoji-picker";
|
import { EmojiPicker } from "./emoji-picker";
|
||||||
import { Icon, Spinner } from "./icon";
|
import { Icon, Spinner } from "./icon";
|
||||||
import { LanguageSelect } from "./language-select";
|
import { LanguageSelect } from "./language-select";
|
||||||
|
import ProgressBar from "./progress-bar";
|
||||||
|
|
||||||
interface MarkdownTextAreaProps {
|
interface MarkdownTextAreaProps {
|
||||||
initialContent?: string;
|
initialContent?: string;
|
||||||
|
@ -41,12 +46,17 @@ interface MarkdownTextAreaProps {
|
||||||
siteLanguages: number[]; // TODO same
|
siteLanguages: number[]; // TODO same
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ImageUploadStatus {
|
||||||
|
total: number;
|
||||||
|
uploaded: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface MarkdownTextAreaState {
|
interface MarkdownTextAreaState {
|
||||||
content?: string;
|
content?: string;
|
||||||
languageId?: number;
|
languageId?: number;
|
||||||
previewMode: boolean;
|
previewMode: boolean;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
imageLoading: boolean;
|
imageUploadStatus?: ImageUploadStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class MarkdownTextArea extends Component<
|
export class MarkdownTextArea extends Component<
|
||||||
|
@ -56,12 +66,12 @@ export class MarkdownTextArea extends Component<
|
||||||
private id = `comment-textarea-${randomStr()}`;
|
private id = `comment-textarea-${randomStr()}`;
|
||||||
private formId = `comment-form-${randomStr()}`;
|
private formId = `comment-form-${randomStr()}`;
|
||||||
private tribute: any;
|
private tribute: any;
|
||||||
|
|
||||||
state: MarkdownTextAreaState = {
|
state: MarkdownTextAreaState = {
|
||||||
content: this.props.initialContent,
|
content: this.props.initialContent,
|
||||||
languageId: this.props.initialLanguageId,
|
languageId: this.props.initialLanguageId,
|
||||||
previewMode: false,
|
previewMode: false,
|
||||||
loading: false,
|
loading: false,
|
||||||
imageLoading: false,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
constructor(props: any, context: any) {
|
constructor(props: any, context: any) {
|
||||||
|
@ -110,8 +120,8 @@ export class MarkdownTextArea extends Component<
|
||||||
this.props.onReplyCancel?.();
|
this.props.onReplyCancel?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
let textarea: any = document.getElementById(this.id);
|
const textarea: any = document.getElementById(this.id);
|
||||||
let form: any = document.getElementById(this.formId);
|
const form: any = document.getElementById(this.formId);
|
||||||
form.reset();
|
form.reset();
|
||||||
setTimeout(() => autosize.update(textarea), 10);
|
setTimeout(() => autosize.update(textarea), 10);
|
||||||
}
|
}
|
||||||
|
@ -139,7 +149,7 @@ export class MarkdownTextArea extends Component<
|
||||||
onInput={linkEvent(this, this.handleContentChange)}
|
onInput={linkEvent(this, this.handleContentChange)}
|
||||||
onPaste={linkEvent(this, this.handleImageUploadPaste)}
|
onPaste={linkEvent(this, this.handleImageUploadPaste)}
|
||||||
required
|
required
|
||||||
disabled={this.props.disabled}
|
disabled={this.isDisabled}
|
||||||
rows={2}
|
rows={2}
|
||||||
maxLength={this.props.maxLength ?? markdownFieldCharacterLimit}
|
maxLength={this.props.maxLength ?? markdownFieldCharacterLimit}
|
||||||
placeholder={this.props.placeholder}
|
placeholder={this.props.placeholder}
|
||||||
|
@ -150,6 +160,20 @@ export class MarkdownTextArea extends Component<
|
||||||
dangerouslySetInnerHTML={mdToHtml(this.state.content)}
|
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>
|
</div>
|
||||||
<label className="sr-only" htmlFor={this.id}>
|
<label className="sr-only" htmlFor={this.id}>
|
||||||
{i18n.t("body")}
|
{i18n.t("body")}
|
||||||
|
@ -161,7 +185,7 @@ export class MarkdownTextArea extends Component<
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="btn btn-sm btn-secondary mr-2"
|
className="btn btn-sm btn-secondary mr-2"
|
||||||
disabled={this.props.disabled || this.state.loading}
|
disabled={this.isDisabled}
|
||||||
>
|
>
|
||||||
{this.state.loading ? (
|
{this.state.loading ? (
|
||||||
<Spinner />
|
<Spinner />
|
||||||
|
@ -200,36 +224,16 @@ export class MarkdownTextArea extends Component<
|
||||||
languageId ? Array.of(languageId) : undefined
|
languageId ? Array.of(languageId) : undefined
|
||||||
}
|
}
|
||||||
siteLanguages={this.props.siteLanguages}
|
siteLanguages={this.props.siteLanguages}
|
||||||
multiple={false}
|
|
||||||
onChange={this.handleLanguageChange}
|
onChange={this.handleLanguageChange}
|
||||||
|
disabled={this.isDisabled}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<button
|
{this.getFormatButton("bold", this.handleInsertBold)}
|
||||||
className="btn btn-sm text-muted"
|
{this.getFormatButton("italic", this.handleInsertItalic)}
|
||||||
data-tippy-content={i18n.t("bold")}
|
{this.getFormatButton("link", this.handleInsertLink)}
|
||||||
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>
|
|
||||||
<EmojiPicker
|
<EmojiPicker
|
||||||
onEmojiClick={e => this.handleEmoji(this, e)}
|
onEmojiClick={e => this.handleEmoji(this, e)}
|
||||||
|
disabled={this.isDisabled}
|
||||||
></EmojiPicker>
|
></EmojiPicker>
|
||||||
<form className="btn btn-sm text-muted font-weight-bold">
|
<form className="btn btn-sm text-muted font-weight-bold">
|
||||||
<label
|
<label
|
||||||
|
@ -239,7 +243,7 @@ export class MarkdownTextArea extends Component<
|
||||||
}`}
|
}`}
|
||||||
data-tippy-content={i18n.t("upload_image")}
|
data-tippy-content={i18n.t("upload_image")}
|
||||||
>
|
>
|
||||||
{this.state.imageLoading ? (
|
{this.state.imageUploadStatus ? (
|
||||||
<Spinner />
|
<Spinner />
|
||||||
) : (
|
) : (
|
||||||
<Icon icon="image" classes="icon-inline" />
|
<Icon icon="image" classes="icon-inline" />
|
||||||
|
@ -251,74 +255,22 @@ export class MarkdownTextArea extends Component<
|
||||||
accept="image/*,video/*"
|
accept="image/*,video/*"
|
||||||
name="file"
|
name="file"
|
||||||
className="d-none"
|
className="d-none"
|
||||||
disabled={!UserService.Instance.myUserInfo}
|
multiple
|
||||||
|
disabled={!UserService.Instance.myUserInfo || this.isDisabled}
|
||||||
onChange={linkEvent(this, this.handleImageUpload)}
|
onChange={linkEvent(this, this.handleImageUpload)}
|
||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
<button
|
{this.getFormatButton("header", this.handleInsertHeader)}
|
||||||
className="btn btn-sm text-muted"
|
{this.getFormatButton(
|
||||||
data-tippy-content={i18n.t("header")}
|
"strikethrough",
|
||||||
aria-label={i18n.t("header")}
|
this.handleInsertStrikethrough
|
||||||
onClick={linkEvent(this, this.handleInsertHeader)}
|
)}
|
||||||
>
|
{this.getFormatButton("quote", this.handleInsertQuote)}
|
||||||
<Icon icon="header" classes="icon-inline" />
|
{this.getFormatButton("list", this.handleInsertList)}
|
||||||
</button>
|
{this.getFormatButton("code", this.handleInsertCode)}
|
||||||
<button
|
{this.getFormatButton("subscript", this.handleInsertSubscript)}
|
||||||
className="btn btn-sm text-muted"
|
{this.getFormatButton("superscript", this.handleInsertSuperscript)}
|
||||||
data-tippy-content={i18n.t("strikethrough")}
|
{this.getFormatButton("spoiler", this.handleInsertSpoiler)}
|
||||||
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>
|
|
||||||
<a
|
<a
|
||||||
href={markdownHelpUrl}
|
href={markdownHelpUrl}
|
||||||
className="btn btn-sm text-muted font-weight-bold"
|
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) {
|
handleEmoji(i: MarkdownTextArea, e: any) {
|
||||||
let value = e.native;
|
let value = e.native;
|
||||||
if (value == null) {
|
if (value == null) {
|
||||||
|
@ -350,53 +335,87 @@ export class MarkdownTextArea extends Component<
|
||||||
}
|
}
|
||||||
|
|
||||||
handleImageUploadPaste(i: MarkdownTextArea, event: any) {
|
handleImageUploadPaste(i: MarkdownTextArea, event: any) {
|
||||||
let image = event.clipboardData.files[0];
|
const image = event.clipboardData.files[0];
|
||||||
if (image) {
|
if (image) {
|
||||||
i.handleImageUpload(i, image);
|
i.handleImageUpload(i, image);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handleImageUpload(i: MarkdownTextArea, event: any) {
|
handleImageUpload(i: MarkdownTextArea, event: any) {
|
||||||
let file: any;
|
const files: File[] = [];
|
||||||
if (event.target) {
|
if (event.target) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
file = event.target.files[0];
|
files.push(...event.target.files);
|
||||||
} else {
|
} else {
|
||||||
file = event;
|
files.push(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
i.setState({ imageLoading: true });
|
if (files.length > maxUploadImages) {
|
||||||
|
toast(
|
||||||
uploadImage(file)
|
i18n.t("too_many_images_upload", {
|
||||||
.then(res => {
|
count: maxUploadImages,
|
||||||
console.log("pictrs upload:");
|
formattedCount: numToSI(maxUploadImages),
|
||||||
console.log(res);
|
}),
|
||||||
if (res.msg === "ok") {
|
"danger"
|
||||||
const imageMarkdown = `![](${res.url})`;
|
);
|
||||||
const content = i.state.content;
|
} else {
|
||||||
i.setState({
|
i.setState({
|
||||||
content: content ? `${content}\n${imageMarkdown}` : imageMarkdown,
|
imageUploadStatus: { total: files.length, uploaded: 0 },
|
||||||
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");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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() {
|
contentChange() {
|
||||||
|
@ -595,11 +614,11 @@ export class MarkdownTextArea extends Component<
|
||||||
}
|
}
|
||||||
|
|
||||||
quoteInsert() {
|
quoteInsert() {
|
||||||
let textarea: any = document.getElementById(this.id);
|
const textarea: any = document.getElementById(this.id);
|
||||||
let selectedText = window.getSelection()?.toString();
|
const selectedText = window.getSelection()?.toString();
|
||||||
let content = this.state.content;
|
const { content } = this.state;
|
||||||
if (selectedText) {
|
if (selectedText) {
|
||||||
let quotedText =
|
const quotedText =
|
||||||
selectedText
|
selectedText
|
||||||
.split("\n")
|
.split("\n")
|
||||||
.map(t => `> ${t}`)
|
.map(t => `> ${t}`)
|
||||||
|
@ -619,9 +638,16 @@ export class MarkdownTextArea extends Component<
|
||||||
}
|
}
|
||||||
|
|
||||||
getSelectedText(): string {
|
getSelectedText(): string {
|
||||||
let textarea: any = document.getElementById(this.id);
|
const { selectionStart: start, selectionEnd: end } =
|
||||||
let start: number = textarea.selectionStart;
|
document.getElementById(this.id) as any;
|
||||||
let end: number = textarea.selectionEnd;
|
|
||||||
return start !== end ? this.state.content?.substring(start, end) ?? "" : "";
|
return start !== end ? this.state.content?.substring(start, end) ?? "" : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get isDisabled() {
|
||||||
|
return (
|
||||||
|
this.state.loading ||
|
||||||
|
this.props.disabled ||
|
||||||
|
!!this.state.imageUploadStatus
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
44
src/shared/components/common/progress-bar.tsx
Normal file
44
src/shared/components/common/progress-bar.tsx
Normal 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;
|
|
@ -481,12 +481,7 @@ export class EmojiForm extends Component<any, EmojiFormState> {
|
||||||
console.log("pictrs upload:");
|
console.log("pictrs upload:");
|
||||||
console.log(res);
|
console.log(res);
|
||||||
if (res.msg === "ok") {
|
if (res.msg === "ok") {
|
||||||
pictrsDeleteToast(
|
pictrsDeleteToast(file.name, res.delete_url as string);
|
||||||
`${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 {
|
} else {
|
||||||
toast(JSON.stringify(res), "danger");
|
toast(JSON.stringify(res), "danger");
|
||||||
let hash = res.files?.at(0)?.file;
|
let hash = res.files?.at(0)?.file;
|
||||||
|
|
|
@ -596,12 +596,7 @@ export class PostForm extends Component<PostFormProps, PostFormState> {
|
||||||
if (res.msg === "ok") {
|
if (res.msg === "ok") {
|
||||||
i.state.form.url = res.url;
|
i.state.form.url = res.url;
|
||||||
i.setState({ imageLoading: false });
|
i.setState({ imageLoading: false });
|
||||||
pictrsDeleteToast(
|
pictrsDeleteToast(file.name, res.delete_url as string);
|
||||||
`${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 {
|
} else {
|
||||||
i.setState({ imageLoading: false });
|
i.setState({ imageLoading: false });
|
||||||
toast(JSON.stringify(res), "danger");
|
toast(JSON.stringify(res), "danger");
|
||||||
|
|
|
@ -77,9 +77,34 @@ export const trendingFetchLimit = 6;
|
||||||
export const mentionDropdownFetchLimit = 10;
|
export const mentionDropdownFetchLimit = 10;
|
||||||
export const commentTreeMaxDepth = 8;
|
export const commentTreeMaxDepth = 8;
|
||||||
export const markdownFieldCharacterLimit = 50000;
|
export const markdownFieldCharacterLimit = 50000;
|
||||||
|
export const maxUploadImages = 20;
|
||||||
|
export const concurrentImageUpload = 4;
|
||||||
|
|
||||||
export const relTags = "noopener nofollow";
|
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[] = [];
|
let customEmojis: EmojiMartCategory[] = [];
|
||||||
export let customEmojisLookup: Map<string, CustomEmojiView> = new Map<
|
export let customEmojisLookup: Map<string, CustomEmojiView> = new Map<
|
||||||
string,
|
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()) {
|
if (isBrowser()) {
|
||||||
let backgroundColor = `var(--${background})`;
|
const backgroundColor = `var(--${background})`;
|
||||||
Toastify({
|
Toastify({
|
||||||
text: text,
|
text: text,
|
||||||
backgroundColor: backgroundColor,
|
backgroundColor: backgroundColor,
|
||||||
|
@ -500,15 +525,19 @@ export function toast(text: string, background = "success") {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function pictrsDeleteToast(
|
export function pictrsDeleteToast(filename: string, deleteUrl: string) {
|
||||||
clickToDeleteText: string,
|
|
||||||
deletePictureText: string,
|
|
||||||
failedDeletePictureText: string,
|
|
||||||
deleteUrl: string
|
|
||||||
) {
|
|
||||||
if (isBrowser()) {
|
if (isBrowser()) {
|
||||||
let backgroundColor = `var(--light)`;
|
const clickToDeleteText = i18n.t("click_to_delete_picture", { filename });
|
||||||
let toast = Toastify({
|
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,
|
text: clickToDeleteText,
|
||||||
backgroundColor: backgroundColor,
|
backgroundColor: backgroundColor,
|
||||||
gravity: "top",
|
gravity: "top",
|
||||||
|
@ -528,6 +557,7 @@ export function pictrsDeleteToast(
|
||||||
},
|
},
|
||||||
close: true,
|
close: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
toast.showToast();
|
toast.showToast();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue