// ------------------------------------------------------------------------- // ! Base // ------------------------------------------------------------------------- const matches = (elm, selector) => { let m = (elm.document || elm.ownerDocument).querySelectorAll(selector); let i = matches.length; while (--i >= 0 && m.item(i) !== elm) {} return i > -1; }; const AnimeClient = { /** * Placeholder function */ noop: () => {}, /** * DOM selector * * @param {string} selector - The dom selector string * @param {object} [context] * @return {[HTMLElement]} - array of dom elements */ $(selector, context = null) { if (typeof selector !== 'string') { return selector; } context = (context !== null && context.nodeType === 1) ? context : document; let elements = []; if (selector.match(/^#([\w]+$)/)) { elements.push(document.getElementById(selector.split('#')[1])); } else { elements = [].slice.apply(context.querySelectorAll(selector)); } return elements; }, /** * Does the selector exist on the current page? * * @param {string} selector * @returns {boolean} */ hasElement (selector) { return AnimeClient.$(selector).length > 0; }, /** * Scroll to the top of the Page * * @return {void} */ scrollToTop () { const el = AnimeClient.$('header')[0]; el.scrollIntoView(true); }, /** * Hide the selected element * * @param {string|Element} sel - the selector of the element to hide * @return {void} */ hide (sel) { if (typeof sel === 'string') { sel = AnimeClient.$(sel); } if (Array.isArray(sel)) { sel.forEach(el => el.setAttribute('hidden', 'hidden')); } else { sel.setAttribute('hidden', 'hidden'); } }, /** * UnHide the selected element * * @param {string|Element} sel - the selector of the element to hide * @return {void} */ show (sel) { if (typeof sel === 'string') { sel = AnimeClient.$(sel); } if (Array.isArray(sel)) { sel.forEach(el => el.removeAttribute('hidden')); } else { sel.removeAttribute('hidden'); } }, /** * Display a message box * * @param {string} type - message type: info, error, success * @param {string} message - the message itself * @return {void} */ showMessage (type, message) { let template = `
${message}
`; let sel = AnimeClient.$('.message'); if (sel[0] !== undefined) { sel[0].remove(); } AnimeClient.$('header')[0].insertAdjacentHTML('beforeend', template); }, /** * Finds the closest parent element matching the passed selector * * @param {HTMLElement} current - the current HTMLElement * @param {string} parentSelector - selector for the parent element * @return {HTMLElement|null} - the parent element */ closestParent (current, parentSelector) { if (Element.prototype.closest !== undefined) { return current.closest(parentSelector); } while (current !== document.documentElement) { if (matches(current, parentSelector)) { return current; } current = current.parentElement; } return null; }, /** * Generate a full url from a relative path * * @param {string} path - url path * @return {string} - full url */ url (path) { let uri = `//${document.location.host}`; uri += (path.charAt(0) === '/') ? path : `/${path}`; return uri; }, /** * Throttle execution of a function * * @see https://remysharp.com/2010/07/21/throttling-function-calls * @see https://jsfiddle.net/jonathansampson/m7G64/ * @param {Number} interval - the minimum throttle time in ms * @param {Function} fn - the function to throttle * @param {Object} [scope] - the 'this' object for the function * @return {Function} */ throttle (interval, fn, scope) { let wait = false; return function (...args) { const context = scope || this; if ( ! wait) { fn.apply(context, args); wait = true; setTimeout(function() { wait = false; }, interval); } }; }, }; // ------------------------------------------------------------------------- // ! Events // ------------------------------------------------------------------------- function addEvent(sel, event, listener) { // Recurse! if (! event.match(/^([\w\-]+)$/)) { event.split(' ').forEach((evt) => { addEvent(sel, evt, listener); }); } sel.addEventListener(event, listener, false); } function delegateEvent(sel, target, event, listener) { // Attach the listener to the parent addEvent(sel, event, (e) => { // Get live version of the target selector AnimeClient.$(target, sel).forEach((element) => { if(e.target == element) { listener.call(element, e); e.stopPropagation(); } }); }); } /** * Add an event listener * * @param {string|HTMLElement} sel - the parent selector to bind to * @param {string} event - event name(s) to bind * @param {string|HTMLElement|function} target - the element to directly bind the event to * @param {function} [listener] - event listener callback * @return {void} */ AnimeClient.on = (sel, event, target, listener) => { if (listener === undefined) { listener = target; AnimeClient.$(sel).forEach((el) => { addEvent(el, event, listener); }); } else { AnimeClient.$(sel).forEach((el) => { delegateEvent(el, target, event, listener); }); } }; // ------------------------------------------------------------------------- // ! Ajax // ------------------------------------------------------------------------- /** * Url encoding for non-get requests * * @param data * @returns {string} * @private */ function ajaxSerialize(data) { let pairs = []; Object.keys(data).forEach((name) => { let value = data[name].toString(); name = encodeURIComponent(name); value = encodeURIComponent(value); pairs.push(`${name}=${value}`); }); return pairs.join('&'); } /** * Make an ajax request * * Config:{ * data: // data to send with the request * type: // http verb of the request, defaults to GET * success: // success callback * error: // error callback * } * * @param {string} url - the url to request * @param {Object} config - the configuration object * @return {XMLHttpRequest} */ AnimeClient.ajax = (url, config) => { // Set some sane defaults const defaultConfig = { data: {}, type: 'GET', dataType: '', success: AnimeClient.noop, mimeType: 'application/x-www-form-urlencoded', error: AnimeClient.noop }; config = { ...defaultConfig, ...config, }; let request = new XMLHttpRequest(); let method = String(config.type).toUpperCase(); if (method === 'GET') { url += (url.match(/\?/)) ? ajaxSerialize(config.data) : `?${ajaxSerialize(config.data)}`; } request.open(method, url); request.onreadystatechange = () => { if (request.readyState === 4) { let responseText = ''; if (request.responseType === 'json') { responseText = JSON.parse(request.responseText); } else { responseText = request.responseText; } if (request.status > 299) { config.error.call(null, request.status, responseText, request.response); } else { config.success.call(null, responseText, request.status); } } }; if (config.dataType === 'json') { config.data = JSON.stringify(config.data); config.mimeType = 'application/json'; } else { config.data = ajaxSerialize(config.data); } request.setRequestHeader('Content-Type', config.mimeType); if (method === 'GET') { request.send(null); } else { request.send(config.data); } return request }; /** * Do a get request * * @param {string} url * @param {object|function} data * @param {function} [callback] * @return {XMLHttpRequest} */ AnimeClient.get = (url, data, callback = null) => { if (callback === null) { callback = data; data = {}; } return AnimeClient.ajax(url, { data, success: callback }); }; // ---------------------------------------------------------------------------- // Event subscriptions // ---------------------------------------------------------------------------- AnimeClient.on('header', 'click', '.message', hide); AnimeClient.on('form.js-delete', 'submit', confirmDelete); AnimeClient.on('.js-clear-cache', 'click', clearAPICache); AnimeClient.on('.vertical-tabs input', 'change', scrollToSection); AnimeClient.on('.media-filter', 'input', filterMedia); // ---------------------------------------------------------------------------- // Handler functions // ---------------------------------------------------------------------------- /** * Hide the html element attached to the event * * @param event * @return void */ function hide (event) { AnimeClient.hide(event.target); } /** * Confirm deletion of an item * * @param event * @return void */ function confirmDelete (event) { const proceed = confirm('Are you ABSOLUTELY SURE you want to delete this item?'); if (proceed === false) { event.preventDefault(); event.stopPropagation(); } } /** * Clear the API cache, and show a message if the cache is cleared * * @return void */ function clearAPICache () { AnimeClient.get('/cache_purge', () => { AnimeClient.showMessage('success', 'Successfully purged api cache'); }); } /** * Scroll to the accordion/vertical tab section just opened * * @param event * @return void */ function scrollToSection (event) { const el = event.currentTarget.parentElement; const rect = el.getBoundingClientRect(); const top = rect.top + window.pageYOffset; window.scrollTo({ top, behavior: 'smooth', }); } /** * Filter an anime or manga list * * @param event * @return void */ function filterMedia (event) { const rawFilter = event.target.value; const filter = new RegExp(rawFilter, 'i'); // console.log('Filtering items by: ', filter); if (rawFilter !== '') { // Filter the cover view AnimeClient.$('article.media').forEach(article => { const titleLink = AnimeClient.$('.name a', article)[0]; const title = String(titleLink.textContent).trim(); if ( ! filter.test(title)) { AnimeClient.hide(article); } else { AnimeClient.show(article); } }); // Filter the list view AnimeClient.$('table.media-wrap tbody tr').forEach(tr => { const titleCell = AnimeClient.$('td.align-left', tr)[0]; const titleLink = AnimeClient.$('a', titleCell)[0]; const linkTitle = String(titleLink.textContent).trim(); const textTitle = String(titleCell.textContent).trim(); if ( ! (filter.test(linkTitle) || filter.test(textTitle))) { AnimeClient.hide(tr); } else { AnimeClient.show(tr); } }); } else { AnimeClient.show('article.media'); AnimeClient.show('table.media-wrap tbody tr'); } } // ---------------------------------------------------------------------------- // Other event setup // ---------------------------------------------------------------------------- (() => { // Var is intentional var hidden = null; var visibilityChange = null; if (typeof document.hidden !== "undefined") { hidden = "hidden"; visibilityChange = "visibilitychange"; } else if (typeof document.msHidden !== "undefined") { hidden = "msHidden"; visibilityChange = "msvisibilitychange"; } else if (typeof document.webkitHidden !== "undefined") { hidden = "webkitHidden"; visibilityChange = "webkitvisibilitychange"; } function handleVisibilityChange() { // Check the user's session to see if they are currently logged-in // when the page becomes visible if ( ! document[hidden]) { AnimeClient.get('/heartbeat', (beat) => { const status = JSON.parse(beat); // If the session is expired, immediately reload so that // you can't attempt to do an action that requires authentication if (status.hasAuth !== true) { location.reload(); } }); } } if (hidden === null) { console.info('Page visibility API not supported, JS session check will not work'); } else { document.addEventListener(visibilityChange, handleVisibilityChange, false); } })(); if ('serviceWorker' in navigator) { navigator.serviceWorker.register('/sw.js').then(reg => { console.log('Service worker registered', reg.scope); }).catch(error => { console.error('Failed to register service worker', error); }); } // Click on hidden MAL checkbox so // that MAL id is passed AnimeClient.on('main', 'change', '.big-check', (e) => { const id = e.target.id; document.getElementById(`mal_${id}`).checked = true; }); function renderAnimeSearchResults (data) { const results = []; data.forEach(x => { const item = x.attributes; const titles = item.titles.join('
'); results.push(` `); }); return results.join(''); } function renderMangaSearchResults (data) { const results = []; data.forEach(x => { const item = x.attributes; const titles = item.titles.join('
'); results.push(` `); }); return results.join(''); } const search = (query) => { // Show the loader AnimeClient.show('.cssload-loader'); // Do the api search return AnimeClient.get(AnimeClient.url('/anime-collection/search'), { query }, (searchResults, status) => { searchResults = JSON.parse(searchResults); // Hide the loader AnimeClient.hide('.cssload-loader'); // Show the results AnimeClient.$('#series-list')[ 0 ].innerHTML = renderAnimeSearchResults(searchResults.data); }); }; if (AnimeClient.hasElement('.anime #search')) { let prevRequest = null; AnimeClient.on('#search', 'input', AnimeClient.throttle(250, (e) => { const query = encodeURIComponent(e.target.value); if (query === '') { return; } if (prevRequest !== null) { prevRequest.abort(); } prevRequest = search(query); })); } // Action to increment episode count AnimeClient.on('body.anime.list', 'click', '.plus-one', (e) => { let parentSel = AnimeClient.closestParent(e.target, 'article'); let watchedCount = parseInt(AnimeClient.$('.completed_number', parentSel)[ 0 ].textContent, 10) || 0; let totalCount = parseInt(AnimeClient.$('.total_number', parentSel)[ 0 ].textContent, 10); let title = AnimeClient.$('.name a', parentSel)[ 0 ].textContent; // Setup the update data let data = { id: parentSel.dataset.kitsuId, mal_id: parentSel.dataset.malId, data: { progress: watchedCount + 1 } }; // If the episode count is 0, and incremented, // change status to currently watching if (isNaN(watchedCount) || watchedCount === 0) { data.data.status = 'current'; } // If you increment at the last episode, mark as completed if ((!isNaN(watchedCount)) && (watchedCount + 1) === totalCount) { data.data.status = 'completed'; } AnimeClient.show('#loading-shadow'); // okay, lets actually make some changes! AnimeClient.ajax(AnimeClient.url('/anime/increment'), { data, dataType: 'json', type: 'POST', success: (res) => { const resData = JSON.parse(res); if (resData.errors) { AnimeClient.hide('#loading-shadow'); AnimeClient.showMessage('error', `Failed to update ${title}. `); AnimeClient.scrollToTop(); return; } if (resData.data.status === 'COMPLETED') { AnimeClient.hide(parentSel); } AnimeClient.hide('#loading-shadow'); AnimeClient.showMessage('success', `Successfully updated ${title}`); AnimeClient.$('.completed_number', parentSel)[ 0 ].textContent = ++watchedCount; AnimeClient.scrollToTop(); }, error: () => { AnimeClient.hide('#loading-shadow'); AnimeClient.showMessage('error', `Failed to update ${title}. `); AnimeClient.scrollToTop(); } }); }); const search$1 = (query) => { AnimeClient.show('.cssload-loader'); return AnimeClient.get(AnimeClient.url('/manga/search'), { query }, (searchResults, status) => { searchResults = JSON.parse(searchResults); AnimeClient.hide('.cssload-loader'); AnimeClient.$('#series-list')[ 0 ].innerHTML = renderMangaSearchResults(searchResults.data); }); }; if (AnimeClient.hasElement('.manga #search')) { let prevRequest = null; AnimeClient.on('#search', 'input', AnimeClient.throttle(250, (e) => { let query = encodeURIComponent(e.target.value); if (query === '') { return; } if (prevRequest !== null) { prevRequest.abort(); } prevRequest = search$1(query); })); } /** * Javascript for editing manga, if logged in */ AnimeClient.on('.manga.list', 'click', '.edit-buttons button', (e) => { let thisSel = e.target; let parentSel = AnimeClient.closestParent(e.target, 'article'); let type = thisSel.classList.contains('plus-one-chapter') ? 'chapter' : 'volume'; let completed = parseInt(AnimeClient.$(`.${type}s_read`, parentSel)[ 0 ].textContent, 10) || 0; let total = parseInt(AnimeClient.$(`.${type}_count`, parentSel)[ 0 ].textContent, 10); let mangaName = AnimeClient.$('.name', parentSel)[ 0 ].textContent; if (isNaN(completed)) { completed = 0; } // Setup the update data let data = { id: parentSel.dataset.kitsuId, mal_id: parentSel.dataset.malId, data: { progress: completed } }; // If the episode count is 0, and incremented, // change status to currently reading if (isNaN(completed) || completed === 0) { data.data.status = 'current'; } // If you increment at the last chapter, mark as completed if ((!isNaN(completed)) && (completed + 1) === total) { data.data.status = 'completed'; } // Update the total count data.data.progress = ++completed; AnimeClient.show('#loading-shadow'); AnimeClient.ajax(AnimeClient.url('/manga/increment'), { data, dataType: 'json', type: 'POST', mimeType: 'application/json', success: () => { if (data.data.status === 'completed') { AnimeClient.hide(parentSel); } AnimeClient.hide('#loading-shadow'); AnimeClient.$(`.${type}s_read`, parentSel)[ 0 ].textContent = completed; AnimeClient.showMessage('success', `Successfully updated ${mangaName}`); AnimeClient.scrollToTop(); }, error: () => { AnimeClient.hide('#loading-shadow'); AnimeClient.showMessage('error', `Failed to update ${mangaName}`); AnimeClient.scrollToTop(); } }); });