瀏覽代碼

Added new function to add cumstom translation strings on Admin panel

Yevhenii Pertiaka 1 年之前
父節點
當前提交
b1525d4221

+ 4 - 0
client/components/settings/settingHeader.jade

@@ -20,6 +20,10 @@ template(name="settingHeaderBar")
         i.fa(class="fa-paperclip")
         span {{_ 'attachments'}}
 
+      a.setting-header-btn.informations(href="{{pathFor 'translation'}}")
+        i.fa(class="fa-font")
+        span {{_ 'translation'}}
+
       a.setting-header-btn.informations(href="{{pathFor 'information'}}")
         i.fa(class="fa-info-circle")
         span {{_ 'info'}}

+ 67 - 0
client/components/settings/translationBody.css

@@ -0,0 +1,67 @@
+.main-body {
+  overflow: scroll;
+}
+table {
+  color: #000;
+}
+table td,
+table th {
+  border: 1px solid #d2d0d0;
+  text-align: left;
+  padding: 8px;
+}
+table tr:nth-child(even) {
+  background-color: #ddd;
+}
+.ext-box {
+  display: flex;
+  flex-direction: row;
+  height: 34px;
+}
+.ext-box .ext-box-left {
+  display: flex;
+  width: 100%;
+  gap: 10px;
+}
+.ext-box span {
+  vertical-align: center;
+  line-height: 34px;
+}
+.ext-box input,
+.ext-box button {
+  padding: 0;
+}
+.ext-box button {
+  min-width: 90px;
+}
+.content-wrapper {
+  margin-top: 10px;
+}
+.buttonsContainer {
+  display: flex;
+}
+.buttonsContainer input {
+  margin: 0;
+}
+.buttonsContainer div {
+  margin: auto;
+}
+.more-settings-translation {
+  margin-left: 10px;
+}
+#cancelBtn {
+  margin-left: 5% !important;
+  background: #ffa500;
+  color: #fff;
+}
+#deleteAction {
+  margin-left: 5% !important;
+}
+p.js-translation-language {
+  font-weight: bold;
+  color: #000;
+}
+p.js-translation-text {
+  font-weight: bold;
+  color: #000;
+}

+ 105 - 0
client/components/settings/translationBody.jade

