diff --git a/client/components/settings/settingHeader.jade b/client/components/settings/settingHeader.jade index 72ed611c8..41434270e 100644 --- a/client/components/settings/settingHeader.jade +++ b/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'}} diff --git a/client/components/settings/translationBody.css b/client/components/settings/translationBody.css new file mode 100644 index 000000000..8dc613c76 --- /dev/null +++ b/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; +} diff --git a/client/components/settings/translationBody.jade b/client/components/settings/translationBody.jade new file mode 100644 index 000000000..073207bde --- /dev/null +++ b/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'}}") diff --git a/client/components/settings/translationBody.js b/client/components/settings/translationBody.js new file mode 100644 index 000000000..c9a5c71ad --- /dev/null +++ b/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(); + } +}); diff --git a/config/router.js b/config/router.js index 695d2bcc9..cdbf106c1 100644 --- a/config/router.js +++ b/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' }); diff --git a/imports/i18n/data/en.i18n.json b/imports/i18n/data/en.i18n.json index a4779c62f..8cf8ed4fa 100644 --- a/imports/i18n/data/en.i18n.json +++ b/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" } diff --git a/imports/i18n/tap.js b/imports/i18n/tap.js index e2d7a31e8..51c605add 100644 --- a/imports/i18n/tap.js +++ b/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`); diff --git a/imports/reactiveCache.js b/imports/reactiveCache.js index 1001eb38a..87ec06246 100644 --- a/imports/reactiveCache.js +++ b/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" diff --git a/models/translation.js b/models/translation.js new file mode 100644 index 000000000..989db1e0f --- /dev/null +++ b/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; diff --git a/server/publications/translation.js b/server/publications/translation.js new file mode 100644 index 000000000..2868329f0 --- /dev/null +++ b/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; +});