1 import { myAuthRequired, setIsoData } from "@utils/app";
2 import { capitalizeFirstLetter } from "@utils/helpers";
3 import { Component, linkEvent } from "inferno";
9 } from "lemmy-js-client";
10 import { customEmojisLookup } from "../../markdown";
11 import { HttpService, I18NextService } from "../../services";
12 import { pictrsDeleteToast, toast } from "../../toast";
13 import { EmojiMart } from "../common/emoji-mart";
14 import { Icon, Spinner } from "../common/icon";
15 import { Paginator } from "../common/paginator";
17 interface EmojiFormProps {
18 onEdit(form: EditCustomEmoji): void;
19 onCreate(form: CreateCustomEmoji): void;
20 onDelete(form: DeleteCustomEmoji): void;
23 interface EmojiFormState {
24 siteRes: GetSiteResponse;
25 customEmojis: CustomEmojiViewForm[];
29 interface CustomEmojiViewForm {
41 export class EmojiForm extends Component<EmojiFormProps, EmojiFormState> {
42 private isoData = setIsoData(this.context);
43 private itemsPerPage = 15;
44 private emptyState: EmojiFormState = {
45 siteRes: this.isoData.site_res,
46 customEmojis: this.isoData.site_res.custom_emojis.map((x, index) => ({
47 id: x.custom_emoji.id,
48 category: x.custom_emoji.category,
49 shortcode: x.custom_emoji.shortcode,
50 image_url: x.custom_emoji.image_url,
51 alt_text: x.custom_emoji.alt_text,
52 keywords: x.keywords.map(x => x.keyword).join(" "),
54 page: 1 + Math.floor(index / this.itemsPerPage),
59 state: EmojiFormState;
60 private scrollRef: any = {};
61 constructor(props: any, context: any) {
62 super(props, context);
63 this.state = this.emptyState;
65 this.handlePageChange = this.handlePageChange.bind(this);
66 this.handleEmojiClick = this.handleEmojiClick.bind(this);
70 <div className="home-emojis-form col-12">
71 <h1 className="h4 mb-4">{I18NextService.i18n.t("custom_emojis")}</h1>
72 {customEmojisLookup.size > 0 && (
75 onEmojiClick={this.handleEmojiClick}
76 pickerOptions={this.configurePicker()}
80 <div className="table-responsive">
83 className="table table-sm table-hover align-middle"
85 <thead className="pointer">
87 <th>{I18NextService.i18n.t("column_emoji")}</th>
88 <th className="text-right">
89 {I18NextService.i18n.t("column_shortcode")}
91 <th className="text-right">
92 {I18NextService.i18n.t("column_category")}
94 <th className="text-right d-lg-table-cell d-none">
95 {I18NextService.i18n.t("column_imageurl")}
97 <th className="text-right">
98 {I18NextService.i18n.t("column_alttext")}
100 <th className="text-right d-lg-table-cell">
101 {I18NextService.i18n.t("column_keywords")}
103 <th style="width:121px"></th>
107 {this.state.customEmojis
109 Number((this.state.page - 1) * this.itemsPerPage),
111 (this.state.page - 1) * this.itemsPerPage +
115 .map((cv, index) => (
116 <tr key={index} ref={e => (this.scrollRef[cv.shortcode] = e)}>
117 <td style="text-align:center;">
118 {cv.image_url.length > 0 && (
120 className="icon-emoji-admin"
125 {cv.image_url.length === 0 && (
127 // TODO: Fix this linting violation
128 // eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
130 className="btn btn-sm btn-secondary pointer"
131 htmlFor={`file-uploader-${index}`}
132 data-tippy-content={I18NextService.i18n.t(
136 {capitalizeFirstLetter(
137 I18NextService.i18n.t("upload"),
140 name={`file-uploader-${index}`}
141 id={`file-uploader-${index}`}
146 { form: this, index: index },
147 this.handleImageUpload,
153 <td className="text-right">
156 placeholder="ShortCode"
157 className="form-control"
161 { form: this, index: index },
162 this.handleEmojiShortCodeChange,
166 <td className="text-right">
169 placeholder="Category"
170 className="form-control"
173 { form: this, index: index },
174 this.handleEmojiCategoryChange,
178 <td className="text-right d-lg-table-cell d-none">
182 className="form-control"
185 { form: this, index: index, overrideValue: null },
186 this.handleEmojiImageUrlChange,
190 <td className="text-right">
193 placeholder="Alt Text"
194 className="form-control"
197 { form: this, index: index },
198 this.handleEmojiAltTextChange,
202 <td className="text-right d-lg-table-cell">
205 placeholder="Keywords"
206 className="form-control"
209 { form: this, index: index },
210 this.handleEmojiKeywordChange,
216 <span title={this.getEditTooltip(cv)}>
221 : "text-muted ") + "btn btn-link btn-animate"
225 this.handleEditEmojiClick,
227 data-tippy-content={I18NextService.i18n.t("save")}
228 aria-label={I18NextService.i18n.t("save")}
229 disabled={!this.canEdit(cv)}
234 capitalizeFirstLetter(
235 I18NextService.i18n.t("save"),
241 className="btn btn-link btn-animate text-muted"
243 { i: this, index: index, cv: cv },
244 this.handleDeleteEmojiClick,
246 data-tippy-content={I18NextService.i18n.t("delete")}
247 aria-label={I18NextService.i18n.t("delete")}
248 disabled={cv.loading}
249 title={I18NextService.i18n.t("delete")}
253 classes="icon-inline text-danger"
264 className="btn btn-sm btn-secondary me-2"
265 onClick={linkEvent(this, this.handleAddEmojiClick)}
267 {I18NextService.i18n.t("add_custom_emoji")}
270 <Paginator page={this.state.page} onChange={this.handlePageChange} />
276 canEdit(cv: CustomEmojiViewForm) {
277 const noEmptyFields =
278 cv.alt_text.length > 0 &&
279 cv.category.length > 0 &&
280 cv.image_url.length > 0 &&
281 cv.shortcode.length > 0;
282 const noDuplicateShortCodes =
283 this.state.customEmojis.filter(
284 x => x.shortcode === cv.shortcode && x.id !== cv.id,
286 return noEmptyFields && noDuplicateShortCodes && !cv.loading && cv.changed;
289 getEditTooltip(cv: CustomEmojiViewForm) {
290 if (this.canEdit(cv)) return I18NextService.i18n.t("save");
291 else return I18NextService.i18n.t("custom_emoji_save_validation");
294 handlePageChange(page: number) {
295 this.setState({ page: page });
298 handleEmojiClick(e: any) {
299 const view = customEmojisLookup.get(e.id);
301 const page = this.state.customEmojis.find(
302 x => x.id === view.custom_emoji.id,
305 this.setState({ page: page });
306 this.scrollRef[view.custom_emoji.shortcode].scrollIntoView();
311 handleEmojiCategoryChange(
312 props: { form: EmojiForm; index: number },
315 const custom_emojis = [...props.form.state.customEmojis];
317 (props.form.state.page - 1) * props.form.itemsPerPage + props.index;
319 ...props.form.state.customEmojis[pagedIndex],
320 category: event.target.value,
323 custom_emojis[Number(pagedIndex)] = item;
324 props.form.setState({ customEmojis: custom_emojis });
327 handleEmojiShortCodeChange(
328 props: { form: EmojiForm; index: number },
331 const custom_emojis = [...props.form.state.customEmojis];
333 (props.form.state.page - 1) * props.form.itemsPerPage + props.index;
335 ...props.form.state.customEmojis[pagedIndex],
336 shortcode: event.target.value,
339 custom_emojis[Number(pagedIndex)] = item;
340 props.form.setState({ customEmojis: custom_emojis });
343 handleEmojiImageUrlChange(
348 }: { form: EmojiForm; index: number; overrideValue: string | null },
351 form.setState(prevState => {
352 const custom_emojis = [...form.state.customEmojis];
353 const pagedIndex = (form.state.page - 1) * form.itemsPerPage + index;
355 ...form.state.customEmojis[pagedIndex],
356 image_url: overrideValue ?? event.target.value,
359 custom_emojis[Number(pagedIndex)] = item;
362 customEmojis: prevState.customEmojis.map((ce, i) =>
366 image_url: overrideValue ?? event.target.value,
376 handleEmojiAltTextChange(
377 props: { form: EmojiForm; index: number },
380 const custom_emojis = [...props.form.state.customEmojis];
382 (props.form.state.page - 1) * props.form.itemsPerPage + props.index;
384 ...props.form.state.customEmojis[pagedIndex],
385 alt_text: event.target.value,
388 custom_emojis[Number(pagedIndex)] = item;
389 props.form.setState({ customEmojis: custom_emojis });
392 handleEmojiKeywordChange(
393 props: { form: EmojiForm; index: number },
396 const custom_emojis = [...props.form.state.customEmojis];
398 (props.form.state.page - 1) * props.form.itemsPerPage + props.index;
400 ...props.form.state.customEmojis[pagedIndex],
401 keywords: event.target.value,
404 custom_emojis[Number(pagedIndex)] = item;
405 props.form.setState({ customEmojis: custom_emojis });
408 handleDeleteEmojiClick(d: {
411 cv: CustomEmojiViewForm;
413 const pagedIndex = (d.i.state.page - 1) * d.i.itemsPerPage + d.index;
417 auth: myAuthRequired(),
420 const custom_emojis = [...d.i.state.customEmojis];
421 custom_emojis.splice(Number(pagedIndex), 1);
422 d.i.setState({ customEmojis: custom_emojis });
426 handleEditEmojiClick(d: { i: EmojiForm; cv: CustomEmojiViewForm }) {
427 const keywords = d.cv.keywords
429 .filter(x => x.length > 0) as string[];
430 const uniqueKeywords = Array.from(new Set(keywords));
434 category: d.cv.category,
435 image_url: d.cv.image_url,
436 alt_text: d.cv.alt_text,
437 keywords: uniqueKeywords,
438 auth: myAuthRequired(),
442 category: d.cv.category,
443 shortcode: d.cv.shortcode,
444 image_url: d.cv.image_url,
445 alt_text: d.cv.alt_text,
446 keywords: uniqueKeywords,
447 auth: myAuthRequired(),
452 handleAddEmojiClick(form: EmojiForm, event: any) {
453 event.preventDefault();
454 form.setState(prevState => {
456 1 + Math.floor(prevState.customEmojis.length / form.itemsPerPage);
457 const item: CustomEmojiViewForm = {
471 customEmojis: [...prevState.customEmojis, item],
478 { form, index }: { form: EmojiForm; index: number },
483 event.preventDefault();
484 file = event.target.files[0];
489 form.setState(prevState => ({
491 customEmojis: prevState.customEmojis.map((cv, i) =>
492 i === index ? { ...cv, loading: true } : cv,
496 HttpService.client.uploadImage({ image: file }).then(res => {
497 console.log("pictrs upload:");
499 if (res.state === "success") {
500 if (res.data.msg === "ok") {
501 pictrsDeleteToast(file.name, res.data.delete_url as string);
502 form.handleEmojiImageUrlChange(
503 { form: form, index: index, overrideValue: res.data.url as string },
506 } else if (res.data.msg === "too_large") {
507 toast(I18NextService.i18n.t("upload_too_large"), "danger");
509 toast(JSON.stringify(res), "danger");
511 } else if (res.state === "failed") {
512 console.error(res.msg);
513 toast(res.msg, "danger");
518 configurePicker(): any {
520 data: { categories: [], emojis: [], aliases: [] },