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