diff --git a/locale/de/LC_MESSAGES/django.mo b/locale/de/LC_MESSAGES/django.mo index 39fb89af..880e0f50 100644 Binary files a/locale/de/LC_MESSAGES/django.mo and b/locale/de/LC_MESSAGES/django.mo differ diff --git a/locale/de/LC_MESSAGES/django.po b/locale/de/LC_MESSAGES/django.po index f2f85418..152d3e5a 100644 --- a/locale/de/LC_MESSAGES/django.po +++ b/locale/de/LC_MESSAGES/django.po @@ -2,7 +2,7 @@ msgid "" msgstr "" "Project-Id-Version: Baby Buddy\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2022-02-27 18:38+0000\n" +"POT-Creation-Date: 2022-02-27 18:44+0000\n" "Language: de\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" diff --git a/static/babybuddy/css/app.css b/static/babybuddy/css/app.css index a63b55d8..0977403e 100644 --- a/static/babybuddy/css/app.css +++ b/static/babybuddy/css/app.css @@ -10586,6 +10586,10 @@ h3 { font-size: 1.65em; } +.modal-content { + color: #343a40; +} + #view-core\:child .child-photo { max-width: 150px; } diff --git a/static/babybuddy/js/tags_editor.js b/static/babybuddy/js/tags_editor.js index d2d89ef6..cec48bdd 100644 --- a/static/babybuddy/js/tags_editor.js +++ b/static/babybuddy/js/tags_editor.js @@ -1,129 +1,107 @@ (function() { - class TagsEditor { - constructor(tagEditorRoot) { - this.tagEditorRoot = tagEditorRoot; + /** + * Parse a string as hexadecimal number + */ + function hexParse(x) { + return parseInt(x, 16); + } + + /** + * Attempt to compute a high-contrast color from a background color. + * + * (This probably should be researched better because this was + * hand-crafted ad-hoc.) + */ + function computeComplementaryColor(colorStr) { + let avgColor = 0.0; + avgColor += hexParse(colorStr.substring(1, 3)) * -0.5; + avgColor += hexParse(colorStr.substring(3, 5)) * 1.5; + avgColor += hexParse(colorStr.substring(5, 7)) * 1.0; + + if (avgColor > 200) { + return "#101010"; + } else { + return "#E0E0E0"; } - }; + } - window.addEventListener('load', () => { - const widget = document.getElementById('{{ widget.attrs.id }}'); - const csrfToken = document.querySelector('input[name="csrfmiddlewaretoken"]').value; - - const prototype = widget.querySelector('.prototype-tag'); - const currentTags = widget.querySelector('.current_tags'); - const newTags = widget.querySelector('.new-tags'); + // CSRF token should always be present because it is auto-included with + // every tag-editor widget + const CSRF_TOKEN = document.querySelector('input[name="csrfmiddlewaretoken"]').value; - const inputElement = widget.querySelector('input[type="hidden"]'); + function doReq(method, uri, data, success, fail) { + // TODO: prefer jQuery based requests for now - const apiTagsUrl = widget.getAttribute('data-tags-url'); - const createTagInputs = widget.querySelector('.create-tag-inputs'); - const addTagInput = createTagInputs.querySelector('input[type="text"]'); - const addTagButton = createTagInputs.querySelector('.btn-add-new-tag'); - - function doReq(method, uri, data, success, fail) { - const req = new XMLHttpRequest(); - req.addEventListener('load', () => { - if ((req.status >= 200) && (req.status < 300)) { - success(req.responseText, req); - } else { - fail(req.responseText, req); - } - }); - for (const name of ["error", "timeout", "abort"]) { - req.addEventListener(name, () => { - fail(req.responseText, req); - }); - } - req.timeout = 20000; - - req.open(method, uri); - req.setRequestHeader("Content-Type", "application/json"); - req.setRequestHeader("Accept", "application/json"); - req.setRequestHeader("X-CSRFTOKEN", csrfToken); - req.send(data); - } - - function createTagClicked() { - const tagName = addTagInput.value.trim(); - const uriTagName = encodeURIComponent(tagName); - - function success() { - addTagInput.value = ""; - } - - function fail(msg) { - msg = msg || "Error creating tag"; - - addTagInput.select() - alert(msg); - } - - if (!tagName) { - fail('Not a valid tag name'); - return; - } - - const data = JSON.stringify({ - 'name': addTagInput.value - }); - - - function addTag(name, color) { - const foundTag = widget.querySelector(`span[data-value="${name}"]`); - if (foundTag) { - foundTag.parentNode.removeChild(foundTag); - } - - const tag = createNewTag(name, color, "-"); - insertTag(currentTags, tag); - removeTagCallback(tag); - success(); - } - - doReq("GET", `${apiTagsUrl}?name=${uriTagName}`, null, - (text) => { - const json = JSON.parse(text); - if (json.count) { - const tagJson = json.results[0]; - addTag(tagJson.name, tagJson.color); - } else { - doReq("POST", apiTagsUrl, data, - (text) => { - const tagJson = JSON.parse(text); - addTag(tagJson.name, tagJson.color); - }, fail - ); - } - }, fail - ); - } - addTagButton.addEventListener('click', createTagClicked); - addTagInput.addEventListener('keydown', (e) => { - const key = e.key.toLowerCase(); - if (key === "enter") { - e.preventDefault(); - createTagClicked(); + const req = new XMLHttpRequest(); + req.addEventListener('load', () => { + if ((req.status >= 200) && (req.status < 300)) { + success(req.responseText, req); + } else { + fail(req.responseText, req); } }); - - function hexParse(x) { - return parseInt(x, 16); + for (const name of ["error", "timeout", "abort"]) { + req.addEventListener(name, () => { + fail(req.responseText, req); + }); } + req.timeout = 20000; - function computeComplementaryColor(colorStr) { - let avgColor = 0.0; - avgColor += hexParse(colorStr.substring(1, 3)) * -0.5; - avgColor += hexParse(colorStr.substring(3, 5)) * 1.5; - avgColor += hexParse(colorStr.substring(5, 7)) * 1.0; + req.open(method, uri); + req.setRequestHeader("Content-Type", "application/json"); + req.setRequestHeader("Accept", "application/json"); + req.setRequestHeader("X-CSRFTOKEN", CSRF_TOKEN); + req.send(data); + } - if (avgColor > 200) { - return "#101010"; - } else { - return "#E0E0E0"; + /** + * Base class allowing generic operations on the tag lists, like: + * + * - Adding tags to a tag list + * - Updating or creating new tags with a set name and color + * - Controlling the error modal + */ + class TaggingBase { + constructor(widget) { + this.prototype = widget.querySelector('.prototype-tag'); + this.listeners = []; + + this.modalElement = widget.querySelector('.tag-editor-error-modal'); + this.modalBodyNode = this.modalElement.querySelector('.modal-body'); + + // Clean whitespace text nodes between spans + for (const n of this.modalBodyNode.childNodes) { + if (n.nodeType === Node.TEXT_NODE) { + this.modalBodyNode.removeChild(n); + } } } - function updateTag(tag, name, color, actionSymbol) { + showModal(msg) { + const selectedMessage = this.modalBodyNode.querySelector(`span[data-message='${msg}']`); + if (!selectedMessage) { + selectedMessage = this.modalBodyNode.childNodes[0]; + } + + for (const n of this.modalBodyNode.childNodes) { + n.classList.add('d-none'); + } + selectedMessage.classList.remove('d-none'); + + jQuery(this.modalElement).modal('show'); + } + + addTagListUpdatedListener(c) { + this.listeners.push(c); + } + + callTagListUpdatedListeners() { + for (const l of this.listeners) { + l(); + } + } + + updateTag(tag, name, color, actionSymbol) { const actionTextNode = tag.querySelector('.add-remove-icon').childNodes[0]; name = name || tag.getAttribute("data-value"); @@ -139,63 +117,221 @@ actionTextNode.textContent = actionSymbol; } - function createNewTag(name, color, actionSymbol) { - const tag = prototype.cloneNode(true); + createNewTag(name, color, actionSymbol) { + const tag = this.prototype.cloneNode(true); tag.classList.remove("prototype-tag"); tag.classList.add("tag"); - updateTag(tag, name, color, actionSymbol); + this.updateTag(tag, name, color, actionSymbol); return tag; } - function insertTag(list, tag) { + insertTag(list, tag) { list.appendChild(tag); - updateInputList(); + this.callTagListUpdatedListeners(); + } + }; + + /** + * Handler for the edit field allowing to dynamically create new tags. + * + * Handles user inputs for the editor. Calls the 'onInsertNewTag' callback + * when the craetion of a new tag has been requested. All backend handling + * like guareteening that the requested tag exists is handled by this class, + * the only task left is to add the new tag to the tags-list when + * 'onInsertNewTag' is called. + */ + class AddNewTagControl { + /** + * @param widget + * The root DOM element of the widget + * @param taggingBase + * Reference to a common TaggingBase class to be used by this widget + * @param onInsertNewTag + * Callback that is called when a new tag should be added to the + * tags widget. + */ + constructor(widget, taggingBase, onInsertNewTag) { + this.widget = widget; + this.taggingBase = taggingBase; + + this.apiTagsUrl = widget.getAttribute('data-tags-url'); + this.createTagInputs = widget.querySelector('.create-tag-inputs'); + this.addTagInput = this.createTagInputs.querySelector('input[type="text"]'); + this.addTagButton = this.createTagInputs.querySelector('.btn-add-new-tag'); + + this.addTagInput.value = ""; + + this.onInsertNewTag = onInsertNewTag; + + this.addTagButton.addEventListener('click', () => this.onCreateTagClicked()); + this.addTagInput.addEventListener('keydown', (e) => { + const key = e.key.toLowerCase(); + if (key === "enter") { + e.preventDefault(); + this.onCreateTagClicked(); + } + }); } - function registerNewCallback(tag, newParent, newSymbol, onClicked) { + /** + * Callback called when the the "Add" button of the add-tag input is + * clicked or enter is pressed in the editor. + */ + onCreateTagClicked() { + // TODO: Make promise based + + const tagName = this.addTagInput.value.trim(); + const uriTagName = encodeURIComponent(tagName); + + const fail = (msg) => { + this.addTagInput.select(); + this.taggingBase.showModal(msg || "generic"); + }; + + if (!tagName) { + fail('invalid-tag-name'); + return; + } + + const addTag = (name, color) => { + const tag = this.taggingBase.createNewTag(name, color, "-"); + this.addTagInput.value = ""; + this.onInsertNewTag(tag); + }; + + const data = JSON.stringify({ + 'name': this.addTagInput.value + }); + + doReq("GET", `${this.apiTagsUrl}?name=${uriTagName}`, null, + (text) => { + const json = JSON.parse(text); + if (json.count) { + const tagJson = json.results[0]; + addTag(tagJson.name, tagJson.color); + } else { + doReq("POST", this.apiTagsUrl, data, + (text) => { + const tagJson = JSON.parse(text); + addTag(tagJson.name, tagJson.color); + }, () => fail("tag-creation-failed") + ); + } + }, () => fail("tag-checking-failed") + ); + } + }; + + /** + * JavaScript implementation for the tags editor. + * + * This class uses TaggingBase and AddNewTagControl to provide the custom + * tag editor controls. This mainly consists of updating the hidden + * input values with the current list of tags and adding/removing + * tags from the current-tags- or recently-used-lists. + */ + class TagsEditor { + /** + * @param tagEditorRoot + * The root DOM element of the widget. + */ + constructor(tagEditorRoot) { + this.widget = tagEditorRoot; + this.taggingBase = new TaggingBase(this.widget); + this.addTagControl = new AddNewTagControl( + this.widget, this.taggingBase, (t) => this.insertNewTag(t) + ); + + this.currentTags = this.widget.querySelector('.current_tags'); + this.newTags = this.widget.querySelector('.new-tags'); + this.inputElement = this.widget.querySelector('input[type="hidden"]'); + + for (const tag of this.newTags.querySelectorAll(".tag")) { + this.configureAddTag(tag); + } + for (const tag of this.currentTags.querySelectorAll(".tag")) { + this.configureRemoveTag(tag); + } + + this.updateInputList(); + this.taggingBase.addTagListUpdatedListener( + () => this.updateInputList() + ); + } + + /** + * Insert a new tag into the "current tag" list. + * + * Makes sure that no duplicates are present in the widget before adding + * the new tag. If a duplicate is found, the old tag is removed before + * the new one is added. + */ + insertNewTag(tag) { + const name = tag.getAttribute("data-value"); + + const oldTag = this.widget.querySelector(`span[data-value="${name}"]`); + if (oldTag) { + oldTag.parentNode.removeChild(oldTag); + } + + this.taggingBase.insertTag(this.currentTags, tag); + this.configureRemoveTag(tag); + } + + /** + * Registeres a click-callback for a given node. + * + * The callback chain-calls another callback "onClicked" after + * moving the clicked tag from the old tag-list to a new tag list. + */ + registerNewCallback(tag, newParent, onClicked) { function callback(event) { tag.parentNode.removeChild(tag); - updateTag( - tag, - null, - tag.getAttribute("data-color"), - newSymbol - ); - - insertTag(newParent, tag); + this.taggingBase.insertTag(newParent, tag); tag.removeEventListener('click', callback); onClicked(tag); } - tag.addEventListener('click', callback); + tag.addEventListener('click', callback.bind(this)); } - function updateInputList() { + /** + * Updates the value of the hidden input element. + * + * Sets the value from the list of tags added to the currentTags + * DOM element. + */ + updateInputList() { const names = []; - for (const tag of currentTags.querySelectorAll(".tag")) { + for (const tag of this.currentTags.querySelectorAll(".tag")) { const name = tag.getAttribute("data-value"); names.push(`"${name}"`); } - inputElement.value = names.join(","); + this.inputElement.value = names.join(","); } - function addTagCallback(tag) { - registerNewCallback(tag, currentTags, "-", removeTagCallback); - updateInputList(); + /** + * Configure a tag-DOM element as a "add tag" button. + */ + configureAddTag(tag) { + this.taggingBase.updateTag(tag, null, null, "+"); + this.registerNewCallback(tag, this.currentTags, () => this.configureRemoveTag(tag)); + this.updateInputList(); } - function removeTagCallback(tag) { - registerNewCallback(tag, newTags, "+", addTagCallback); - updateInputList(); + /** + * Configure a tag-DOM element as a "remove tag" button. + */ + configureRemoveTag(tag) { + this.taggingBase.updateTag(tag, null, null, "-"); + this.registerNewCallback(tag, this.newTags, () => this.configureAddTag(tag)); + this.updateInputList(); } + }; - for (const tag of newTags.querySelectorAll(".tag")) { - updateTag(tag); - addTagCallback(tag); - } - for (const tag of currentTags.querySelectorAll(".tag")) { - updateTag(tag); - removeTagCallback(tag); + window.addEventListener('load', () => { + for (const el of document.querySelectorAll('.babybuddy-tags-editor')) { + new TagsEditor(el); } }); })(); \ No newline at end of file