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