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