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

View file

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

View file

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

View file

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

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("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;

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

View file

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