]> git.immae.eu Git - github/shaarli/Shaarli.git/blob - assets/default/js/base.js
Merge pull request #1567 from ArthurHoaro/feature/async-title-retrieval
[github/shaarli/Shaarli.git] / assets / default / js / base.js
1 import Awesomplete from 'awesomplete';
2 import he from 'he';
3
4 /**
5 * Find a parent element according to its tag and its attributes
6 *
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).
10 *
11 * @returns Found element or null.
12 */
13 function findParent(element, tagName, attributes) {
14 const parentMatch = (key) => attributes[key] !== '' && element.getAttribute(key).indexOf(attributes[key]) !== -1;
15 while (element) {
16 if (element.tagName.toLowerCase() === tagName) {
17 if (Object.keys(attributes).find(parentMatch)) {
18 return element;
19 }
20 }
21 element = element.parentElement;
22 }
23 return null;
24 }
25
26 /**
27 * Ajax request to refresh the CSRF token.
28 */
29 function refreshToken(basePath, callback) {
30 const xhr = new XMLHttpRequest();
31 xhr.open('GET', `${basePath}/admin/token`);
32 xhr.onload = () => {
33 const elements = document.querySelectorAll('input[name="token"]');
34 [...elements].forEach((element) => {
35 element.setAttribute('value', xhr.responseText);
36 });
37
38 if (callback) {
39 callback(xhr.response);
40 }
41 };
42 xhr.send();
43 }
44
45 function createAwesompleteInstance(element, tags = []) {
46 const awesome = new Awesomplete(Awesomplete.$(element));
47 // Tags are separated by a space
48 awesome.filter = (text, input) => Awesomplete.FILTER_CONTAINS(text, input.match(/[^ ]*$/)[0]);
49 // Insert new selected tag in the input
50 awesome.replace = (text) => {
51 const before = awesome.input.value.match(/^.+ \s*|/)[0];
52 awesome.input.value = `${before}${text} `;
53 };
54 // Highlight found items
55 awesome.item = (text, input) => Awesomplete.ITEM(text, input.match(/[^ ]*$/)[0]);
56 // Don't display already selected items
57 const reg = /(\w+) /g;
58 let match;
59 awesome.data = (item, input) => {
60 while ((match = reg.exec(input))) {
61 if (item === match[1]) {
62 return '';
63 }
64 }
65 return item;
66 };
67 awesome.minChars = 1;
68 if (tags.length) {
69 awesome.list = tags;
70 }
71
72 return awesome;
73 }
74
75 /**
76 * Update awesomplete list of tag for all elements matching the given selector
77 *
78 * @param selector CSS selector
79 * @param tags Array of tags
80 * @param instances List of existing awesomplete instances
81 */
82 function updateAwesompleteList(selector, tags, instances) {
83 if (instances.length === 0) {
84 // First load: create Awesomplete instances
85 const elements = document.querySelectorAll(selector);
86 [...elements].forEach((element) => {
87 instances.push(createAwesompleteInstance(element, tags));
88 });
89 } else {
90 // Update awesomplete tag list
91 instances.map((item) => {
92 item.list = tags;
93 return item;
94 });
95 }
96 return instances;
97 }
98
99 /**
100 * Add the class 'hidden' to city options not attached to the current selected continent.
101 *
102 * @param cities List of <option> elements
103 * @param currentContinent Current selected continent
104 * @param reset Set to true to reset the selected value
105 */
106 function hideTimezoneCities(cities, currentContinent, reset = null) {
107 let first = true;
108 if (reset == null) {
109 reset = false;
110 }
111 [...cities].forEach((option) => {
112 if (option.getAttribute('data-continent') !== currentContinent) {
113 option.className = 'hidden';
114 } else {
115 option.className = '';
116 if (reset === true && first === true) {
117 option.setAttribute('selected', 'selected');
118 first = false;
119 }
120 }
121 });
122 }
123
124 /**
125 * Retrieve an element up in the tree from its class name.
126 */
127 function getParentByClass(el, className) {
128 const p = el.parentNode;
129 if (p == null || p.classList.contains(className)) {
130 return p;
131 }
132 return getParentByClass(p, className);
133 }
134
135 function toggleHorizontal() {
136 [...document.getElementById('shaarli-menu').querySelectorAll('.menu-transform')].forEach((el) => {
137 el.classList.toggle('pure-menu-horizontal');
138 });
139 }
140
141 function toggleMenu(menu) {
142 // set timeout so that the panel has a chance to roll up
143 // before the menu switches states
144 if (menu.classList.contains('open')) {
145 setTimeout(toggleHorizontal, 500);
146 } else {
147 toggleHorizontal();
148 }
149 menu.classList.toggle('open');
150 document.getElementById('menu-toggle').classList.toggle('x');
151 }
152
153 function closeMenu(menu) {
154 if (menu.classList.contains('open')) {
155 toggleMenu(menu);
156 }
157 }
158
159 function toggleFold(button, description, thumb) {
160 // Switch fold/expand - up = fold
161 if (button.classList.contains('fa-chevron-up')) {
162 button.title = document.getElementById('translation-expand').innerHTML;
163 if (description != null) {
164 description.style.display = 'none';
165 }
166 if (thumb != null) {
167 thumb.style.display = 'none';
168 }
169 } else {
170 button.title = document.getElementById('translation-fold').innerHTML;
171 if (description != null) {
172 description.style.display = 'block';
173 }
174 if (thumb != null) {
175 thumb.style.display = 'block';
176 }
177 }
178 button.classList.toggle('fa-chevron-down');
179 button.classList.toggle('fa-chevron-up');
180 }
181
182 function removeClass(element, classname) {
183 element.className = element.className.replace(new RegExp(`(?:^|\\s)${classname}(?:\\s|$)`), ' ');
184 }
185
186 function init(description) {
187 function resize() {
188 /* Fix jumpy resizing: https://stackoverflow.com/a/18262927/1484919 */
189 const scrollTop = window.pageYOffset
190 || (document.documentElement || document.body.parentNode || document.body).scrollTop;
191
192 description.style.height = 'auto';
193 description.style.height = `${description.scrollHeight + 10}px`;
194
195 window.scrollTo(0, scrollTop);
196 }
197
198 /* 0-timeout to get the already changed text */
199 function delayedResize() {
200 window.setTimeout(resize, 0);
201 }
202
203 const observe = (element, event, handler) => {
204 element.addEventListener(event, handler, false);
205 };
206 observe(description, 'change', resize);
207 observe(description, 'cut', delayedResize);
208 observe(description, 'paste', delayedResize);
209 observe(description, 'drop', delayedResize);
210 observe(description, 'keydown', delayedResize);
211
212 resize();
213 }
214
215 (() => {
216 const basePath = document.querySelector('input[name="js_base_path"]').value;
217
218 /**
219 * Handle responsive menu.
220 * Source: http://purecss.io/layouts/tucked-menu-vertical/
221 */
222 const menu = document.getElementById('shaarli-menu');
223 const WINDOW_CHANGE_EVENT = ('onorientationchange' in window) ? 'orientationchange' : 'resize';
224
225 const menuToggle = document.getElementById('menu-toggle');
226 if (menuToggle != null) {
227 menuToggle.addEventListener('click', () => toggleMenu(menu));
228 }
229
230 window.addEventListener(WINDOW_CHANGE_EVENT, () => closeMenu(menu));
231
232 /**
233 * Fold/Expand shaares description and thumbnail.
234 */
235 const foldAllButtons = document.getElementsByClassName('fold-all');
236 const foldButtons = document.getElementsByClassName('fold-button');
237
238 [...foldButtons].forEach((foldButton) => {
239 // Retrieve description
240 let description = null;
241 let thumbnail = null;
242 const linklistItem = getParentByClass(foldButton, 'linklist-item');
243 if (linklistItem != null) {
244 description = linklistItem.querySelector('.linklist-item-description');
245 thumbnail = linklistItem.querySelector('.linklist-item-thumbnail');
246 if (description != null || thumbnail != null) {
247 foldButton.style.display = 'inline';
248 }
249 }
250
251 foldButton.addEventListener('click', (event) => {
252 event.preventDefault();
253 toggleFold(event.target, description, thumbnail);
254 });
255 });
256
257 if (foldAllButtons != null) {
258 [].forEach.call(foldAllButtons, (foldAllButton) => {
259 foldAllButton.addEventListener('click', (event) => {
260 event.preventDefault();
261 const state = foldAllButton.firstElementChild.getAttribute('class').indexOf('down') !== -1 ? 'down' : 'up';
262 [].forEach.call(foldButtons, (foldButton) => {
263 if ((foldButton.firstElementChild.classList.contains('fa-chevron-up') && state === 'down')
264 || (foldButton.firstElementChild.classList.contains('fa-chevron-down') && state === 'up')
265 ) {
266 return;
267 }
268 // Retrieve description
269 let description = null;
270 let thumbnail = null;
271 const linklistItem = getParentByClass(foldButton, 'linklist-item');
272 if (linklistItem != null) {
273 description = linklistItem.querySelector('.linklist-item-description');
274 thumbnail = linklistItem.querySelector('.linklist-item-thumbnail');
275 if (description != null || thumbnail != null) {
276 foldButton.style.display = 'inline';
277 }
278 }
279
280 toggleFold(foldButton.firstElementChild, description, thumbnail);
281 });
282 foldAllButton.firstElementChild.classList.toggle('fa-chevron-down');
283 foldAllButton.firstElementChild.classList.toggle('fa-chevron-up');
284 foldAllButton.title = state === 'down'
285 ? document.getElementById('translation-fold-all').innerHTML
286 : document.getElementById('translation-expand-all').innerHTML;
287 });
288 });
289 }
290
291 /**
292 * Confirmation message before deletion.
293 */
294 const deleteLinks = document.querySelectorAll('.confirm-delete');
295 [...deleteLinks].forEach((deleteLink) => {
296 deleteLink.addEventListener('click', (event) => {
297 if (!confirm(document.getElementById('translation-delete-tag').innerHTML)) {
298 event.preventDefault();
299 }
300 });
301 });
302
303 /**
304 * Close alerts
305 */
306 const closeLinks = document.querySelectorAll('.pure-alert-close');
307 [...closeLinks].forEach((closeLink) => {
308 closeLink.addEventListener('click', (event) => {
309 const alert = getParentByClass(event.target, 'pure-alert-closable');
310 alert.style.display = 'none';
311 });
312 });
313
314 /**
315 * New version dismiss.
316 * Hide the message for one week using localStorage.
317 */
318 const newVersionDismiss = document.getElementById('new-version-dismiss');
319 const newVersionMessage = document.querySelector('.new-version-message');
320 if (newVersionMessage != null
321 && localStorage.getItem('newVersionDismiss') != null
322 && parseInt(localStorage.getItem('newVersionDismiss'), 10) + (7 * 24 * 60 * 60 * 1000) > (new Date()).getTime()
323 ) {
324 newVersionMessage.style.display = 'none';
325 }
326 if (newVersionDismiss != null) {
327 newVersionDismiss.addEventListener('click', () => {
328 localStorage.setItem('newVersionDismiss', (new Date()).getTime().toString());
329 });
330 }
331
332 const hiddenReturnurl = document.getElementsByName('returnurl');
333 if (hiddenReturnurl != null) {
334 hiddenReturnurl.value = window.location.href;
335 }
336
337 /**
338 * Autofocus text fields
339 */
340 const autofocusElements = document.querySelectorAll('.autofocus');
341 let breakLoop = false;
342 [].forEach.call(autofocusElements, (autofocusElement) => {
343 if (autofocusElement.value === '' && !breakLoop) {
344 autofocusElement.focus();
345 breakLoop = true;
346 }
347 });
348
349 /**
350 * Handle sub menus/forms
351 */
352 const openers = document.getElementsByClassName('subheader-opener');
353 if (openers != null) {
354 [...openers].forEach((opener) => {
355 opener.addEventListener('click', (event) => {
356 event.preventDefault();
357
358 const id = opener.getAttribute('data-open-id');
359 const sub = document.getElementById(id);
360
361 if (sub != null) {
362 [...document.getElementsByClassName('subheader-form')].forEach((element) => {
363 if (element !== sub) {
364 removeClass(element, 'open');
365 }
366 });
367
368 sub.classList.toggle('open');
369 }
370 });
371 });
372 }
373
374 /**
375 * Remove CSS target padding (for fixed bar)
376 */
377 if (location.hash !== '') {
378 const anchor = document.getElementById(location.hash.substr(1));
379 if (anchor != null) {
380 const padsize = anchor.clientHeight;
381 window.scroll(0, window.scrollY - padsize);
382 anchor.style.paddingTop = '0';
383 }
384 }
385
386 /**
387 * Text area resizer
388 */
389 const description = document.getElementById('lf_description');
390
391 if (description != null) {
392 init(description);
393 // Submit editlink form with CTRL + Enter in the text area.
394 description.addEventListener('keydown', (event) => {
395 if (event.ctrlKey && event.keyCode === 13) {
396 document.getElementById('button-save-edit').click();
397 }
398 });
399 }
400
401 /**
402 * Bookmarklet alert
403 */
404 const bookmarkletLinks = document.querySelectorAll('.bookmarklet-link');
405 const bkmMessage = document.getElementById('bookmarklet-alert');
406 [].forEach.call(bookmarkletLinks, (link) => {
407 link.addEventListener('click', (event) => {
408 event.preventDefault();
409 alert(bkmMessage.value);
410 });
411 });
412
413 const continent = document.getElementById('continent');
414 const city = document.getElementById('city');
415 if (continent != null && city != null) {
416 continent.addEventListener('change', () => {
417 hideTimezoneCities(city, continent.options[continent.selectedIndex].value, true);
418 });
419 hideTimezoneCities(city, continent.options[continent.selectedIndex].value, false);
420 }
421
422 /**
423 * Bulk actions
424 */
425 const linkCheckboxes = document.querySelectorAll('.link-checkbox');
426 const bar = document.getElementById('actions');
427 [...linkCheckboxes].forEach((checkbox) => {
428 checkbox.style.display = 'inline-block';
429 checkbox.addEventListener('change', () => {
430 const linkCheckedCheckboxes = document.querySelectorAll('.link-checkbox:checked');
431 const count = [...linkCheckedCheckboxes].length;
432 if (count === 0 && bar.classList.contains('open')) {
433 bar.classList.toggle('open');
434 } else if (count > 0 && !bar.classList.contains('open')) {
435 bar.classList.toggle('open');
436 }
437 });
438 });
439
440 const deleteButton = document.getElementById('actions-delete');
441 const token = document.getElementById('token');
442 if (deleteButton != null && token != null) {
443 deleteButton.addEventListener('click', (event) => {
444 event.preventDefault();
445
446 const links = [];
447 const linkCheckedCheckboxes = document.querySelectorAll('.link-checkbox:checked');
448 [...linkCheckedCheckboxes].forEach((checkbox) => {
449 links.push({
450 id: checkbox.value,
451 title: document.querySelector(`.linklist-item[data-id="${checkbox.value}"] .linklist-link`).innerHTML,
452 });
453 });
454
455 let message = `Are you sure you want to delete ${links.length} links?\n`;
456 message += 'This action is IRREVERSIBLE!\n\nTitles:\n';
457 const ids = [];
458 links.forEach((item) => {
459 message += ` - ${item.title}\n`;
460 ids.push(item.id);
461 });
462
463 if (window.confirm(message)) {
464 window.location = `${basePath}/admin/shaare/delete?id=${ids.join('+')}&token=${token.value}`;
465 }
466 });
467 }
468
469 const changeVisibilityButtons = document.querySelectorAll('.actions-change-visibility');
470 if (changeVisibilityButtons != null && token != null) {
471 [...changeVisibilityButtons].forEach((button) => {
472 button.addEventListener('click', (event) => {
473 event.preventDefault();
474 const visibility = event.target.getAttribute('data-visibility');
475
476 const links = [];
477 const linkCheckedCheckboxes = document.querySelectorAll('.link-checkbox:checked');
478 [...linkCheckedCheckboxes].forEach((checkbox) => {
479 links.push({
480 id: checkbox.value,
481 title: document.querySelector(`.linklist-item[data-id="${checkbox.value}"] .linklist-link`).innerHTML,
482 });
483 });
484
485 const ids = links.map((item) => item.id);
486 window.location = (
487 `${basePath}/admin/shaare/visibility?token=${token.value}&newVisibility=${visibility}&id=${ids.join('+')}`
488 );
489 });
490 });
491 }
492
493 /**
494 * Select all button
495 */
496 const selectAllButtons = document.querySelectorAll('.select-all-button');
497 [...selectAllButtons].forEach((selectAllButton) => {
498 selectAllButton.addEventListener('click', (e) => {
499 e.preventDefault();
500 const checked = selectAllButton.classList.contains('filter-off');
501 [...selectAllButtons].forEach((selectAllButton2) => {
502 selectAllButton2.classList.toggle('filter-off');
503 selectAllButton2.classList.toggle('filter-on');
504 });
505 [...linkCheckboxes].forEach((linkCheckbox) => {
506 linkCheckbox.checked = checked;
507 linkCheckbox.dispatchEvent(new Event('change'));
508 });
509 });
510 });
511
512 /**
513 * Tag list operations
514 *
515 * TODO: support error code in the backend for AJAX requests
516 */
517 const tagList = document.querySelector('input[name="taglist"]');
518 let existingTags = tagList ? tagList.value.split(' ') : [];
519 let awesomepletes = [];
520
521 // Display/Hide rename form
522 const renameTagButtons = document.querySelectorAll('.rename-tag');
523 [...renameTagButtons].forEach((rename) => {
524 rename.addEventListener('click', (event) => {
525 event.preventDefault();
526 const block = findParent(event.target, 'div', { class: 'tag-list-item' });
527 const form = block.querySelector('.rename-tag-form');
528 if (form.style.display === 'none' || form.style.display === '') {
529 form.style.display = 'block';
530 } else {
531 form.style.display = 'none';
532 }
533 block.querySelector('input').focus();
534 });
535 });
536
537 // Rename a tag with an AJAX request
538 const renameTagSubmits = document.querySelectorAll('.validate-rename-tag');
539 [...renameTagSubmits].forEach((rename) => {
540 rename.addEventListener('click', (event) => {
541 event.preventDefault();
542 const block = findParent(event.target, 'div', { class: 'tag-list-item' });
543 const input = block.querySelector('.rename-tag-input');
544 const totag = input.value.replace('/"/g', '\\"');
545 if (totag.trim() === '') {
546 return;
547 }
548 const refreshedToken = document.getElementById('token').value;
549 const fromtag = block.getAttribute('data-tag');
550 const fromtagUrl = block.getAttribute('data-tag-url');
551 const xhr = new XMLHttpRequest();
552 xhr.open('POST', `${basePath}/admin/tags`);
553 xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
554 xhr.onload = () => {
555 if (xhr.status !== 200) {
556 alert(`An error occurred. Return code: ${xhr.status}`);
557 location.reload();
558 } else {
559 block.setAttribute('data-tag', totag);
560 block.setAttribute('data-tag-url', encodeURIComponent(totag));
561 input.setAttribute('name', totag);
562 input.setAttribute('value', totag);
563 findParent(input, 'div', { class: 'rename-tag-form' }).style.display = 'none';
564 block.querySelector('a.tag-link').innerHTML = he.encode(totag);
565 block
566 .querySelector('a.tag-link')
567 .setAttribute('href', `${basePath}/?searchtags=${encodeURIComponent(totag)}`);
568 block
569 .querySelector('a.count')
570 .setAttribute('href', `${basePath}/add-tag/${encodeURIComponent(totag)}`);
571 block
572 .querySelector('a.rename-tag')
573 .setAttribute('href', `${basePath}/admin/tags?fromtag=${encodeURIComponent(totag)}`);
574
575 // Refresh awesomplete values
576 existingTags = existingTags.map((tag) => (tag === fromtag ? totag : tag));
577 awesomepletes = updateAwesompleteList('.rename-tag-input', existingTags, awesomepletes);
578 }
579 };
580 xhr.send(`renametag=1&fromtag=${fromtagUrl}&totag=${encodeURIComponent(totag)}&token=${refreshedToken}`);
581 refreshToken(basePath);
582 });
583 });
584
585 // Validate input with enter key
586 const renameTagInputs = document.querySelectorAll('.rename-tag-input');
587 [...renameTagInputs].forEach((rename) => {
588 rename.addEventListener('keypress', (event) => {
589 if (event.keyCode === 13) { // enter
590 findParent(event.target, 'div', { class: 'tag-list-item' }).querySelector('.validate-rename-tag').click();
591 }
592 });
593 });
594
595 // Delete a tag with an AJAX query (alert popup confirmation)
596 const deleteTagButtons = document.querySelectorAll('.delete-tag');
597 [...deleteTagButtons].forEach((rename) => {
598 rename.style.display = 'inline';
599 rename.addEventListener('click', (event) => {
600 event.preventDefault();
601 const block = findParent(event.target, 'div', { class: 'tag-list-item' });
602 const tag = block.getAttribute('data-tag');
603 const tagUrl = block.getAttribute('data-tag-url');
604 const refreshedToken = document.getElementById('token').value;
605
606 if (confirm(`Are you sure you want to delete the tag "${tag}"?`)) {
607 const xhr = new XMLHttpRequest();
608 xhr.open('POST', `${basePath}/admin/tags`);
609 xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
610 xhr.onload = () => {
611 block.remove();
612 };
613 xhr.send(`deletetag=1&fromtag=${tagUrl}&token=${refreshedToken}`);
614 refreshToken(basePath);
615
616 existingTags = existingTags.filter((tagItem) => tagItem !== tag);
617 awesomepletes = updateAwesompleteList('.rename-tag-input', existingTags, awesomepletes);
618 }
619 });
620 });
621
622 const autocompleteFields = document.querySelectorAll('input[data-multiple]');
623 [...autocompleteFields].forEach((autocompleteField) => {
624 awesomepletes.push(createAwesompleteInstance(autocompleteField));
625 });
626
627 const exportForm = document.querySelector('#exportform');
628 if (exportForm != null) {
629 exportForm.addEventListener('submit', (event) => {
630 event.preventDefault();
631
632 refreshToken(basePath, () => {
633 event.target.submit();
634 });
635 });
636 }
637 })();