@@ -0,0 +1,105 @@
+template(name="translation")
+  .setting-content
+    unless currentUser.isAdmin
+      | {{_ 'error-notAuthorized'}}
+    else
+      .content-title.ext-box
+        .ext-box-left
+          if loading.get
+            +spinner
+          else if translationSetting.get
+            span
+              i.fa.fa-font
+              unless isMiniScreen
+                | {{_ 'translation'}}
+            input#searchTranslationInput(placeholder="{{_ 'search'}}")
+            button#searchTranslationButton
+              i.fa.fa-search
+              | {{_ 'search'}}
+            .ext-box-right
+              span {{#unless isMiniScreen}}{{_ 'translation-number'}}{{/unless}} #{translationNumber}
+
+      .content-body
+        .side-menu
+          ul
+            li.active
+              a.js-translation-menu(data-id="translation-setting")
+                i.fa.fa-font
+                | {{_ 'translation'}}
+        .main-body
+          if loading.get
+            +spinner
+          else if translationSetting.get
+            +translationGeneral
+
+template(name="translationGeneral")
+  table
+    tbody
+      tr
+        th {{_ 'language'}}
+        th {{_ 'text'}}
+        th {{_ 'translation-text'}}
+        th
+          +newTranslationRow
+      each translation in translationList
+        +translationRow(translationId=translation._id)
+
+template(name="newTranslationRow")
+  a.new-translation
+    i.fa.fa-plus-square
+    | {{_ 'new'}}
+
+template(name="translationRow")
+  tr
+    td {{translationData.language}}
+    td {{translationData.text}}
+    td {{translationData.translationText}}
+    td
+      a.edit-translation
+        i.fa.fa-edit
+        | {{_ 'edit'}}
+      a.more-settings-translation
+        i.fa.fa-ellipsis-h
+
+template(name="editTranslationPopup")
+  form
+    label
+      | {{_ 'language'}}
+      input.js-translation-language(type="text" value=translation.language required readonly)
+    label
+      | {{_ 'text'}}
+      input.js-translation-text(type="text" value=translation.text required readonly)
+    label
+      | {{_ 'translation-text'}}
+      input.js-translation-translation-text(type="text" value=translation.translationText)
+    hr
+    div.buttonsContainer
+      input.primary.wide(type="submit" value="{{_ 'save'}}")
+
+template(name="newTranslationPopup")
+  form
+    label
+      | {{_ 'language'}}
+      input.js-translation-language(type="text" value="en" required)
+    label
+      | {{_ 'text'}}
+      span.error.hide.text-taken
+        | {{_ 'error-text-taken'}}
+      input.js-translation-text(type="text" value="" required)
+    label
+      | {{_ 'translation-text'}}
+      input.js-translation-translation-text(type="text" value="")
+    hr
+    div.buttonsContainer
+      input.primary.wide(type="submit" value="{{_ 'save'}}")
+
+template(name="settingsTranslationPopup")
+  ul.pop-over-list
+    li
+      form
+        label
+          | {{_ 'delete-translation-confirm-popup'}}
+        br
+        label.hide.orgId(type="text" value=org._id)
+        div.buttonsContainer
+          input#deleteButton.card-details-red.right.wide(type="button" value="{{_ 'delete'}}")

+ 214 - 0
client/components/settings/translationBody.js

@@ -0,0 +1,214 @@
+import { ReactiveCache } from '/imports/reactiveCache';
+
+const translationsPerPage = 25;
+
+BlazeComponent.extendComponent({
+  mixins() {
+    return [Mixins.InfiniteScrolling];
+  },
+  onCreated() {
+    this.error = new ReactiveVar('');
+    this.loading = new ReactiveVar(false);
+    this.translationSetting = new ReactiveVar(true);
+    this.findTranslationsOptions = new ReactiveVar({});
+    this.numberTranslations = new ReactiveVar(0);
+
+    this.page = new ReactiveVar(1);
+    this.loadNextPageLocked = false;
+    this.callFirstWith(null, 'resetNextPeak');
+    this.autorun(() => {
+      const limitTranslations = this.page.get() * translationsPerPage;
+
+      this.subscribe('translation', this.findTranslationsOptions.get(), 0, () => {
+        this.loadNextPageLocked = false;
+        const nextPeakBefore = this.callFirstWith(null, 'getNextPeak');
+        this.calculateNextPeak();
+        const nextPeakAfter = this.callFirstWith(null, 'getNextPeak');
+        if (nextPeakBefore === nextPeakAfter) {
+          this.callFirstWith(null, 'resetNextPeak');
+        }
+      });
+    });
+  },
+  events() {
+    return [
+      {
+        'click #searchTranslationButton'() {
+          this.filterTranslation();
+        },
+        'keydown #searchTranslationInput'(event) {
+          if (event.keyCode === 13 && !event.shiftKey) {
+            this.filterTranslation();
+          }
+        },
+        'click #newTranslationButton'() {
+          Popup.open('newTranslation');
+        },
+        'click a.js-translation-menu': this.switchMenu,
+      },
+    ];
+  },
+  filterTranslation() {
+    const value = $('#searchTranslationInput').first().val();
+    if (value === '') {
+      this.findTranslationsOptions.set({});
+    } else {
+      const regex = new RegExp(value, 'i');
+      this.findTranslationsOptions.set({
+        $or: [
+          { language: regex },
+          { text: regex },
+          { translationText: regex },
+        ],
+      });
+    }
+  },
+  loadNextPage() {
+    if (this.loadNextPageLocked === false) {
+      this.page.set(this.page.get() + 1);
+      this.loadNextPageLocked = true;
+    }
+  },
+  calculateNextPeak() {
+    const element = this.find('.main-body');
+    if (element) {
+      const altitude = element.scrollHeight;
+      this.callFirstWith(this, 'setNextPeak', altitude);
+    }
+  },
+  reachNextPeak() {
+    this.loadNextPage();
+  },
+  setError(error) {
+    this.error.set(error);
+  },
+  setLoading(w) {
+    this.loading.set(w);
+  },
+  translationList() {
+    const translations = ReactiveCache.getTranslations(this.findTranslationsOptions.get(), {
+      sort: { modifiedAt: 1 },
+      fields: { _id: true },
+    });
+    this.numberTranslations.set(translations.length);
+    return translations;
+  },
+  translationNumber() {
+    return this.numberTranslations.get();
+  },
+  switchMenu(event) {
+    const target = $(event.target);
+    if (!target.hasClass('active')) {
+      $('.side-menu li.active').removeClass('active');
+      target.parent().addClass('active');
+      const targetID = target.data('id');
+      this.translationSetting.set('translation-setting' === targetID);
+    }
+  },
+}).register('translation');
+
+Template.translationRow.helpers({
+  translationData() {
+    return ReactiveCache.getTranslation(this.translationId);
+  },
+});
+
+Template.editTranslationPopup.helpers({
+  translation() {
+    return ReactiveCache.getTranslation(this.translationId);
+  },
+  errorMessage() {
+    return Template.instance().errorMessage.get();
+  },
+});
+
+Template.newTranslationPopup.onCreated(function () {
+  this.errorMessage = new ReactiveVar('');
+});
+
+Template.newTranslationPopup.helpers({
+  translation() {
+    return ReactiveCache.getTranslation(this.translationId);
+  },
+  errorMessage() {
+    return Template.instance().errorMessage.get();
+  },
+});
+
+BlazeComponent.extendComponent({
+  onCreated() {},
+  translation() {
+    return ReactiveCache.getTranslation(this.translationId);
+  },
+  events() {
+    return [
+      {
+        'click a.edit-translation': Popup.open('editTranslation'),
+        'click a.more-settings-translation': Popup.open('settingsTranslation'),
+      },
+    ];
+  },
+}).register('translationRow');
+
+BlazeComponent.extendComponent({
+  events() {
+    return [
+      {
+        'click a.new-translation': Popup.open('newTranslation'),
+      },
+    ];
+  },
+}).register('newTranslationRow');
+
+Template.editTranslationPopup.events({
+  submit(event, templateInstance) {
+    event.preventDefault();
+    const translation = ReactiveCache.getTranslation(this.translationId);
+    const translationText = templateInstance.find('.js-translation-translation-text').value.trim();
+
+    Meteor.call(
+      'setTranslationText',
+      translation,
+      translationText
+    );
+
+    Popup.back();
+  },
+});
+
+Template.newTranslationPopup.events({
+  submit(event, templateInstance) {
+    event.preventDefault();
+    const language = templateInstance.find('.js-translation-language').value.trim();
+    const text = templateInstance.find('.js-translation-text').value.trim();
+    const translationText = templateInstance.find('.js-translation-translation-text').value.trim();
+
+    Meteor.call(
+      'setCreateTranslation',
+      language,
+      text,
+      translationText,
+      function(error) {
+        const textMessageElement = templateInstance.$('.text-taken');
+        if (error) {
+          const errorElement = error.error;
+          if (errorElement === 'text-already-taken') {
+            textMessageElement.show();
+          }
+        } else {
+          textMessageElement.hide();
+          Popup.back();
+        }
+      },
+    );
+    Popup.back();
+  },
+});
+
+Template.settingsTranslationPopup.events({
+  'click #deleteButton'(event) {
+    event.preventDefault();
+    Translation.remove(this.translationId);
+    Popup.back();
+  }
+});

+ 24 - 0
config/router.js

@@ -381,6 +381,30 @@ FlowRouter.route('/attachments', {
   },
 });
 
+FlowRouter.route('/translation', {
+  name: 'translation',
+  triggersEnter: [
+    AccountsTemplates.ensureSignedIn,
+    () => {
+      Session.set('currentBoard', null);
+      Session.set('currentList', null);
+      Session.set('currentCard', null);
+      Session.set('popupCardId', null);
+      Session.set('popupCardBoardId', null);
+
+      Filter.reset();
+      Session.set('sortBy', '');
+      EscapeActions.executeAll();
+    },
+  ],
+  action() {
+    BlazeLayout.render('defaultLayout', {
+      headerBar: 'settingHeaderBar',
+      content: 'translation',
+    });
+  },
+});
+
 FlowRouter.notFound = {
   action() {
     BlazeLayout.render('defaultLayout', { content: 'notFound' });

+ 6 - 1
imports/i18n/data/en.i18n.json

@@ -1229,5 +1229,10 @@
   "allowed-avatar-filetypes": "Allowed avatar filetypes:",
   "invalid-file": "If filename is invalid, upload or rename is cancelled.",
   "preview-pdf-not-supported": "Your device does not support previewing PDF. Try downloading instead.",
-  "drag-board": "Drag board"
+  "drag-board": "Drag board",
+  "translation-number": "The number of custom strings is: ",
+  "delete-translation-confirm-popup": "Are you sure you want to delete this custom string? There is no undo.",
+  "translation": "Translation",
+  "text": "Text",
+  "translation-text": "Translation text"
 }

+ 13 - 1
imports/i18n/tap.js

@@ -1,5 +1,6 @@
 import { Meteor } from 'meteor/meteor';
 import { ReactiveVar } from 'meteor/reactive-var';
+import Translation from '/models/translation';
 import i18next from 'i18next';
 import sprintf from 'i18next-sprintf-postprocessor';
 import languages from './languages';
@@ -45,7 +46,18 @@ export const TAPi18n = {
   },
   async loadLanguage(language) {
     if (language in languages && 'load' in languages[language]) {
-      const data = await languages[language].load();
+      let data = await languages[language].load();
+      let custom_translations = [];
+      if (Meteor.isServer) {
+        custom_translations = Translation.find({language: language}, {fields: { text: true, translationText: true }}).fetch();
+      } else if (Meteor.isClient) {
+        await Meteor.subscribe('translation', {language: language}, 0);
+        custom_translations = ReactiveCache.getTranslations({language: language}, {fields: { text: true, translationText: true }});
+      }
+      if (custom_translations && custom_translations.length > 0) {
+        data = custom_translations.reduce((acc, cur) => (acc[cur.text]=cur.translationText, acc), data);
+      }
+
       this.i18n.addResourceBundle(language, DEFAULT_NAMESPACE, data);
     } else {
       throw new Error(`Language ${language} is not supported`);

+ 11 - 0
imports/reactiveCache.js

@@ -1261,6 +1261,17 @@ ReactiveCache = {
     }
     return ret;
   },
+  getTranslations(selector, options, getQuery) {
+    let ret = Translation.find(selector, options);
+    if (getQuery !== true) {
+      ret = ret.fetch();
+    }
+    return ret;
+  },
+  getTranslation(idOrFirstObjectSelector, options) {
+    const ret = Translation.findOne(idOrFirstObjectSelector, options);
+    return ret;
+  }
 }
 
 // Client side little MiniMongo DB "Index"

+ 128 - 0
models/translation.js

@@ -0,0 +1,128 @@
+Translation = new Mongo.Collection('translation');
+
+/**
+ * A Organization User in wekan
+ */
+Translation.attachSchema(
+  new SimpleSchema({
+    language: {
+      /**
+       * the language
+       */
+      type: String,
+      max: 5,
+    },
+    text: {
+      /**
+       * the text
+       */
+      type: String,
+    },
+    translationText: {
+      /**
+       * the translation text
+       */
+      type: String,
+    },
+    createdAt: {
+      /**
+       * creation date of the organization user
+       */
+      type: Date,
+      // eslint-disable-next-line consistent-return
+      autoValue() {
+        if (this.isInsert) {
+          return new Date();
+        } else if (this.isUpsert) {
+          return { $setOnInsert: new Date() };
+        } else {
+          this.unset();
+        }
+      },
+    },
+    modifiedAt: {
+      type: Date,
+      denyUpdate: false,
+      // eslint-disable-next-line consistent-return
+      autoValue() {
+        if (this.isInsert || this.isUpsert || this.isUpdate) {
+          return new Date();
+        } else {
+          this.unset();
+        }
+      },
+    },
+  }),
+);
+
+if (Meteor.isServer) {
+  Translation.allow({
+    insert(userId, doc) {
+      const user = ReactiveCache.getUser(userId) || ReactiveCache.getCurrentUser();
+      if (user?.isAdmin)
+        return true;
+      if (!user) {
+        return false;
+      }
+      return doc._id === userId;
+    },
+    update(userId, doc) {
+      const user = ReactiveCache.getUser(userId) || ReactiveCache.getCurrentUser();
+      if (user?.isAdmin)
+        return true;
+      if (!user) {
+        return false;
+      }
+      return doc._id === userId;
+    },
+    remove(userId, doc) {
+      const user = ReactiveCache.getUser(userId) || ReactiveCache.getCurrentUser();
+      if (user?.isAdmin)
+        return true;
+      if (!user) {
+        return false;
+      }
+      return doc._id === userId;
+    },
+    fetch: [],
+  });
+
+  Meteor.methods({
+    setCreateTranslation(
+      language,
+      text,
+      translationText,
+    ) {
+      check(language, String);
+      check(text, String);
+      check(translationText, String);
+
+      const nTexts = ReactiveCache.getTranslations({ language, text }).length;
+      if (nTexts > 0) {
+        throw new Meteor.Error('text-already-taken');
+      } else {
+        Translation.insert({
+          language,
+          text,
+          translationText,
+        });
+      }
+    },
+    setTranslationText(translation, translationText) {
+      check(translation, Object);
+      check(translationText, String);
+      Translation.update(translation, {
+        $set: { translationText: translationText },
+      });
+    },
+  });
+}
+
+if (Meteor.isServer) {
+  // Index for Organization User.
+  Meteor.startup(() => {
+    Translation._collection.createIndex({ modifiedAt: -1 });
+  });
+}
+
+export default Translation;

+ 27 - 0
server/publications/translation.js

@@ -0,0 +1,27 @@
+import { ReactiveCache } from '/imports/reactiveCache';
+
+Meteor.publish('translation', function(query, limit) {
+  check(query, Match.OneOf(Object, null));
+  check(limit, Number);
+
+  let ret = [];
+  const user = ReactiveCache.getCurrentUser();
+
+  if (user && user.isAdmin) {
+    ret = ReactiveCache.getTranslations(query,
+      {
+        limit,
+        sort: { modifiedAt: -1 },
+        fields: {
+          language: 1,
+          text: 1,
+          translationText: 1,
+          createdAt: 1,
+        }
+      },
+      true,
+    );
+  }
+
+  return ret;
+});