1 import Awesomplete
from 'awesomplete';
5 * Find a parent element according to its tag and its attributes
7 * @param element Element where to start the search
8 * @param tagName Expected parent tag name
9 * @param attributes Associative array of expected attributes (name=>value).
11 * @returns Found element or null.
13 function findParent(element
, tagName
, attributes
) {
14 const parentMatch
= (key
) => attributes
[key
] !== '' && element
.getAttribute(key
).indexOf(attributes
[key
]) !== -1;
16 if (element
.tagName
.toLowerCase() === tagName
) {
17 if (Object
.keys(attributes
).find(parentMatch
)) {
21 element
= element
.parentElement
;
27 * Ajax request to refresh the CSRF token.
29 function refreshToken(basePath
, callback
) {
30 const xhr
= new XMLHttpRequest();
31 xhr
.open('GET', `${basePath}/admin/token`);
33 const elements
= document
.querySelectorAll('input[name="token"]');
34 [...elements
].forEach((element
) => {
35 element
.setAttribute('value', xhr
.responseText
);
39 callback(xhr
.response
);
45 function createAwesompleteInstance(element
, separator
, tags
= []) {
46 const awesome
= new Awesomplete(Awesomplete
.$(element
));
48 // Tags are separated by separator
49 awesome
.filter
= (text
, input
) => Awesomplete
.FILTER_CONTAINS(text
, input
.match(new RegExp(`[^${separator}]*$`))[0]);
50 // Insert new selected tag in the input
51 awesome
.replace
= (text
) => {
52 const before
= awesome
.input
.value
.match(new RegExp(`^.+${separator}+|`))[0];
53 awesome
.input
.value
= `${before}${text}${separator}`;
55 // Highlight found items
56 awesome
.item
= (text
, input
) => Awesomplete
.ITEM(text
, input
.match(new RegExp(`[^${separator}]*$`))[0]);
57 // Don't display already selected items
58 // WARNING: pseudo classes does not seem to work with string litterals...
59 const reg
= new RegExp(`([^${separator}]+)${separator}`, 'g');
61 awesome
.data
= (item
, input
) => {
62 while ((match
= reg
.exec(input
))) {
63 if (item
=== match
[1]) {
78 * Update awesomplete list of tag for all elements matching the given selector
80 * @param selector CSS selector
81 * @param tags Array of tags
82 * @param instances List of existing awesomplete instances
83 * @param separator Tags separator character
85 function updateAwesompleteList(selector
, tags
, instances
, separator
) {
86 if (instances
.length
=== 0) {
87 // First load: create Awesomplete instances
88 const elements
= document
.querySelectorAll(selector
);
89 [...elements
].forEach((element
) => {
90 instances
.push(createAwesompleteInstance(element
, separator
, tags
));
93 // Update awesomplete tag list
94 instances
.map((item
) => {
103 * Add the class 'hidden' to city options not attached to the current selected continent.
105 * @param cities List of <option> elements
106 * @param currentContinent Current selected continent
107 * @param reset Set to true to reset the selected value
109 function hideTimezoneCities(cities
, currentContinent
, reset
= null) {
114 [...cities
].forEach((option
) => {
115 if (option
.getAttribute('data-continent') !== currentContinent
) {
116 option
.className
= 'hidden';
118 option
.className
= '';
119 if (reset
=== true && first
=== true) {
120 option
.setAttribute('selected', 'selected');
128 * Retrieve an element up in the tree from its class name.
130 function getParentByClass(el
, className
) {
131 const p
= el
.parentNode
;
132 if (p
== null || p
.classList
.contains(className
)) {
135 return getParentByClass(p
, className
);
138 function toggleHorizontal() {
139 [...document
.getElementById('shaarli-menu').querySelectorAll('.menu-transform')].forEach((el
) => {
140 el
.classList
.toggle('pure-menu-horizontal');
144 function toggleMenu(menu
) {
145 // set timeout so that the panel has a chance to roll up
146 // before the menu switches states
147 if (menu
.classList
.contains('open')) {
148 setTimeout(toggleHorizontal
, 500);
152 menu
.classList
.toggle('open');
153 document
.getElementById('menu-toggle').classList
.toggle('x');
156 function closeMenu(menu
) {
157 if (menu
.classList
.contains('open')) {
162 function toggleFold(button
, description
, thumb
) {
163 // Switch fold/expand - up = fold
164 if (button
.classList
.contains('fa-chevron-up')) {
165 button
.title
= document
.getElementById('translation-expand').innerHTML
;
166 if (description
!= null) {
167 description
.style
.display
= 'none';
170 thumb
.style
.display
= 'none';
173 button
.title
= document
.getElementById('translation-fold').innerHTML
;
174 if (description
!= null) {
175 description
.style
.display
= 'block';
178 thumb
.style
.display
= 'block';
181 button
.classList
.toggle('fa-chevron-down');
182 button
.classList
.toggle('fa-chevron-up');
185 function removeClass(element
, classname
) {
186 element
.className
= element
.className
.replace(new RegExp(`(?:^|\\s)${classname}(?:\\s|$)`), ' ');
189 function init(description
) {
191 /* Fix jumpy resizing: https://stackoverflow.com/a/18262927/1484919 */
192 const scrollTop
= window
.pageYOffset
193 || (document
.documentElement
|| document
.body
.parentNode
|| document
.body
).scrollTop
;
195 description
.style
.height
= 'auto';
196 description
.style
.height
= `${description.scrollHeight + 10}px`;
198 window
.scrollTo(0, scrollTop
);
201 /* 0-timeout to get the already changed text */
202 function delayedResize() {
203 window
.setTimeout(resize
, 0);
206 const observe
= (element
, event
, handler
) => {
207 element
.addEventListener(event
, handler
, false);
209 observe(description
, 'change', resize
);
210 observe(description
, 'cut', delayedResize
);
211 observe(description
, 'paste', delayedResize
);
212 observe(description
, 'drop', delayedResize
);
213 observe(description
, 'keydown', delayedResize
);
219 const basePath
= document
.querySelector('input[name="js_base_path"]').value
;
220 const tagsSeparatorElement
= document
.querySelector('input[name="tags_separator"]');
221 const tagsSeparator
= tagsSeparatorElement
? tagsSeparatorElement
.value
|| ' ' : ' ';
224 * Handle responsive menu.
225 * Source: http://purecss.io/layouts/tucked-menu-vertical/
227 const menu
= document
.getElementById('shaarli-menu');
228 const WINDOW_CHANGE_EVENT
= ('onorientationchange' in window
) ? 'orientationchange' : 'resize';
230 const menuToggle
= document
.getElementById('menu-toggle');
231 if (menuToggle
!= null) {
232 menuToggle
.addEventListener('click', () => toggleMenu(menu
));
235 window
.addEventListener(WINDOW_CHANGE_EVENT
, () => closeMenu(menu
));
238 * Fold/Expand shaares description and thumbnail.
240 const foldAllButtons
= document
.getElementsByClassName('fold-all');
241 const foldButtons
= document
.getElementsByClassName('fold-button');
243 [...foldButtons
].forEach((foldButton
) => {
244 // Retrieve description
245 let description
= null;
246 let thumbnail
= null;
247 const linklistItem
= getParentByClass(foldButton
, 'linklist-item');
248 if (linklistItem
!= null) {
249 description
= linklistItem
.querySelector('.linklist-item-description');
250 thumbnail
= linklistItem
.querySelector('.linklist-item-thumbnail');
251 if (description
!= null || thumbnail
!= null) {
252 foldButton
.style
.display
= 'inline';
256 foldButton
.addEventListener('click', (event
) => {
257 event
.preventDefault();
258 toggleFold(event
.target
, description
, thumbnail
);
262 if (foldAllButtons
!= null) {
263 [].forEach
.call(foldAllButtons
, (foldAllButton
) => {
264 foldAllButton
.addEventListener('click', (event
) => {
265 event
.preventDefault();
266 const state
= foldAllButton
.firstElementChild
.getAttribute('class').indexOf('down') !== -1 ? 'down' : 'up';
267 [].forEach
.call(foldButtons
, (foldButton
) => {
268 if ((foldButton
.firstElementChild
.classList
.contains('fa-chevron-up') && state
=== 'down')
269 || (foldButton
.firstElementChild
.classList
.contains('fa-chevron-down') && state
=== 'up')
273 // Retrieve description
274 let description
= null;
275 let thumbnail
= null;
276 const linklistItem
= getParentByClass(foldButton
, 'linklist-item');
277 if (linklistItem
!= null) {
278 description
= linklistItem
.querySelector('.linklist-item-description');
279 thumbnail
= linklistItem
.querySelector('.linklist-item-thumbnail');
280 if (description
!= null || thumbnail
!= null) {
281 foldButton
.style
.display
= 'inline';
285 toggleFold(foldButton
.firstElementChild
, description
, thumbnail
);
287 foldAllButton
.firstElementChild
.classList
.toggle('fa-chevron-down');
288 foldAllButton
.firstElementChild
.classList
.toggle('fa-chevron-up');
289 foldAllButton
.title
= state
=== 'down'
290 ? document
.getElementById('translation-fold-all').innerHTML
291 : document
.getElementById('translation-expand-all').innerHTML
;
297 * Confirmation message before deletion.
299 const deleteLinks
= document
.querySelectorAll('.confirm-delete');
300 [...deleteLinks
].forEach((deleteLink
) => {
301 deleteLink
.addEventListener('click', (event
) => {
302 const type
= event
.currentTarget
.getAttribute('data-type') || 'link';
303 if (!confirm(document
.getElementById(`translation-delete-${type}`).innerHTML
)) {
304 event
.preventDefault();
312 const closeLinks
= document
.querySelectorAll('.pure-alert-close');
313 [...closeLinks
].forEach((closeLink
) => {
314 closeLink
.addEventListener('click', (event
) => {
315 const alert
= getParentByClass(event
.target
, 'pure-alert-closable');
316 alert
.style
.display
= 'none';
321 * New version dismiss.
322 * Hide the message for one week using localStorage.
324 const newVersionDismiss
= document
.getElementById('new-version-dismiss');
325 const newVersionMessage
= document
.querySelector('.new-version-message');
326 if (newVersionMessage
!= null
327 && localStorage
.getItem('newVersionDismiss') != null
328 && parseInt(localStorage
.getItem('newVersionDismiss'), 10) + (7 * 24 * 60 * 60 * 1000) > (new Date()).getTime()
330 newVersionMessage
.style
.display
= 'none';
332 if (newVersionDismiss
!= null) {
333 newVersionDismiss
.addEventListener('click', () => {
334 localStorage
.setItem('newVersionDismiss', (new Date()).getTime().toString());
338 const hiddenReturnurl
= document
.getElementsByName('returnurl');
339 if (hiddenReturnurl
!= null) {
340 hiddenReturnurl
.value
= window
.location
.href
;
344 * Autofocus text fields
346 const autofocusElements
= document
.querySelectorAll('.autofocus');
347 let breakLoop
= false;
348 [].forEach
.call(autofocusElements
, (autofocusElement
) => {
349 if (autofocusElement
.value
=== '' && !breakLoop
) {
350 autofocusElement
.focus();
356 * Handle sub menus/forms
358 const openers
= document
.getElementsByClassName('subheader-opener');
359 if (openers
!= null) {
360 [...openers
].forEach((opener
) => {
361 opener
.addEventListener('click', (event
) => {
362 event
.preventDefault();
364 const id
= opener
.getAttribute('data-open-id');
365 const sub
= document
.getElementById(id
);
368 [...document
.getElementsByClassName('subheader-form')].forEach((element
) => {
369 if (element
!== sub
) {
370 removeClass(element
, 'open');
374 sub
.classList
.toggle('open');
381 * Remove CSS target padding (for fixed bar)
383 if (location
.hash
!== '') {
384 const anchor
= document
.getElementById(location
.hash
.substr(1));
385 if (anchor
!= null) {
386 const padsize
= anchor
.clientHeight
;
387 window
.scroll(0, window
.scrollY
- padsize
);
388 anchor
.style
.paddingTop
= '0';
395 const description
= document
.getElementById('lf_description');
397 if (description
!= null) {
399 // Submit editlink form with CTRL + Enter in the text area.
400 description
.addEventListener('keydown', (event
) => {
401 if (event
.ctrlKey
&& event
.keyCode
=== 13) {
402 document
.getElementById('button-save-edit').click();
410 const bookmarkletLinks
= document
.querySelectorAll('.bookmarklet-link');
411 const bkmMessage
= document
.getElementById('bookmarklet-alert');
412 [].forEach
.call(bookmarkletLinks
, (link
) => {
413 link
.addEventListener('click', (event
) => {
414 event
.preventDefault();
415 alert(bkmMessage
.value
);
419 const continent
= document
.getElementById('continent');
420 const city
= document
.getElementById('city');
421 if (continent
!= null && city
!= null) {
422 continent
.addEventListener('change', () => {
423 hideTimezoneCities(city
, continent
.options
[continent
.selectedIndex
].value
, true);
425 hideTimezoneCities(city
, continent
.options
[continent
.selectedIndex
].value
, false);
431 const linkCheckboxes
= document
.querySelectorAll('.link-checkbox');
432 const bar
= document
.getElementById('actions');
433 [...linkCheckboxes
].forEach((checkbox
) => {
434 checkbox
.style
.display
= 'inline-block';
435 checkbox
.addEventListener('change', () => {
436 const linkCheckedCheckboxes
= document
.querySelectorAll('.link-checkbox:checked');
437 const count
= [...linkCheckedCheckboxes
].length
;
438 if (count
=== 0 && bar
.classList
.contains('open')) {
439 bar
.classList
.toggle('open');
440 } else if (count
> 0 && !bar
.classList
.contains('open')) {
441 bar
.classList
.toggle('open');
446 const deleteButton
= document
.getElementById('actions-delete');
447 const token
= document
.getElementById('token');
448 if (deleteButton
!= null && token
!= null) {
449 deleteButton
.addEventListener('click', (event
) => {
450 event
.preventDefault();
453 const linkCheckedCheckboxes
= document
.querySelectorAll('.link-checkbox:checked');
454 [...linkCheckedCheckboxes
].forEach((checkbox
) => {
457 title: document
.querySelector(`.linklist-item[data-id="${checkbox.value}"] .linklist-link`).innerHTML
,
461 let message
= `Are you sure you want to delete ${links.length} links?\n`;
462 message
+= 'This action is IRREVERSIBLE!\n\nTitles:\n';
464 links
.forEach((item
) => {
465 message
+= ` - ${item.title}\n`;
469 if (window
.confirm(message
)) {
470 window
.location
= `${basePath}/admin/shaare/delete?id=${ids.join('+')}&token=${token.value}`;
475 const changeVisibilityButtons
= document
.querySelectorAll('.actions-change-visibility');
476 if (changeVisibilityButtons
!= null && token
!= null) {
477 [...changeVisibilityButtons
].forEach((button
) => {
478 button
.addEventListener('click', (event
) => {
479 event
.preventDefault();
480 const visibility
= event
.target
.getAttribute('data-visibility');
483 const linkCheckedCheckboxes
= document
.querySelectorAll('.link-checkbox:checked');
484 [...linkCheckedCheckboxes
].forEach((checkbox
) => {
487 title: document
.querySelector(`.linklist-item[data-id="${checkbox.value}"] .linklist-link`).innerHTML
,
491 const ids
= links
.map((item
) => item
.id
);
493 `${basePath}/admin/shaare/visibility?token=${token.value}&newVisibility=${visibility}&id=${ids.join('+')}`
502 const selectAllButtons
= document
.querySelectorAll('.select-all-button');
503 [...selectAllButtons
].forEach((selectAllButton
) => {
504 selectAllButton
.addEventListener('click', (e
) => {
506 const checked
= selectAllButton
.classList
.contains('filter-off');
507 [...selectAllButtons
].forEach((selectAllButton2
) => {
508 selectAllButton2
.classList
.toggle('filter-off');
509 selectAllButton2
.classList
.toggle('filter-on');
511 [...linkCheckboxes
].forEach((linkCheckbox
) => {
512 linkCheckbox
.checked
= checked
;
513 linkCheckbox
.dispatchEvent(new Event('change'));
519 * Tag list operations
521 * TODO: support error code in the backend for AJAX requests
523 const tagList
= document
.querySelector('input[name="taglist"]');
524 let existingTags
= tagList
? tagList
.value
.split(' ') : [];
525 let awesomepletes
= [];
527 // Display/Hide rename form
528 const renameTagButtons
= document
.querySelectorAll('.rename-tag');
529 [...renameTagButtons
].forEach((rename
) => {
530 rename
.addEventListener('click', (event
) => {
531 event
.preventDefault();
532 const block
= findParent(event
.target
, 'div', { class: 'tag-list-item' });
533 const form
= block
.querySelector('.rename-tag-form');
534 if (form
.style
.display
=== 'none' || form
.style
.display
=== '') {
535 form
.style
.display
= 'block';
537 form
.style
.display
= 'none';
539 block
.querySelector('input').focus();
543 // Rename a tag with an AJAX request
544 const renameTagSubmits
= document
.querySelectorAll('.validate-rename-tag');
545 [...renameTagSubmits
].forEach((rename
) => {
546 rename
.addEventListener('click', (event
) => {
547 event
.preventDefault();
548 const block
= findParent(event
.target
, 'div', { class: 'tag-list-item' });
549 const input
= block
.querySelector('.rename-tag-input');
550 const totag
= input
.value
.replace('/"/g', '\\"');
551 if (totag
.trim() === '') {
554 const refreshedToken
= document
.getElementById('token').value
;
555 const fromtag
= block
.getAttribute('data-tag');
556 const fromtagUrl
= block
.getAttribute('data-tag-url');
557 const xhr
= new XMLHttpRequest();
558 xhr
.open('POST', `${basePath}/admin/tags`);
559 xhr
.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
561 if (xhr
.status
!== 200) {
562 alert(`An error occurred. Return code: ${xhr.status}`);
565 block
.setAttribute('data-tag', totag
);
566 block
.setAttribute('data-tag-url', encodeURIComponent(totag
));
567 input
.setAttribute('name', totag
);
568 input
.setAttribute('value', totag
);
569 findParent(input
, 'div', { class: 'rename-tag-form' }).style
.display
= 'none';
570 block
.querySelector('a.tag-link').innerHTML
= he
.encode(totag
);
572 .querySelector('a.tag-link')
573 .setAttribute('href', `${basePath}/?searchtags=${encodeURIComponent(totag)}`);
575 .querySelector('a.count')
576 .setAttribute('href', `${basePath}/add-tag/${encodeURIComponent(totag)}`);
578 .querySelector('a.rename-tag')
579 .setAttribute('href', `${basePath}/admin/tags?fromtag=${encodeURIComponent(totag)}`);
581 // Refresh awesomplete values
582 existingTags
= existingTags
.map((tag
) => (tag
=== fromtag
? totag : tag
));
583 awesomepletes
= updateAwesompleteList('.rename-tag-input', existingTags
, awesomepletes
, tagsSeparator
);
586 xhr
.send(`renametag=1&fromtag=${fromtagUrl}&totag=${encodeURIComponent(totag)}&token=${refreshedToken}`);
587 refreshToken(basePath
);
591 // Validate input with enter key
592 const renameTagInputs
= document
.querySelectorAll('.rename-tag-input');
593 [...renameTagInputs
].forEach((rename
) => {
594 rename
.addEventListener('keypress', (event
) => {
595 if (event
.keyCode
=== 13) { // enter
596 findParent(event
.target
, 'div', { class: 'tag-list-item' }).querySelector('.validate-rename-tag').click();
601 // Delete a tag with an AJAX query (alert popup confirmation)
602 const deleteTagButtons
= document
.querySelectorAll('.delete-tag');
603 [...deleteTagButtons
].forEach((rename
) => {
604 rename
.style
.display
= 'inline';
605 rename
.addEventListener('click', (event
) => {
606 event
.preventDefault();
607 const block
= findParent(event
.target
, 'div', { class: 'tag-list-item' });
608 const tag
= block
.getAttribute('data-tag');
609 const tagUrl
= block
.getAttribute('data-tag-url');
610 const refreshedToken
= document
.getElementById('token').value
;
612 if (confirm(`Are you sure you want to delete the tag "${tag}"?`)) {
613 const xhr
= new XMLHttpRequest();
614 xhr
.open('POST', `${basePath}/admin/tags`);
615 xhr
.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
619 xhr
.send(`deletetag=1&fromtag=${tagUrl}&token=${refreshedToken}`);
620 refreshToken(basePath
);
622 existingTags
= existingTags
.filter((tagItem
) => tagItem
!== tag
);
623 awesomepletes
= updateAwesompleteList('.rename-tag-input', existingTags
, awesomepletes
, tagsSeparator
);
628 const autocompleteFields
= document
.querySelectorAll('input[data-multiple]');
629 [...autocompleteFields
].forEach((autocompleteField
) => {
630 awesomepletes
.push(createAwesompleteInstance(autocompleteField
, tagsSeparator
));
633 const exportForm
= document
.querySelector('#exportform');
634 if (exportForm
!= null) {
635 exportForm
.addEventListener('submit', (event
) => {
636 event
.preventDefault();
638 refreshToken(basePath
, () => {
639 event
.target
.submit();
644 const bulkCreationButton
= document
.querySelector('.addlink-batch-show-more-block');
645 if (bulkCreationButton
!= null) {
646 const toggleBulkCreationVisibility
= (showMoreBlockElement
, formElement
) => {
647 if (bulkCreationButton
.classList
.contains('pure-u-0')) {
648 showMoreBlockElement
.classList
.remove('pure-u-0');
649 formElement
.classList
.add('pure-u-0');
651 showMoreBlockElement
.classList
.add('pure-u-0');
652 formElement
.classList
.remove('pure-u-0');
656 const bulkCreationForm
= document
.querySelector('.addlink-batch-form-block');
658 toggleBulkCreationVisibility(bulkCreationButton
, bulkCreationForm
);
659 bulkCreationButton
.querySelector('a').addEventListener('click', (e
) => {
661 toggleBulkCreationVisibility(bulkCreationButton
, bulkCreationForm
);
664 // Force to send falsy value if the checkbox is not checked.
665 const privateButton
= bulkCreationForm
.querySelector('input[type="checkbox"][name="private"]');
666 const privateHiddenButton
= bulkCreationForm
.querySelector('input[type="hidden"][name="private"]');
667 privateButton
.addEventListener('click', () => {
668 privateHiddenButton
.disabled
= !privateHiddenButton
.disabled
;
670 privateHiddenButton
.disabled
= privateButton
.checked
;