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