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