]> Untitled Git - lemmy-ui.git/blob - src/shared/components/home/emojis-form.tsx
Merge branch 'main' into fix/fix-badges-spacing-componentize
[lemmy-ui.git] / src / shared / components / home / emojis-form.tsx
1 import { myAuthRequired, setIsoData } from "@utils/app";
2 import { capitalizeFirstLetter } from "@utils/helpers";
3 import { Component, linkEvent } from "inferno";
4 import {
5   CreateCustomEmoji,
6   DeleteCustomEmoji,
7   EditCustomEmoji,
8   GetSiteResponse,
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 { HtmlTags } from "../common/html-tags";
15 import { Icon, Spinner } from "../common/icon";
16 import { Paginator } from "../common/paginator";
17
18 interface EmojiFormProps {
19   onEdit(form: EditCustomEmoji): void;
20   onCreate(form: CreateCustomEmoji): void;
21   onDelete(form: DeleteCustomEmoji): void;
22 }
23
24 interface EmojiFormState {
25   siteRes: GetSiteResponse;
26   customEmojis: CustomEmojiViewForm[];
27   page: number;
28 }
29
30 interface CustomEmojiViewForm {
31   id: number;
32   category: string;
33   shortcode: string;
34   image_url: string;
35   alt_text: string;
36   keywords: string;
37   changed: boolean;
38   page: number;
39   loading: boolean;
40 }
41
42 export class EmojiForm extends Component<EmojiFormProps, EmojiFormState> {
43   private isoData = setIsoData(this.context);
44   private itemsPerPage = 15;
45   private emptyState: EmojiFormState = {
46     siteRes: this.isoData.site_res,
47     customEmojis: this.isoData.site_res.custom_emojis.map((x, index) => ({
48       id: x.custom_emoji.id,
49       category: x.custom_emoji.category,
50       shortcode: x.custom_emoji.shortcode,
51       image_url: x.custom_emoji.image_url,
52       alt_text: x.custom_emoji.alt_text,
53       keywords: x.keywords.map(x => x.keyword).join(" "),
54       changed: false,
55       page: 1 + Math.floor(index / this.itemsPerPage),
56       loading: false,
57     })),
58     page: 1,
59   };
60   state: EmojiFormState;
61   private scrollRef: any = {};
62   constructor(props: any, context: any) {
63     super(props, context);
64     this.state = this.emptyState;
65
66     this.handlePageChange = this.handlePageChange.bind(this);
67     this.handleEmojiClick = this.handleEmojiClick.bind(this);
68   }
69   get documentTitle(): string {
70     return I18NextService.i18n.t("custom_emojis");
71   }
72
73   render() {
74     return (
75       <div className="home-emojis-form col-12">
76         <HtmlTags
77           title={this.documentTitle}
78           path={this.context.router.route.match.url}
79         />
80         <h1 className="h4 mb-4">{I18NextService.i18n.t("custom_emojis")}</h1>
81         {customEmojisLookup.size > 0 && (
82           <div>
83             <EmojiMart
84               onEmojiClick={this.handleEmojiClick}
85               pickerOptions={this.configurePicker()}
86             ></EmojiMart>
87           </div>
88         )}
89         <div className="table-responsive">
90           <table
91             id="emojis_table"
92             className="table table-sm table-hover align-middle"
93           >
94             <thead className="pointer">
95               <tr>
96                 <th>{I18NextService.i18n.t("column_emoji")}</th>
97                 <th className="text-right">
98                   {I18NextService.i18n.t("column_shortcode")}
99                 </th>
100                 <th className="text-right">
101                   {I18NextService.i18n.t("column_category")}
102                 </th>
103                 <th className="text-right d-lg-table-cell d-none">
104                   {I18NextService.i18n.t("column_imageurl")}
105                 </th>
106                 <th className="text-right">
107                   {I18NextService.i18n.t("column_alttext")}
108                 </th>
109                 <th className="text-right d-lg-table-cell">
110                   {I18NextService.i18n.t("column_keywords")}
111                 </th>
112                 <th style="width:121px"></th>
113               </tr>
114             </thead>
115             <tbody>
116               {this.state.customEmojis
117                 .slice(
118                   Number((this.state.page - 1) * this.itemsPerPage),
119                   Number(
120                     (this.state.page - 1) * this.itemsPerPage +
121                       this.itemsPerPage
122                   )
123                 )
124                 .map((cv, index) => (
125                   <tr key={index} ref={e => (this.scrollRef[cv.shortcode] = e)}>
126                     <td style="text-align:center;">
127                       {cv.image_url.length > 0 && (
128                         <img
129                           className="icon-emoji-admin"
130                           src={cv.image_url}
131                           alt={cv.alt_text}
132                         />
133                       )}
134                       {cv.image_url.length === 0 && (
135                         <label
136                           // TODO: Fix this linting violation
137                           // eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
138                           tabIndex={0}
139                           className="btn btn-sm btn-secondary pointer"
140                           htmlFor={`file-uploader-${index}`}
141                           data-tippy-content={I18NextService.i18n.t(
142                             "upload_image"
143                           )}
144                         >
145                           {capitalizeFirstLetter(
146                             I18NextService.i18n.t("upload")
147                           )}
148                           <input
149                             name={`file-uploader-${index}`}
150                             id={`file-uploader-${index}`}
151                             type="file"
152                             accept="image/*"
153                             className="d-none"
154                             onChange={linkEvent(
155                               { form: this, index: index },
156                               this.handleImageUpload
157                             )}
158                           />
159                         </label>
160                       )}
161                     </td>
162                     <td className="text-right">
163                       <input
164                         type="text"
165                         placeholder="ShortCode"
166                         className="form-control"
167                         disabled={cv.id > 0}
168                         value={cv.shortcode}
169                         onInput={linkEvent(
170                           { form: this, index: index },
171                           this.handleEmojiShortCodeChange
172                         )}
173                       />
174                     </td>
175                     <td className="text-right">
176                       <input
177                         type="text"
178                         placeholder="Category"
179                         className="form-control"
180                         value={cv.category}
181                         onInput={linkEvent(
182                           { form: this, index: index },
183                           this.handleEmojiCategoryChange
184                         )}
185                       />
186                     </td>
187                     <td className="text-right d-lg-table-cell d-none">
188                       <input
189                         type="text"
190                         placeholder="Url"
191                         className="form-control"
192                         value={cv.image_url}
193                         onInput={linkEvent(
194                           { form: this, index: index, overrideValue: null },
195                           this.handleEmojiImageUrlChange
196                         )}
197                       />
198                     </td>
199                     <td className="text-right">
200                       <input
201                         type="text"
202                         placeholder="Alt Text"
203                         className="form-control"
204                         value={cv.alt_text}
205                         onInput={linkEvent(
206                           { form: this, index: index },
207                           this.handleEmojiAltTextChange
208                         )}
209                       />
210                     </td>
211                     <td className="text-right d-lg-table-cell">
212                       <input
213                         type="text"
214                         placeholder="Keywords"
215                         className="form-control"
216                         value={cv.keywords}
217                         onInput={linkEvent(
218                           { form: this, index: index },
219                           this.handleEmojiKeywordChange
220                         )}
221                       />
222                     </td>
223                     <td>
224                       <div>
225                         <span title={this.getEditTooltip(cv)}>
226                           <button
227                             className={
228                               (this.canEdit(cv)
229                                 ? "text-success "
230                                 : "text-muted ") + "btn btn-link btn-animate"
231                             }
232                             onClick={linkEvent(
233                               { i: this, cv: cv },
234                               this.handleEditEmojiClick
235                             )}
236                             data-tippy-content={I18NextService.i18n.t("save")}
237                             aria-label={I18NextService.i18n.t("save")}
238                             disabled={!this.canEdit(cv)}
239                           >
240                             {cv.loading ? (
241                               <Spinner />
242                             ) : (
243                               capitalizeFirstLetter(
244                                 I18NextService.i18n.t("save")
245                               )
246                             )}
247                           </button>
248                         </span>
249                         <button
250                           className="btn btn-link btn-animate text-muted"
251                           onClick={linkEvent(
252                             { i: this, index: index, cv: cv },
253                             this.handleDeleteEmojiClick
254                           )}
255                           data-tippy-content={I18NextService.i18n.t("delete")}
256                           aria-label={I18NextService.i18n.t("delete")}
257                           disabled={cv.loading}
258                           title={I18NextService.i18n.t("delete")}
259                         >
260                           <Icon
261                             icon="trash"
262                             classes="icon-inline text-danger"
263                           />
264                         </button>
265                       </div>
266                     </td>
267                   </tr>
268                 ))}
269             </tbody>
270           </table>
271           <br />
272           <button
273             className="btn btn-sm btn-secondary me-2"
274             onClick={linkEvent(this, this.handleAddEmojiClick)}
275           >
276             {I18NextService.i18n.t("add_custom_emoji")}
277           </button>
278
279           <Paginator page={this.state.page} onChange={this.handlePageChange} />
280         </div>
281       </div>
282     );
283   }
284
285   canEdit(cv: CustomEmojiViewForm) {
286     const noEmptyFields =
287       cv.alt_text.length > 0 &&
288       cv.category.length > 0 &&
289       cv.image_url.length > 0 &&
290       cv.shortcode.length > 0;
291     const noDuplicateShortCodes =
292       this.state.customEmojis.filter(
293         x => x.shortcode == cv.shortcode && x.id != cv.id
294       ).length == 0;
295     return noEmptyFields && noDuplicateShortCodes && !cv.loading && cv.changed;
296   }
297
298   getEditTooltip(cv: CustomEmojiViewForm) {
299     if (this.canEdit(cv)) return I18NextService.i18n.t("save");
300     else return I18NextService.i18n.t("custom_emoji_save_validation");
301   }
302
303   handlePageChange(page: number) {
304     this.setState({ page: page });
305   }
306
307   handleEmojiClick(e: any) {
308     const view = customEmojisLookup.get(e.id);
309     if (view) {
310       const page = this.state.customEmojis.find(
311         x => x.id == view.custom_emoji.id
312       )?.page;
313       if (page) {
314         this.setState({ page: page });
315         this.scrollRef[view.custom_emoji.shortcode].scrollIntoView();
316       }
317     }
318   }
319
320   handleEmojiCategoryChange(
321     props: { form: EmojiForm; index: number },
322     event: any
323   ) {
324     const custom_emojis = [...props.form.state.customEmojis];
325     const pagedIndex =
326       (props.form.state.page - 1) * props.form.itemsPerPage + props.index;
327     const item = {
328       ...props.form.state.customEmojis[pagedIndex],
329       category: event.target.value,
330       changed: true,
331     };
332     custom_emojis[Number(pagedIndex)] = item;
333     props.form.setState({ customEmojis: custom_emojis });
334   }
335
336   handleEmojiShortCodeChange(
337     props: { form: EmojiForm; index: number },
338     event: any
339   ) {
340     const custom_emojis = [...props.form.state.customEmojis];
341     const pagedIndex =
342       (props.form.state.page - 1) * props.form.itemsPerPage + props.index;
343     const item = {
344       ...props.form.state.customEmojis[pagedIndex],
345       shortcode: event.target.value,
346       changed: true,
347     };
348     custom_emojis[Number(pagedIndex)] = item;
349     props.form.setState({ customEmojis: custom_emojis });
350   }
351
352   handleEmojiImageUrlChange(
353     {
354       form,
355       index,
356       overrideValue,
357     }: { form: EmojiForm; index: number; overrideValue: string | null },
358     event: any
359   ) {
360     form.setState(prevState => {
361       const custom_emojis = [...form.state.customEmojis];
362       const pagedIndex = (form.state.page - 1) * form.itemsPerPage + index;
363       const item = {
364         ...form.state.customEmojis[pagedIndex],
365         image_url: overrideValue ?? event.target.value,
366         changed: true,
367       };
368       custom_emojis[Number(pagedIndex)] = item;
369       return {
370         ...prevState,
371         customEmojis: prevState.customEmojis.map((ce, i) =>
372           i === pagedIndex
373             ? {
374                 ...ce,
375                 image_url: overrideValue ?? event.target.value,
376                 changed: true,
377                 loading: false,
378               }
379             : ce
380         ),
381       };
382     });
383   }
384
385   handleEmojiAltTextChange(
386     props: { form: EmojiForm; index: number },
387     event: any
388   ) {
389     const custom_emojis = [...props.form.state.customEmojis];
390     const pagedIndex =
391       (props.form.state.page - 1) * props.form.itemsPerPage + props.index;
392     const item = {
393       ...props.form.state.customEmojis[pagedIndex],
394       alt_text: event.target.value,
395       changed: true,
396     };
397     custom_emojis[Number(pagedIndex)] = item;
398     props.form.setState({ customEmojis: custom_emojis });
399   }
400
401   handleEmojiKeywordChange(
402     props: { form: EmojiForm; index: number },
403     event: any
404   ) {
405     const custom_emojis = [...props.form.state.customEmojis];
406     const pagedIndex =
407       (props.form.state.page - 1) * props.form.itemsPerPage + props.index;
408     const item = {
409       ...props.form.state.customEmojis[pagedIndex],
410       keywords: event.target.value,
411       changed: true,
412     };
413     custom_emojis[Number(pagedIndex)] = item;
414     props.form.setState({ customEmojis: custom_emojis });
415   }
416
417   handleDeleteEmojiClick(d: {
418     i: EmojiForm;
419     index: number;
420     cv: CustomEmojiViewForm;
421   }) {
422     const pagedIndex = (d.i.state.page - 1) * d.i.itemsPerPage + d.index;
423     if (d.cv.id != 0) {
424       d.i.props.onDelete({
425         id: d.cv.id,
426         auth: myAuthRequired(),
427       });
428     } else {
429       const custom_emojis = [...d.i.state.customEmojis];
430       custom_emojis.splice(Number(pagedIndex), 1);
431       d.i.setState({ customEmojis: custom_emojis });
432     }
433   }
434
435   handleEditEmojiClick(d: { i: EmojiForm; cv: CustomEmojiViewForm }) {
436     const keywords = d.cv.keywords
437       .split(" ")
438       .filter(x => x.length > 0) as string[];
439     const uniqueKeywords = Array.from(new Set(keywords));
440     if (d.cv.id !== 0) {
441       d.i.props.onEdit({
442         id: d.cv.id,
443         category: d.cv.category,
444         image_url: d.cv.image_url,
445         alt_text: d.cv.alt_text,
446         keywords: uniqueKeywords,
447         auth: myAuthRequired(),
448       });
449     } else {
450       d.i.props.onCreate({
451         category: d.cv.category,
452         shortcode: d.cv.shortcode,
453         image_url: d.cv.image_url,
454         alt_text: d.cv.alt_text,
455         keywords: uniqueKeywords,
456         auth: myAuthRequired(),
457       });
458     }
459   }
460
461   handleAddEmojiClick(form: EmojiForm, event: any) {
462     event.preventDefault();
463     form.setState(prevState => {
464       const page =
465         1 + Math.floor(prevState.customEmojis.length / form.itemsPerPage);
466       const item: CustomEmojiViewForm = {
467         id: 0,
468         shortcode: "",
469         alt_text: "",
470         category: "",
471         image_url: "",
472         keywords: "",
473         changed: false,
474         page: page,
475         loading: false,
476       };
477
478       return {
479         ...prevState,
480         customEmojis: [...prevState.customEmojis, item],
481         page,
482       };
483     });
484   }
485
486   handleImageUpload(
487     { form, index }: { form: EmojiForm; index: number },
488     event: any
489   ) {
490     let file: any;
491     if (event.target) {
492       event.preventDefault();
493       file = event.target.files[0];
494     } else {
495       file = event;
496     }
497
498     form.setState(prevState => ({
499       ...prevState,
500       customEmojis: prevState.customEmojis.map((cv, i) =>
501         i === index ? { ...cv, loading: true } : cv
502       ),
503     }));
504
505     HttpService.client.uploadImage({ image: file }).then(res => {
506       console.log("pictrs upload:");
507       console.log(res);
508       if (res.state === "success") {
509         if (res.data.msg === "ok") {
510           pictrsDeleteToast(file.name, res.data.delete_url as string);
511           form.handleEmojiImageUrlChange(
512             { form: form, index: index, overrideValue: res.data.url as string },
513             event
514           );
515         } else if (res.data.msg === "too_large") {
516           toast(I18NextService.i18n.t("upload_too_large"), "danger");
517         } else {
518           toast(JSON.stringify(res), "danger");
519         }
520       } else if (res.state === "failed") {
521         console.error(res.msg);
522         toast(res.msg, "danger");
523       }
524     });
525   }
526
527   configurePicker(): any {
528     return {
529       data: { categories: [], emojis: [], aliases: [] },
530       maxFrequentRows: 0,
531       dynamicWidth: true,
532     };
533   }
534 }