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 const reg
= new RegExp(`/(\w+)${separator}/g`);
60 awesome
.data
= (item
, input
) => {
61 while ((match
= reg
.exec(input
))) {
62 if (item
=== match
[1]) {
77 * Update awesomplete list of tag for all elements matching the given selector
79 * @param selector CSS selector
80 * @param tags Array of tags
81 * @param instances List of existing awesomplete instances
82 * @param separator Tags separator character
84 function updateAwesompleteList(selector
, tags
, instances
, separator
) {
85 if (instances
.length
=== 0) {
86 // First load: create Awesomplete instances
87 const elements
= document
.querySelectorAll(selector
);
88 [...elements
].forEach((element
) => {
89 instances
.push(createAwesompleteInstance(element
, separator
, tags
));
92 // Update awesomplete tag list
93 instances
.map((item
) => {
102 * Add the class 'hidden' to city options not attached to the current selected continent.
104 * @param cities List of <option> elements
105 * @param currentContinent Current selected continent
106 * @param reset Set to true to reset the selected value
108 function hideTimezoneCities(cities
, currentContinent
, reset
= null) {
113 [...cities
].forEach((option
) => {
114 if (option
.getAttribute('data-continent') !== currentContinent
) {
115 option
.className
= 'hidden';
117 option
.className
= '';
118 if (reset
=== true && first
=== true) {
119 option
.setAttribute('selected', 'selected');
127 * Retrieve an element up in the tree from its class name.
129 function getParentByClass(el
, className
) {
130 const p
= el
.parentNode
;
131 if (p
== null || p
.classList
.contains(className
)) {
134 return getParentByClass(p
, className
);
137 function toggleHorizontal() {
138 [...document
.getElementById('shaarli-menu').querySelectorAll('.menu-transform')].forEach((el
) => {
139 el
.classList
.toggle('pure-menu-horizontal');
143 function toggleMenu(menu
) {
144 // set timeout so that the panel has a chance to roll up
145 // before the menu switches states
146 if (menu
.classList
.contains('open')) {
147 setTimeout(toggleHorizontal
, 500);
151 menu
.classList
.toggle('open');
152 document
.getElementById('menu-toggle').classList
.toggle('x');
155 function closeMenu(menu
) {
156 if (menu
.classList
.contains('open')) {
161 function toggleFold(button
, description
, thumb
) {
162 // Switch fold/expand - up = fold
163 if (button
.classList
.contains('fa-chevron-up')) {
164 button
.title
= document
.getElementById('translation-expand').innerHTML
;
165 if (description
!= null) {
166 description
.style
.display
= 'none';
169 thumb
.style
.display
= 'none';
172 button
.title
= document
.getElementById('translation-fold').innerHTML
;
173 if (description
!= null) {
174 description
.style
.display
= 'block';
177 thumb
.style
.display
= 'block';
180 button
.classList
.toggle('fa-chevron-down');
181 button
.classList
.toggle('fa-chevron-up');
184 function removeClass(element
, classname
) {
185 element
.className
= element
.className
.replace(new RegExp(`(?:^|\\s)${classname}(?:\\s|$)`), ' ');
188 function init(description
) {
190 /* Fix jumpy resizing: https://stackoverflow.com/a/18262927/1484919 */
191 const scrollTop
= window
.pageYOffset
192 || (document
.documentElement
|| document
.body
.parentNode
|| document
.body
).scrollTop
;
194 description
.style
.height
= 'auto';
195 description
.style
.height
= `${description.scrollHeight + 10}px`;
197 window
.scrollTo(0, scrollTop
);
200 /* 0-timeout to get the already changed text */
201 function delayedResize() {
202 window
.setTimeout(resize
, 0);
205 const observe
= (element
, event
, handler
) => {
206 element
.addEventListener(event
, handler
, false);
208 observe(description
, 'change', resize
);
209 observe(description
, 'cut', delayedResize
);
210 observe(description
, 'paste', delayedResize
);
211 observe(description
, 'drop', delayedResize
);
212 observe(description
, 'keydown', delayedResize
);
218 const basePath
= document
.querySelector('input[name="js_base_path"]').value
;
219 const tagsSeparatorElement
= document
.querySelector('input[name="tags_separator"]');
220 const tagsSeparator
= tagsSeparatorElement
? tagsSeparatorElement
.value
|| '\s' : '\s';
223 * Handle responsive menu.
224 * Source: http://purecss.io/layouts/tucked-menu-vertical/
226 const menu
= document
.getElementById('shaarli-menu');
227 const WINDOW_CHANGE_EVENT
= ('onorientationchange' in window
) ? 'orientationchange' : 'resize';
229 const menuToggle
= document
.getElementById('menu-toggle');
230 if (menuToggle
!= null) {
231 menuToggle
.addEventListener('click', () => toggleMenu(menu
));
234 window
.addEventListener(WINDOW_CHANGE_EVENT
, () => closeMenu(menu
));
237 * Fold/Expand shaares description and thumbnail.
239 const foldAllButtons
= document
.getElementsByClassName('fold-all');
240 const foldButtons
= document
.getElementsByClassName('fold-button');
242 [...foldButtons
].forEach((foldButton
) => {
243 // Retrieve description
244 let description
= null;
245 let thumbnail
= null;
246 const linklistItem
= getParentByClass(foldButton
, 'linklist-item');
247 if (linklistItem
!= null) {
248 description
= linklistItem
.querySelector('.linklist-item-description');
249 thumbnail
= linklistItem
.querySelector('.linklist-item-thumbnail');
250 if (description
!= null || thumbnail
!= null) {
251 foldButton
.style
.display
= 'inline';
255 foldButton
.addEventListener('click', (event
) => {
256 event
.preventDefault();
257 toggleFold(event
.target
, description
, thumbnail
);
261 if (foldAllButtons
!= null) {
262 [].forEach
.call(foldAllButtons
, (foldAllButton
) => {
263 foldAllButton
.addEventListener('click', (event
) => {
264 event
.preventDefault();
265 const state
= foldAllButton
.firstElementChild
.getAttribute('class').indexOf('down') !== -1 ? 'down' : 'up';
266 [].forEach
.call(foldButtons
, (foldButton
) => {
267 if ((foldButton
.firstElementChild
.classList
.contains('fa-chevron-up') && state
=== 'down')
268 || (foldButton
.firstElementChild
.classList
.contains('fa-chevron-down') && state
=== 'up')
272 // Retrieve description
273 let description
= null;
274 let thumbnail
= null;
275 const linklistItem
= getParentByClass(foldButton
, 'linklist-item');
276 if (linklistItem
!= null) {
277 description
= linklistItem
.querySelector('.linklist-item-description');
278 thumbnail
= linklistItem
.querySelector('.linklist-item-thumbnail');
279 if (description
!= null || thumbnail
!= null) {
280 foldButton
.style
.display
= 'inline';
284 toggleFold(foldButton
.firstElementChild
, description
, thumbnail
);
286 foldAllButton
.firstElementChild
.classList
.toggle('fa-chevron-down');
287 foldAllButton
.firstElementChild
.classList
.toggle('fa-chevron-up');
288 foldAllButton
.title
= state
=== 'down'
289 ? document
.getElementById('translation-fold-all').innerHTML
290 : document
.getElementById('translation-expand-all').innerHTML
;
296 * Confirmation message before deletion.
298 const deleteLinks
= document
.querySelectorAll('.confirm-delete');
299 [...deleteLinks
].forEach((deleteLink
) => {
300 deleteLink
.addEventListener('click', (event
) => {
301 const type
= event
.currentTarget
.getAttribute('data-type') || 'link';
302 if (!confirm(document
.getElementById(`translation-delete-${type}`).innerHTML
)) {
303 event
.preventDefault();
311 const closeLinks
= document
.querySelectorAll('.pure-alert-close');
312 [...closeLinks
].forEach((closeLink
) => {
313 closeLink
.addEventListener('click', (event
) => {
314 const alert
= getParentByClass(event
.target
, 'pure-alert-closable');
315 alert
.style
.display
= 'none';
320 * New version dismiss.
321 * Hide the message for one week using localStorage.
323 const newVersionDismiss
= document
.getElementById('new-version-dismiss');
324 const newVersionMessage
= document
.querySelector('.new-version-message');
325 if (newVersionMessage
!= null
326 && localStorage
.getItem('newVersionDismiss') != null
327 && parseInt(localStorage
.getItem('newVersionDismiss'), 10) + (7 * 24 * 60 * 60 * 1000) > (new Date()).getTime()
329 newVersionMessage
.style
.display
= 'none';
331 if (newVersionDismiss
!= null) {
332 newVersionDismiss
.addEventListener('click', () => {
333 localStorage
.setItem('newVersionDismiss', (new Date()).getTime().toString());
337 const hiddenReturnurl
= document
.getElementsByName('returnurl');
338 if (hiddenReturnurl
!= null) {
339 hiddenReturnurl
.value
= window
.location
.href
;
343 * Autofocus text fields
345 const autofocusElements
= document
.querySelectorAll('.autofocus');
346 let breakLoop
= false;
347 [].forEach
.call(autofocusElements
, (autofocusElement
) => {
348 if (autofocusElement
.value
=== '' && !breakLoop
) {
349 autofocusElement
.focus();
355 * Handle sub menus/forms
357 const openers
= document
.getElementsByClassName('subheader-opener');
358 if (openers
!= null) {
359 [...openers
].forEach((opener
) => {
360 opener
.addEventListener('click', (event
) => {
361 event
.preventDefault();
363 const id
= opener
.getAttribute('data-open-id');
364 const sub
= document
.getElementById(id
);
367 [...document
.getElementsByClassName('subheader-form')].forEach((element
) => {
368 if (element
!== sub
) {
369 removeClass(element
, 'open');
373 sub
.classList
.toggle('open');
380 * Remove CSS target padding (for fixed bar)
382 if (location
.hash
!== '') {
383 const anchor
= document
.getElementById(location
.hash
.substr(1));
384 if (anchor
!= null) {
385 const padsize
= anchor
.clientHeight
;
386 window
.scroll(0, window
.scrollY
- padsize
);
387 anchor
.style
.paddingTop
= '0';
394 const description
= document
.getElementById('lf_description');
396 if (description
!= null) {
398 // Submit editlink form with CTRL + Enter in the text area.
399 description
.addEventListener('keydown', (event
) => {
400 if (event
.ctrlKey
&& event
.keyCode
=== 13) {
401 document
.getElementById('button-save-edit').click();
409 const bookmarkletLinks
= document
.querySelectorAll('.bookmarklet-link');
410 const bkmMessage
= document
.getElementById('bookmarklet-alert');
411 [].forEach
.call(bookmarkletLinks
, (link
) => {
412 link
.addEventListener('click', (event
) => {
413 event
.preventDefault();
414 alert(bkmMessage
.value
);
418 const continent
= document
.getElementById('continent');
419 const city
= document
.getElementById('city');
420 if (continent
!= null && city
!= null) {
421 continent
.addEventListener('change', () => {
422 hideTimezoneCities(city
, continent
.options
[continent
.selectedIndex
].value
, true);
424 hideTimezoneCities(city
, continent
.options
[continent
.selectedIndex
].value
, false);
430 const linkCheckboxes
= document
.querySelectorAll('.link-checkbox');
431 const bar
= document
.getElementById('actions');
432 [...linkCheckboxes
].forEach((checkbox
) => {
433 checkbox
.style
.display
= 'inline-block';
434 checkbox
.addEventListener('change', () => {
435 const linkCheckedCheckboxes
= document
.querySelectorAll('.link-checkbox:checked');
436 const count
= [...linkCheckedCheckboxes
].length
;
437 if (count
=== 0 && bar
.classList
.contains('open')) {
438 bar
.classList
.toggle('open');
439 } else if (count
> 0 && !bar
.classList
.contains('open')) {
440 bar
.classList
.toggle('open');
445 const deleteButton
= document
.getElementById('actions-delete');
446 const token
= document
.getElementById('token');
447 if (deleteButton
!= null && token
!= null) {
448 deleteButton
.addEventListener('click', (event
) => {
449 event
.preventDefault();
452 const linkCheckedCheckboxes
= document
.querySelectorAll('.link-checkbox:checked');
453 [...linkCheckedCheckboxes
].forEach((checkbox
) => {
456 title: document
.querySelector(`.linklist-item[data-id="${checkbox.value}"] .linklist-link`).innerHTML
,
460 let message
= `Are you sure you want to delete ${links.length} links?\n`;
461 message
+= 'This action is IRREVERSIBLE!\n\nTitles:\n';
463 links
.forEach((item
) => {
464 message
+= ` - ${item.title}\n`;
468 if (window
.confirm(message
)) {
469 window
.location
= `${basePath}/admin/shaare/delete?id=${ids.join('+')}&token=${token.value}`;
474 const changeVisibilityButtons
= document
.querySelectorAll('.actions-change-visibility');
475 if (changeVisibilityButtons
!= null && token
!= null) {
476 [...changeVisibilityButtons
].forEach((button
) => {
477 button
.addEventListener('click', (event
) => {
478 event
.preventDefault();
479 const visibility
= event
.target
.getAttribute('data-visibility');
482 const linkCheckedCheckboxes
= document
.querySelectorAll('.link-checkbox:checked');
483 [...linkCheckedCheckboxes
].forEach((checkbox
) => {
486 title: document
.querySelector(`.linklist-item[data-id="${checkbox.value}"] .linklist-link`).innerHTML
,
490 const ids
= links
.map((item
) => item
.id
);
492 `${basePath}/admin/shaare/visibility?token=${token.value}&newVisibility=${visibility}&id=${ids.join('+')}`
501 const selectAllButtons
= document
.querySelectorAll('.select-all-button');
502 [...selectAllButtons
].forEach((selectAllButton
) => {
503 selectAllButton
.addEventListener('click', (e
) => {
505 const checked
= selectAllButton
.classList
.contains('filter-off');
506 [...selectAllButtons
].forEach((selectAllButton2
) => {
507 selectAllButton2
.classList
.toggle('filter-off');
508 selectAllButton2
.classList
.toggle('filter-on');
510 [...linkCheckboxes
].forEach((linkCheckbox
) => {
511 linkCheckbox
.checked
= checked
;
512 linkCheckbox
.dispatchEvent(new Event('change'));
518 * Tag list operations
520 * TODO: support error code in the backend for AJAX requests
522 const tagList
= document
.querySelector('input[name="taglist"]');
523 let existingTags
= tagList
? tagList
.value
.split(' ') : [];
524 let awesomepletes
= [];
526 // Display/Hide rename form
527 const renameTagButtons
= document
.querySelectorAll('.rename-tag');
528 [...renameTagButtons
].forEach((rename
) => {
529 rename
.addEventListener('click', (event
) => {
530 event
.preventDefault();
531 const block
= findParent(event
.target
, 'div', { class: 'tag-list-item' });
532 const form
= block
.querySelector('.rename-tag-form');
533 if (form
.style
.display
=== 'none' || form
.style
.display
=== '') {
534 form
.style
.display
= 'block';
536 form
.style
.display
= 'none';
538 block
.querySelector('input').focus();
542 // Rename a tag with an AJAX request
543 const renameTagSubmits
= document
.querySelectorAll('.validate-rename-tag');
544 [...renameTagSubmits
].forEach((rename
) => {
545 rename
.addEventListener('click', (event
) => {
546 event
.preventDefault();
547 const block
= findParent(event
.target
, 'div', { class: 'tag-list-item' });
548 const input
= block
.querySelector('.rename-tag-input');
549 const totag
= input
.value
.replace('/"/g', '\\"');
550 if (totag
.trim() === '') {
553 const refreshedToken
= document
.getElementById('token').value
;
554 const fromtag
= block
.getAttribute('data-tag');
555 const fromtagUrl
= block
.getAttribute('data-tag-url');
556 const xhr
= new XMLHttpRequest();
557 xhr
.open('POST', `${basePath}/admin/tags`);
558 xhr
.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
560 if (xhr
.status
!== 200) {
561 alert(`An error occurred. Return code: ${xhr.status}`);
564 block
.setAttribute('data-tag', totag
);
565 block
.setAttribute('data-tag-url', encodeURIComponent(totag
));
566 input
.setAttribute('name', totag
);
567 input
.setAttribute('value', totag
);
568 findParent(input
, 'div', { class: 'rename-tag-form' }).style
.display
= 'none';
569 block
.querySelector('a.tag-link').innerHTML
= he
.encode(totag
);
571 .querySelector('a.tag-link')
572 .setAttribute('href', `${basePath}/?searchtags=${encodeURIComponent(totag)}`);
574 .querySelector('a.count')
575 .setAttribute('href', `${basePath}/add-tag/${encodeURIComponent(totag)}`);
577 .querySelector('a.rename-tag')
578 .setAttribute('href', `${basePath}/admin/tags?fromtag=${encodeURIComponent(totag)}`);
580 // Refresh awesomplete values
581 existingTags
= existingTags
.map((tag
) => (tag
=== fromtag
? totag : tag
));
582 awesomepletes
= updateAwesompleteList('.rename-tag-input', existingTags
, awesomepletes
, tagsSeparator
);
585 xhr
.send(`renametag=1&fromtag=${fromtagUrl}&totag=${encodeURIComponent(totag)}&token=${refreshedToken}`);
586 refreshToken(basePath
);
590 // Validate input with enter key
591 const renameTagInputs
= document
.querySelectorAll('.rename-tag-input');
592 [...renameTagInputs
].forEach((rename
) => {
593 rename
.addEventListener('keypress', (event
) => {
594 if (event
.keyCode
=== 13) { // enter
595 findParent(event
.target
, 'div', { class: 'tag-list-item' }).querySelector('.validate-rename-tag').click();
600 // Delete a tag with an AJAX query (alert popup confirmation)
601 const deleteTagButtons
= document
.querySelectorAll('.delete-tag');
602 [...deleteTagButtons
].forEach((rename
) => {
603 rename
.style
.display
= 'inline';
604 rename
.addEventListener('click', (event
) => {
605 event
.preventDefault();
606 const block
= findParent(event
.target
, 'div', { class: 'tag-list-item' });
607 const tag
= block
.getAttribute('data-tag');
608 const tagUrl
= block
.getAttribute('data-tag-url');
609 const refreshedToken
= document
.getElementById('token').value
;
611 if (confirm(`Are you sure you want to delete the tag "${tag}"?`)) {
612 const xhr
= new XMLHttpRequest();
613 xhr
.open('POST', `${basePath}/admin/tags`);
614 xhr
.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
618 xhr
.send(`deletetag=1&fromtag=${tagUrl}&token=${refreshedToken}`);
619 refreshToken(basePath
);
621 existingTags
= existingTags
.filter((tagItem
) => tagItem
!== tag
);
622 awesomepletes
= updateAwesompleteList('.rename-tag-input', existingTags
, awesomepletes
, tagsSeparator
);
627 const autocompleteFields
= document
.querySelectorAll('input[data-multiple]');
628 [...autocompleteFields
].forEach((autocompleteField
) => {
629 awesomepletes
.push(createAwesompleteInstance(autocompleteField
, tagsSeparator
));
632 const exportForm
= document
.querySelector('#exportform');
633 if (exportForm
!= null) {
634 exportForm
.addEventListener('submit', (event
) => {
635 event
.preventDefault();
637 refreshToken(basePath
, () => {
638 event
.target
.submit();
643 const bulkCreationButton
= document
.querySelector('.addlink-batch-show-more-block');
644 if (bulkCreationButton
!= null) {
645 const toggleBulkCreationVisibility
= (showMoreBlockElement
, formElement
) => {
646 if (bulkCreationButton
.classList
.contains('pure-u-0')) {
647 showMoreBlockElement
.classList
.remove('pure-u-0');
648 formElement
.classList
.add('pure-u-0');
650 showMoreBlockElement
.classList
.add('pure-u-0');
651 formElement
.classList
.remove('pure-u-0');
655 const bulkCreationForm
= document
.querySelector('.addlink-batch-form-block');
657 toggleBulkCreationVisibility(bulkCreationButton
, bulkCreationForm
);
658 bulkCreationButton
.querySelector('a').addEventListener('click', (e
) => {
660 toggleBulkCreationVisibility(bulkCreationButton
, bulkCreationForm
);
663 // Force to send falsy value if the checkbox is not checked.
664 const privateButton
= bulkCreationForm
.querySelector('input[type="checkbox"][name="private"]');
665 const privateHiddenButton
= bulkCreationForm
.querySelector('input[type="hidden"][name="private"]');
666 privateButton
.addEventListener('click', () => {
667 privateHiddenButton
.disabled
= !privateHiddenButton
.disabled
;
669 privateHiddenButton
.disabled
= privateButton
.checked
;