Completed
Push — master ( 9095e9...b8914e )
by Angus
02:47
created

public/assets/js/pages/dashboard_beta.js (1 issue)

Severity

Upgrade to new PHP Analysis Engine

These results are based on our legacy PHP analysis, consider migrating to our new PHP analysis engine instead. Learn more

1
/* globals page, base_url, use_live_countdown_timer, list_sort_type, list_sort_order, site_aliases */
2
$(function(){
3
	'use strict';
4
	if(page !== 'dashboard_beta') { return false; }
5
6
	//Set favicon to unread ver.
7
	if(! /^\/list\//.test(location.pathname)) {
8
		// updateFavicon();
9
	}
10
11
	//Click to hide notice
12
	$('#update-notice').on('closed.bs.alert', function() {
13
		$.post(base_url + 'ajax/hide_notice');
14
	});
15
16
	// setupStickyListHeader();
17
18
	/** FUNCTIONS **/
19
20
	function setupStickyListHeader() {
21
		let $window    = $(window),
22
		    nav        = $('#list-nav'),
23
		    offset     = nav.offset().top - nav.find('ul').height()/* - 21*/,
24
		    list_table = $('table[data-list]');
25
		if(offset > 10) {
26
			//normal load
27
			$window.on('scroll', function() {
28
				//FIXME: Using .scroll for this seems really slow. Is there no pure CSS way of doing this?
29
				//FIXME: The width of the nav doesn't auto-adjust to change window width (since we're calcing it in JS)..
30
				handleScroll();
31
			});
32
			handleScroll(); //Make sure we also trigger on page load.
33
		} else {
34
			//page was loaded via less but less hasn't parsed yet.
35
			let existCondition = setInterval(function() {
36
				if($('style[id="less:less-main"]').length) {
37
					offset = nav.offset().top - nav.find('> ul').height() - 2; //reset offset
38
39
					$window.on('scroll', function() {
40
						handleScroll();
41
					});
42
					handleScroll(); //Make sure we also trigger on page load.
43
44
					clearInterval(existCondition);
45
				}
46
			}, 500);
47
		}
48
49
		function handleScroll() {
50
			if($window.scrollTop() >= offset) {
51
				list_table.css('margin-top', '97px');
52
				nav.addClass('fixed-header');
53
				nav.css('width', $('#list-nav').parent().width() + 'px');
54
			} else {
55
				list_table.css('margin-top', '5px');
56
				nav.removeClass('fixed-header');
57
				nav.css('width', 'initial');
58
			}
59
		}
60
	}
61
62
	function updateUnread(table, row) {
63
		let totalUnread  = table.find('tr .update-read:not([style])').length,
64
		    unread_e     = row.find('> td:eq(0)'),
65
		    chapter_e    = row.find('> td:eq(2)'),
66
		    update_icons = row.find('.update-read, .ignore-latest');
67
68
		//Hide update icons
69
		update_icons.hide();
70
71
		//Update updated-at time for sorting purposes.
72
		chapter_e.attr('data-updated-at', (new Date()).toISOString().replace(/^([0-9]+-[0-9]+-[0-9]+)T([0-9]+:[0-9]+:[0-9]+)\.[0-9]+Z$/, '$1 $2'));
73
		table.trigger('updateCell', [chapter_e[0], false, null]);
74
75
		//Update unread status for sorting purposes.
76
		unread_e.find(' > span').text('1');
77
		table.trigger('updateCell', [unread_e[0], false, null]);
78
79
		//Update header text
80
		let unreadText = (totalUnread > 0 ? ` (${totalUnread} unread)` : '');
81
		table.find('thead > tr > th:eq(1) > div').text('Series'+unreadText);
82
83
		//Update data attr
84
		table.attr('data-unread', totalUnread);
85
86
		//Update favicon
87
		if(table.attr('data-list') === 'reading') {
88
			updateFavicon();
89
		}
90
	}
91
92
	function updateFavicon() {
93
		let unreadCount = $('table[data-list=reading]').attr('data-unread').toString();
94
		unreadCount = parseInt(unreadCount) > 99 ? '99+' : unreadCount;
95
96
		let favicon = $('link[rel="shortcut icon"]');
97
		if(parseInt(unreadCount) !== 0) {
98
			let canvas  = $('<canvas/>', {id: 'faviconCanvas', style: '/*display: none*/'})[0];
99
			//Bug?: Unable to set this via jQuery for some reason..
100
			canvas.width  = 32;
101
			canvas.height = 32;
102
103
			let context = canvas.getContext('2d');
104
105
			let imageObj = new Image();
106
			imageObj.onload = function(){
107
				context.drawImage(imageObj, 0, 0, 32, 32);
108
109
				context.font      = 'Bold 17px Helvetica';
110
				context.textAlign = 'right';
111
112
				context.lineWidth   = 3;
113
				context.strokeStyle = 'white';
114
				context.strokeText(unreadCount, 32, 30);
115
116
				context.fillStyle = 'black';
117
				context.fillText(unreadCount, 32, 30);
118
119
				favicon.attr('href', canvas.toDataURL());
120
			};
121
			imageObj.src = `${base_url}favicon.ico`;
122
		} else {
123
			favicon.attr('href', `${base_url}favicon.ico`);
124
		}
125
	}
126
127
128
	function _handleAjaxError(jqXHR, textStatus, errorThrown) {
129
		switch(jqXHR.status) {
130
			case 400:
131
				alert('ERROR: ' + errorThrown);
132
				break;
133
			case 401:
134
				alert('Session has expired, please re-log to continue.');
135
				break;
136
			case 429:
137
				alert('ERROR: Rate limit reached.');
138
				break;
139
			default:
140
				alert('ERROR: Something went wrong!\n'+errorThrown);
141
				break;
142
		}
143
	}
144
145
	function handleInactive(inactive_titles) {
146
		//TODO: The <ul> list should be hidden by default, and only shown if a button is clicked?
147
148
		let $inactiveContainer     = $('#inactive-container'),
149
		    $inactiveListContainer = $inactiveContainer.find('> #inactive-list-container'),
150
		    $inactiveList          = $inactiveListContainer.find('> ul');
151
152
		if(Object.keys(inactive_titles).length) {
153
			for (let url in inactive_titles) {
154
				if(inactive_titles.hasOwnProperty(url)) {
155
					let domain      = url.split('/')[2],
156
					    domainClass = domain;
157
					if(site_aliases[domainClass]) {
158
						domainClass = site_aliases[domainClass];
159
					}
160
					domainClass = domainClass.replace(/\./g, '-');
161
162
					//FIXME: Don't append if already exists in list!
163
					$('<li/>').append(
164
						$('<i/>', {class: `sprite-site sprite-${domainClass}`, title: domain})).append(
165
						$('<a/>', {text: ' '+inactive_titles[url], href: url})
166
					).appendTo($inactiveList);
167
				}
168
			}
169
170
			$inactiveContainer.removeAttr('hidden');
171
		} else {
172
			$inactiveContainer.attr('hidden');
173
174
			$inactiveList.find('> li').empty();
175
		}
176
177
		$('#inactive-display').on('click', function() {
178
			$(this).hide();
179
			$inactiveListContainer.removeAttr('hidden');
180
		});
181
	}
182
183
	let decodeEntities = (function() {
184
		// this prevents any overhead from creating the object each time
185
		let element = document.createElement('div');
186
187
		function decodeHTMLEntities (str) {
188
			if(str && typeof str === 'string') {
189
				// strip script/html tags
190
				str = str.replace(/<script[^>]*>([\S\s]*?)<\/script>/gmi, '');
191
				str = str.replace(/<\/?\w(?:[^"'>]|"[^"]*"|'[^']*')*>/gmi, '');
192
				element.innerHTML = str;
193
				str = element.textContent;
194
				element.textContent = '';
195
			}
196
197
			return str;
198
		}
199
200
		return decodeHTMLEntities;
201
	})();
202
203
	/**
204
	 * @class TrackrApp
205
	 */
206
	class TrackrApp {
207
		constructor() {
208
			let _class = this;
209
210
			this.isDashboard = !(/^\/list\//.test(location.pathname));
211
212
			this.refreshData();
213
			this.$tables = $('.tracker-table');
214
215
			this.enabledCategories = Object.keys(this.data.series).filter((n) => ['custom1', 'custom2', 'custom3'].includes(n));
216
217
			this.tablesorterDefaults = {
218
				initialized: function(table) {
219
					//fix for being unable to sort title column by asc on a single click if using "Unread (Alphabetical)" sort
220
					//SEE: https://github.com/Mottie/tablesorter/issues/1445#issuecomment-321537911
221
					let sortVars = table.config.sortVars;
222
					sortVars.forEach(function(el) {
223
						// reset the internal counter
224
						el.count = -1;
225
					});
226
227
					$(table.config.headerList[4]).find('.fa-spin').remove();
228
				},
229
230
				//FIXME: This is kinda unneeded, and it does add a longer delay to the tablesorter load, but we need it for setting the header sort direction icons..
231
				sortList: _class.getListSort(list_sort_type, list_sort_order),
232
233
				headers : {
234
					1 : { sortInitialOrder : 'asc'  },
235
					2 : { sortInitialOrder : 'desc', sorter: 'updated-at' },
236
					3 : { sortInitialOrder : 'desc', sorter: 'latest' }
237
				},
238
239
				textExtraction: {
240
					1: function (node) {
241
						// return only the text from the text node (ignores DIV contents)
242
						return $(node).find('.title').text();
243
					}
244
				},
245
246
				widgets: ['zebra', 'filter'],
247
				widgetOptions : {
248
					filter_external : '#search',
249
					filter_columnFilters: false,
250
					filter_saveFilters : false,
251
					filter_reset: '.reset',
252
					filter_searchFiltered: false //FIXME: This is a temp fix for #201. More info here: https://mottie.github.io/tablesorter/docs/#widget-filter-searchfiltered
253
				}
254
			};
255
		}
256
257
		refreshData() {
258
			//FIXME: This shouldn't be async: false?
259
			$.ajax(
260
				{
261
					url: base_url + 'api/internal/get_list/all',
262
					dataType: 'json',
263
					async: false,
264
					success: (json) => (this.data = json)
265
				}
266
			);
267
		}
268
269
		start() {
270
			this.setupNav();
271
272
			this.generateLists(this.data.series);
273
			this.startTablesorter();
274
			// handleInactive(json.extra_data.inactive_titles);
275
		}
276
277
		setupNav() {
278
			this.setupCategoryTabs();
279
280
			this.setupUpdateTimer();
281
282
			this.setupModifySelectedEvent();
283
			this.setupMoveToEvent();
284
285
			//NOTE: Search event is handled via Tablesorter.
286
287
			this.setupNavToggle();
288
			this.setupSorterEvent();
289
		}
290
		setupCategoryTabs() {
291
			let $categoryNav = $('#list-nav-category').find('> .navbar-nav'),
292
			    $moveSelect  = $('#move-input');
293
294
			for (let i = 0, len = this.enabledCategories.length; i < len; i++) {
295
				let categoryStub = this.enabledCategories[i],
296
				    categoryName = this.data.series[categoryStub].name;
297
298
				$categoryNav.append(
299
					$('<li/>').append(
300
						$('<a/>', {href: '#', 'data-list': categoryStub, text: categoryName})
301
					)
302
				);
303
304
				$moveSelect.append(
305
					$('<option/>', {value: categoryStub, text: categoryName})
306
				);
307
			}
308
309
			//Change list when clicking category tabs
310
			$categoryNav.find('> li > a').on('click', function(e) {
311
				e.preventDefault();
312
313
				//Change category active state
314
				$(this).closest('ul').find('> .active').removeClass('active');
315
				$(this).parent().addClass('active');
316
317
				$('.tracker-table:visible').hide();
318
319
				let datalist  = $(this).attr('data-list'),
320
				    $newTable = $(`.tracker-table[data-list="${datalist}"]`);
321
322
				$newTable.show();
323
324
				//Trigger update to generate even/odd rows. Tablesorter doesn't appear to auto-generate on hidden tables for some reason..
325
				if(!$newTable.has('.odd, .even').length) {
326
					$newTable.trigger('update', [true]);
327
				}
328
329
				//Scroll to top of page
330
				$('html, body').animate({ scrollTop: 0 }, 'slow');
331
			});
332
		}
333
		setupUpdateTimer() {
334
			if(typeof use_live_countdown_timer !== 'undefined' && use_live_countdown_timer && this.isDashboard) {
335
				let $timer = $('#update-timer'),
336
				    timer_arr = $timer.text().split(':'),
337
				    time_left = parseInt((timer_arr[0] * 60 * 60).toString(), 10) + parseInt((timer_arr[1] * 60).toString(), 10) + parseInt(timer_arr[2], 10);
338
				let timer = setInterval(() => {
339
					let hours   = parseInt((time_left / 60 / 60).toString(), 10),
340
					    minutes = parseInt((time_left / 60 % 60).toString(), 10),
341
					    seconds = parseInt((time_left % 60).toString(), 10);
342
343
					if(hours.length === 1)   { hours   = '0' + hours;   }
344
					if(minutes.length === 1) { minutes = '0' + minutes; }
345
					if(seconds.length === 1) { seconds = '0' + seconds; }
346
347
					$timer.text(hours + ':' + minutes + ':' + seconds);
348
349
					if (--time_left < 0) {
350
						clearInterval(timer);
351
352
						//Wait one minute, then change favicon to alert user of update
353
						setTimeout(function(){
354
							//TODO: This "should" just be favicon.updated.ico, and we should handle any ENV stuff on the backend
355
							$('link[rel*="icon"]').attr('href', `${base_url}favicon.production.updated.ico`);
356
357
							//location.reload(); //TODO: We should have an option for this?
358
						}, 60000);
359
					}
360
				}, 1000);
361
			}
362
		}
363
		setupModifySelectedEvent() {
364
			let _class = this;
365
366
			$('#mass-action').find('> select').on('change', function() {
367
				let redirect = false;
368
369
				let $checked_rows = $('.tracker-table:visible').find('tr:has(td input[type=checkbox]:checked)'),
370
				    total_rows   = $checked_rows.length;
371
				if(total_rows > 0) {
372
					let row_ids = $($checked_rows).map(function() {
373
						return parseInt($(this).attr('data-id').toString());
374
					}).toArray();
375
376
					let postData = {
377
						'id[]' : row_ids
378
					};
379
					switch($(this).val()) {
380
						case 'delete':
381
							if(confirm(`Are you sure you want to delete the ${total_rows} selected row(s)?`)) {
382
								window.onbeforeunload = null;
383
								$.post(base_url + 'ajax/delete_inline', postData, () => {
384
									redirect = true;
385
									location.reload();
386
								}).fail((jqXHR, textStatus, errorThrown) => {
387
									_handleAjaxError(jqXHR, textStatus, errorThrown);
388
								});
389
							}
390
391
							break;
392
393
						case 'tag':
394
							if(confirm(`Are you sure you want to edit the tags of ${total_rows} selected row(s)?`)) {
395
								let tags = prompt('Tags: ');
396
								_class.validateTagList(tags, (tag_list_new) => {
397
									postData.tag_string = tag_list_new;
398
399
									window.onbeforeunload = null;
400
									$.post(base_url + 'ajax/mass_tag_update', postData, () => {
401
										redirect = true;
402
										location.reload(); //unlike a normal tag update, it's probably better to just force a reload here.
403
									}).fail((jqXHR, textStatus, errorThrown) => {
404
										_handleAjaxError(jqXHR, textStatus, errorThrown);
405
									});
406
								});
407
							}
408
							break;
409
410
						default:
411
							//do nothing
412
							break;
413
					}
414
				} else {
415
					alert('No selected series found.');
416
				}
417
418
				if($(this).val() !== 'n/a' && !redirect) { console.log('resetting value'); $(this).val('n/a'); } //Reset change if user hasn't followed through with mass action
419
			});
420
		}
421
		setupMoveToEvent() {
422
			$('#move-input').on('change', function() {
423
				let selected      = $(this).find(':selected'),
424
				    selected_name = selected.text();
425
				if(selected.is('[value]')) {
426
					let $checked_rows = $('.tracker-table:visible').find('tr:has(td input[type=checkbox]:checked)'),
427
					    total_rows   = $checked_rows.length;
428
					if($checked_rows.length > 0 && confirm(`Are you sure you want to move the ${total_rows} selected row(s) to the ${selected_name} category?`)) {
429
						let row_ids = $($checked_rows).map(function() {
0 ignored issues
show
There were too many errors found in this file; checking aborted after 35%.

If JSHint finds too many errors in a file, it aborts checking altogether because it suspects a configuration issue.

Further Reading:

Loading history...
430
							return parseInt($(this).attr('data-id').toString());
431
						}).toArray();
432
433
						window.onbeforeunload = null;
434
						$.post(base_url + 'ajax/set_category', {'id[]' : row_ids, category : selected.attr('value')}, () => {
435
							location.reload();
436
						}).fail((jqXHR, textStatus, errorThrown) => {
437
							_handleAjaxError(jqXHR, textStatus, errorThrown);
438
						});
439
					} else {
440
						$('#move-input').val('---');
441
					}
442
				}
443
			});
444
		}
445
		setupNavToggle() {
446
			$('#toggle-nav-options').on('click', function(e) {
447
				e.preventDefault();
448
449
				let $icon    = $(this).find('> i'),
450
				    $options = $('#nav-options');
451
				$icon.toggleClass('down');
452
453
				if($icon.hasClass('down')) {
454
					$options.hide().slideDown(500);
455
				} else {
456
					$options.show().slideUp(500);
457
				}
458
			});
459
		}
460
		setupSorterEvent() {
461
			let _class = this;
462
463
			//Setup sorter event
464
			$('.list_sort').on('change', function() {
465
				let $tables    = $('.tracker-table'),
466
				    type       = $('select[name=list_sort_type]').val(),
467
				    $order_ele = $('select[name=list_sort_order]'),
468
				    order      = $order_ele.val();
469
470
				if(type === 'n/a') { return; } //do nothing, if n/a
471
472
				if($(this).attr('name') === 'list_sort_type') {
473
					//Type has changed, so set order to default.
474
					switch(type) {
475
						case 'unread_latest':
476
							order = 'desc';
477
							break;
478
479
						case 'my_status':
480
							order = 'desc';
481
							break;
482
483
						case 'latest':
484
							order = 'desc';
485
							break;
486
487
						default:
488
							order = 'asc';
489
							break;
490
					}
491
					$order_ele.val(order); //thankfully .val doesn't re-trigger .change
492
				}
493
494
				$tables.trigger('sorton', [ _class.getListSort(type, order) ]);
495
			});
496
497
			// Make sure order inputs are updated if sort is changed elsewhere
498
			$('.tracker-table').on('sortEnd', function(/**e, table**/) {
499
				let $type_ele  = $('select[name=list_sort_type]'),
500
				    $order_ele = $('select[name=list_sort_order]'),
501
				    sortList = this.config.sortList,
502
				    sort = sortList.reduce(function(acc, cur/*, i*/) {
503
					    acc[cur[0]] = cur[1];
504
					    return acc;
505
				    }, {});
506
507
				let sortType  = 'n/a',
508
				    sortOrder = 'asc';
509
				switch(Object.keys(sort).join()) {
510
					case '0,1':
511
						if(sort[0] === 0) {
512
							sortType  = 'unread';
513
							sortOrder = (sort[1] === 0 ? 'asc' : 'desc');
514
						}
515
						break;
516
517
					case '0,3':
518
						if(sort[0] === 0) {
519
							sortType  = 'unread-latest';
520
							sortOrder = (sort[1] === 0 ? 'asc' : 'desc');
521
						}
522
						break;
523
524
					case '1':
525
						sortType = 'alphabetical';
526
						sortOrder = (sort[1] === 0 ? 'asc' : 'desc');
527
						break;
528
529
					case '2':
530
						sortType = 'my_status';
531
						sortOrder = (sort[2] === 0 ? 'asc' : 'desc');
532
						break;
533
534
					case '3':
535
						sortType = 'latest';
536
						sortOrder = (sort[3] === 0 ? 'asc' : 'desc');
537
						break;
538
539
					default:
540
						//we already default to n/a
541
						break;
542
				}
543
544
				$type_ele.val(sortType);
545
				$order_ele.val(sortOrder);
546
			});
547
		}
548
549
		generateLists(series) {
550
			// Generate Tables
551
			// FIXME: We should generate lists for activated categories, even if empty.
552
			for (let [seriesStub, seriesData] of Object.entries(series)) {
553
				let mangaList = seriesData.manga,
554
				    unreadCount = seriesData.unreadCount;
555
556
				//region let table = ...;
557
				let $table = $('<table/>', {'class': 'tablesorter-bootstrap tracker-table', 'style': (seriesStub === 'reading') ? '' : 'display : none', 'data-list': seriesStub, 'data-unread': unreadCount}).append(
558
					$('<thead/>').append(
559
						$('<tr/>').append(
560
							$('<th/>', {class: 'header read'})).append(
561
							$('<th/>', {class: 'header read'}).append(
562
								$('<div/>', {class: 'tablesorter-header-inner', text: 'Series '+(unreadCount > 0 ? `(${unreadCount})` : '')})
563
							)).append(
564
							$('<th/>', {class: 'header read'}).append(
565
								$('<div/>', {class: 'tablesorter-header-inner', text: 'My Status'})
566
							)).append(
567
							$('<th/>', {class: 'header read'}).append(
568
								$('<div/>', {class: 'tablesorter-header-inner', text: 'Latest Release'})
569
							)).append(
570
							$('<th/>', {'data-sorter': 'false'}).append(
571
								$('<i/>', {class: 'fa fa-spinner fa-spin'})
572
							)
573
						)
574
					)
575
				);
576
				//endregion
577
578
				//FIXME: Closure compiler toggled off.
579
580
				let $tbody = $('<tbody/>');
581
582
				mangaList.forEach(manga => { // jshint ignore:line
583
					let tr = generateRow(manga);
584
					$tbody.append(tr);
585
				});
586
				$table.append($tbody);
587
588
				$table.appendTo('#list-container');
589
			}
590
			this.$tables = $('.tracker-table'); //Reset cache.
591
			this.setupListEvents();
592
593
			/**
594
			 * @param {Object}      manga
595
			 * @param {String}      manga.id
596
			 * @param {Object}      manga.generated_current_data
597
			 * @param {Object}      manga.generated_current_data.url
598
			 * @param {Object}      manga.generated_current_data.number
599
			 * @param {Object}      manga.generated_latest_data
600
			 * @param {Object}      manga.generated_latest_data.url
601
			 * @param {Object}      manga.generated_latest_data.number
602
			 * @param {Object|null} manga.generated_ignore_data
603
			 * @param {Object|null} manga.generated_ignore_data.url
604
			 * @param {Object|null} manga.generated_ignore_data.number
605
			 * @param {String}      manga.full_title_url
606
			 * @param {Number}      manga.new_chapter_exists
607
			 * @param {String}      manga.tag_list
608
			 * @param {Boolean}     manga.has_tags
609
			 * @param {String}      manga.mal_id
610
			 * @param {String}      manga.mal_type
611
			 * @param {String}      manga.last_updated
612
			 * @param {Object}      manga.title_data
613
			 * @param {String}      manga.title_data.id
614
			 * @param {String}      manga.title_data.title
615
			 * @param {String}      manga.title_data.title_url
616
			 * @param {String}      manga.title_data.latest_chapter
617
			 * @param {String}      manga.title_data.current_chapter
618
			 * @param {String|null} manga.title_data.ignore_chapter
619
			 * @param {String}      manga.title_data.last_updated
620
			 * @param {String}      manga.title_data.time_class
621
			 * @param {Number}      manga.title_data.status
622
			 * @param {Number}      manga.title_data.failed_checks
623
			 * @param {Boolean}     manga.title_data.active
624
			 * @param {Object}      manga.site_data
625
			 * @param {String}      manga.site_data.id
626
			 * @param {String}      manga.site_data.site
627
			 * @param {String}      manga.site_data.status
628
			 * @param {String}      manga.mal_icon
629
			 */
630
			function generateRow(manga) {
631
				let $mal_node = null;
632
				if(manga.mal_id && manga.mal_type === 'chapter') {
633
					$mal_node = $('<span/>')
634
						.append(document.createTextNode('('))
635
						.append($('<small/>', {text: (manga.mal_id !== '0' ? manga.mal_id : 'none')}))
636
						.append(document.createTextNode(')'));
637
				}
638
639
				//region let tr = ...;
640
				let $tr = $('<tr/>', {'data-id': manga.id}).append(
641
					$('<td/>')
642
						.append($('<span/>', {hidden: true, text: manga.new_chapter_exists}))
643
						.append($('<input/>', {type: 'checkbox', name: 'check'}))
644
				).append(
645
					$('<td/>')
646
						.append(
647
							$('<div/>', {class: 'row-icons'})
648
								.append($('<i/>', {class: `sprite-time ${manga.title_data.time_class}`, title: manga.last_updated}))
649
								.append($('<i/>', {class: `sprite-site sprite-${manga.site_data.site.replace(/\./g, '-')}`, title: manga.site_data.site}))
650
								.append(manga.mal_icon)
651
						)
652
						.append($('<a/>', {href: manga.full_title_url, rel: 'nofollow', class: 'title', 'data-title': decodeEntities(manga.title_data.title_url), target: '_blank', text: decodeEntities(manga.title_data.title)}))
653
654
						.append($('<small/>', {class: 'toggle-info pull-right text-muted', text: 'More info'}))
655
						.append($('<div/>', {class: 'more-info'}).append(
656
							$('<small/>')
657
								.append($('<a/>', {href: `/history/${manga.title_data.id}`, text: 'History'}))
658
								.append(document.createTextNode(' | '))
659
								.append($('<a/>', {href: '#', class: 'set-mal-id', 'data-mal-id': manga.mal_id, 'data-mal-type': manga.mal_type, text: 'Set MAL ID'}))
660
								.append($mal_node)
661
662
								.append(document.createTextNode(' | Tags ('))
663
								.append($('<a/>', {href: '#', class: 'edit-tags small', text: 'Edit'}))
664
								.append(document.createTextNode('): '))
665
								.append(
666
									$('<span/>', {class: 'text-lowercase tag-list', })
667
										.append(manga.has_tags ? (manga.tag_list.split(',').map((e) => { return $('<i/>', {class: 'tag', text: e}).get(0); })) : document.createTextNode('none')))
668
669
								.append(
670
									$('<div/>', {class: 'input-group tag-edit', hidden: true})
671
										.append($('<input/>', {type: 'text', class: 'form-control', placeholder: 'tag1,tag2,tag3', maxlength: 255, pattern: '[a-z0-9-_,]{0,255}', value: manga.tag_list}))
672
										.append(
673
											$('<span/>', {class: 'input-group-btn'})
674
												.append($('<button/>', {class: 'btn btn-default', type: 'button', text: 'Save'}))))))
675
				).append(
676
					$('<td/>', {'data-updated-at': manga.last_updated})
677
						.append($('<a/>', {class: 'chp-release current', href: manga.generated_current_data.url, rel: 'nofollow', target: '_blank', text: decodeEntities(manga.generated_current_data.number)}))
678
						.append(manga.title_data.ignore_chapter ? $('<span/>', {class: 'hidden-chapter', title: 'The latest chapter was marked as ignored.', text: manga.generated_ignore_data.number}) : null)
679
				).append(
680
					$('<td/>')
681
						.append(
682
							manga.generated_latest_data.number !== 'No chapters found' ?
683
								$('<a/>', {class: 'chp-release latest', href: manga.generated_latest_data.url, rel: 'nofollow', 'data-chapter': manga.title_data.latest_chapter, target: '_blank', text: decodeEntities(manga.generated_latest_data.number)})
684
								:
685
								$('<i/>', {title: 'Title page still appears to exist, but chapters have been removed. This is usually due to DMCA.', text: 'No chapters found'})
686
						)
687
				).append(
688
					$('<td/>')
689
						.append(
690
							manga.site_data.status === 'disabled' ?
691
								$('<i/>', {class: 'fa fa-exclamation-triangle', 'aria-hidden': 'true', style: 'color: red', title: `Tracking has been disabled for this series as the site '${manga.site_data.site}' is disabled`})
692
								:
693
								null
694
						)
695
						.append(
696
							manga.new_chapter_exists === 0 ?
697
								$('<div/>', {class: 'row-icons'})
698
									.append(
699
										$('<span/>', {class: 'list-icon ignore-latest', title: 'Ignore latest chapter. Useful when latest chapter isn\'t actually the latest chapter.'})
700
											.append($('<i/>', {class:' fa fa-bell-slash', 'aria-hidden': 'true'}))
701
									)
702
									.append(
703
										$('<span/>', {class: 'list-icon update-read', title: 'I\'ve read the latest chapter!'})
704
											.append($('<i/>', {class: 'fa fa-refresh', 'aria-hidden': 'true'}))
705
									)
706
								:
707
								null
708
						)
709
				);
710
				//endregion
711
				if(manga.site_data.status === 'disabled') {
712
					$tr.addClass('bg-danger');
713
				}
714
				else if(manga.title_data.status === 255) {
715
					$tr.addClass('bg-danger');
716
					$tr.attr('title', 'This title is no longer being updated as it has been marked as deleted/ignored.');
717
				}
718
				else if(manga.title_data.failed_checks >= 5) {
719
					$tr.addClass('bg-danger');
720
					$tr.attr('title', 'The last 5+ updates for this title have failed, as such it may not be completely up to date.');
721
				}
722
				else if(manga.title_data.failed_checks > 0) {
723
					$tr.addClass('bg-warning');
724
					$tr.attr('title', 'The last update for this title failed, as such it may not be completely up to date.');
725
				}
726
727
				return $tr;
728
			}
729
		}
730
		setupListEvents() {
731
			//This makes it easier to press the row checkbox.
732
			this.$tables.find('> tbody > tr > td:nth-of-type(1)').on('click', function (e) {
733
				if(!$(e.target).is('input')) {
734
					let $checkbox = $(this).find('> input[type=checkbox]');
735
					$($checkbox).prop('checked', !$checkbox.prop('checked'));
736
				}
737
			});
738
739
			//Requires user confirm to change page if any boxes are checked
740
			$('input[name=check]').on('change', function() {
741
				if(window.onbeforeunload === null) {
742
					window.onbeforeunload = function (e) {
743
						e = e || window.event;
744
745
						let dialogText = 'You appear to have some checked titles.\nDo you still wish to continue?';
746
						// For IE and Firefox prior to version 4
747
						if (e) { e.returnValue = dialogText; }
748
749
						// For others, except Chrome (https://bugs.chromium.org/p/chromium/issues/detail?id=587940)
750
						return dialogText;
751
					};
752
				} else if($('.tracker-table:visible').find('tr:has(td input[type=checkbox]:checked)').length === 0) {
753
					window.onbeforeunload = null;
754
				}
755
			});
756
757
			//This shows/hides the row info row.
758
			$('.toggle-info').on('click', function(e) {
759
				e.preventDefault();
760
761
				$(this).find('+ .more-info').toggle();
762
				if($(this).text() === 'More info') {
763
					$(this).text('Hide info');
764
				} else {
765
					$(this).text('More info');
766
767
					//Hide input when hiding info
768
					$(this).closest('tr').find('.tag-edit').attr('hidden', true);
769
				}
770
			});
771
772
			//Set MAL ID
773
			$('.set-mal-id').on('click', function(e) {
774
				e.preventDefault();
775
776
				let _this          = this,
777
				    current_mal_id = $(this).attr('data-mal-id');
778
779
				//If trackr.moe already has it's own MAL id for the series, ask if the user wants to override it (if they haven't already).
780
				if($(this).attr('data-mal-type') === 'title' && $(this).attr('data-mal-id') && !confirm('A MAL ID already exists for this series on our backend.\n Are you sure you want to override it?')) { return; }
781
782
				let new_mal_id     = prompt('MAL ID:', current_mal_id);
783
				if(/^([0-9]+|none)?$/.test(new_mal_id)) {
784
					if(/^[0-9]+$/.test(new_mal_id)) { new_mal_id = parseInt(new_mal_id); } //Stops people submitting multiple 0s
785
786
					let tr         = $(this).closest('tr'),
787
					    td         = tr.find('td:eq(1)'),
788
					    table      = $(this).closest('table'),
789
					    id         = tr.attr('data-id'),
790
					    icon_link  = $(td).find('.sprite-myanimelist-net').parent(),
791
					    iconN_link = $(td).find('.sprite-myanimelist-net-none').parent(),
792
					    id_text    = $(this).find('+ span'),
793
					    deferred   = $.Deferred();
794
795
					if(new_mal_id !== '' && new_mal_id !== 'none' && new_mal_id !== 0) {
796
						set_mal_id(id, new_mal_id, () => {
797
							$(iconN_link).remove(); //Make sure to remove MAL none icon when changing ID
798
							if(icon_link.length) {
799
								//icon exists, just change link
800
								$(icon_link).attr('href', 'https://myanimelist.net/manga/'+new_mal_id);
801
								$(icon_link).find('.sprite-myanimelist-net').attr('title', new_mal_id);
802
							} else {
803
								$($('<a/>', {href: 'https://myanimelist.net/manga/'+new_mal_id, class: 'mal-link'}).append(
804
									$('<i/>', {class: 'sprite-site sprite-myanimelist-net', title: new_mal_id})
805
								)).prepend(' ').insertAfter(td.find('.sprite-site'));
806
							}
807
808
							set_id_text($(_this), id_text, new_mal_id);
809
810
							deferred.resolve();
811
						});
812
					} else {
813
						if(new_mal_id === 'none' || new_mal_id === 0) {
814
							set_mal_id(id, '0', () => {
815
								icon_link.remove();
816
								iconN_link.remove();
817
818
								$($('<a/>', {class: 'mal-link'}).append(
819
									$('<i/>', {class: 'sprite-site sprite-myanimelist-net-none', title: new_mal_id})
820
								)).prepend(' ').insertAfter(td.find('.sprite-site'));
821
822
								set_id_text($(_this), id_text, 'none');
823
824
								deferred.resolve();
825
							});
826
						} else {
827
							set_mal_id(id, null, () => {
828
								icon_link.remove();
829
								iconN_link.remove();
830
								id_text.remove();
831
832
								deferred.resolve();
833
							});
834
						}
835
					}
836
837
					deferred.done(() => {
838
						$(this).attr('data-mal-id', new_mal_id);
839
						table.trigger('updateCell', [td[0], false, null]);
840
					});
841
				} else if (new_mal_id === null) {
842
					//input cancelled, do nothing
843
				} else {
844
					alert('MAL ID can only contain numbers.');
845
				}
846
847
				function set_id_text(_this, id_text, text) {
848
					text = (text !== '0' ? text : 'none');
849
					if(id_text.length) {
850
						id_text.find('small').text(text);
851
					} else {
852
						$('<span/>').append(
853
							$('<small/>', {text: text})
854
						).prepend(' (').append(')').insertAfter(_this);
855
					}
856
				}
857
858
				function set_mal_id(id, mal_id, successCallback) {
859
					successCallback = successCallback || function(){};
860
861
					let postData = {
862
						'id'     : id,
863
						'mal_id' : mal_id
864
					};
865
					$.post(base_url + 'ajax/set_mal_id', postData, () => {
866
						successCallback();
867
					}).fail((jqXHR, textStatus, errorThrown) => {
868
						_handleAjaxError(jqXHR, textStatus, errorThrown);
869
					});
870
				}
871
			});
872
873
			this.setupTagEditor();
874
875
			//Ignore latest chapter
876
			$('.ignore-latest').on('click', function() {
877
				let row             = $(this).closest('tr'),
878
				    table           = $(this).closest('table'),
879
				    chapter_id      = $(row).attr('data-id'),
880
				    current_chapter = $(row).find('.current'),
881
				    latest_chapter  = $(row).find('.latest');
882
883
				if(confirm('Ignore latest chapter?')) {
884
					let postData = {
885
						id      : chapter_id,
886
						chapter : latest_chapter.attr('data-chapter')
887
					};
888
					$.post(base_url + 'ajax/ignore_inline', postData, () => {
889
						$(current_chapter).parent().append(
890
							$('<span/>', {class: 'hidden-chapter', title: 'This latest chapter was marked as ignored.', text: $(latest_chapter).text()})
891
						);
892
893
						updateUnread(table, row);
894
					}).fail((jqXHR, textStatus, errorThrown) => {
895
						_handleAjaxError(jqXHR, textStatus, errorThrown);
896
					});
897
				}
898
			});
899
900
			//Update latest chapter (via "I've read the latest chapter")
901
			$('.update-read').on('click', function(e, data) {
902
				let row             = $(this).closest('tr'),
903
				    table           = $(this).closest('table'),
904
				    chapter_id      = $(row).attr('data-id'),
905
				    current_chapter = $(row).find('.current'),
906
				    latest_chapter  = $(row).find('.latest');
907
908
				if (!(data && data.isUserscript)) {
909
					let postData = {
910
						id     : chapter_id,
911
						chapter: latest_chapter.attr('data-chapter')
912
					};
913
					$.post(base_url + 'ajax/update_inline', postData, () => {
914
						$(current_chapter)
915
							.attr('href', $(latest_chapter).attr('href'))
916
							.text($(latest_chapter).text());
917
918
						updateUnread(table, row);
919
					}).fail((jqXHR, textStatus, errorThrown) => {
920
						_handleAjaxError(jqXHR, textStatus, errorThrown);
921
					});
922
				} else {
923
					console.log('Userscript is updating table...');
924
925
					//Userscript handles updating current_chapter url/text.
926
927
					if(data.isLatest) {
928
						updateUnread(table, row);
929
					} else {
930
						let chapter_e = current_chapter.parent();
931
932
						//Update updated-at time for sorting purposes.
933
						chapter_e.attr('data-updated-at', (new Date()).toISOString().replace(/^([0-9]+-[0-9]+-[0-9]+)T([0-9]+:[0-9]+:[0-9]+)\.[0-9]+Z$/, '$1 $2'));
934
						table.trigger('updateCell', [chapter_e[0], false, null]);
935
					}
936
				}
937
			});
938
		}
939
		setupTagEditor() {
940
			let _class = this;
941
			//Toggle input on clicking "Edit"
942
			$('.edit-tags').on('click', function(e) {
943
				e.preventDefault();
944
				let editorEle = $(this).parent().find('.tag-edit');
945
				editorEle.attr('hidden', function(_, attr){ return !attr; });
946
				if(!editorEle[0].hasAttribute('hidden')) {
947
					//NOTE: setTimeout is required here due to a chrome bug.
948
					setTimeout(function(){
949
						let input = editorEle.find('> input');
950
						input.focus();
951
952
						//Resetting value to force pointer to end of line
953
						//SEE: https://stackoverflow.com/a/8631903
954
						let tmp_val = input.val();
955
						input.val('');
956
						input.val(tmp_val);
957
					}, 1);
958
				}
959
			});
960
961
962
			//Simulate "Save" click on enter press.
963
			$('.tag-edit input').on('keypress', function(e) {
964
				if(e.which === /* enter */ 13) {
965
					$(this).closest('.tag-edit').find('[type=button]').click();
966
				}
967
			});
968
969
			//Submit tags
970
			$('.tag-edit [type=button]').on('click', function() {
971
				let _this = this;
972
				//CHECK: We would use jQuery.validate here but I don't think it works without an actual form.
973
				let input    = $(this).closest('.tag-edit').find('input'),
974
				    tag_list = input.val().toString().trim().replace(/,,/g, ','),
975
				    id       = $(this).closest('tr').attr('data-id'),
976
				    table    = $(this).closest('table'),
977
				    td       = $(this).closest('td');
978
979
				//Validation
980
				_class.validateTagList(tag_list, (tag_list_new) => {
981
					let postData = {
982
						'id'         : id,
983
						'tag_string' : tag_list_new
984
					};
985
					$.post(base_url + 'ajax/tag_update', postData, () => {
986
						$(input).val(tag_list_new);
987
988
						let $tag_list = $(_this).closest('.more-info').find('.tag-list');
989
						if(!tag_list_new) {
990
							$tag_list.text('none');
991
						} else {
992
							let tagArr = tag_list_new.split(',').map((e/*, i*/) => {
993
								return $('<i/>', {class: 'tag', text: e});
994
							});
995
							$tag_list.html(tagArr);
996
						}
997
998
						table.trigger('updateCell', [td[0], false, null]);
999
1000
						$(_this).closest('.tag-edit').attr('hidden', function(_, attr){ return !attr; });
1001
					}).fail((jqXHR, textStatus, errorThrown) => {
1002
						_handleAjaxError(jqXHR, textStatus, errorThrown);
1003
					});
1004
				});
1005
			});
1006
		}
1007
		validateTagList(tag_list, callback) {
1008
			if(!$.isArray(tag_list)) { tag_list = tag_list.trim().replace(/,,/g, ','); }
1009
1010
			if(/^[a-z0-9\-_,:]{0,255}$/.test(tag_list)) {
1011
				let tag_array    = Array.from(new Set(tag_list.split(','))).filter(function(n){ return n !== ''; }),
1012
				    tag_list_new = tag_array.join(',');
1013
				if($.inArray('none', tag_array) === -1) {
1014
					if((tag_list.match(/\bmal:(?:[0-9]+|none)\b/g) || []).length <= 1) {
1015
						callback(tag_list_new);
1016
					} else {
1017
						alert('You can only use one MAL ID tag per series');
1018
					}
1019
				} else {
1020
					alert('"none" is a restricted tag.');
1021
				}
1022
			} else {
1023
				//Tag list is invalid.
1024
				alert('Tags can only contain: lowercase a-z, 0-9, -, :, & _. They can also only have one MAL metatag.');
1025
			}
1026
		}
1027
1028
		startTablesorter() {
1029
			//region Setup sorting methods & metatags.
1030
			$.tablesorter.addParser(
1031
				{
1032
					id: 'updated-at',
1033
1034
					is: function() {
1035
						return false; // return false so this parser is not auto detected
1036
					},
1037
1038
					format: function(s, table, cell/*, cellIndex*/) {
1039
						return parseInt($(cell).attr('data-updated-at').replace(/[^0-9]+/g, '').toString());
1040
					},
1041
1042
					type: 'numeric'
1043
				}
1044
			);
1045
1046
			$.tablesorter.addParser(
1047
				{
1048
					id: 'latest',
1049
1050
					is: function() {
1051
						return false; // return false so this parser is not auto detected
1052
					},
1053
1054
					format: function(s, table, cell/*, cellIndex*/) {
1055
						return parseInt($(cell).closest('tr').find('td:eq(1) .sprite-time').attr('title').replace(/[^0-9]+/g, '').toString());
1056
					},
1057
1058
					type: 'numeric'
1059
				}
1060
			);
1061
1062
			/**
1063
			 * @return {boolean|null}
1064
			 */
1065
			$.tablesorter.filter.types.FindMalId = function( config, data ) {
1066
				if(/^mal:(?:[0-9]+|any|none|notset|duplicates?)$/.test(data.iFilter)) {
1067
					let searchID  = data.iFilter.match(/^mal:([0-9]+|any|none|notset|duplicates?)$/)[1].toLowerCase();
1068
					if(searchID === 'duplicates') { searchID = 'duplicate'; }
1069
1070
					let status = false;
1071
					switch(searchID) {
1072
						case 'any':
1073
							status = (data.$row.find('> td:eq(1) i.sprite-myanimelist-net').length > 0);
1074
							break;
1075
1076
						case 'none':
1077
							status = (data.$row.find('> td:eq(1) i.sprite-myanimelist-net-none').length > 0);
1078
							break;
1079
1080
						case 'notset':
1081
							status = (data.$row.find('> td:eq(1) i[class*="sprite-myanimelist-net"]').length === 0);
1082
							break;
1083
1084
						case 'duplicate':
1085
							if(data.$row.find('> td:eq(1) i.sprite-myanimelist-net').length > 0) {
1086
								let malID      = data.$row.find('> td:eq(1) i.sprite-myanimelist-net').attr('title'),
1087
								    $foundRows = data.$row.parent().find(`tr > td:nth-of-type(2) i.sprite-myanimelist-net[title=${malID}]`);
1088
1089
								status = ($foundRows.length > 1);
1090
							}
1091
							break;
1092
1093
						default:
1094
							let currentID = data.$row.find('> td:eq(1) i.sprite-myanimelist-net').attr('title');
1095
							status = (searchID === currentID);
1096
							break;
1097
					}
1098
1099
					return status;
1100
				}
1101
				return null;
1102
			};
1103
1104
			/**
1105
			 * @return {boolean|null}
1106
			 */
1107
			$.tablesorter.filter.types.FindSite = function( config, data ) {
1108
				if(/^site:[\w-.]+$/.test(data.iFilter)) {
1109
					let searchSite  = data.iFilter.match(/^site:([\w-.]+)$/i)[1].replace(/\./g, '-').toLowerCase(),
1110
					    currentSite = data.$row.find('> td:eq(1) .sprite-site').attr('class').split(' ')[1].substr(7);
1111
1112
					return searchSite === currentSite;
1113
				}
1114
				return null;
1115
			};
1116
1117
			/**
1118
			 * @return {boolean|null}
1119
			 */
1120
			$.tablesorter.filter.types.FindTag = function(config, data) {
1121
				if(/^tag:[a-z0-9\-_:,]{1,255}$/.test(data.iFilter)) {
1122
					let searchTagList  = data.iFilter.match(/^tag:([a-z0-9\-_:,]{1,255})$/)[1],
1123
					    searchTagArray = searchTagList.split(',');
1124
1125
					let rowTagArray = data.$row.find('td:eq(1) .tag-list .tag').map((i, e) => {
1126
						return $(e).text();
1127
					}).toArray();
1128
1129
					return searchTagArray.every(tag => ($.inArray(tag, rowTagArray) !== -1));
1130
				}
1131
				return null;
1132
			};
1133
			/**
1134
			 * @return {boolean|null}
1135
			 */
1136
			$.tablesorter.filter.types.FindChecked = function( config, data ) {
1137
				if(/^checked:(?:yes|no)$/.test(data.iFilter)) {
1138
					let checked = data.iFilter.match(/^checked:(yes|no)$/)[1].toLowerCase();
1139
1140
					let status = data.$row.find('> td:eq(0) input').is(':checked');
1141
					if(checked === 'no') { status = !status; }
1142
1143
					return status;
1144
				}
1145
				return null;
1146
			};
1147
1148
			//The range filter uses "to" as a designator which can cause issues when searching. - SEE: #221
1149
			//FIXME: We should try and presserve the original filter and just remove to "to" designator. Same goes to the "and" designator for
1150
			delete $.tablesorter.filter.types.range;
1151
			//endregion
1152
1153
			this.refreshTablesorter();
1154
		}
1155
		refreshTablesorter() {
1156
			this.$tables.trigger('destroy');
1157
1158
			this.$tables.tablesorter(this.tablesorterDefaults);
1159
		}
1160
		static getListSort(type, order) {
1161
			let sortArr = [];
1162
1163
			let sortOrder = (order === 'asc' ? 'a' : 'd');
1164
			switch(type) {
1165
				case 'unread':
1166
					sortArr = [[/* unread */ 0, 'a'], [/* title*/ 1, sortOrder]];
1167
					break;
1168
1169
				case 'unread_latest':
1170
					sortArr = [[/* unread */ 0, 'a'], [/* title*/ 3, sortOrder]];
1171
					break;
1172
1173
				case 'alphabetical':
1174
					sortArr = [[/* title */ 1, sortOrder]];
1175
					break;
1176
1177
				case 'my_status':
1178
					sortArr = [[/* unread */ 2, sortOrder]];
1179
					break;
1180
1181
				case 'latest':
1182
					sortArr = [[/* unread */ 3, sortOrder]];
1183
					break;
1184
1185
				default:
1186
					break;
1187
			}
1188
1189
			return sortArr;
1190
		}
1191
	}
1192
	let App = new TrackrApp();
1193
	App.start();
1194
});
1195