From 145b80356d6809a3885681d5df2391d2e25bcab8 Mon Sep 17 00:00:00 2001 From: Patrick Jentsch <p.jentsch@uni-bielefeld.de> Date: Wed, 1 Mar 2023 16:31:41 +0100 Subject: [PATCH] Redesign corpus page and add possibility to add followers by username for owner --- app/corpora/routes.py | 32 +- .../js/RessourceDisplays/CorpusDisplay.js | 37 +- app/static/js/Utils.js | 112 +++--- app/templates/corpora/corpus.html.j2 | 327 +++++++++++++----- 4 files changed, 310 insertions(+), 198 deletions(-) diff --git a/app/corpora/routes.py b/app/corpora/routes.py index 9a69ebd8..1ea34a4d 100644 --- a/app/corpora/routes.py +++ b/app/corpora/routes.py @@ -3,6 +3,7 @@ from flask import ( abort, current_app, flash, + jsonify, make_response, Markup, redirect, @@ -49,22 +50,34 @@ def fake_add(): return '' -@bp.route('/<hashid:corpus_id>/is_public/enable', methods=['POST']) +@bp.route('/<hashid:corpus_id>/is_public', methods=['POST']) @login_required @owner_or_admin_required() -def enable_corpus_is_public(corpus_id): +def update_corpus_is_public(corpus_id): + is_public = request.json + if not isinstance(is_public, bool): + response = jsonify('The request body must be a boolean') + response.status_code = 400 + abort(response) corpus = Corpus.query.get_or_404(corpus_id) - corpus.is_public = True + corpus.is_public = is_public db.session.commit() return '', 204 -@bp.route('/<hashid:corpus_id>/is_public/disable', methods=['POST']) +@bp.route('/<hashid:corpus_id>/followers/add', methods=['POST']) @login_required @owner_or_admin_required() -def disable_corpus_is_public(corpus_id): +def add_corpus_followers(corpus_id): + usernames = request.json + if not (isinstance(usernames, list) or all(isinstance(u, str) for u in usernames)): + response = jsonify('The request body must be a list of strings') + response.status_code = 400 + abort(response) corpus = Corpus.query.get_or_404(corpus_id) - corpus.is_public = False + for username in usernames: + user = User.query.filter_by(username=username, is_public=True).first_or_404() + user.follow_corpus(corpus) db.session.commit() return '', 204 @@ -169,14 +182,12 @@ def create_corpus(): @login_required def corpus(corpus_id): corpus = Corpus.query.get_or_404(corpus_id) - exp_date = (datetime.utcnow() + timedelta(days=7)).strftime('%b %d, %Y') - roles = [x.name for x in CorpusFollowerRole.query.all()] + corpus_follower_roles = CorpusFollowerRole.query.all() if corpus.user == current_user or current_user.is_administrator(): return render_template( 'corpora/corpus.html.j2', corpus=corpus, - exp_date=exp_date, - roles=roles, + corpus_follower_roles=corpus_follower_roles, title='Corpus' ) if current_user.is_following_corpus(corpus) or corpus.is_public: @@ -191,6 +202,7 @@ def corpus(corpus_id): ) abort(403) + @bp.route('/<hashid:corpus_id>/generate-corpus-share-link', methods=['GET', 'POST']) @login_required @corpus_follower_permission_required('GENERATE_SHARE_LINK') diff --git a/app/static/js/RessourceDisplays/CorpusDisplay.js b/app/static/js/RessourceDisplays/CorpusDisplay.js index 4e8e8a9a..ab462e9b 100644 --- a/app/static/js/RessourceDisplays/CorpusDisplay.js +++ b/app/static/js/RessourceDisplays/CorpusDisplay.js @@ -12,16 +12,6 @@ class CorpusDisplay extends RessourceDisplay { .addEventListener('click', (event) => { Utils.deleteCorpusRequest(this.userId, this.corpusId); }); - this.displayElement - .querySelector('.action-switch[data-action="toggle-is-public"]') - .addEventListener('click', (event) => { - if (event.target.tagName !== 'INPUT') {return;} - if (event.target.checked) { - Utils.enableCorpusIsPublicRequest(this.userId, this.corpusId); - } else { - Utils.disableCorpusIsPublicRequest(this.userId, this.corpusId); - } - }); } init(user) { @@ -31,7 +21,6 @@ class CorpusDisplay extends RessourceDisplay { this.setStatus(corpus.status); this.setTitle(corpus.title); this.setNumTokens(corpus.num_tokens); - this.setShareLink(); } onPatch(patch) { @@ -82,7 +71,7 @@ class CorpusDisplay extends RessourceDisplay { } setStatus(status) { - let elements = this.displayElement.querySelectorAll('.corpus-analyse-trigger') + let elements = this.displayElement.querySelectorAll('.action-button[data-action="analyze"]'); for (let element of elements) { if (['BUILT', 'STARTING_ANALYSIS_SESSION', 'RUNNING_ANALYSIS_SESSION', 'CANCELING_ANALYSIS_SESSION'].includes(status)) { element.classList.remove('disabled'); @@ -118,28 +107,4 @@ class CorpusDisplay extends RessourceDisplay { new Date(creationDate).toLocaleString("en-US") ); } - - setShareLink() { - let generateShareLinkButton = this.displayElement.querySelector('#generate-share-link-button'); - let copyShareLinkButton = this.displayElement.querySelector('#copy-share-link-button'); - let shareLinkInput = this.displayElement.querySelector('#share-link-input'); - let shareLinkContainer = this.displayElement.querySelector('#share-link-container'); - let roleSelect = this.displayElement.querySelector('#role-select'); - let expirationDate = this.displayElement.querySelector('#expiration'); - - - generateShareLinkButton.addEventListener('click', () => { - Utils.generateCorpusShareLinkRequest(`${this.corpusId}`, roleSelect.value, expirationDate.value) - .then((shareLink) => { - shareLinkContainer.classList.remove('hide'); - shareLinkInput.value = shareLink; - }); - }); - - copyShareLinkButton.addEventListener('click', () => { - shareLinkInput.select(); - navigator.clipboard.writeText(shareLinkInput.value); - app.flash(`Copied!`, 'success'); - }); - } } diff --git a/app/static/js/Utils.js b/app/static/js/Utils.js index 865ea0d4..c5a55e89 100644 --- a/app/static/js/Utils.js +++ b/app/static/js/Utils.js @@ -69,15 +69,23 @@ class Utils { return Utils.mergeObjectsDeep(mergedObject, ...objects.slice(2)); } - static updateCorpusFollowerRole(corpusId, followerId, roleName) { + static updateCorpusIsPublicRequest(corpusId, isPublic) { return new Promise((resolve, reject) => { - fetch(`/corpora/${corpusId}/followers/${followerId}/role`, {method: 'POST', headers: {Accept: 'application/json', 'Content-Type': 'application/json'}, body: JSON.stringify({role: roleName})}) + let fetchRessource = `/corpora/${corpusId}/is_public`; + let fetchOptions = { + method: 'POST', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }, + body: JSON.stringify(isPublic) + }; + fetch(fetchRessource, fetchOptions) .then( (response) => { if (response.ok) { - app.flash('Role updated', 'corpus'); + app.flash(`Corpus is now ${isPublic ? 'public' : 'private'}`, 'corpus'); resolve(response); - return; } else { app.flash(`${response.statusText}`, 'error'); reject(response); @@ -91,79 +99,49 @@ class Utils { }); } - static enableCorpusIsPublicRequest(userId, corpusId) { + static updateCorpusFollowerRole(corpusId, followerId, roleName) { return new Promise((resolve, reject) => { - let corpus; - try { - corpus = app.data.users[userId].corpora[corpusId]; - } catch (error) { - corpus = {}; - } - - let modalElement = Utils.HTMLToElement( - ` - <div class="modal"> - <div class="modal-content"> - <h4>Hier könnte eine Warnung stehen</h4> - <p></p> - </div> - <div class="modal-footer"> - <a class="action-button btn modal-close waves-effect waves-light" data-action="cancel">Cancel</a> - <a class="action-button btn modal-close red waves-effect waves-light" data-action="confirm">Confirm</a> - </div> - </div> - ` - ); - document.querySelector('#modals').appendChild(modalElement); - let modal = M.Modal.init( - modalElement, - { - dismissible: false, - onCloseEnd: () => { - modal.destroy(); - modalElement.remove(); - } - } - ); - - let confirmElement = modalElement.querySelector('.action-button[data-action="confirm"]'); - confirmElement.addEventListener('click', (event) => { - let corpusTitle = corpus?.title; - fetch(`/corpora/${corpusId}/is_public/enable`, {method: 'POST', headers: {Accept: 'application/json'}}) - .then( - (response) => { - if (response.status === 403) {app.flash('Forbidden', 'error'); reject(response);} - if (response.status === 404) {app.flash('Not Found', 'error'); reject(response);} - app.flash(`Corpus "${corpusTitle}" is public now`, 'corpus'); + fetch(`/corpora/${corpusId}/followers/${followerId}/role`, {method: 'POST', headers: {Accept: 'application/json', 'Content-Type': 'application/json'}, body: JSON.stringify({role: roleName})}) + .then( + (response) => { + if (response.ok) { + app.flash('Role updated', 'corpus'); resolve(response); - }, - (response) => { - app.flash('Something went wrong', 'error'); + return; + } else { + app.flash(`${response.statusText}`, 'error'); reject(response); } - ); - }); - modal.open(); + }, + (response) => { + app.flash('Something went wrong', 'error'); + reject(response); + } + ); }); } - static disableCorpusIsPublicRequest(userId, corpusId) { + static addCorpusFollowersRequest(corpusId, usernames) { return new Promise((resolve, reject) => { - let corpus; - try { - corpus = app.data.users[userId].corpora[corpusId]; - } catch (error) { - corpus = {}; - } - - let corpusTitle = corpus?.title; - fetch(`/corpora/${corpusId}/is_public/disable`, {method: 'POST', headers: {Accept: 'application/json'}}) + let fetchRessource = `/corpora/${corpusId}/followers/add`; + let fetchOptions = { + method: 'POST', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }, + body: JSON.stringify(usernames) + }; + fetch(fetchRessource, fetchOptions) .then( (response) => { - if (response.status === 403) {app.flash('Forbidden', 'error'); reject(response);} - if (response.status === 404) {app.flash('Not Found', 'error'); reject(response);} - app.flash(`Corpus "${corpusTitle}" is private now`, 'corpus'); - resolve(response); + if (response.ok) { + app.flash(`${usernames.length > 1 ? 'Users are' : 'User is'} following now`, 'corpus'); + resolve(response); + } else { + app.flash(`${response.statusText}`, 'error'); + reject(response); + } }, (response) => { app.flash('Something went wrong', 'error'); diff --git a/app/templates/corpora/corpus.html.j2 b/app/templates/corpora/corpus.html.j2 index 790d4fac..299c86f8 100644 --- a/app/templates/corpora/corpus.html.j2 +++ b/app/templates/corpora/corpus.html.j2 @@ -8,13 +8,11 @@ <div class="container"> <div class="row" data-corpus-id="{{ corpus.hashid }}" data-user-id="{{ corpus.user.hashid }}" id="corpus-display"> <div class="col s12"> - <div class="row"> - <div class="col s8 m9 l10"> - <h1 id="title"><span class="corpus-title"></span></h1> - </div> - <div class="col s4 m3 l2 right-align"> - <p> </p> - <p> </p> + <h1 id="title"><span class="corpus-title"></span></h1> + </div> + <div class="col s12 l8"> + <div class="card service-color-border border-darken" data-service="corpus-analysis" style="border-top: 10px solid"> + <div class="card-content"> <span class="chip corpus-status corpus-status-color corpus-status-text white-text"></span> <div class="active preloader-wrapper small corpus-status-spinner"> <div class="spinner-layer spinner-blue-only"> @@ -29,11 +27,6 @@ </div> </div> </div> - </div> - </div> - - <div class="card service-color-border border-darken" data-service="corpus-analysis" style="border-top: 10px solid"> - <div class="card-content"> <div class="row"> <div class="col s12"> <div class="input-field"> @@ -57,12 +50,35 @@ </div> </div> </div> - <div class="card-action"> - <div class="right-align"> - <a class="btn corpus-analyse-trigger disabled waves-effect waves-light" href="{{ url_for('corpora.analyse_corpus', corpus_id=corpus.id) }}"><i class="material-icons left">search</i>Analyze</a> - <a class="action-button btn disabled waves-effect waves-light" data-action="build-request"><i class="nopaque-icons left">K</i>Build</a> - <a class="btn disabled export-corpus-trigger waves-effect waves-light" href="{{ url_for('corpora.export_corpus', corpus_id=corpus.id) }}"><i class="material-icons left">import_export</i>Export</a> - <a class="action-button btn red waves-effect waves-light" data-action="delete-request"><i class="material-icons left">delete</i>Delete</a> + </div> + </div> + + <div class="col s12 l4"> + <div class="card"> + <div class="card-content"> + <span class="card-title">Actions</span> + <div class="row"> + <div class="col s12 l6" style="padding: 0 2.5px;"> + <a class="action-button btn disabled waves-effect waves-light" data-action="build-request" style="width: 100%;"><i class="nopaque-icons left">K</i>Build</a> + </div> + <div class="col s12 l6" style="padding: 0 2.5px;"> + <a class="action-button btn disabled waves-effect waves-light" data-action="analyze" href="{{ url_for('corpora.analyse_corpus', corpus_id=corpus.id) }}" style="width: 100%;"><i class="material-icons left">search</i>Analyze</a> + </div> + <div class="col s12 l6" style="padding: 5px 2.5px 0 2.5px;"> + <a class="btn waves-effect waves-light modal-trigger" href="#publishing-modal" style="width: 100%;"><i class="material-icons left">publish</i>Publishing</a> + </div> + <div class="col s12 l6" style="padding: 5px 2.5px 0 2.5px;"> + <a class="action-button btn red waves-effect waves-light" data-action="delete-request" style="width: 100%;"><i class="material-icons left">delete</i>Delete</a> + </div> + </div> + <span class="card-title">Social</span> + <div class="row"> + <div class="col s12 l6" style="padding: 0 2.5px;"> + <a class="btn waves-effect waves-light modal-trigger" href="#invite-user-modal" style="width: 100%;"><i class="material-icons left">person_add</i>invite user</a> + </div> + <div class="col s12 l6" style="padding: 0 2.5px;"> + <a class="btn waves-effect waves-light modal-trigger" href="#share-link-modal" style="width: 100%;"><i class="material-icons left">link</i>Share link</a> + </div> </div> </div> </div> @@ -80,73 +96,8 @@ </div> </div> - <div class="col s12"> - <div class="card"> - <div class="card-content"> - <span class="card-title">Share your Corpus</span> - <br> - <p></p> - <p><b>Change your Corpus Status to Public</b></p> - <p><i>Other users can only see the meta data of your corpus. The files of the corpus remain private and can only be viewed via a share link.</i></p> - <br> - <div class="action-switch switch" data-action="toggle-is-public"> - <span class="share"></span> - <label> - <input {% if corpus.is_public %}checked{% endif %} type="checkbox"> - <span class="lever"></span> - public - </label> - </div> - <br> - <p></p> - <hr style="height:1px;border:none;color:grey;background-color:grey;"> - <br> - <p></p> - <p><b>Create a link to share your corpus files with your team</b></p> - <p><i>With the link other users follow your corpus directly, if it has not expired. - You can set different roles via the link, you can also edit them later in the menu below. - It is recommended not to set the expiration date of the link too far.</i></p> - <br> - <div class="row"> - <div class="col s4"> - <div class="input-field"> - <select id="role-select"> - {% for role in roles%} - <option value="{{role}}">{{role}}</option> - {% endfor %} - </select> - <label>Role</label> - </div> - </div> - </div> - <div class="row"> - <div class="col s4"> - <div class="input-field"> - <input type="text" class="datepicker" value="{{exp_date}}" id="expiration"> - <label for="expiration-date">Expiration date</label> - </div> - </div> - </div> - <div class="row"> - <div class="col s12"> - <a class="action-button btn waves-effect waves-light" id="generate-share-link-button">Generate Share Link</a> - </div> - <div class="col s12 hide" id="share-link-container"> - <p></p> - <br> - <div class="row"> - <div class="col s1"> - <a class="action-button btn-small waves-effect waves-light" id="copy-share-link-button">Copy</a> - </div> - <div class="col s11"> - <input id="share-link-input" readonly> - </div> - </div> - </div> - </div> - </div> - </div> - </div> + <div class="col s12"></div> + <div class="col s12"> <div class="card"> <div class="card-content"> @@ -159,9 +110,215 @@ </div> {% endblock page_content %} +{% block modals %} +{{ super() }} +<div class="modal" id="publishing-modal"> + <div class="modal-content"> + <h4>Change your Corpus publishing status</h4> + <p><i>Other users can only see the meta data of your corpus. The files of the corpus remain private and can only be viewed via a share link.</i></p> + <br> + <div class="switch"> + <label> + private + <input {% if corpus.is_public %}checked{% endif %} id="publishing-modal-is-public-switch" type="checkbox"> + <span class="lever"></span> + public + </label> + </div> + </div> + <div class="modal-footer"> + <a class="modal-close waves-effect waves-green btn-flat">Close</a> + </div> +</div> + +<div class="modal no-autoinit" id="invite-user-modal"> + <div class="modal-content"> + <h4>Invite a nopaque user by username</h4> + <p> + Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. + </p> + <div class="row"> + <div class="col s10"> + <div class="chips no-autoinit" id="invite-user-modal-search"></div> + </div> + <div class="col s2"> + <br class="hide-on-med-and-down"> + <a class="btn modal-close waves-effect waves-light" id="invite-user-modal-invite-button">Invite<i class="material-icons right">send</i></a> + </div> + </div> + </div> + <div class="modal-footer"> + <a class="modal-close waves-effect waves-green btn-flat">Close</a> + </div> +</div> + +<div class="modal no-autoinit" id="share-link-modal"> + <div class="modal-content"> + <h4>Create a link to share your corpus</h4> + <p> + With the link other users follow your corpus directly, if it has not expired. + You can set different roles via the link, you can also edit them later in the menu below. + It is recommended not to set the expiration date of the link too far. + </p> + <div class="row"> + <div class="col s12 l2"> + <div class="input-field"> + <i class="material-icons prefix">badge</i> + <select id="share-link-modal-corpus-follower-role-select"> + {% for corpus_follower_role in corpus_follower_roles %} + <option value="{{ corpus_follower_role.hashid }}">{{ corpus_follower_role.name }}</option> + {% endfor %} + </select> + <label>Role</label> + </div> + </div> + <div class="col s12 l2"> + <div class="input-field"> + <i class="material-icons prefix">calendar_month</i> + <input type="text" class="datepicker no-autoinit" id="share-link-modal-expiration-date-datepicker"> + <label for="expiration-date">Expiration date</label> + </div> + </div> + <div class="col s12 l2"> + <br class="hide-on-med-and-down"> + <a class="btn waves-effect waves-light" id="share-link-modal-create-button">Create<i class="material-icons right">send</i></a> + </div> + + <div class="col s12 l6"> + <div class="row hide" id="share-link-modal-output-container"> + <div class="col s9"> + <div class="input-field"> + <input disabled id="share-link-modal-output-field" readonly type="text"> + </div> + </div> + <div class="col s3"> + <br class="hide-on-med-and-down"> + <a class="btn-small waves-effect waves-light" id="share-link-modal-output-copy-button"><i class="material-icons left">content_copy</i>Copy</a> + </div> + </div> + </div> + </div> + </div> + <div class="modal-footer"> + <a class="modal-close waves-effect waves-green btn-flat">Close</a> + </div> +</div> +{% endblock modals %} + {% block scripts %} {{ super() }} <script> + let corpusId = {{ corpus.hashid|tojson }}; let corpusDisplay = new CorpusDisplay(document.querySelector('#corpus-display')); + + // #region publishing_modal_js + let publishingModalIsPublicSwitchElement = document.querySelector('#publishing-modal-is-public-switch'); + publishingModalIsPublicSwitchElement.addEventListener('change', (event) => { + let newIsPublic = publishingModalIsPublicSwitchElement.checked; + Utils.updateCorpusIsPublicRequest(corpusId, newIsPublic) + .catch((response) => { + publishingModalIsPublicSwitchElement.checked = !newIsPublic; + }); + }); + // #endregion publishing_modal_js + + // #region invite_user_modal_js + let inviteUserModalElement = document.querySelector('#invite-user-modal'); + let inviteUserModalSearchElement = document.querySelector('#invite-user-modal-search'); + let inviteUserModalInviteButtonElement = document.querySelector('#invite-user-modal-invite-button'); + + let inviteUserModalSearch = M.Chips.init( + inviteUserModalSearchElement, + { + autocompleteOptions: { + data: { + 'nopaque': '/users/3V8Aqpg74JvxOd9o/avatar', + 'pjentsch': '/users/3V8Aqpg74JvxOd9o/avatar', + 'pjentsch2': '/users/3V8Aqpg74JvxOd9o/avatar' + } + }, + limit: 3, + onChipAdd: (a, chipElement) => { + if (!(chipElement.firstChild.data in inviteUserModalSearch.autocomplete.options.data)) { + chipElement.firstElementChild.click(); + } + }, + placeholder: 'Enter a username', + secondaryPlaceholder: 'Add more users' + } + ); + + M.Modal.init( + inviteUserModalElement, + { + onOpenStart: (modalElement, modalTriggerElement) => { + while (inviteUserModalSearch.chipsData.length > 0) { + inviteUserModalSearch.deleteChip(0); + } + } + } + ) + + inviteUserModalInviteButtonElement.addEventListener('click', (event) => { + let usernames = inviteUserModalSearch.chipsData.map((chipData) => chipData.tag); + Utils.addCorpusFollowersRequest(corpusId, usernames); + }); + // #endregion invite_user_modal_js + + // #region share_link_modal_js + let shareLinkModalElement = document.querySelector('#share-link-modal'); + let shareLinkModalCorpusFollowerRoleSelectElement = document.querySelector('#share-link-modal-corpus-follower-role-select'); + let shareLinkModalExpirationDateDatepickerElement = document.querySelector('#share-link-modal-expiration-date-datepicker'); + let shareLinkModalCreateButtonElement = document.querySelector('#share-link-modal-create-button'); + let shareLinkModalOutputContainerElement = document.querySelector('#share-link-modal-output-container'); + let shareLinkModalOutputFieldElement = document.querySelector('#share-link-modal-output-field'); + let shareLinkModalOutputCopyButtonElement = document.querySelector('#share-link-modal-output-copy-button'); + + let today = new Date(); + let tomorrow = new Date(); + tomorrow.setDate(today.getDate() + 1); + let oneWeekLater = new Date(); + oneWeekLater.setDate(today.getDate() + 7); + let fourWeeksLater = new Date(); + fourWeeksLater.setDate(today.getDate() + 28); + + M.Datepicker.init( + shareLinkModalExpirationDateDatepickerElement, + { + container: document.querySelector('main'), + defaultDate: oneWeekLater, + setDefaultDate: true, + minDate: tomorrow, + maxDate: fourWeeksLater + } + ); + + M.Modal.init( + shareLinkModalElement, + { + onOpenStart: (modalElement, modalTriggerElement) => { + shareLinkModalOutputFieldElement.value = ''; + shareLinkModalOutputContainerElement.classList.add('hide'); + } + } + ) + + shareLinkModalCreateButtonElement.addEventListener('click', (event) => { + Utils.generateCorpusShareLinkRequest(corpusId, shareLinkModalCorpusFollowerRoleSelectElement.value, shareLinkModalExpirationDateDatepickerElement.value) + .then((shareLink) => { + shareLinkModalOutputContainerElement.classList.remove('hide'); + shareLinkModalOutputFieldElement.value = shareLink; + }); + }); + + shareLinkModalOutputCopyButtonElement.addEventListener('click', (event) => { + navigator.clipboard.writeText(shareLinkModalOutputFieldElement.value) + .then( + () => {app.flash('Copied!');}, + () => {app.flash('Could not copy to clipboard. Please copy manually.', 'error');} + ); + + }); + // #endregion share_link_modal_js </script> {% endblock scripts %} -- GitLab