diff --git a/assets/js/app.js b/assets/js/app.js
index b09fb1e5..9db37635 100644
--- a/assets/js/app.js
+++ b/assets/js/app.js
@@ -1,15 +1,17 @@
-// We need to import the CSS so that webpack will load it.
-// The MiniCssExtractPlugin is used to separate it out into
-// its own CSS file.
-
-// webpack automatically bundles all modules in your
-// entry points. Those entry points can be configured
-// in "webpack.config.js".
-//
-// Import dependencies
+// This is a manifest file that'll be compiled into including all the files listed below.
+// Add new JavaScript/Coffee code in separate files in this directory and they'll automatically
+// be included in the compiled file accessible from http://example.com/assets/application.js
+// It's not advisable to add code directly here, but if you do, it'll appear at the bottom of the
+// the compiled file.
//
-// Import local files
-//
-// Local files can be imported directly using relative paths, for example:
-// import socket from "./socket"
+// Third-party code, polyfills
+import './vendor/promise.polyfill';
+import './vendor/fetch.polyfill';
+import './vendor/closest.polyfill';
+import './vendor/customevent.polyfill';
+import './vendor/es6.polyfill';
+
+// Our code
+import './ujs';
+import './when-ready';
diff --git a/assets/js/autocomplete.js b/assets/js/autocomplete.js
new file mode 100644
index 00000000..b5e4d5de
--- /dev/null
+++ b/assets/js/autocomplete.js
@@ -0,0 +1,156 @@
+/**
+ * Autocomplete.
+ */
+
+const cache = {};
+let inputField, originalTerm;
+
+function removeParent() {
+ const parent = document.querySelector('.autocomplete');
+ if (parent) parent.parentNode.removeChild(parent);
+}
+
+function removeSelected() {
+ const selected = document.querySelector('.autocomplete__item--selected');
+ if (selected) selected.classList.remove('autocomplete__item--selected');
+}
+
+function changeSelected(firstOrLast, current, sibling) {
+ if (current && sibling) { // if the currently selected item has a sibling, move selection to it
+ current.classList.remove('autocomplete__item--selected');
+ sibling.classList.add('autocomplete__item--selected');
+ }
+ else if (current) { // if the next keypress will take the user outside the list, restore the unautocompleted term
+ inputField.value = originalTerm;
+ removeSelected();
+ }
+ else if (firstOrLast) { // if no item in the list is selected, select the first or last
+ firstOrLast.classList.add('autocomplete__item--selected');
+ }
+}
+
+function keydownHandler(event) {
+ const selected = document.querySelector('.autocomplete__item--selected'),
+ firstItem = document.querySelector('.autocomplete__item:first-of-type'),
+ lastItem = document.querySelector('.autocomplete__item:last-of-type');
+
+ if (event.keyCode === 38) changeSelected(lastItem, selected, selected && selected.previousSibling); // ArrowUp
+ if (event.keyCode === 40) changeSelected(firstItem, selected, selected && selected.nextSibling); // ArrowDown
+ if (event.keyCode === 13 || event.keyCode === 27 || event.keyCode === 188) removeParent(); // Enter || Esc || Comma
+ if (event.keyCode === 38 || event.keyCode === 40) { // ArrowUp || ArrowDown
+ const newSelected = document.querySelector('.autocomplete__item--selected');
+ if (newSelected) inputField.value = newSelected.dataset.value;
+ event.preventDefault();
+ }
+}
+
+function createItem(list, suggestion) {
+ const item = document.createElement('li');
+ item.className = 'autocomplete__item';
+
+ item.textContent = suggestion.label;
+ item.dataset.value = suggestion.value;
+
+ item.addEventListener('mouseover', () => {
+ removeSelected();
+ item.classList.add('autocomplete__item--selected');
+ inputField.value = item.dataset.value;
+ });
+
+ item.addEventListener('mouseout', () => {
+ inputField.value = originalTerm;
+ removeSelected();
+ });
+
+ item.addEventListener('click', () => {
+ inputField.dispatchEvent(
+ new CustomEvent('autocomplete', {
+ detail: {
+ type: 'click',
+ label: suggestion.label,
+ value: suggestion.value,
+ }
+ })
+ );
+ });
+
+ list.appendChild(item);
+}
+
+function createList(suggestions) {
+ const parent = document.querySelector('.autocomplete'),
+ list = document.createElement('ul');
+ list.className = 'autocomplete__list';
+
+ suggestions.forEach(suggestion => createItem(list, suggestion));
+
+ parent.appendChild(list);
+}
+
+function createParent() {
+ const parent = document.createElement('div');
+ parent.className = 'autocomplete';
+
+ // Position the parent below the inputfield
+ parent.style.position = 'absolute';
+ parent.style.left = `${inputField.offsetLeft}px`;
+ // Take the inputfield offset, add its height and subtract the amount by which the parent element has scrolled
+ parent.style.top = `${inputField.offsetTop + inputField.offsetHeight - inputField.parentNode.scrollTop}px`;
+
+ // We append the parent at the end of body
+ document.body.appendChild(parent);
+}
+
+function showAutocomplete(suggestions, targetInput) {
+ // Remove old autocomplete suggestions
+ removeParent();
+
+ // Save suggestions in cache
+ cache[targetInput.value] = suggestions;
+
+ // If the input target is not empty, still visible, and suggestions were found
+ if (targetInput.value && targetInput.style.display !== 'none' && suggestions.length) {
+ createParent();
+ createList(suggestions);
+ inputField.addEventListener('keydown', keydownHandler);
+ }
+}
+
+function getSuggestions() {
+ return fetch(inputField.dataset.acSource + inputField.value).then(response => response.json());
+}
+
+function listenAutocomplete() {
+ let timeout;
+
+ document.addEventListener('input', event => {
+ removeParent();
+
+ window.clearTimeout(timeout);
+ // Use a timeout to delay requests until the user has stopped typing
+ timeout = window.setTimeout(() => {
+ inputField = event.target;
+ originalTerm = inputField.value;
+ const {ac, acMinLength} = inputField.dataset;
+
+ if (ac && (inputField.value.length >= acMinLength)) {
+
+ if (cache[inputField.value]) {
+ showAutocomplete(cache[inputField.value], event.target);
+ }
+ else {
+ // inputField could get overwritten while the suggestions are being fetched - use event.target
+ getSuggestions().then(suggestions => showAutocomplete(suggestions, event.target));
+ }
+
+ }
+ }, 300);
+ });
+
+ // If there's a click outside the inputField, remove autocomplete
+ document.addEventListener('click', event => {
+ if (event.target && event.target !== inputField) removeParent();
+ });
+}
+
+export { listenAutocomplete };
diff --git a/assets/js/booru.js b/assets/js/booru.js
new file mode 100644
index 00000000..70db2221
--- /dev/null
+++ b/assets/js/booru.js
@@ -0,0 +1,114 @@
+import { $ } from './utils/dom';
+import parseSearch from './match_query';
+import store from './utils/store';
+
+/* Store a tag locally, marking the retrieval time */
+function persistTag(tagData) {
+ tagData.fetchedAt = new Date().getTime() / 1000;
+ store.set(`bor_tags_${tagData.id}`, tagData);
+}
+
+function isStale(tag) {
+ const now = new Date().getTime() / 1000;
+ return tag.fetchedAt === null || tag.fetchedAt < (now - 604800);
+}
+
+function clearTags() {
+ Object.keys(localStorage).forEach(key => {
+ if (key.substring(0, 9) === 'bor_tags_') {
+ store.remove(key);
+ }
+ });
+}
+
+/* Returns a single tag, or a dummy tag object if we don't know about it yet */
+function getTag(tagId) {
+ const stored = store.get(`bor_tags_${tagId}`);
+
+ if (stored) {
+ return stored;
+ }
+
+ return {
+ id: tagId,
+ name: '(unknown tag)',
+ images: 0,
+ spoiler_image_uri: null,
+ };
+}
+
+/* Fetches lots of tags in batches and stores them locally */
+function fetchAndPersistTags(tagIds) {
+ const chunk = 40;
+ for (let i = 0; i < tagIds.length; i += chunk) {
+ const ids = tagIds.slice(i, i + chunk);
+
+ fetch(`${window.booru.apiEndpoint}tags/fetch_many.json?ids[]=${ids.join('&ids[]=')}`)
+ .then(response => response.json())
+ .then(data => data.tags.forEach(tag => persistTag(tag)));
+ }
+}
+
+/* Figure out which tags in the list we don't know about */
+function fetchNewOrStaleTags(tagIds) {
+ const fetchIds = [];
+
+ tagIds.forEach(t => {
+ const stored = store.get(`bor_tags_${t}`);
+ if (!stored || isStale(stored)) {
+ fetchIds.push(t);
+ }
+ });
+
+ fetchAndPersistTags(fetchIds);
+}
+
+function verifyTagsVersion(latest) {
+ if (store.get('bor_tags_version') !== latest) {
+ clearTags();
+ store.set('bor_tags_version', latest);
+ }
+}
+
+function initializeFilters() {
+ const tags = window.booru.spoileredTagList
+ .concat(window.booru.hiddenTagList)
+ .filter((a, b, c) => c.indexOf(a) === b);
+
+ verifyTagsVersion(window.booru.tagsVersion);
+ fetchNewOrStaleTags(tags);
+}
+
+function unmarshal(data) {
+ try { return JSON.parse(data); }
+ catch (_) { return data; }
+}
+
+function loadBooruData() {
+ const booruData = document.querySelector('.js-datastore').dataset;
+
+ // Assign all elements to booru because lazy
+ for (const prop in booruData) {
+ window.booru[prop] = unmarshal(booruData[prop]);
+ }
+
+ window.booru.hiddenFilter = parseSearch(window.booru.hiddenFilter);
+ window.booru.spoileredFilter = parseSearch(window.booru.spoileredFilter);
+
+ // Fetch tag metadata and set up filtering
+ initializeFilters();
+
+ // CSRF
+ window.booru.csrfToken = $('meta[name="csrf-token"]').content;
+ window.booru.csrfParam = $('meta[name="csrf-param"]').content;
+}
+
+function BooruOnRails() {
+ this.apiEndpoint = '/api/v2/';
+ this.hiddenTag = '/tagblocked.svg';
+ this.tagsVersion = 5;
+}
+
+window.booru = new BooruOnRails();
+
+export { getTag, loadBooruData };
diff --git a/assets/js/boorujs.js b/assets/js/boorujs.js
new file mode 100644
index 00000000..e5e4841e
--- /dev/null
+++ b/assets/js/boorujs.js
@@ -0,0 +1,109 @@
+/**
+ * BoorUJS
+ *
+ * Apply event-based actions through data-* attributes. The attributes are structured like so: [data-event-action]
+ */
+
+import { $ } from './utils/dom';
+import { fetchHtml, handleError } from './utils/requests';
+import { showBlock } from './utils/image';
+import { addTag } from './tagsinput';
+import { toggleSubscription, markRead } from './notifications';
+
+// Event types and any qualifying conditions - return true to not run action
+const types = {
+ click(event) { return event.button !== 0; /* Left-click only */ },
+
+ change() { /* No qualifier */ },
+
+ fetchcomplete() { /* No qualifier */ },
+};
+
+const actions = {
+ hide(data) { selectorCb(data.base, data.value, el => el.classList.add('hidden')); },
+
+ show(data) { selectorCb(data.base, data.value, el => el.classList.remove('hidden')); },
+
+ toggle(data) { selectorCb(data.base, data.value, el => el.classList.toggle('hidden')); },
+
+ submit(data) { selectorCb(data.base, data.value, el => el.submit()); },
+
+ disable(data) { selectorCb(data.base, data.value, el => el.disabled = true); },
+
+ copy(data) { document.querySelector(data.value).select(); document.execCommand('copy'); },
+
+ inputvalue(data) { document.querySelector(data.value).value = data.el.dataset.setValue; },
+
+ selectvalue(data) { document.querySelector(data.value).value = data.el.querySelector(':checked').dataset.setValue; },
+
+ focus(data) { document.querySelector(data.value).focus(); },
+
+ preventdefault() { /* The existence of this entry is enough */ },
+
+ addtag(data) {
+ addTag(document.querySelector(data.el.closest('[data-target]').dataset.target), data.el.dataset.tagName);
+ },
+
+ tab(data) {
+ const block = data.el.parentNode.parentNode,
+ newTab = $(`.block__tab[data-tab="${data.value}"]`),
+ loadTab = data.el.dataset.loadTab;
+
+ // Switch tab
+ const selectedTab = block.querySelector('.selected');
+ if (selectedTab) {
+ selectedTab.classList.remove('selected');
+ }
+ data.el.classList.add('selected');
+
+ // Switch contents
+ this.hide({ base: block, value: '.block__tab' });
+ this.show({ base: block, value: `.block__tab[data-tab="${data.value}"]` });
+
+ // If the tab has a 'data-load-tab' attribute, load and insert the content
+ if (loadTab && !newTab.dataset.loaded) {
+ fetchHtml(loadTab)
+ .then(handleError)
+ .then(response => response.text())
+ .then(response => newTab.innerHTML = response)
+ .then(() => newTab.dataset.loaded = true)
+ .catch(() => newTab.textContent = 'Error!');
+ }
+
+ },
+
+ unfilter(data) { showBlock(data.el.closest('.image-show-container')); },
+
+ toggleSubscription,
+
+ markRead,
+
+};
+
+// Use this function to apply a callback to elements matching the selectors
+function selectorCb(base = document, selector, cb) {
+ [].forEach.call(base.querySelectorAll(selector), cb);
+}
+
+function matchAttributes(event) {
+ if (!types[event.type](event)) {
+ for (const action in actions) {
+
+ const attr = `data-${event.type}-${action.toLowerCase()}`,
+ el = event.target && event.target.closest(`[${attr}]`),
+ value = el && el.getAttribute(attr);
+
+ if (el) {
+ // Return true if you don't want to preventDefault
+ actions[action]({ attr, el, value }) || event.preventDefault();
+ }
+
+ }
+ }
+}
+
+function registerEvents() {
+ for (const type in types) document.addEventListener(type, matchAttributes);
+}
+
+export { registerEvents };
diff --git a/assets/js/burger.js b/assets/js/burger.js
new file mode 100644
index 00000000..c76e5ae8
--- /dev/null
+++ b/assets/js/burger.js
@@ -0,0 +1,73 @@
+/**
+ * Hamburger menu.
+ */
+
+function switchClasses(element, oldClass, newClass) {
+ element.classList.remove(oldClass);
+ element.classList.add(newClass);
+}
+
+function open(burger, content, body, root) {
+ switchClasses(content, 'close', 'open');
+ switchClasses(burger, 'close', 'open');
+
+ root.classList.add('no-overflow-x');
+ body.classList.add('no-overflow');
+}
+
+function close(burger, content, body, root) {
+ switchClasses(content, 'open', 'close');
+ switchClasses(burger, 'open', 'close');
+
+ /* the CSS animation closing the menu finishes in 300ms */
+ setTimeout(() => {
+ root.classList.remove('no-overflow-x');
+ body.classList.remove('no-overflow');
+ }, 300);
+}
+
+function copyUserLinksTo(burger) {
+ const copy = links => {
+ burger.appendChild(document.createElement('hr'));
+
+ [].slice.call(links).forEach(link => {
+ const burgerLink = link.cloneNode(true);
+
+ burgerLink.className = '';
+ burger.appendChild(burgerLink);
+ });
+ };
+
+ const linksContainers = document.querySelectorAll('.js-burger-links');
+
+ [].slice.call(linksContainers).forEach(container => copy(container.children));
+}
+
+function setupBurgerMenu() {
+ const burger = document.getElementById('burger');
+ const toggle = document.getElementById('js-burger-toggle');
+ const content = document.getElementById('container');
+ const body = document.body;
+ const root = document.documentElement;
+
+ copyUserLinksTo(burger);
+
+ toggle.addEventListener('click', event => {
+ event.stopPropagation();
+ event.preventDefault();
+
+ if (content.classList.contains('open')) {
+ close(burger, content, body, root);
+ }
+ else {
+ open(burger, content, body, root);
+ }
+ });
+ content.addEventListener('click', () => {
+ if (content.classList.contains('open')) {
+ close(burger, content, body, root);
+ }
+ });
+}
+
+export { setupBurgerMenu };
diff --git a/assets/js/cable.js b/assets/js/cable.js
new file mode 100644
index 00000000..03ed74e2
--- /dev/null
+++ b/assets/js/cable.js
@@ -0,0 +1,11 @@
+// Action Cable provides the framework to deal with WebSockets in Rails.
+// You can generate new channels where WebSocket features live using the rails generate channel command.
+let cable;
+
+function setupCable() {
+ if (window.booru.userIsSignedIn) {
+ cable = ActionCable.createConsumer();
+ }
+}
+
+export { cable, setupCable };
diff --git a/assets/js/captcha.js b/assets/js/captcha.js
new file mode 100644
index 00000000..e9884cb5
--- /dev/null
+++ b/assets/js/captcha.js
@@ -0,0 +1,33 @@
+/**
+ * Fetch captchas.
+ */
+import { $$, hideEl } from './utils/dom';
+import { fetchJson, handleError } from './utils/requests';
+
+function insertCaptcha(checkbox) {
+ // Also hide any associated labels
+ checkbox.checked = false;
+ hideEl(checkbox);
+ hideEl($$(`label[for="${checkbox.id}"]`));
+
+ fetchJson('POST', '/captchas')
+ .then(handleError)
+ .then(r => r.text())
+ .then(r => {
+ checkbox.insertAdjacentHTML('afterend', r);
+ checkbox.parentElement.removeChild(checkbox);
+ }).catch(() => {
+ checkbox.insertAdjacentHTML('afterend', '
Failed to fetch challenge from server!
');
+ checkbox.parentElement.removeChild(checkbox);
+ });
+}
+
+function bindCaptchaLinks() {
+ document.addEventListener('click', event => {
+ if (event.target && event.target.closest('.js-captcha')) {
+ insertCaptcha(event.target.closest('.js-captcha'));
+ }
+ });
+}
+
+export { bindCaptchaLinks };
diff --git a/assets/js/comment.js b/assets/js/comment.js
new file mode 100644
index 00000000..72c6fcf5
--- /dev/null
+++ b/assets/js/comment.js
@@ -0,0 +1,197 @@
+/**
+ * Comments.
+ */
+
+import { $ } from './utils/dom';
+import { showOwnedComments } from './communications/comment';
+import { filterNode } from './imagesclientside';
+import { fetchHtml } from './utils/requests';
+
+function handleError(response) {
+
+ const errorMessage = 'Comment failed to load!
';
+
+ if (!response.ok) {
+ return errorMessage;
+ }
+ return response.text();
+
+}
+
+function commentPosted(response) {
+
+ const commentEditTab = $('#js-comment-form a[data-click-tab="write"]'),
+ commentEditForm = $('#js-comment-form'),
+ container = document.getElementById('comments'),
+ requestOk = response.ok;
+
+ commentEditTab.click();
+ commentEditForm.reset();
+
+ if (requestOk) {
+ response.text().then(text => displayComments(container, text));
+ }
+ else {
+ window.location.reload();
+ window.scrollTo(0, 0); // Error message is displayed at the top of the page (flash)
+ }
+
+}
+
+function loadParentPost(event) {
+
+ const clickedLink = event.target,
+ // Find the comment containing the link that was clicked
+ fullComment = clickedLink.closest('article.block'),
+ // Look for a potential image and comment ID
+ commentMatches = /(\w+)#comment_(\w+)$/.exec(clickedLink.getAttribute('href'));
+
+ // If the clicked link is already active, just clear the parent comments
+ if (clickedLink.classList.contains('active_reply_link')) {
+ clearParentPost(clickedLink, fullComment);
+
+ return true;
+ }
+
+ if (commentMatches) {
+
+ // If the regex matched, get the image and comment ID
+ const [ , imageId, commentId ] = commentMatches;
+
+ // Use .html because the default response is JSON
+ fetchHtml(`/images/${imageId}/comments/${commentId}.html`)
+ .then(handleError)
+ .then(data => {
+ clearParentPost(clickedLink, fullComment);
+ insertParentPost(data, clickedLink, fullComment);
+ });
+
+ return true;
+
+ }
+
+}
+
+function insertParentPost(data, clickedLink, fullComment) {
+
+ // Add the 'subthread' class to the comment with the clicked link
+ fullComment.classList.add('subthread');
+
+ // Insert parent comment
+ fullComment.insertAdjacentHTML('beforebegin', data);
+
+ // Add class subthread and fetched-comment - use separate add()-methods to support IE11
+ fullComment.previousSibling.classList.add('subthread');
+ fullComment.previousSibling.classList.add('fetched-comment');
+
+ // Execute timeago on the new comment - it was not present when first run
+ window.booru.timeAgo(fullComment.previousSibling.getElementsByTagName('time'));
+
+ // Add class active_reply_link to the clicked link
+ clickedLink.classList.add('active_reply_link');
+
+ // Filter images (if any) in the loaded comment
+ filterNode(fullComment.previousSibling);
+
+}
+
+function clearParentPost(clickedLink, fullComment) {
+
+ // Remove any previous siblings with the class fetched-comment
+ while (fullComment.previousSibling && fullComment.previousSibling.classList.contains('fetched-comment')) {
+ fullComment.previousSibling.parentNode.removeChild(fullComment.previousSibling);
+ }
+
+ // Remove class active_reply_link from all links in the comment
+ [].slice.call(fullComment.getElementsByClassName('active_reply_link')).forEach(link => {
+ link.classList.remove('active_reply_link');
+ });
+
+ // If this full comment isn't a fetched comment, remove the subthread class.
+ if (!fullComment.classList.contains('fetched-comment')) {
+ fullComment.classList.remove('subthread');
+ }
+
+}
+
+function displayComments(container, commentsHtml) {
+
+ container.innerHTML = commentsHtml;
+
+ // Execute timeago on comments
+ window.booru.timeAgo(document.getElementsByTagName('time'));
+
+ // Filter images in the comments
+ filterNode(container);
+
+ // Show options on own comments
+ showOwnedComments();
+
+}
+
+function loadComments(event) {
+
+ const container = document.getElementById('comments'),
+ hasHref = event.target && event.target.getAttribute('href'),
+ hasHash = window.location.hash && window.location.hash.match(/#comment_([a-f0-9]+)/),
+ getURL = hasHref || (hasHash ? `${container.dataset.currentUrl}?comment_id=${window.location.hash.substring(9, window.location.hash.length)}`
+ : container.dataset.currentUrl);
+
+ fetchHtml(getURL)
+ .then(handleError)
+ .then(data => {
+
+ displayComments(container, data);
+
+ // Make sure the :target CSS selector applies to the inserted content
+ // https://bugs.chromium.org/p/chromium/issues/detail?id=98561
+ if (hasHash) {
+ // eslint-disable-next-line
+ window.location = window.location;
+ }
+ });
+
+ return true;
+
+}
+
+function setupComments() {
+ const comments = document.getElementById('comments'),
+ hasHash = window.location.hash && window.location.hash.match(/^#comment_([a-f0-9]+)$/),
+ targetOnPage = hasHash ? Boolean($(window.location.hash)) : true;
+
+ // Load comments over AJAX if we are on a page with element #comments
+ if (comments) {
+ if (!comments.dataset.loaded || !targetOnPage) {
+ // There is no event associated with the initial load, so use false
+ loadComments(false);
+ }
+ else {
+ filterNode(comments);
+ showOwnedComments();
+ }
+ }
+
+ // Define clickable elements and the function to execute on click
+ const targets = {
+ 'article[id*="comment"] .communication__body__text a[href]': loadParentPost,
+ '#comments .pagination a[href]': loadComments,
+ '#js-refresh-comments': loadComments,
+ };
+
+ document.addEventListener('click', event => {
+ if (event.button === 0) { // Left-click only
+ for (const target in targets) {
+ if (event.target && event.target.closest(target)) {
+ targets[target](event) && event.preventDefault();
+ }
+ }
+ }
+ });
+
+ document.addEventListener('fetchcomplete', event => {
+ if (event.target.id === 'js-comment-form') commentPosted(event.detail);
+ });
+}
+
+export { setupComments };
diff --git a/assets/js/communications/comment.js b/assets/js/communications/comment.js
new file mode 100644
index 00000000..a4661c61
--- /dev/null
+++ b/assets/js/communications/comment.js
@@ -0,0 +1,10 @@
+import { $ } from '../utils/dom';
+
+function showOwnedComments() {
+ const editableComments = $('.js-editable-comments');
+ const editableCommentIds = editableComments && JSON.parse(editableComments.dataset.editable);
+
+ if (editableCommentIds) editableCommentIds.forEach(id => $(`#comment_${id} .owner-options`).classList.remove('hidden'));
+}
+
+export { showOwnedComments };
diff --git a/assets/js/communications/post.js b/assets/js/communications/post.js
new file mode 100644
index 00000000..d2172220
--- /dev/null
+++ b/assets/js/communications/post.js
@@ -0,0 +1,10 @@
+import { $ } from '../utils/dom';
+
+function showOwnedPosts() {
+ const editablePost = $('.js-editable-posts');
+ const editablePostIds = editablePost && JSON.parse(editablePost.dataset.editable);
+
+ if (editablePostIds) editablePostIds.forEach(id => $(`#post_${id} .owner-options`).classList.remove('hidden'));
+}
+
+export { showOwnedPosts };
diff --git a/assets/js/duplicate_reports.js b/assets/js/duplicate_reports.js
new file mode 100644
index 00000000..a6794ec4
--- /dev/null
+++ b/assets/js/duplicate_reports.js
@@ -0,0 +1,42 @@
+/**
+ * Interactive behavior for duplicate reports.
+ */
+
+import { $, $$ } from './utils/dom';
+
+function setupDupeReports() {
+ const [ onion, slider ] = $$('.onion-skin__image, .onion-skin__slider');
+ const swipe = $('.swipe__image');
+
+ if (swipe) setupSwipe(swipe);
+ if (onion) setupOnionSkin(onion, slider);
+}
+
+function setupSwipe(swipe) {
+ const [ clip, divider ] = $$('#clip rect, #divider', swipe);
+ const { width } = swipe.viewBox.baseVal;
+
+ function moveDivider({ clientX }) {
+ // Move center to cursor
+ const rect = swipe.getBoundingClientRect();
+ const newX = (clientX - rect.left) * (width / rect.width);
+
+ divider.setAttribute('x', newX);
+ clip.setAttribute('width', newX);
+ }
+
+ swipe.addEventListener('mousemove', moveDivider);
+}
+
+function setupOnionSkin(onion, slider) {
+ const target = $('#target', onion);
+
+ function setOpacity() {
+ target.setAttribute('opacity', slider.value);
+ }
+
+ setOpacity();
+ slider.addEventListener('input', setOpacity);
+}
+
+export { setupDupeReports };
diff --git a/assets/js/fingerprint.js b/assets/js/fingerprint.js
new file mode 100644
index 00000000..aac85635
--- /dev/null
+++ b/assets/js/fingerprint.js
@@ -0,0 +1,51 @@
+/**
+ * Fingerprints
+ */
+
+// http://stackoverflow.com/a/34842797
+function hashCode(str) {
+ return str.split('').reduce((prevHash, currVal) =>
+ ((prevHash << 5) - prevHash) + currVal.charCodeAt(0), 0) >>> 0;
+}
+
+function createFingerprint() {
+ const prints = [
+ navigator.userAgent,
+ navigator.cpuClass,
+ navigator.oscpu,
+ navigator.platform,
+
+ navigator.browserLanguage,
+ navigator.language,
+ navigator.systemLanguage,
+ navigator.userLanguage,
+
+ screen.availLeft,
+ screen.availTop,
+ screen.availWidth,
+ screen.height,
+ screen.width,
+
+ window.devicePixelRatio,
+ new Date().getTimezoneOffset(),
+ ];
+
+ return hashCode(prints.join(''));
+}
+
+function setFingerprintCookie() {
+ let fingerprint;
+
+ // The prepended 'c' acts as a crude versioning mechanism.
+ try {
+ fingerprint = `c${createFingerprint()}`;
+ }
+ // If fingerprinting fails, use fakeprint "c1836832948" as a last resort.
+ catch (err) {
+ fingerprint = 'c1836832948';
+ }
+
+ document.cookie = `_ses=${fingerprint}; path=/`;
+}
+
+export { setFingerprintCookie };
diff --git a/assets/js/galleries.js b/assets/js/galleries.js
new file mode 100644
index 00000000..6cf97865
--- /dev/null
+++ b/assets/js/galleries.js
@@ -0,0 +1,45 @@
+/**
+ * Gallery rearrangement.
+ */
+
+import { arraysEqual } from './utils/array';
+import { $, $$ } from './utils/dom';
+import { initDraggables } from './utils/draggable';
+import { fetchJson } from './utils/requests';
+
+export function setupGalleryEditing() {
+ if (!$('.rearrange-button')) return;
+
+ const [ rearrangeEl, saveEl ] = $$('.rearrange-button');
+ const sortableEl = $('#sortable');
+ const containerEl = $('.js-resizable-media-container');
+
+ // Copy array
+ let oldImages = window.booru.galleryImages.slice();
+ let newImages = window.booru.galleryImages.slice();
+
+ initDraggables();
+
+ $$('.media-box', containerEl).forEach(i => i.draggable = true);
+
+ rearrangeEl.addEventListener('click', () => {
+ sortableEl.classList.add('editing');
+ containerEl.classList.add('drag-container');
+ });
+
+ saveEl.addEventListener('click', () => {
+ sortableEl.classList.remove('editing');
+ containerEl.classList.remove('drag-container');
+
+ newImages = $$('.image-container', containerEl).map(i => parseInt(i.dataset.imageId, 10));
+
+ // If nothing changed, don't bother.
+ if (arraysEqual(newImages, oldImages)) return;
+
+ fetchJson('PATCH', saveEl.dataset.reorderPath, {
+ image_ids: newImages,
+
+ // copy the array again so that we have the newly updated set
+ }).then(() => oldImages = newImages.slice());
+ });
+}
diff --git a/assets/js/image_expansion.js b/assets/js/image_expansion.js
new file mode 100644
index 00000000..5b639976
--- /dev/null
+++ b/assets/js/image_expansion.js
@@ -0,0 +1,160 @@
+import { clearEl } from './utils/dom';
+import store from './utils/store';
+
+const imageVersions = {
+ // [width, height]
+ small: [320, 240],
+ medium: [800, 600],
+ large: [1280, 1024]
+};
+
+/**
+ * Picks the appropriate image version for a given width and height
+ * of the viewport and the image dimensions.
+ */
+function selectVersion(imageWidth, imageHeight) {
+ let viewWidth = document.documentElement.clientWidth,
+ viewHeight = document.documentElement.clientHeight;
+
+ // load hires if that's what you asked for
+ if (store.get('serve_hidpi')) {
+ viewWidth *= window.devicePixelRatio || 1;
+ viewHeight *= window.devicePixelRatio || 1;
+ }
+
+ if (viewWidth > 1024 && imageHeight > 1024 && imageHeight > 2.5 * imageWidth) {
+ // Treat as comic-sized dimensions..
+ return 'tall';
+ }
+
+ // Find a version that is larger than the view in one/both axes
+ // .find() is not supported in older browsers, using a loop
+ for (let i = 0, versions = Object.keys(imageVersions); i < versions.length; ++i) {
+ const version = versions[i],
+ dimensions = imageVersions[version],
+ versionWidth = Math.min(imageWidth, dimensions[0]),
+ versionHeight = Math.min(imageHeight, dimensions[1]);
+ if (versionWidth > viewWidth || versionHeight > viewHeight) {
+ return version;
+ }
+ }
+
+ // If the view is larger than any available version, display the original image
+ return 'full';
+}
+
+/**
+ * Given a target container element, chooses and scales an image
+ * to an appropriate dimension.
+ */
+function pickAndResize(elem) {
+ const imageWidth = parseInt(elem.getAttribute('data-width'), 10),
+ imageHeight = parseInt(elem.getAttribute('data-height'), 10),
+ scaled = elem.getAttribute('data-scaled'),
+ uris = JSON.parse(elem.getAttribute('data-uris'));
+ let version = 'full';
+
+ if (scaled === 'true') {
+ version = selectVersion(imageWidth, imageHeight);
+ }
+
+ const uri = uris[version];
+ let imageFormat = /\.(\w+?)$/.exec(uri)[1];
+
+ if (version === 'full' && store.get('serve_webm') && Boolean(uris.mp4)) {
+ imageFormat = 'mp4';
+ }
+
+ // Check if we need to change to avoid flickering
+ if (imageFormat === 'mp4' || imageFormat === 'webm') {
+ for (const sourceEl of elem.querySelectorAll('video source')) {
+ if (sourceEl.src.endsWith(uri) || (imageFormat === 'mp4' && sourceEl.src.endsWith(uris.mp4))) return;
+ }
+
+ // Scrub out the target element.
+ clearEl(elem);
+ }
+
+ if (imageFormat === 'mp4') {
+ elem.classList.add('full-height');
+ elem.insertAdjacentHTML('afterbegin',
+ ``
+ );
+ }
+ else if (imageFormat === 'webm') {
+ elem.insertAdjacentHTML('afterbegin',
+ ``
+ );
+ const video = elem.querySelector('video');
+ if (scaled === 'true') {
+ video.className = 'image-scaled';
+ }
+ else if (scaled === 'partscaled') {
+ video.className = 'image-partscaled';
+ }
+ }
+ else {
+ let image;
+ if (scaled === 'true') {
+ image = ``;
+ }
+ else if (scaled === 'partscaled') {
+ image = ``;
+ }
+ else {
+ image = ``;
+ }
+ if (elem.innerHTML === image) return;
+
+ clearEl(elem);
+ elem.insertAdjacentHTML('afterbegin', image);
+ }
+}
+
+/**
+ * Bind an event to an image container for updating an image on
+ * click/tap.
+ */
+function bindImageForClick(target) {
+ target.addEventListener('click', () => {
+ if (target.getAttribute('data-scaled') === 'true') {
+ target.setAttribute('data-scaled', 'partscaled');
+ }
+ else if (target.getAttribute('data-scaled') === 'partscaled') {
+ target.setAttribute('data-scaled', 'false');
+ }
+ else {
+ target.setAttribute('data-scaled', 'true');
+ }
+
+ pickAndResize(target);
+ });
+}
+
+function bindImageTarget() {
+ const target = document.getElementById('image_target');
+ if (target) {
+ pickAndResize(target);
+ bindImageForClick(target);
+ window.addEventListener('resize', () => {
+ pickAndResize(target);
+ });
+ }
+}
+
+export { bindImageTarget };
diff --git a/assets/js/imagesclientside.js b/assets/js/imagesclientside.js
new file mode 100644
index 00000000..7dc3fb31
--- /dev/null
+++ b/assets/js/imagesclientside.js
@@ -0,0 +1,76 @@
+/**
+ * Client-side image filtering/spoilering.
+ */
+
+import { $$, escapeHtml } from './utils/dom';
+import { setupInteractions } from './interactions';
+import { showThumb, showBlock, spoilerThumb, spoilerBlock, hideThumb } from './utils/image';
+import { getHiddenTags, getSpoileredTags, imageHitsTags, imageHitsComplex, displayTags } from './utils/tag';
+
+function runFilter(img, test, runCallback) {
+ if (!test || test.length === 0) return false;
+
+ runCallback(img, test);
+
+ // I don't like this.
+ window.booru.imagesWithDownvotingDisabled.push(img.dataset.imageId);
+
+ return true;
+}
+
+// ---
+
+function filterThumbSimple(img, tagsHit) { hideThumb(img, tagsHit[0].spoiler_image_uri || window.booru.hiddenTag, `[HIDDEN] ${displayTags(tagsHit)}`); }
+function spoilerThumbSimple(img, tagsHit) { spoilerThumb(img, tagsHit[0].spoiler_image_uri || window.booru.hiddenTag, displayTags(tagsHit)); }
+function filterThumbComplex(img) { hideThumb(img, window.booru.hiddenTag, '[HIDDEN] (Complex Filter)'); }
+function spoilerThumbComplex(img) { spoilerThumb(img, window.booru.hiddenTag, '(Complex Filter)'); }
+
+function filterBlockSimple(img, tagsHit) { spoilerBlock(img, tagsHit[0].spoiler_image_uri || window.booru.hiddenTag, `This image is tagged ${escapeHtml(tagsHit[0].name)}
, which is hidden by `); }
+function spoilerBlockSimple(img, tagsHit) { spoilerBlock(img, tagsHit[0].spoiler_image_uri || window.booru.hiddenTag, `This image is tagged ${escapeHtml(tagsHit[0].name)}
, which is spoilered by `); }
+function filterBlockComplex(img) { spoilerBlock(img, window.booru.hiddenTag, 'This image was hidden by a complex tag expression in '); }
+function spoilerBlockComplex(img) { spoilerBlock(img, window.booru.hiddenTag, 'This image was spoilered by a complex tag expression in '); }
+
+// ---
+
+function thumbTagFilter(tags, img) { return runFilter(img, imageHitsTags(img, tags), filterThumbSimple); }
+function thumbComplexFilter(complex, img) { return runFilter(img, imageHitsComplex(img, complex), filterThumbComplex); }
+function thumbTagSpoiler(tags, img) { return runFilter(img, imageHitsTags(img, tags), spoilerThumbSimple); }
+function thumbComplexSpoiler(complex, img) { return runFilter(img, imageHitsComplex(img, complex), spoilerThumbComplex); }
+
+function blockTagFilter(tags, img) { return runFilter(img, imageHitsTags(img, tags), filterBlockSimple); }
+function blockComplexFilter(complex, img) { return runFilter(img, imageHitsComplex(img, complex), filterBlockComplex); }
+function blockTagSpoiler(tags, img) { return runFilter(img, imageHitsTags(img, tags), spoilerBlockSimple); }
+function blockComplexSpoiler(complex, img) { return runFilter(img, imageHitsComplex(img, complex), spoilerBlockComplex); }
+
+// ---
+
+function filterNode(node = document) {
+ const hiddenTags = getHiddenTags(), spoileredTags = getSpoileredTags();
+ const { hiddenFilter, spoileredFilter } = window.booru;
+
+ // Image thumb boxes with vote and fave buttons on them
+ $$('.image-container', node)
+ .filter(img => !thumbTagFilter(hiddenTags, img))
+ .filter(img => !thumbComplexFilter(hiddenFilter, img))
+ .filter(img => !thumbTagSpoiler(spoileredTags, img))
+ .filter(img => !thumbComplexSpoiler(spoileredFilter, img))
+ .forEach(img => showThumb(img));
+
+ // Individual image pages and images in posts/comments
+ $$('.image-show-container', node)
+ .filter(img => !blockTagFilter(hiddenTags, img))
+ .filter(img => !blockComplexFilter(hiddenFilter, img))
+ .filter(img => !blockTagSpoiler(spoileredTags, img))
+ .filter(img => !blockComplexSpoiler(spoileredFilter, img))
+ .forEach(img => showBlock(img));
+}
+
+function initImagesClientside() {
+ window.booru.imagesWithDownvotingDisabled = [];
+ // This fills the imagesWithDownvotingDisabled array
+ filterNode(document);
+ // Once the array is populated, we can initialize interactions
+ setupInteractions();
+}
+
+export { initImagesClientside, filterNode };
diff --git a/assets/js/interactions.js b/assets/js/interactions.js
new file mode 100644
index 00000000..fed1ea66
--- /dev/null
+++ b/assets/js/interactions.js
@@ -0,0 +1,201 @@
+/**
+ * Interactions.
+ */
+
+import { fetchJson } from './utils/requests';
+
+const endpoints = {
+ fave: `${window.booru.apiEndpoint}interactions/fave`,
+ vote: `${window.booru.apiEndpoint}interactions/vote`,
+ hide: `${window.booru.apiEndpoint}interactions/hide`,
+};
+
+const spoilerDownvoteMsg =
+ 'Neigh! - Remove spoilered tags from your filters to downvote from thumbnails';
+
+/* Quick helper function to less verbosely iterate a QSA */
+function onImage(id, selector, cb) {
+ [].forEach.call(
+ document.querySelectorAll(`${selector}[data-image-id="${id}"]`), cb);
+}
+
+function setScore(imageId, data) {
+ onImage(imageId, '.score',
+ el => el.textContent = data.score);
+ onImage(imageId, '.votes',
+ el => el.textContent = data.votes);
+ onImage(imageId, '.favorites',
+ el => el.textContent = data.favourites);
+ onImage(imageId, '.upvotes',
+ el => el.textContent = data.upvotes);
+ onImage(imageId, '.downvotes',
+ el => el.textContent = data.downvotes);
+}
+
+/* These change the visual appearance of interaction links.
+ * Their classes also effect their behavior due to event delegation. */
+
+function showUpvoted(imageId) {
+ onImage(imageId, '.interaction--upvote',
+ el => el.classList.add('active'));
+}
+
+function showDownvoted(imageId) {
+ onImage(imageId, '.interaction--downvote',
+ el => el.classList.add('active'));
+}
+
+function showFaved(imageId) {
+ onImage(imageId, '.interaction--fave',
+ el => el.classList.add('active'));
+}
+
+function showHidden(imageId) {
+ onImage(imageId, '.interaction--hide',
+ el => el.classList.add('active'));
+}
+
+function resetVoted(imageId) {
+ onImage(imageId, '.interaction--upvote',
+ el => el.classList.remove('active'));
+
+ onImage(imageId, '.interaction--downvote',
+ el => el.classList.remove('active'));
+}
+
+function resetFaved(imageId) {
+ onImage(imageId, '.interaction--fave',
+ el => el.classList.remove('active'));
+}
+
+function resetHidden(imageId) {
+ onImage(imageId, '.interaction--hide',
+ el => el.classList.remove('active'));
+}
+
+function interact(type, imageId, value) {
+ return fetchJson('PUT', endpoints[type], {
+ class: 'Image', id: imageId, value
+ })
+ .then(res => res.json())
+ .then(res => setScore(imageId, res));
+}
+
+function displayInteractionSet(interactions) {
+ interactions.forEach(i => {
+ switch (i.interaction_type) {
+ case 'faved':
+ showFaved(i.image_id);
+ break;
+ case 'hidden':
+ showHidden(i.image_id);
+ break;
+ default:
+ if (i.value === 'up') showUpvoted(i.image_id);
+ if (i.value === 'down') showDownvoted(i.image_id);
+ }
+ });
+}
+
+function loadInteractions() {
+
+ /* Set up the actual interactions */
+ displayInteractionSet(window.booru.interactions);
+
+ /* Next part is only for image index pages
+ * TODO: find a better way to do this */
+ if (!document.getElementById('imagelist_container')) return;
+
+ /* Users will blind downvote without this */
+ window.booru.imagesWithDownvotingDisabled.forEach(i => {
+ onImage(i, '.interaction--downvote', a => {
+
+ // TODO Use a 'js-' class to target these instead
+ const icon = a.querySelector('i') || a.querySelector('.oc-icon-small');
+
+ icon.setAttribute('title', spoilerDownvoteMsg);
+ a.classList.add('disabled');
+ a.addEventListener('click', event => {
+ event.stopPropagation();
+ event.preventDefault();
+ }, true);
+
+ });
+ });
+
+}
+
+const targets = {
+
+ /* Active-state targets first */
+ '.interaction--upvote.active'(imageId) {
+ interact('vote', imageId, 'false')
+ .then(() => resetVoted(imageId));
+ },
+ '.interaction--downvote.active'(imageId) {
+ interact('vote', imageId, 'false')
+ .then(() => resetVoted(imageId));
+ },
+ '.interaction--fave.active'(imageId) {
+ interact('fave', imageId, 'false')
+ .then(() => resetFaved(imageId));
+ },
+ '.interaction--hide.active'(imageId) {
+ interact('hide', imageId, 'false')
+ .then(() => resetHidden(imageId));
+ },
+
+ /* Inactive targets */
+ '.interaction--upvote:not(.active)'(imageId) {
+ interact('vote', imageId, 'up')
+ .then(() => { resetVoted(imageId); showUpvoted(imageId); });
+ },
+ '.interaction--downvote:not(.active)'(imageId) {
+ interact('vote', imageId, 'down')
+ .then(() => { resetVoted(imageId); showDownvoted(imageId); });
+ },
+ '.interaction--fave:not(.active)'(imageId) {
+ interact('fave', imageId, 'true')
+ .then(() => { resetVoted(imageId); showFaved(imageId); showUpvoted(imageId); });
+ },
+ '.interaction--hide:not(.active)'(imageId) {
+ interact('hide', imageId, 'true')
+ .then(() => { showHidden(imageId); });
+ },
+
+};
+
+function bindInteractions() {
+ document.addEventListener('click', event => {
+
+ if (event.button === 0) { // Is it a left-click?
+ for (const target in targets) {
+ /* Event delgation doesn't quite grab what we want here. */
+ const link = event.target && event.target.closest(target);
+
+ if (link) {
+ event.preventDefault();
+ targets[target](link.dataset.imageId);
+ }
+ }
+ }
+
+ });
+}
+
+function loggedOutInteractions() {
+ [].forEach.call(document.querySelectorAll('.interaction--fave,.interaction--upvote,.interaction--downvote'),
+ a => a.setAttribute('href', '/users/sign_in'));
+}
+
+function setupInteractions() {
+ if (window.booru.userIsSignedIn) {
+ bindInteractions();
+ loadInteractions();
+ }
+ else {
+ loggedOutInteractions();
+ }
+}
+
+export { setupInteractions, displayInteractionSet };
diff --git a/assets/js/match_query.js b/assets/js/match_query.js
new file mode 100644
index 00000000..1d3d9c93
--- /dev/null
+++ b/assets/js/match_query.js
@@ -0,0 +1,871 @@
+/**
+* booru.match_query: A port and modification of the search_parser library for
+* performing client-side filtering.
+*/
+
+const tokenList = [
+ ['fuzz', /^~(?:\d+(\.\d+)?|\.\d+)/],
+ ['boost', /^\^[\-\+]?\d+(\.\d+)?/],
+ ['quoted_lit', /^\s*"(?:(?:[^"]|\\")+)"/],
+ ['lparen', /^\s*\(\s*/],
+ ['rparen', /^\s*\)\s*/],
+ ['and_op', /^\s*(?:\&\&|AND)\s+/],
+ ['and_op', /^\s*,\s*/],
+ ['or_op', /^\s*(?:\|\||OR)\s+/],
+ ['not_op', /^\s*NOT(?:\s+|(?=\())/],
+ ['not_op', /^\s*[\!\-]\s*/],
+ ['space', /^\s+/],
+ ['word', /^(?:[^\s,\(\)\^~]|\\[\s,\(\)\^~])+/],
+ ['word', /^(?:[^\s,\(\)]|\\[\s,\(\)])+/]
+ ],
+ numberFields = ['id', 'width', 'height', 'aspect_ratio',
+ 'comment_count', 'score', 'upvotes', 'downvotes',
+ 'faves'],
+ dateFields = ['created_at'],
+ literalFields = ['tags', 'orig_sha512_hash', 'sha512_hash',
+ 'score', 'uploader', 'source_url', 'description'],
+ termSpaceToImageField = {
+ tags: 'data-image-tag-aliases',
+ score: 'data-score',
+ upvotes: 'data-upvotes',
+ downvotes: 'data-downvotes',
+ uploader: 'data-uploader',
+ // Yeah, I don't think this is reasonably supportable.
+ // faved_by: 'data-faved-by',
+ id: 'data-image-id',
+ width: 'data-width',
+ height: 'data-height',
+ aspect_ratio: 'data-aspect-ratio',
+ comment_count: 'data-comment-count',
+ source_url: 'data-source-url',
+ faves: 'data-faves',
+ sha512_hash: 'data-sha512',
+ orig_sha512_hash: 'data-orig-sha512',
+ created_at: 'data-created-at'
+ };
+
+
+function SearchTerm(termStr, options) {
+ this.term = termStr.trim();
+ this.parsed = false;
+}
+
+SearchTerm.prototype.append = function(substr) {
+ this.term += substr;
+ this.parsed = false;
+};
+
+SearchTerm.prototype.parseRangeField = function(field) {
+ let qual;
+
+ if (numberFields.indexOf(field) !== -1) {
+ return [field, 'eq', 'number'];
+ }
+
+ if (dateFields.indexOf(field) !== -1) {
+ return [field, 'eq', 'date'];
+ }
+
+ qual = /^(\w+)\.([lg]te?|eq)$/.exec(field);
+
+ if (qual) {
+ if (numberFields.indexOf(qual[1]) !== -1) {
+ return [qual[1], qual[2], 'number'];
+ }
+
+ if (dateFields.indexOf(qual[1]) !== -1) {
+ return [qual[1], qual[2], 'date'];
+ }
+ }
+
+ return null;
+};
+
+SearchTerm.prototype.parseRelativeDate = function(dateVal, qual) {
+ const match = /(\d+) (second|minute|hour|day|week|month|year)s? ago/.exec(dateVal);
+ const bounds = {
+ second: 1000,
+ minute: 60000,
+ hour: 3600000,
+ day: 86400000,
+ week: 604800000,
+ month: 2592000000,
+ year: 31536000000
+ };
+
+ if (match) {
+ const amount = parseInt(match[1], 10);
+ const scale = bounds[match[2]];
+
+ const now = new Date().getTime();
+ const bottomDate = new Date(now - (amount * scale));
+ const topDate = new Date(now - ((amount - 1) * scale));
+
+ switch (qual) {
+ case 'lte':
+ return [bottomDate, 'lt'];
+ case 'gte':
+ return [bottomDate, 'gte'];
+ case 'lt':
+ return [bottomDate, 'lt'];
+ case 'gt':
+ return [bottomDate, 'gte'];
+ default:
+ return [[bottomDate, topDate], 'eq'];
+ }
+ }
+ else {
+ throw `Cannot parse date string: ${dateVal}`;
+ }
+};
+
+SearchTerm.prototype.parseAbsoluteDate = function(dateVal, qual) {
+ let parseRes = [
+ /^(\d{4})/,
+ /^\-(\d{2})/,
+ /^\-(\d{2})/,
+ /^(?:\s+|T|t)(\d{2})/,
+ /^:(\d{2})/,
+ /^:(\d{2})/
+ ],
+ timeZoneOffset = [0, 0],
+ timeData = [0, 0, 1, 0, 0, 0],
+ bottomDate = null,
+ topDate = null,
+ i,
+ match,
+ origDateVal = dateVal;
+
+ match = /([\+\-])(\d{2}):(\d{2})$/.exec(dateVal);
+ if (match) {
+ timeZoneOffset[0] = parseInt(match[2], 10);
+ timeZoneOffset[1] = parseInt(match[3], 10);
+ if (match[1] === '-') {
+ timeZoneOffset[0] *= -1;
+ timeZoneOffset[1] *= -1;
+ }
+ dateVal = dateVal.substr(0, dateVal.length - 6);
+ }
+ else {
+ dateVal = dateVal.replace(/[Zz]$/, '');
+ }
+
+ for (i = 0; i < parseRes.length; i += 1) {
+ if (dateVal.length === 0) {
+ break;
+ }
+
+ match = parseRes[i].exec(dateVal);
+ if (match) {
+ if (i === 1) {
+ timeData[i] = parseInt(match[1], 10) - 1;
+ }
+ else {
+ timeData[i] = parseInt(match[1], 10);
+ }
+ dateVal = dateVal.substr(
+ match[0].length, dateVal.length - match[0].length
+ );
+ }
+ else {
+ throw `Cannot parse date string: ${origDateVal}`;
+ }
+ }
+
+ if (dateVal.length > 0) {
+ throw `Cannot parse date string: ${origDateVal}`;
+ }
+
+ // Apply the user-specified time zone offset. The JS Date constructor
+ // is very flexible here.
+ timeData[3] -= timeZoneOffset[0];
+ timeData[4] -= timeZoneOffset[1];
+
+ switch (qual) {
+ case 'lte':
+ timeData[i - 1] += 1;
+ return [Date.UTC.apply(Date, timeData), 'lt'];
+ case 'gte':
+ return [Date.UTC.apply(Date, timeData), 'gte'];
+ case 'lt':
+ return [Date.UTC.apply(Date, timeData), 'lt'];
+ case 'gt':
+ timeData[i - 1] += 1;
+ return [Date.UTC.apply(Date, timeData), 'gte'];
+ default:
+ bottomDate = Date.UTC.apply(Date, timeData);
+ timeData[i - 1] += 1;
+ topDate = Date.UTC.apply(Date, timeData);
+ return [[bottomDate, topDate], 'eq'];
+ }
+};
+
+SearchTerm.prototype.parseDate = function(dateVal, qual) {
+ try {
+ return this.parseAbsoluteDate(dateVal, qual);
+ }
+ catch (_) {
+ return this.parseRelativeDate(dateVal, qual);
+ }
+};
+
+SearchTerm.prototype.parse = function(substr) {
+ let matchArr,
+ rangeParsing,
+ candidateTermSpace,
+ termCandidate;
+
+ this.wildcardable = !this.fuzz && !/^"([^"]|\\")+"$/.test(this.term);
+
+ if (!this.wildcardable && !this.fuzz) {
+ this.term = this.term.substr(1, this.term.length - 2);
+ }
+
+ this.term = this._normalizeTerm();
+
+ // N.B.: For the purposes of this parser, boosting effects are ignored.
+
+ // Default.
+ this.termSpace = 'tags';
+ this.termType = 'literal';
+
+ matchArr = this.term.split(':');
+
+ if (matchArr.length > 1) {
+ candidateTermSpace = matchArr[0];
+ termCandidate = matchArr.slice(1).join(':');
+ rangeParsing = this.parseRangeField(candidateTermSpace);
+
+ if (rangeParsing) {
+ this.termSpace = rangeParsing[0];
+ this.termType = rangeParsing[2];
+
+ if (this.termType === 'date') {
+ rangeParsing = this.parseDate(termCandidate, rangeParsing[1]);
+ this.term = rangeParsing[0];
+ this.compare = rangeParsing[1];
+ }
+ else {
+ this.term = parseFloat(termCandidate);
+ this.compare = rangeParsing[1];
+ }
+
+ this.wildcardable = false;
+ }
+ else if (literalFields.indexOf(candidateTermSpace) !== -1) {
+ this.termType = 'literal';
+ this.term = termCandidate;
+ this.termSpace = candidateTermSpace;
+ }
+ else if (candidateTermSpace == 'my') {
+ this.termType = 'my';
+ this.termSpace = termCandidate;
+ }
+ }
+
+ if (this.wildcardable) {
+ // Transforms wildcard match into regular expression.
+ // A custom NFA with caching may be more sophisticated but not
+ // likely to be faster.
+ this.term = new RegExp(
+ `^${
+ this.term.replace(/([.+^$[\]\\(){}|-])/g, '\\$1')
+ .replace(/([^\\]|[^\\](?:\\\\)+)\*/g, '$1.*')
+ .replace(/^(?:\\\\)*\*/g, '.*')
+ .replace(/([^\\]|[^\\](?:\\\\)+)\?/g, '$1.?')
+ .replace(/^(?:\\\\)*\?/g, '.?')
+ }$`, 'i'
+ );
+ }
+
+ // Update parse status flag to indicate the new properties are ready.
+ this.parsed = true;
+};
+
+SearchTerm.prototype._normalizeTerm = function() {
+ if (!this.wildcardable) {
+ return this.term.replace('\"', '"');
+ }
+ return this.term.replace(/\\([^\*\?])/g, '$1');
+};
+
+SearchTerm.prototype.fuzzyMatch = function(targetStr) {
+ let targetDistance,
+ i,
+ j,
+ // Work vectors, representing the last three populated
+ // rows of the dynamic programming matrix of the iterative
+ // optimal string alignment calculation.
+ v0 = [],
+ v1 = [],
+ v2 = [],
+ temp;
+
+ if (this.fuzz < 1.0) {
+ targetDistance = targetStr.length * (1.0 - this.fuzz);
+ }
+ else {
+ targetDistance = this.fuzz;
+ }
+
+ targetStr = targetStr.toLowerCase();
+
+ for (i = 0; i <= targetStr.length; i += 1) {
+ v1.push(i);
+ }
+
+ for (i = 0; i < this.term.length; i += 1) {
+ v2[0] = i;
+ for (j = 0; j < targetStr.length; j += 1) {
+ const cost = this.term[i] === targetStr[j] ? 0 : 1;
+ v2[j + 1] = Math.min(
+ // Deletion.
+ v1[j + 1] + 1,
+ // Insertion.
+ v2[j] + 1,
+ // Substitution or No Change.
+ v1[j] + cost
+ );
+ if (i > 1 && j > 1 && this.term[i] === targetStr[j - 1] &&
+ targetStr[i - 1] === targetStr[j]) {
+ v2[j + 1] = Math.min(v2[j], v0[j - 1] + cost);
+ }
+ }
+ // Rotate dem vec pointers bra.
+ temp = v0;
+ v0 = v1;
+ v1 = v2;
+ v2 = temp;
+ }
+
+ return v1[targetStr.length] <= targetDistance;
+};
+
+SearchTerm.prototype.exactMatch = function(targetStr) {
+ return this.term.toLowerCase() === targetStr.toLowerCase();
+};
+
+SearchTerm.prototype.wildcardMatch = function(targetStr) {
+ return this.term.test(targetStr);
+};
+
+SearchTerm.prototype.interactionMatch = function(imageID, type, interaction, interactions) {
+ let ret = false;
+
+ interactions.forEach(v => {
+ if (v.image_id == imageID && v.interaction_type == type && (interaction == null || v.value == interaction)) {
+ ret = true;
+
+ return;
+ }
+ });
+
+ return ret;
+};
+
+SearchTerm.prototype.match = function(target) {
+ let ret = false,
+ ohffs = this,
+ compFunc,
+ numbuh,
+ date;
+
+ if (!this.parsed) {
+ this.parse();
+ }
+
+ if (this.termType === 'literal') {
+ // Literal matching.
+ if (this.fuzz) {
+ compFunc = this.fuzzyMatch;
+ }
+ else if (this.wildcardable) {
+ compFunc = this.wildcardMatch;
+ }
+ else {
+ compFunc = this.exactMatch;
+ }
+
+ if (this.termSpace === 'tags') {
+ target.getAttribute('data-image-tag-aliases').split(', ').every(
+ str => {
+ if (compFunc.call(ohffs, str)) {
+ ret = true;
+ return false;
+ }
+ return true;
+ }
+ );
+ }
+ else {
+ ret = compFunc.call(
+ this, target.getAttribute(termSpaceToImageField[this.termSpace])
+ );
+ }
+ }
+ else if (this.termType === 'my' && window.booru.interactions.length > 0) {
+ // Should work with most my:conditions except watched.
+ switch (this.termSpace) {
+ case 'faves':
+ ret = this.interactionMatch(target.getAttribute('data-image-id'), 'faved', null, window.booru.interactions);
+
+ break;
+ case 'upvotes':
+ ret = this.interactionMatch(target.getAttribute('data-image-id'), 'voted', 'up', window.booru.interactions);
+
+ break;
+ case 'downvotes':
+ ret = this.interactionMatch(target.getAttribute('data-image-id'), 'voted', 'down', window.booru.interactions);
+
+ break;
+ default:
+ ret = false; // Other my: interactions aren't supported, return false to prevent them from triggering spoiler.
+
+ break;
+ }
+ }
+ else if (this.termType === 'date') {
+ // Date matching.
+ date = (new Date(
+ target.getAttribute(termSpaceToImageField[this.termSpace])
+ )).getTime();
+
+ switch (this.compare) {
+ // The open-left, closed-right date range specified by the
+ // date/time format limits the types of comparisons that are
+ // done compared to numeric ranges.
+ case 'lt':
+ ret = this.term > date;
+ break;
+ case 'gte':
+ ret = this.term <= date;
+ break;
+ default:
+ ret = this.term[0] <= date && this.term[1] > date;
+ }
+ }
+ else {
+ // Range matching.
+ numbuh = parseFloat(
+ target.getAttribute(termSpaceToImageField[this.termSpace])
+ );
+
+ if (isNaN(this.term)) {
+ ret = false;
+ }
+ else if (this.fuzz) {
+ ret = this.term <= numbuh + this.fuzz &&
+ this.term + this.fuzz >= numbuh;
+ }
+ else {
+ switch (this.compare) {
+ case 'lt':
+ ret = this.term > numbuh;
+ break;
+ case 'gt':
+ ret = this.term < numbuh;
+ break;
+ case 'lte':
+ ret = this.term >= numbuh;
+ break;
+ case 'gte':
+ ret = this.term <= numbuh;
+ break;
+ default:
+ ret = this.term === numbuh;
+ }
+ }
+ }
+
+ return ret;
+};
+
+function generateLexArray(searchStr, options) {
+ let opQueue = [],
+ searchTerm = null,
+ boost = null,
+ fuzz = null,
+ lparenCtr = 0,
+ negate = false,
+ groupNegate = false,
+ tokenStack = [],
+ boostFuzzStr = '';
+
+ while (searchStr.length > 0) {
+ tokenList.every(tokenArr => {
+ let tokenName = tokenArr[0],
+ tokenRE = tokenArr[1],
+ match = tokenRE.exec(searchStr),
+ balanced, op;
+
+ if (match) {
+ match = match[0];
+
+ if (Boolean(searchTerm) && (
+ ['and_op', 'or_op'].indexOf(tokenName) !== -1 ||
+ tokenName === 'rparen' && lparenCtr === 0)) {
+ // Set options.
+ searchTerm.boost = boost;
+ searchTerm.fuzz = fuzz;
+ // Push to stack.
+ tokenStack.push(searchTerm);
+ // Reset term and options data.
+ searchTerm = fuzz = boost = null;
+ boostFuzzStr = '';
+ lparenCtr = 0;
+
+ if (negate) {
+ tokenStack.push('not_op');
+ negate = false;
+ }
+ }
+
+ switch (tokenName) {
+ case 'and_op':
+ while (opQueue[0] === 'and_op') {
+ tokenStack.push(opQueue.shift());
+ }
+ opQueue.unshift('and_op');
+ break;
+ case 'or_op':
+ while (opQueue[0] === 'and_op' || opQueue[0] === 'or_op') {
+ tokenStack.push(opQueue.shift());
+ }
+ opQueue.unshift('or_op');
+ break;
+ case 'not_op':
+ if (searchTerm) {
+ // We're already inside a search term, so it does
+ // not apply, obv.
+ searchTerm.append(match);
+ }
+ else {
+ negate = !negate;
+ }
+ break;
+ case 'lparen':
+ if (searchTerm) {
+ // If we are inside the search term, do not error
+ // out just yet; instead, consider it as part of
+ // the search term, as a user convenience.
+ searchTerm.append(match);
+ lparenCtr += 1;
+ }
+ else {
+ opQueue.unshift('lparen');
+ groupNegate = negate;
+ negate = false;
+ }
+ break;
+ case 'rparen':
+ if (lparenCtr > 0) {
+ searchTerm.append(match);
+ lparenCtr -= 1;
+ }
+ else {
+ balanced = false;
+ while (opQueue.length) {
+ op = opQueue.shift();
+ if (op === 'lparen') {
+ balanced = true;
+ break;
+ }
+ tokenStack.push(op);
+ }
+ }
+ if (groupNegate) {
+ tokenStack.push('not_op');
+ groupNegate = false;
+ }
+ break;
+ case 'fuzz':
+ if (searchTerm) {
+ // For this and boost operations, we store the
+ // current match so far to a temporary string in
+ // case this is actually inside the term.
+ fuzz = parseFloat(match.substr(1));
+ boostFuzzStr += match;
+ }
+ else {
+ searchTerm = new SearchTerm(match, options);
+ }
+ break;
+ case 'boost':
+ if (searchTerm) {
+ boost = match.substr(1);
+ boostFuzzStr += match;
+ }
+ else {
+ searchTerm = new SearchTerm(match, options);
+ }
+ break;
+ case 'quoted_lit':
+ if (searchTerm) {
+ searchTerm.append(match);
+ }
+ else {
+ searchTerm = new SearchTerm(match, options);
+ }
+ break;
+ case 'word':
+ if (searchTerm) {
+ if (fuzz || boost) {
+ boost = fuzz = null;
+ searchTerm.append(boostFuzzStr);
+ boostFuzzStr = '';
+ }
+ searchTerm.append(match);
+ }
+ else {
+ searchTerm = new SearchTerm(match, options);
+ }
+ break;
+ default:
+ // Append extra spaces within search terms.
+ if (searchTerm) {
+ searchTerm.append(match);
+ }
+ }
+
+ // Truncate string and restart the token tests.
+ searchStr = searchStr.substr(
+ match.length, searchStr.length - match.length
+ );
+
+ // Break since we have found a match.
+ return false;
+ }
+
+ return true;
+ });
+ }
+
+ // Append final tokens to the stack, starting with the search term.
+ if (searchTerm) {
+ searchTerm.boost = boost;
+ searchTerm.fuzz = fuzz;
+ tokenStack.push(searchTerm);
+ }
+ if (negate) {
+ tokenStack.push('not_op');
+ }
+
+ if (opQueue.indexOf('rparen') !== -1 ||
+ opQueue.indexOf('lparen') !== -1) {
+ throw 'Mismatched parentheses.';
+ }
+
+ // Memory-efficient concatenation of remaining operators queue to the
+ // token stack.
+ tokenStack.push.apply(tokenStack, opQueue);
+
+ return tokenStack;
+}
+
+function parseTokens(lexicalArray) {
+ let operandStack = [],
+ negate, op1, op2, parsed;
+ lexicalArray.forEach((token, i) => {
+ if (token !== 'not_op') {
+ negate = lexicalArray[i + 1] === 'not_op';
+
+ if (typeof token === 'string') {
+ op2 = operandStack.pop();
+ op1 = operandStack.pop();
+
+ if (typeof op1 === 'undefined' || typeof op2 === 'undefined') {
+ throw 'Missing operand.';
+ }
+
+ operandStack.push(new SearchAST(token, negate, op1, op2));
+ }
+ else {
+ if (negate) {
+ operandStack.push(new SearchAST(null, true, token));
+ }
+ else {
+ operandStack.push(token);
+ }
+ }
+ }
+ });
+
+ if (operandStack.length > 1) {
+ throw 'Missing operator.';
+ }
+
+ op1 = operandStack.pop();
+
+ if (typeof op1 === 'undefined') {
+ return new SearchAST();
+ }
+
+ if (isTerminal(op1)) {
+ return new SearchAST(null, false, op1);
+ }
+
+ return op1;
+}
+
+function parseSearch(searchStr, options) {
+ return parseTokens(generateLexArray(searchStr, options));
+}
+
+function isTerminal(operand) {
+ // Whether operand is a terminal SearchTerm.
+ return typeof operand.term !== 'undefined';
+}
+
+function SearchAST(op, negate, leftOperand, rightOperand) {
+ this.negate = Boolean(negate);
+ this.leftOperand = leftOperand || null;
+ this.op = op || null;
+ this.rightOperand = rightOperand || null;
+}
+
+function combineOperands(ast1, ast2, parentAST) {
+ if (parentAST.op === 'and_op') {
+ ast1 = ast1 && ast2;
+ }
+ else {
+ ast1 = ast1 || ast2;
+ }
+
+ if (parentAST.negate) {
+ return !ast1;
+ }
+
+ return ast1;
+}
+
+// Evaluation of the AST in regard to a target image
+SearchAST.prototype.hitsImage = function(image) {
+ let treeStack = [],
+ // Left side node.
+ ast1 = this,
+ // Right side node.
+ ast2,
+ // Parent node of the current subtree.
+ parentAST;
+
+ // Build the initial tree node traversal stack, of the "far left" side.
+ // The general idea is to accumulate from the bottom and make stacks
+ // of right-hand subtrees that themselves accumulate upward. The left
+ // side node, ast1, will always be a Boolean representing the left-side
+ // evaluated value, up to the current subtree (parentAST).
+ while (!isTerminal(ast1)) {
+ treeStack.push(ast1);
+ ast1 = ast1.leftOperand;
+
+ if (!ast1) {
+ // Empty tree.
+ return false;
+ }
+ }
+
+ ast1 = ast1.match(image);
+ treeStack.push(ast1);
+
+ while (treeStack.length > 0) {
+ parentAST = treeStack.pop();
+
+ if (parentAST === null) {
+ // We are at the end of a virtual stack for a right node
+ // subtree. We switch the result of this stack from left
+ // (ast1) to right (ast2), pop the original left node,
+ // and finally pop the parent subtree itself. See near the
+ // end of this function to view how this is populated.
+ ast2 = ast1;
+ ast1 = treeStack.pop();
+ parentAST = treeStack.pop();
+ }
+ else {
+ // First, check to see if we can do a short-circuit
+ // evaluation to skip evaluating the right side entirely.
+ if (!ast1 && parentAST.op === 'and_op') {
+ ast1 = parentAST.negate;
+ continue;
+ }
+
+ if (ast1 && parentAST.op === 'or_op') {
+ ast1 = !parentAST.negate;
+ continue;
+ }
+
+ // If we are not at the end of a stack, grab the right
+ // node. The left node (ast1) is currently a terminal Boolean.
+ ast2 = parentAST.rightOperand;
+ }
+
+ if (typeof ast2 === 'boolean') {
+ ast1 = combineOperands(ast1, ast2, parentAST);
+ }
+ else if (!ast2) {
+ // A subtree with a single node. This is generally the case
+ // for negated tokens.
+ if (parentAST.negate) {
+ ast1 = !ast1;
+ }
+ }
+ else if (isTerminal(ast2)) {
+ // We are finally at a leaf and can evaluate.
+ ast2 = ast2.match(image);
+ ast1 = combineOperands(ast1, ast2, parentAST);
+ }
+ else {
+ // We are at a node whose right side is a new subtree.
+ // We will build a new "virtual" stack, but instead of
+ // building a new Array, we can insert a null object as a
+ // marker.
+ treeStack.push(parentAST, ast1, null);
+
+ do {
+ treeStack.push(ast2);
+ ast2 = ast2.leftOperand;
+ } while (!isTerminal(ast2));
+
+ ast1 = ast2.match(image);
+ }
+ }
+
+ return ast1;
+};
+
+SearchAST.prototype.dumpTree = function() {
+ // Dumps to string a simple diagram of the syntax tree structure
+ // (starting with this object as the root) for debugging purposes.
+ let retStrArr = [],
+ treeQueue = [['', this]],
+ treeArr,
+ prefix,
+ tree;
+
+ while (treeQueue.length > 0) {
+ treeArr = treeQueue.shift();
+ prefix = treeArr[0];
+ tree = treeArr[1];
+
+ if (isTerminal(tree)) {
+ retStrArr.push(`${prefix}-> ${tree.term}`);
+ }
+ else {
+ if (tree.negate) {
+ retStrArr.push(`${prefix}+ NOT_OP`);
+ prefix += '\t';
+ }
+ if (tree.op) {
+ retStrArr.push(`${prefix}+ ${tree.op.toUpperCase()}`);
+ prefix += '\t';
+ treeQueue.unshift([prefix, tree.rightOperand]);
+ treeQueue.unshift([prefix, tree.leftOperand]);
+ }
+ else {
+ treeQueue.unshift([prefix, tree.leftOperand]);
+ }
+ }
+ }
+
+ return retStrArr.join('\n');
+};
+
+export default parseSearch;
diff --git a/assets/js/misc.js b/assets/js/misc.js
new file mode 100644
index 00000000..39109504
--- /dev/null
+++ b/assets/js/misc.js
@@ -0,0 +1,84 @@
+/**
+ * Misc
+ */
+
+import store from './utils/store';
+import { $ } from './utils/dom';
+
+let touchMoved = false;
+
+function formResult({target, detail}) {
+
+ const elements = {
+ '#description-form': '.image-description',
+ '#uploader-form': '.image_uploader',
+ '#source-form': '#image-source'
+ };
+
+ function showResult(resultEl, formEl, response) {
+ resultEl.innerHTML = response;
+ resultEl.classList.remove('hidden');
+ formEl.classList.add('hidden');
+ formEl.querySelector('input[type="submit"]').disabled = false;
+ }
+
+ for (const element in elements) {
+ if (target.matches(element)) detail.text().then(text => showResult($(elements[element]), target, text));
+ }
+
+}
+
+function revealSpoiler(event) {
+
+ const { target } = event;
+ const spoiler = target.closest('.spoiler');
+ let imgspoiler = target.closest('.spoiler .imgspoiler, .spoiler-revealed .imgspoiler');
+ const showContainer = target.closest('.image-show-container');
+
+ // Prevent reveal if touchend came after touchmove event
+ if (touchMoved) {
+ touchMoved = false;
+ return;
+ }
+
+ if (spoiler) {
+ if (showContainer) {
+ const imageShow = showContainer.querySelector('.image-show');
+ if (!imageShow.classList.contains('hidden') && imageShow.classList.contains('spoiler-pending')) {
+ imageShow.classList.remove('spoiler-pending');
+ return;
+ }
+ }
+
+ spoiler.classList.remove('spoiler');
+ spoiler.classList.add('spoiler-revealed');
+ // Prevent click-through to links on mobile platforms
+ if (event.type === 'touchend') event.preventDefault();
+
+ if (!imgspoiler) {
+ imgspoiler = spoiler.querySelector('.imgspoiler');
+ }
+ }
+
+ if (imgspoiler) {
+ imgspoiler.classList.remove('imgspoiler');
+ imgspoiler.classList.add('imgspoiler-revealed');
+ if (event.type === 'touchend' && !event.defaultPrevented) {
+ event.preventDefault();
+ }
+ }
+
+}
+
+function setupEvents() {
+ const extrameta = $('#extrameta');
+
+ if (store.get('hide_uploader') && extrameta) extrameta.classList.add('hidden');
+
+ document.addEventListener('fetchcomplete', formResult);
+ document.addEventListener('click', revealSpoiler);
+ document.addEventListener('touchend', revealSpoiler);
+ document.addEventListener('touchmove', () => touchMoved = true);
+}
+
+export { setupEvents };
diff --git a/assets/js/notifications.js b/assets/js/notifications.js
new file mode 100644
index 00000000..7105b9ca
--- /dev/null
+++ b/assets/js/notifications.js
@@ -0,0 +1,77 @@
+/**
+ * Notifications
+ */
+
+import { fetchJson, handleError } from './utils/requests';
+import { $, $$, hideEl, toggleEl } from './utils/dom';
+import store from './utils/store';
+
+const NOTIFICATION_INTERVAL = 600000,
+ NOTIFICATION_EXPIRES = 300000;
+
+function makeRequest(verb, action, body) {
+ return fetchJson(verb, `${window.booru.apiEndpoint}notifications/${action}.json`, body).then(handleError);
+}
+
+
+function toggleSubscription(data) {
+ const { subscriptionId, subscriptionType } = data.el.dataset;
+ const subscriptionElements = $$(`.js-notification-${subscriptionType + subscriptionId}`);
+
+ makeRequest('PUT', data.value, { id: subscriptionId, actor_class: subscriptionType }) // eslint-disable-line camelcase
+ .then(() => toggleEl(subscriptionElements))
+ .catch(() => data.el.textContent = 'Error!');
+}
+
+
+function markRead(data) {
+ const notificationId = data.value;
+ const notification = $(`.js-notification-id-${notificationId}`);
+
+ makeRequest('PUT', 'mark_read', { id: notificationId })
+ .then(() => hideEl(notification))
+ .catch(() => data.el.textContent = 'Error!');
+}
+
+
+function getNewNotifications() {
+ if (document.hidden || !store.hasExpired('notificationCount')) {
+ return;
+ }
+
+ makeRequest('GET', 'unread').then(response => response.json()).then(({ data }) => {
+ updateNotificationTicker(data.length);
+ storeNotificationCount(data.length);
+ });
+}
+
+
+function updateNotificationTicker(notificationCount) {
+ const ticker = $('.js-notification-ticker');
+ const parsedNotificationCount = Number(notificationCount);
+
+ ticker.dataset.notificationCount = parsedNotificationCount;
+ ticker.textContent = parsedNotificationCount;
+}
+
+
+function storeNotificationCount(notificationCount) {
+ // The current number of notifications are stored along with the time when the data expires
+ store.setWithExpireTime('notificationCount', notificationCount, NOTIFICATION_EXPIRES);
+}
+
+
+function setupNotifications() {
+ if (!window.booru.userIsSignedIn) return;
+
+ // Fetch notifications from the server at a regular interval
+ setInterval(getNewNotifications, NOTIFICATION_INTERVAL);
+
+ // Update the current number of notifications based on the latest page load
+ storeNotificationCount($('.js-notification-ticker').dataset.notificationCount);
+
+ // Update ticker when the stored value changes - this will occur in all open tabs
+ store.watch('notificationCount', updateNotificationTicker);
+}
+
+export { setupNotifications, toggleSubscription, markRead };
diff --git a/assets/js/poll.js b/assets/js/poll.js
new file mode 100644
index 00000000..6786412a
--- /dev/null
+++ b/assets/js/poll.js
@@ -0,0 +1,36 @@
+import { $, $$, clearEl, removeEl, insertBefore } from './utils/dom';
+
+function pollOptionCreator() {
+ const addPollOptionButton = $('.js-poll-add-option');
+
+ if (!addPollOptionButton) {
+ return;
+ }
+
+ const form = addPollOptionButton.closest('form');
+ const maxOptionCount = parseInt($('.js-max-option-count', form).innerHTML, 10);
+ addPollOptionButton.addEventListener('click', e => {
+ e.preventDefault();
+
+ let existingOptionCount = $$('.js-poll-option', form).length;
+ if (existingOptionCount < maxOptionCount) {
+ // The element right before the add button will always be the last field, make a copy
+ const prevFieldCopy = addPollOptionButton.previousElementSibling.cloneNode(true);
+ // Clear its value and increment the N in "Option N" in the placeholder attribute
+ clearEl($$('.js-option-id', prevFieldCopy));
+ const input = $('.js-option-label', prevFieldCopy);
+ input.value = '';
+ input.setAttribute('placeholder', input.getAttribute('placeholder').replace(/\d+$/, m => parseInt(m, 10) + 1));
+ // Insert copy before the button
+ insertBefore(addPollOptionButton, prevFieldCopy);
+ existingOptionCount++;
+ }
+
+ // Remove the button if we reached the max number of options
+ if (existingOptionCount >= maxOptionCount) {
+ removeEl(addPollOptionButton);
+ }
+ });
+}
+
+export { pollOptionCreator };
diff --git a/assets/js/preview.js b/assets/js/preview.js
new file mode 100644
index 00000000..c071591f
--- /dev/null
+++ b/assets/js/preview.js
@@ -0,0 +1,88 @@
+/**
+ * Textile previews (posts, comments, messages)
+ */
+
+import { fetchJson } from './utils/requests';
+import { filterNode } from './imagesclientside';
+
+function handleError(response) {
+ const errorMessage = 'Preview failed to load!
';
+
+ if (!response.ok) {
+ return errorMessage;
+ }
+
+ return response.text();
+}
+
+function commentReply(user, url, textarea, quote) {
+ const text = `"@${user}":${url}`;
+ let newval = textarea.value;
+
+ if (newval && /\n$/.test(newval)) newval += '\n';
+ newval += `${text}\n`;
+
+ if (quote) {
+ newval += `[bq="${user.replace('"', '\'')}"] ${quote} [/bq]\n`;
+ }
+
+ textarea.value = newval;
+ textarea.selectionStart = textarea.selectionEnd = newval.length;
+
+ const writeTabToggle = document.querySelector('a[data-click-tab="write"]:not(.selected)');
+ if (writeTabToggle) writeTabToggle.click();
+
+ textarea.focus();
+}
+
+function getPreview(body, anonymous, previewTab, isImage = false) {
+ let path = '/posts/preview';
+
+ if (isImage) path = '/images/preview';
+
+ fetchJson('POST', path, { body, anonymous })
+ .then(handleError)
+ .then(data => {
+ previewTab.innerHTML = data;
+ filterNode(previewTab);
+ });
+}
+
+function setupPreviews() {
+ let textarea = document.querySelector('.js-preview-input');
+ let imageDesc = false;
+
+ if (!textarea) {
+ textarea = document.querySelector('.js-preview-description');
+ imageDesc = true;
+ }
+
+ const previewButton = document.querySelector('a[data-click-tab="preview"]');
+ const previewTab = document.querySelector('.block__tab[data-tab="preview"]');
+ const previewAnon = document.querySelector('.preview-anonymous') || false;
+
+ if (!textarea || !previewButton) {
+ return;
+ }
+
+ previewButton.addEventListener('click', () => {
+ if (previewTab.previewedText === textarea.value) return;
+ previewTab.previewedText = textarea.value;
+
+ getPreview(textarea.value, Boolean(previewAnon.checked), previewTab, imageDesc);
+ });
+
+ previewAnon && previewAnon.addEventListener('click', () => {
+ getPreview(textarea.value, Boolean(previewAnon.checked), previewTab, imageDesc);
+ });
+
+ document.addEventListener('click', event => {
+ if (event.target && event.target.closest('.post-reply')) {
+ const link = event.target.closest('.post-reply');
+ commentReply(link.dataset.author, link.getAttribute('href'), textarea, link.dataset.post);
+ event.preventDefault();
+ }
+ });
+}
+
+export { setupPreviews };
diff --git a/assets/js/quick-tag.js b/assets/js/quick-tag.js
new file mode 100644
index 00000000..56fc1540
--- /dev/null
+++ b/assets/js/quick-tag.js
@@ -0,0 +1,113 @@
+/**
+ * Quick Tag
+ */
+
+import store from './utils/store';
+import { $, $$, toggleEl, onLeftClick } from './utils/dom';
+import { fetchJson, handleError } from './utils/requests';
+
+const imageQueueStorage = 'quickTagQueue';
+const currentTagStorage = 'quickTagName';
+
+function currentQueue() { return store.get(imageQueueStorage) || []; }
+
+function currentTags() { return store.get(currentTagStorage) || ''; }
+
+function getTagButton() { return $('.js-quick-tag'); }
+
+function setTagButton(text) { $('.js-quick-tag--submit span').textContent = text; }
+
+function toggleActiveState() {
+
+ toggleEl($('.js-quick-tag'),
+ $('.js-quick-tag--abort'),
+ $('.js-quick-tag--submit'));
+
+ setTagButton(`Submit (${currentTags()})`);
+
+ $$('.media-box__header').forEach(el => el.classList.toggle('media-box__header--unselected'));
+ $$('.media-box__header').forEach(el => el.classList.remove('media-box__header--selected'));
+ currentQueue().forEach(id => $$(`.media-box__header[data-image-id="${id}"]`).forEach(el => el.classList.add('media-box__header--selected')));
+
+}
+
+function activate() {
+
+ store.set(currentTagStorage, window.prompt('A comma-delimited list of tags you want to add:'));
+
+ if (currentTags()) toggleActiveState();
+
+}
+
+function reset() {
+
+ store.remove(currentTagStorage);
+ store.remove(imageQueueStorage);
+
+ toggleActiveState();
+
+}
+
+function submit() {
+
+ setTagButton(`Wait... (${currentTags()})`);
+
+ fetchJson('PUT', '/admin/batch/tags', {
+ tags: currentTags(),
+ image_ids: currentQueue(),
+ })
+ .then(handleError)
+ .then(r => r.json())
+ .then(data => {
+
+ if (data.failed.length) window.alert(`Failed to add tags to the images with these IDs: ${data.failed}`);
+
+ reset();
+
+ });
+
+}
+
+function modifyImageQueue(mediaBox) {
+
+ if (currentTags()) {
+ const imageId = mediaBox.dataset.imageId,
+ queue = currentQueue(),
+ isSelected = queue.includes(imageId);
+
+ isSelected ? queue.splice(queue.indexOf(imageId), 1)
+ : queue.push(imageId);
+
+ $$(`.media-box__header[data-image-id="${imageId}"]`).forEach(el => el.classList.toggle('media-box__header--selected'));
+
+ store.set(imageQueueStorage, queue);
+ }
+
+}
+
+function clickHandler(event) {
+
+ const targets = {
+ '.js-quick-tag': activate,
+ '.js-quick-tag--abort': reset,
+ '.js-quick-tag--submit': submit,
+ '.media-box': modifyImageQueue,
+ };
+
+ for (const target in targets) {
+ if (event.target && event.target.closest(target)) {
+ targets[target](event.target.closest(target));
+ currentTags() && event.preventDefault();
+ }
+ }
+
+}
+
+function setupQuickTag() {
+
+ if (getTagButton() && currentTags()) toggleActiveState();
+ if (getTagButton()) onLeftClick(clickHandler);
+
+}
+
+export { setupQuickTag };
diff --git a/assets/js/resizablemedia.js b/assets/js/resizablemedia.js
new file mode 100644
index 00000000..4031cb75
--- /dev/null
+++ b/assets/js/resizablemedia.js
@@ -0,0 +1,71 @@
+let mediaContainers;
+
+/* Hardcoded dimensions of thumb boxes; at mediaLargeMinSize, large box becomes a small one (font size gets diminished).
+ * At minimum width, the large box still has four digit fave/score numbers and five digit comment number fitting in a single row
+ * (small box may lose the number of comments in a hidden overflow) */
+const mediaLargeMaxSize = 250, mediaLargeMinSize = 190, mediaSmallMaxSize = 156, mediaSmallMinSize = 140;
+/* Margin between thumbs (6) + borders (2) + 1 extra px to correct rounding errors */
+const mediaBoxOffset = 9;
+
+export function processResizableMedia() {
+ [].slice.call(mediaContainers).forEach(container => {
+ const containerHasLargeBoxes = container.querySelector('.media-box__content--large') !== null,
+ containerWidth = container.offsetWidth - 14; /* subtract container padding */
+
+ /* If at least three large boxes fit in a single row, we do not downsize them to small ones.
+ * This ensures that desktop users get less boxes in a row, but with bigger images inside. */
+ const largeBoxesFitting = Math.floor(containerWidth / (mediaLargeMinSize + mediaBoxOffset));
+ if (largeBoxesFitting >= 3) {
+ /* At the same time, we don't want small boxes to be upscaled. */
+ if (containerHasLargeBoxes) {
+ /* Larger boxes are preferred to more items in a row */
+ setMediaSize(container, containerWidth, mediaLargeMinSize, mediaLargeMaxSize);
+ }
+ }
+ /* Mobile users, on the other hand, should get as many boxes in a row as possible */
+ else {
+ setMediaSize(container, containerWidth, mediaSmallMinSize, mediaSmallMaxSize);
+ }
+ });
+}
+
+function applyMediaSize(container, size) {
+ const mediaItems = container.querySelectorAll('.media-box__content');
+
+ [].slice.call(mediaItems).forEach(item => {
+ item.style.width = `${size}px`;
+ item.style.height = `${size}px`;
+
+ const header = item.parentNode.childNodes[0];
+ // TODO: Make this proper and/or rethink this entire croc of bullshit
+ item.parentNode.style.width = `${size}px`;
+ /* When the large box has width less than mediaLargeMinSize, the header gets wrapped and occupies more than one line.
+ * To prevent that, we add a class that diminishes its padding and font size. */
+ if (size < mediaLargeMinSize) {
+ header.classList.add('media-box__header--small');
+ }
+ else {
+ header.classList.remove('media-box__header--small');
+ }
+ });
+}
+
+function setMediaSize(container, containerWidth, minMediaSize, maxMediaSize) {
+ const maxThumbsFitting = Math.floor(containerWidth / (minMediaSize + mediaBoxOffset)),
+ minThumbsFitting = Math.floor(containerWidth / (maxMediaSize + mediaBoxOffset)),
+ fitThumbs = Math.round((maxThumbsFitting + minThumbsFitting) / 2),
+ thumbSize = Math.max(Math.floor(containerWidth / fitThumbs) - 9, minMediaSize);
+
+ applyMediaSize(container, thumbSize);
+}
+
+function initializeListener() {
+ mediaContainers = document.querySelectorAll('.js-resizable-media-container');
+
+ if (mediaContainers.length > 0) {
+ window.addEventListener('resize', processResizableMedia);
+ processResizableMedia();
+ }
+}
+
+export { initializeListener };
diff --git a/assets/js/search.js b/assets/js/search.js
new file mode 100644
index 00000000..864ea231
--- /dev/null
+++ b/assets/js/search.js
@@ -0,0 +1,45 @@
+import { $, $$ } from './utils/dom';
+import { addTag } from './tagsinput';
+
+function showHelp(subject, type) {
+ $$('[data-search-help]').forEach(helpBox => {
+ if (helpBox.getAttribute('data-search-help') === type) {
+ $('.js-search-help-subject', helpBox).textContent = subject;
+ helpBox.classList.remove('hidden');
+ }
+ else {
+ helpBox.classList.add('hidden');
+ }
+ });
+}
+
+function prependToLast(field, value) {
+ const separatorIndex = field.value.lastIndexOf(',');
+ const advanceBy = field.value[separatorIndex + 1] === ' ' ? 2 : 1;
+ field.value = field.value.slice(0, separatorIndex + advanceBy) + value + field.value.slice(separatorIndex + advanceBy);
+}
+
+function selectLast(field, characterCount) {
+ field.focus();
+
+ field.selectionStart = field.value.length - characterCount;
+ field.selectionEnd = field.value.length;
+}
+
+function executeFormHelper(e) {
+ const searchField = $('.js-search-field');
+ const attr = name => e.target.getAttribute(name);
+
+ attr('data-search-add') && addTag(searchField, attr('data-search-add'));
+ attr('data-search-show-help') && showHelp(e.target.textContent, attr('data-search-show-help'));
+ attr('data-search-select-last') && selectLast(searchField, parseInt(attr('data-search-select-last'), 10));
+ attr('data-search-prepend') && prependToLast(searchField, attr('data-search-prepend'));
+}
+
+function setupSearch() {
+ const form = $('.js-search-form');
+
+ form && form.addEventListener('click', executeFormHelper);
+}
+
+export { setupSearch };
diff --git a/assets/js/settings.js b/assets/js/settings.js
new file mode 100644
index 00000000..99fa8719
--- /dev/null
+++ b/assets/js/settings.js
@@ -0,0 +1,29 @@
+/**
+ * Settings.
+ */
+
+import { $, $$ } from './utils/dom';
+import store from './utils/store';
+
+export function setupSettings() {
+
+ if (!$('#js-setting-table')) return;
+
+ const localCheckboxes = $$('[data-tab="local"] input[type="checkbox"]');
+ const themeSelect = $('#user_theme');
+ const styleSheet = $('head link[rel="stylesheet"]');
+
+ // Local settings
+ localCheckboxes.forEach(checkbox => {
+ checkbox.checked = Boolean(store.get(checkbox.id));
+ checkbox.addEventListener('change', () => {
+ store.set(checkbox.id, checkbox.checked);
+ });
+ });
+
+ // Theme preview
+ themeSelect && themeSelect.addEventListener('change', () => {
+ styleSheet.href = themeSelect.options[themeSelect.selectedIndex].dataset.themePath;
+ });
+
+}
diff --git a/assets/js/shortcuts.js b/assets/js/shortcuts.js
new file mode 100644
index 00000000..4edb6e2c
--- /dev/null
+++ b/assets/js/shortcuts.js
@@ -0,0 +1,44 @@
+/**
+ * Keyboard shortcuts
+ */
+
+function getHover() {
+ const thumbBoxHover = document.querySelector('.media-box:hover');
+ if (thumbBoxHover) return thumbBoxHover.dataset.imageId;
+}
+
+function click(selector) {
+ const el = document.querySelector(selector);
+ if (el) el.click();
+}
+
+function isOK(event) {
+ return !event.altKey && !event.ctrlKey && !event.metaKey &&
+ document.activeElement.tagName !== 'INPUT' &&
+ document.activeElement.tagName !== 'TEXTAREA';
+}
+
+const keyCodes = {
+ 74() { click('.js-prev'); }, // J - go to previous image
+ 73() { click('.js-up'); }, // I - go to index page
+ 75() { click('.js-next'); }, // K - go to next image
+ 82() { click('.js-rand'); }, // R - go to random image
+ 83() { click('.js-source-link'); }, // S - go to image source
+ 76() { click('.js-tag-sauce-toggle'); }, // L - edit tags
+ 70() { // F - favourite image
+ getHover() ? click(`a.interaction--fave[data-image-id="${getHover()}"]`)
+ : click('.block__header a.interaction--fave');
+ },
+ 85() { // U - upvote image
+ getHover() ? click(`a.interaction--upvote[data-image-id="${getHover()}"]`)
+ : click('.block__header a.interaction--upvote');
+ },
+};
+
+function listenForKeys() {
+ document.addEventListener('keydown', event => {
+ if (isOK(event) && keyCodes[event.keyCode]) { keyCodes[event.keyCode](); event.preventDefault(); }
+ });
+}
+
+export { listenForKeys };
diff --git a/assets/js/staffhider.js b/assets/js/staffhider.js
new file mode 100644
index 00000000..4f3d2339
--- /dev/null
+++ b/assets/js/staffhider.js
@@ -0,0 +1,17 @@
+/**
+ * StaffHider
+ *
+ * Hide staff elements if enabled in the settings.
+ */
+
+import { $$ } from './utils/dom';
+
+function hideStaffTools() {
+ if (window.booru.hideStaffTools) {
+ $$('.js-staff-action').forEach(el => {
+ el.classList.add('hidden');
+ });
+ }
+}
+
+export { hideStaffTools };
diff --git a/assets/js/tags.js b/assets/js/tags.js
new file mode 100644
index 00000000..1a97c196
--- /dev/null
+++ b/assets/js/tags.js
@@ -0,0 +1,61 @@
+/**
+ * Tags Dropdown
+ */
+
+import { showEl, hideEl } from './utils/dom';
+
+function addTag(tagId, list) {
+ list.push(tagId);
+}
+
+function removeTag(tagId, list) {
+ list.splice(list.indexOf(tagId), 1);
+}
+
+function createTagDropdown(tag) {
+ const { userIsSignedIn, userCanEditFilter, watchedTagList, spoileredTagList, hiddenTagList } = window.booru;
+ const [ unwatch, watch, unspoiler, spoiler, unhide, hide, signIn, filter ] = [].slice.call(tag.querySelectorAll('.tag__dropdown__link'));
+ const [ unwatched, watched, spoilered, hidden ] = [].slice.call(tag.querySelectorAll('.tag__state'));
+ const tagId = parseInt(tag.dataset.tagId, 10);
+
+ const actions = {
+ unwatch() { hideEl(unwatch, watched); showEl(watch, unwatched); removeTag(tagId, watchedTagList); },
+ watch() { hideEl(watch, unwatched); showEl(unwatch, watched); addTag(tagId, watchedTagList); },
+
+ unspoiler() { hideEl(unspoiler, spoilered); showEl(spoiler); removeTag(tagId, spoileredTagList); },
+ spoiler() { hideEl(spoiler); showEl(unspoiler, spoilered); addTag(tagId, spoileredTagList); },
+
+ unhide() { hideEl(unhide, hidden); showEl(hide); removeTag(tagId, hiddenTagList); },
+ hide() { hideEl(hide); showEl(unhide, hidden); addTag(tagId, hiddenTagList); },
+ };
+
+ const tagIsWatched = watchedTagList.includes(tagId);
+ const tagIsSpoilered = spoileredTagList.includes(tagId);
+ const tagIsHidden = hiddenTagList.includes(tagId);
+
+ const watchedLink = tagIsWatched ? unwatch : watch;
+ const spoilerLink = tagIsSpoilered ? unspoiler : spoiler;
+ const hiddenLink = tagIsHidden ? unhide : hide;
+
+ // State symbols (-, S, H, +)
+ if (tagIsWatched) showEl(watched);
+ if (tagIsSpoilered) showEl(spoilered);
+ if (tagIsHidden) showEl(hidden);
+ if (!tagIsWatched) showEl(unwatched);
+
+ // Dropdown links
+ if (userIsSignedIn) showEl(watchedLink);
+ if (userCanEditFilter) showEl(spoilerLink);
+ if (userCanEditFilter) showEl(hiddenLink);
+ if (!userIsSignedIn) showEl(signIn);
+ if (userIsSignedIn &&
+ !userCanEditFilter) showEl(filter);
+
+ tag.addEventListener('fetchcomplete', event => actions[event.target.dataset.tagAction]());
+}
+
+function initTagDropdown() {
+ [].forEach.call(document.querySelectorAll('.tag.dropdown'), createTagDropdown);
+}
+
+export { initTagDropdown };
diff --git a/assets/js/tagsinput.js b/assets/js/tagsinput.js
new file mode 100644
index 00000000..019433ae
--- /dev/null
+++ b/assets/js/tagsinput.js
@@ -0,0 +1,141 @@
+/**
+ * Fancy tag editor.
+ */
+
+import { $, $$, clearEl, removeEl, showEl, hideEl, escapeHtml } from './utils/dom';
+
+function setupTagsInput(tagBlock) {
+ const [ textarea, container ] = $$('.js-taginput', tagBlock);
+ const setup = $('.js-tag-block ~ button', tagBlock.parentNode);
+ const inputField = $('input', container);
+
+ let tags = [];
+
+ // Load in the current tag set from the textarea
+ setup.addEventListener('click', importTags);
+
+ // Respond to tags being added
+ textarea.addEventListener('addtag', handleAddTag);
+
+ // Respond to reload event
+ textarea.addEventListener('reload', importTags);
+
+ // Respond to [x] clicks in the tag editor
+ tagBlock.addEventListener('click', handleTagClear);
+
+ // Respond to key sequences in the input field
+ inputField.addEventListener('keydown', handleKeyEvent);
+
+ // Respond to autocomplete form clicks
+ inputField.addEventListener('autocomplete', handleAutocomplete);
+
+ // TODO: Cleanup this bug fix
+ // Switch to fancy tagging if user settings want it
+ if (fancyEditorRequested(tagBlock)) {
+ showEl($$('.js-taginput-fancy'));
+ showEl($$('.js-taginput-hide'));
+ hideEl($$('.js-taginput-plain'));
+ hideEl($$('.js-taginput-show'));
+ importTags();
+ }
+
+
+ function handleAutocomplete(event) {
+ insertTag(event.detail.value);
+ inputField.focus();
+ }
+
+ function handleAddTag(event) {
+ // Ignore if not in tag edit mode
+ if (container.classList.contains('hidden')) return;
+
+ insertTag(event.detail.name);
+ event.stopPropagation();
+ }
+
+ function handleTagClear(event) {
+ if (event.target.dataset.tagName) {
+ event.preventDefault();
+ removeTag(event.target.dataset.tagName, event.target.parentNode);
+ }
+ }
+
+ function handleKeyEvent(event) {
+ const { keyCode, ctrlKey } = event;
+
+ // allow form submission with ctrl+enter if no text was typed
+ if (keyCode === 13 && ctrlKey && inputField.value === '') {
+ return;
+ }
+
+ // backspace on a blank input field
+ if (keyCode === 8 && inputField.value === '') {
+ event.preventDefault();
+ const erased = $('.tag:last-of-type', container);
+
+ if (erased) removeTag(tags[tags.length - 1], erased);
+ }
+
+ // enter or comma
+ if (keyCode === 13 || keyCode === 188) {
+ event.preventDefault();
+ inputField.value.split(',').forEach(t => insertTag(t));
+ inputField.value = '';
+ }
+
+ }
+
+ function insertTag(name) {
+ name = name.trim(); // eslint-disable-line no-param-reassign
+
+ // Add if not degenerate or already present
+ if (name.length === 0 || tags.indexOf(name) !== -1) return;
+ tags.push(name);
+ textarea.value = tags.join(', ');
+
+ // Insert the new element
+ const el = `${escapeHtml(name)} x`;
+ inputField.insertAdjacentHTML('beforebegin', el);
+ inputField.value = '';
+ }
+
+ function removeTag(name, element) {
+ removeEl(element);
+
+ // Remove the tag from the list
+ tags.splice(tags.indexOf(name), 1);
+ textarea.value = tags.join(', ');
+ }
+
+ function importTags() {
+ clearEl(container);
+ container.appendChild(inputField);
+
+ tags = [];
+ textarea.value.split(',').forEach(t => insertTag(t));
+ textarea.value = tags.join(', ');
+ }
+}
+
+function fancyEditorRequested(tagBlock) {
+ // Check whether the user made the fancy editor the default for each type of tag block.
+ return window.booru.fancyTagUpload && tagBlock.classList.contains('fancy-tag-upload') ||
+ window.booru.fancyTagEdit && tagBlock.classList.contains('fancy-tag-edit');
+}
+
+function setupTagListener() {
+ document.addEventListener('addtag', event => {
+ if (event.target.value) event.target.value += ', ';
+ event.target.value += event.detail.name;
+ });
+}
+
+function addTag(textarea, name) {
+ textarea.dispatchEvent(new CustomEvent('addtag', { detail: { name }, bubbles: true }));
+}
+
+function reloadTagsInput(textarea) {
+ textarea.dispatchEvent(new CustomEvent('reload'));
+}
+
+export { setupTagsInput, setupTagListener, addTag, reloadTagsInput };
diff --git a/assets/js/tagsmisc.js b/assets/js/tagsmisc.js
new file mode 100644
index 00000000..78a7c1e9
--- /dev/null
+++ b/assets/js/tagsmisc.js
@@ -0,0 +1,56 @@
+/**
+ * Tags Misc
+ */
+
+import { $$ } from './utils/dom';
+import store from './utils/store';
+import { initTagDropdown} from './tags';
+import { setupTagsInput, reloadTagsInput } from './tagsinput';
+
+function tagInputButtons({target}) {
+ const actions = {
+ save(tagInput) {
+ store.set('tag_input', tagInput.value);
+ },
+ load(tagInput) {
+ // If entry 'tag_input' does not exist, try to use the current list
+ tagInput.value = store.get('tag_input') || tagInput.value;
+ reloadTagsInput(tagInput);
+ },
+ clear(tagInput) {
+ tagInput.value = '';
+ reloadTagsInput(tagInput);
+ },
+ };
+
+ for (const action in actions) {
+ if (target.matches(`#tagsinput-${action}`)) actions[action](document.getElementById('image_tag_input'));
+ }
+}
+
+function setupTags() {
+ $$('.js-tag-block').forEach(el => {
+ setupTagsInput(el);
+ el.classList.remove('js-tag-block');
+ });
+}
+
+function updateTagSauce({target, detail}) {
+ const tagSauce = document.querySelector('.js-tagsauce');
+
+ if (target.matches('#tags-form')) {
+ detail.text().then(text => {
+ tagSauce.outerHTML = text;
+ setupTags();
+ initTagDropdown();
+ });
+ }
+}
+
+function setupTagEvents() {
+ setupTags();
+ document.addEventListener('fetchcomplete', updateTagSauce);
+ document.addEventListener('click', tagInputButtons);
+}
+
+export { setupTagEvents };
diff --git a/assets/js/textiletoolbar.js b/assets/js/textiletoolbar.js
new file mode 100644
index 00000000..ebd97085
--- /dev/null
+++ b/assets/js/textiletoolbar.js
@@ -0,0 +1,172 @@
+/**
+ * Textile toolbar
+ *
+ */
+
+import { $, $$ } from './utils/dom';
+
+const textileSyntax = {
+ bold: {
+ action: wrapSelection,
+ options: { prefix: '*', suffix: '*', shortcutKey: 'b' }
+ },
+ italics: {
+ action: wrapSelection,
+ options: { prefix: '_', suffix: '_', shortcutKey: 'i' }
+ },
+ under: {
+ action: wrapSelection,
+ options: { prefix: '+', suffix: '+', shortcutKey: 'u' }
+ },
+ spoiler: {
+ action: wrapSelection,
+ options: { prefix: '[spoiler]', suffix: '[/spoiler]', shortcutKey: 's' }
+ },
+ code: {
+ action: wrapSelection,
+ options: { prefix: '@', suffix: '@', shortcutKey: 'e' }
+ },
+ strike: {
+ action: wrapSelection,
+ options: { prefix: '-', suffix: '-' }
+ },
+ superscript: {
+ action: wrapSelection,
+ options: { prefix: '^', suffix: '^' }
+ },
+ subscript: {
+ action: wrapSelection,
+ options: { prefix: '~', suffix: '~' }
+ },
+ quote: {
+ action: wrapSelection,
+ options: { prefix: '[bq]', suffix: '[/bq]' }
+ },
+ link: {
+ action: insertLink,
+ options: { prefix: '"', suffix: '":', shortcutKey: 'l' }
+ },
+ image: {
+ action: insertImage,
+ options: { prefix: '!', suffix: '!', shortcutKey: 'k' }
+ },
+ noParse: {
+ action: wrapSelection,
+ options: { prefix: '[==', suffix: '==]' }
+ },
+};
+
+function getSelections(textarea) {
+ let selection = textarea.value.substring(textarea.selectionStart, textarea.selectionEnd),
+ leadingSpace = '',
+ trailingSpace = '',
+ caret;
+
+ // Deselect trailing space and line break
+ for (caret = selection.length - 1; caret > 0; caret--) {
+ if (selection[caret] !== ' ' && selection[caret] !== '\n') break;
+ trailingSpace = selection[caret] + trailingSpace;
+ }
+ selection = selection.substring(0, caret + 1);
+
+ // Deselect leading space and line break
+ for (caret = 0; caret < selection.length; caret++) {
+ if (selection[caret] !== ' ' && selection[caret] !== '\n') break;
+ leadingSpace += selection[caret];
+ }
+ selection = selection.substring(caret);
+
+ return {
+ selectedText: selection,
+ beforeSelection: textarea.value.substring(0, textarea.selectionStart) + leadingSpace,
+ afterSelection: trailingSpace + textarea.value.substring(textarea.selectionEnd),
+ };
+}
+
+function wrapSelection(textarea, options) {
+ const { selectedText, beforeSelection, afterSelection } = getSelections(textarea),
+ { text = selectedText, prefix = '', suffix = '' } = options,
+ // For long comments, record scrollbar position to restore it later
+ scrollTop = textarea.scrollTop,
+ emptyText = text === '';
+
+ let newText = text;
+ if (!emptyText && prefix[0] !== '[') {
+ newText = text.replace(/(\n{2,})/g, match => {
+ return suffix + match + prefix;
+ });
+ }
+
+ textarea.value = beforeSelection + prefix + newText + suffix + afterSelection;
+
+ // If no text were highlighted, place the caret inside
+ // the formatted section, otherwise place it at the end
+ if (emptyText) {
+ textarea.selectionEnd = textarea.value.length - afterSelection.length - suffix.length;
+ }
+ else {
+ textarea.selectionEnd = textarea.value.length - afterSelection.length;
+ }
+ textarea.selectionStart = textarea.selectionEnd;
+ textarea.scrollTop = scrollTop;
+}
+
+function insertLink(textarea, options) {
+ let hyperlink = window.prompt('Link:');
+ if (!hyperlink || hyperlink === '') return;
+
+ // Change on-site link to use relative url
+ if (hyperlink.startsWith(window.location.origin)) hyperlink = hyperlink.substring(window.location.origin.length);
+
+ const prefix = options.prefix,
+ suffix = options.suffix + hyperlink;
+
+ wrapSelection(textarea, { prefix, suffix });
+}
+
+function insertImage(textarea, options) {
+ const hyperlink = window.prompt('Image link:');
+ const { prefix, suffix } = options;
+
+ if (!hyperlink || hyperlink === '') return;
+
+ wrapSelection(textarea, { text: hyperlink, prefix, suffix });
+}
+
+function clickHandler(event) {
+ const button = event.target.closest('.communication__toolbar__button');
+ if (!button) return;
+ const toolbar = button.closest('.communication__toolbar'),
+ // There may be multiple toolbars present on the page,
+ // in the case of image pages with description edit active
+ // we target the textarea that shares the same parent as the toolabr
+ textarea = $('.js-toolbar-input', toolbar.parentNode),
+ id = button.dataset.syntaxId;
+
+ textileSyntax[id].action(textarea, textileSyntax[id].options);
+ textarea.focus();
+}
+
+function shortcutHandler(event) {
+ if (!event.ctrlKey || (window.navigator.platform === 'MacIntel' && !event.metaKey) || event.shiftKey || event.altKey) return;
+ const textarea = event.target,
+ key = event.key.toLowerCase();
+
+ for (const id in textileSyntax) {
+ if (key === textileSyntax[id].options.shortcutKey) {
+ textileSyntax[id].action(textarea, textileSyntax[id].options);
+ event.preventDefault();
+ }
+ }
+}
+
+function setupToolbar() {
+ $$('.communication__toolbar').forEach(toolbar => {
+ toolbar.addEventListener('click', clickHandler);
+ });
+ $$('.js-toolbar-input').forEach(textarea => {
+ textarea.addEventListener('keydown', shortcutHandler);
+ });
+}
+
+export { setupToolbar };
diff --git a/assets/js/timeago.js b/assets/js/timeago.js
new file mode 100644
index 00000000..f3694d6f
--- /dev/null
+++ b/assets/js/timeago.js
@@ -0,0 +1,68 @@
+/*
+ * Frontend timestamps.
+ */
+
+const strings = {
+ seconds: 'less than a minute',
+ minute: 'about a minute',
+ minutes: '%d minutes',
+ hour: 'about an hour',
+ hours: 'about %d hours',
+ day: 'a day',
+ days: '%d days',
+ month: 'about a month',
+ months: '%d months',
+ year: 'about a year',
+ years: '%d years',
+};
+
+function distance(time) {
+ return new Date() - time;
+}
+
+function substitute(key, amount) {
+ return strings[key].replace('%d', Math.round(amount));
+}
+
+function setTimeAgo(el) {
+ const date = new Date(el.getAttribute('datetime'));
+ const distMillis = distance(date);
+
+ const seconds = Math.abs(distMillis) / 1000,
+ minutes = seconds / 60,
+ hours = minutes / 60,
+ days = hours / 24,
+ months = days / 30,
+ years = days / 365;
+
+ const words =
+ seconds < 45 && substitute('seconds', seconds) ||
+ seconds < 90 && substitute('minute', 1) ||
+ minutes < 45 && substitute('minutes', minutes) ||
+ minutes < 90 && substitute('hour', 1) ||
+ hours < 24 && substitute('hours', hours) ||
+ hours < 42 && substitute('day', 1) ||
+ days < 30 && substitute('days', days) ||
+ days < 45 && substitute('month', 1) ||
+ days < 365 && substitute('months', months) ||
+ years < 1.5 && substitute('year', 1) ||
+ substitute('years', years);
+
+ if (!el.getAttribute('title')) {
+ el.setAttribute('title', el.textContent);
+ }
+ el.textContent = words + (distMillis < 0 ? ' from now' : ' ago');
+}
+
+function timeAgo(args) {
+ [].forEach.call(args, el => setTimeAgo(el));
+}
+
+function setupTimestamps() {
+ timeAgo(document.getElementsByTagName('time'));
+ window.setTimeout(setupTimestamps, 60000);
+}
+
+export { setupTimestamps };
+
+window.booru.timeAgo = timeAgo;
diff --git a/assets/js/ujs.js b/assets/js/ujs.js
new file mode 100644
index 00000000..a3ae9e3c
--- /dev/null
+++ b/assets/js/ujs.js
@@ -0,0 +1,106 @@
+import { $$, makeEl, findFirstTextNode } from './utils/dom';
+import { fire, delegate } from './utils/events';
+
+const headers = () => ({
+ Accept: 'text/javascript',
+ 'X-CSRF-Token': window.booru.csrfToken
+});
+
+function confirm(event, target) {
+ if (!window.confirm(target.dataset.confirm)) {
+ event.preventDefault();
+ event.stopImmediatePropagation();
+ return false;
+ }
+}
+
+function disable(event, target) {
+ // failed validations prevent the form from being submitted;
+ // stop here or the form will be permanently locked
+ if (target.type === 'submit' && target.closest(':invalid') !== null) return;
+
+ // Store what's already there so we don't lose it
+ const label = findFirstTextNode(target);
+ if (label) {
+ target.dataset.enableWith = label.nodeValue;
+ label.nodeValue = ` ${target.dataset.disableWith}`;
+ }
+ else {
+ target.dataset.enableWith = target.innerHTML;
+ target.innerHTML = target.dataset.disableWith;
+ }
+
+ // delay is needed because Safari stops the submit if the button is immediately disabled
+ requestAnimationFrame(() => target.disabled = 'disabled');
+}
+
+// you should use button_to instead of link_to[method]!
+function linkMethod(event, target) {
+ event.preventDefault();
+
+ const form = makeEl('form', { action: target.href, method: 'POST' });
+ const csrf = makeEl('input', { type: 'hidden', name: window.booru.csrfParam, value: window.booru.csrfToken });
+ const method = makeEl('input', { type: 'hidden', name: '_method', value: target.dataset.method });
+
+ document.body.appendChild(form);
+
+ form.appendChild(csrf);
+ form.appendChild(method);
+ form.submit();
+}
+
+function formRemote(event, target) {
+ event.preventDefault();
+
+ fetch(target.action, {
+ credentials: 'same-origin',
+ method: (target.dataset.method || target.method || 'POST').toUpperCase(),
+ headers: headers(),
+ body: new FormData(target)
+ }).then(response =>
+ fire(target, 'fetchcomplete', response)
+ );
+}
+
+function formReset(event, target) {
+ $$('[disabled][data-disable-with]', target).forEach(input => {
+ const label = findFirstTextNode(input);
+ if (label) {
+ label.nodeValue = ` ${input.dataset.enableWith}`;
+ }
+ else { input.innerHTML = target.dataset.enableWith; }
+ delete input.dataset.enableWith;
+ input.removeAttribute('disabled');
+ });
+}
+
+function linkRemote(event, target) {
+ event.preventDefault();
+
+ fetch(target.href, {
+ credentials: 'same-origin',
+ method: target.dataset.method.toUpperCase(),
+ headers: headers()
+ }).then(response =>
+ fire(target, 'fetchcomplete', response)
+ );
+}
+
+function leftClick(func) {
+ return (event, target) => { if (event.button === 0) return func(event, target); };
+}
+
+delegate(document, 'click', {
+ 'a[data-confirm],button[data-confirm],input[data-confirm]': leftClick(confirm),
+ 'a[data-disable-with],button[data-disable-with],input[data-disable-with]': leftClick(disable),
+ 'a[data-method]:not([data-remote])': leftClick(linkMethod),
+ 'a[data-remote]': leftClick(linkRemote),
+});
+
+delegate(document, 'submit', {
+ 'form[data-remote]': formRemote
+});
+
+delegate(document, 'reset', {
+ form: formReset
+});
diff --git a/assets/js/upload.js b/assets/js/upload.js
new file mode 100644
index 00000000..d3e98a92
--- /dev/null
+++ b/assets/js/upload.js
@@ -0,0 +1,105 @@
+/**
+ * Fetch and display preview images for various image upload forms.
+ */
+
+import { fetchJson, handleError } from './utils/requests';
+import { $, $$, hideEl, showEl, makeEl, clearEl } from './utils/dom';
+import { addTag } from './tagsinput';
+
+function scrapeUrl(url) {
+ return fetchJson('POST', '/images/scrape_url', { url })
+ .then(handleError)
+ .then(response => response.json());
+}
+
+function setupImageUpload() {
+ const imgPreviews = $('#js-image-upload-previews');
+ if (!imgPreviews) return;
+
+ const form = imgPreviews.closest('form');
+ const [ fileField, remoteUrl, scraperError ] = $$('.js-scraper', form);
+ const [ sourceEl, tagsEl, descrEl ] = $$('.js-image-input', form);
+ const fetchButton = $('#js-scraper-preview');
+
+ function showImages(images) {
+ clearEl(imgPreviews);
+
+ images.forEach((image, index) => {
+ const img = makeEl('img', { className: 'scraper-preview--image' });
+ img.src = image.camo_url;
+ const imgWrap = makeEl('span', { className: 'scraper-preview--image-wrapper' });
+ imgWrap.appendChild(img);
+
+ const label = makeEl('label', { className: 'scraper-preview--label' });
+ const radio = makeEl('input', {
+ type: 'radio',
+ className: 'scraper-preview--input',
+ });
+ if (image.url) {
+ radio.name = 'scraper_cache';
+ radio.value = image.url;
+ }
+ if (index === 0) {
+ radio.checked = true;
+ }
+ label.appendChild(radio);
+ label.appendChild(imgWrap);
+ imgPreviews.appendChild(label);
+ });
+ }
+ function showError() {
+ clearEl(imgPreviews);
+ showEl(scraperError);
+ enableFetch();
+ }
+ function hideError() { hideEl(scraperError); }
+ function disableFetch() { fetchButton.setAttribute('disabled', ''); }
+ function enableFetch() { fetchButton.removeAttribute('disabled'); }
+
+ const reader = new FileReader();
+
+ reader.addEventListener('load', event => {
+ showImages([{
+ camo_url: event.target.result,
+ }]);
+
+ // Clear any currently cached data, because the file field
+ // has higher priority than the scraper:
+ remoteUrl.value = '';
+ hideError();
+ });
+
+ // Watch for files added to the form
+ fileField.addEventListener('change', () => { fileField.files.length && reader.readAsDataURL(fileField.files[0]); });
+
+ // Watch for [Fetch] clicks
+ fetchButton.addEventListener('click', () => {
+ if (!remoteUrl.value) return;
+
+ disableFetch();
+
+ scrapeUrl(remoteUrl.value).then(data => {
+ if (data.errors && data.errors.length > 0) {
+ scraperError.innerText = data.errors.join(' ');
+ showError();
+ return;
+ }
+
+ hideError();
+
+ // Set source
+ if (sourceEl) sourceEl.value = sourceEl.value || data.source_url || '';
+ // Set description
+ if (descrEl) descrEl.value = descrEl.value || data.description || '';
+ // Add author
+ if (tagsEl && data.author_name) addTag(tagsEl, `artist:${data.author_name.toLowerCase()}`);
+ // Clear selected file, if any
+ fileField.value = '';
+ showImages(data.images);
+
+ enableFetch();
+ }).catch(showError);
+ });
+}
+
+export { setupImageUpload };
diff --git a/assets/js/utils/array.js b/assets/js/utils/array.js
new file mode 100644
index 00000000..61f1edf1
--- /dev/null
+++ b/assets/js/utils/array.js
@@ -0,0 +1,13 @@
+// http://stackoverflow.com/a/5306832/1726690
+function moveElement(array, from, to) {
+ array.splice(to, 0, array.splice(from, 1)[0]);
+}
+
+function arraysEqual(array1, array2) {
+ for (let i = 0; i < array1.length; ++i) {
+ if (array1[i] !== array2[i]) return false;
+ }
+ return true;
+}
+
+export { moveElement, arraysEqual };
diff --git a/assets/js/utils/dom.js b/assets/js/utils/dom.js
new file mode 100644
index 00000000..df128d23
--- /dev/null
+++ b/assets/js/utils/dom.js
@@ -0,0 +1,69 @@
+/**
+ * DOM Utils
+ */
+
+function $(selector, context = document) { // Get the first matching element
+ const element = context.querySelector(selector);
+
+ return element || null;
+}
+
+function $$(selector, context = document) { // Get every matching element as an array
+ const elements = context.querySelectorAll(selector);
+
+ return [].slice.call(elements);
+}
+
+function showEl(...elements) {
+ [].concat(...elements).forEach(el => el.classList.remove('hidden'));
+}
+
+function hideEl(...elements) {
+ [].concat(...elements).forEach(el => el.classList.add('hidden'));
+}
+
+function toggleEl(...elements) {
+ [].concat(...elements).forEach(el => el.classList.toggle('hidden'));
+}
+
+function clearEl(...elements) {
+ [].concat(...elements).forEach(el => { while (el.firstChild) el.removeChild(el.firstChild); });
+}
+
+function removeEl(...elements) {
+ [].concat(...elements).forEach(el => el.parentNode.removeChild(el));
+}
+
+function makeEl(tag, attr = {}) {
+ const el = document.createElement(tag);
+ for (const prop in attr) el[prop] = attr[prop];
+ return el;
+}
+
+function insertBefore(existingElement, newElement) {
+ existingElement.parentNode.insertBefore(newElement, existingElement);
+}
+
+function onLeftClick(callback, context = document) {
+ context.addEventListener('click', event => {
+ if (event.button === 0) callback(event);
+ });
+}
+
+function whenReady(callback) { // Execute a function when the DOM is ready
+ if (document.readyState !== 'loading') callback();
+ else document.addEventListener('DOMContentLoaded', callback);
+}
+
+function escapeHtml(html) {
+ return html.replace(/&/g, '&')
+ .replace(/>/g, '>')
+ .replace(/ el.nodeType === Node.TEXT_NODE)[0];
+}
+
+export { $, $$, showEl, hideEl, toggleEl, clearEl, removeEl, makeEl, insertBefore, onLeftClick, whenReady, escapeHtml, findFirstTextNode };
diff --git a/assets/js/utils/draggable.js b/assets/js/utils/draggable.js
new file mode 100644
index 00000000..9ac06d9c
--- /dev/null
+++ b/assets/js/utils/draggable.js
@@ -0,0 +1,70 @@
+import { $$ } from './dom';
+
+let dragSrcEl;
+
+function dragStart(event, target) {
+ target.classList.add('dragging');
+ dragSrcEl = target;
+
+ if (event.dataTransfer.items.length === 0) {
+ event.dataTransfer.setData('text/plain', '');
+ }
+
+ event.dataTransfer.effectAllowed = 'move';
+}
+
+function dragOver(event) {
+ event.preventDefault();
+ event.dataTransfer.dropEffect = 'move';
+}
+
+function dragEnter(event, target) {
+ target.classList.add('over');
+}
+
+function dragLeave(event, target) {
+ target.classList.remove('over');
+}
+
+function drop(event, target) {
+ event.preventDefault();
+
+ dragSrcEl.classList.remove('dragging');
+
+ if (dragSrcEl === target) return;
+
+ // divide the target element into two sets of coordinates
+ // and determine how to act based on the relative mouse positioin
+ const bbox = target.getBoundingClientRect();
+ const detX = bbox.left + (bbox.width / 2);
+
+ if (event.clientX < detX) {
+ target.insertAdjacentElement('beforebegin', dragSrcEl);
+ }
+ else {
+ target.insertAdjacentElement('afterend', dragSrcEl);
+ }
+}
+
+function dragEnd(event, target) {
+ dragSrcEl.classList.remove('dragging');
+
+ $$('.over', target.parentNode).forEach(t => t.classList.remove('over'));
+}
+
+function wrapper(func) {
+ return function(event) {
+ if (!event.target.closest) return;
+ const target = event.target.closest('.drag-container [draggable]');
+ if (target) func(event, target);
+ };
+}
+
+export function initDraggables() {
+ document.addEventListener('dragstart', wrapper(dragStart));
+ document.addEventListener('dragover', wrapper(dragOver));
+ document.addEventListener('dragenter', wrapper(dragEnter));
+ document.addEventListener('dragleave', wrapper(dragLeave));
+ document.addEventListener('dragend', wrapper(dragEnd));
+ document.addEventListener('drop', wrapper(drop));
+}
diff --git a/assets/js/utils/events.js b/assets/js/utils/events.js
new file mode 100644
index 00000000..f5685053
--- /dev/null
+++ b/assets/js/utils/events.js
@@ -0,0 +1,20 @@
+/**
+ * DOM events
+ */
+
+export function fire(el, event, detail) {
+ el.dispatchEvent(new CustomEvent(event, { detail, bubbles: true, cancelable: true }));
+}
+
+export function on(node, event, selector, func) {
+ delegate(node, event, { [selector]: func });
+}
+
+export function delegate(node, event, selectors) {
+ node.addEventListener(event, e => {
+ for (const selector in selectors) {
+ const target = e.target.closest(selector);
+ if (target && selectors[selector](e, target) === false) break;
+ }
+ });
+}
diff --git a/assets/js/utils/image.js b/assets/js/utils/image.js
new file mode 100644
index 00000000..fec1bd2b
--- /dev/null
+++ b/assets/js/utils/image.js
@@ -0,0 +1,128 @@
+import { clearEl } from './dom';
+import store from './store';
+
+function showVideoThumb(img) {
+ const size = img.dataset.size;
+ const uris = JSON.parse(img.dataset.uris);
+ const thumbUri = uris[size];
+
+ const vidEl = img.querySelector('video');
+ if (!vidEl) return false;
+
+ const imgEl = img.querySelector('img');
+ if (!imgEl || imgEl.classList.contains('hidden')) return false;
+
+ imgEl.classList.add('hidden');
+
+ vidEl.innerHTML = `
+
+
+ `;
+ vidEl.classList.remove('hidden');
+ vidEl.play();
+
+ img.querySelector('.js-spoiler-info-overlay').classList.add('hidden');
+
+ return true;
+}
+
+function showThumb(img) {
+ const size = img.dataset.size;
+ const uris = JSON.parse(img.dataset.uris);
+ const thumbUri = uris[size].replace(/webm$/, 'gif');
+
+ const picEl = img.querySelector('picture');
+ if (!picEl) return showVideoThumb(img);
+
+ const imgEl = picEl.querySelector('img');
+ if (!imgEl || imgEl.src.indexOf(thumbUri) !== -1) return false;
+
+ if (store.get('serve_hidpi') && !thumbUri.endsWith('.gif')) {
+ // Check whether the HiDPI option is enabled, and make an exception for GIFs due to their size
+ imgEl.srcset = `${thumbUri} 1x, ${uris.medium} 2x`;
+ }
+
+ imgEl.src = thumbUri;
+ if (uris[size].indexOf('.webm') !== -1) {
+ const overlay = img.querySelector('.js-spoiler-info-overlay');
+ overlay.classList.remove('hidden');
+ overlay.innerHTML = 'WebM';
+ }
+ else {
+ img.querySelector('.js-spoiler-info-overlay').classList.add('hidden');
+ }
+
+ return true;
+}
+
+function showBlock(img) {
+ img.querySelector('.image-filtered').classList.add('hidden');
+ const imageShowClasses = img.querySelector('.image-show').classList;
+ imageShowClasses.remove('hidden');
+ imageShowClasses.add('spoiler-pending');
+}
+
+function hideVideoThumb(img, spoilerUri, reason) {
+ const vidEl = img.querySelector('video');
+ if (!vidEl) return;
+
+ const imgEl = img.querySelector('img');
+ const imgOverlay = img.querySelector('.js-spoiler-info-overlay');
+ if (!imgEl) return;
+
+ imgEl.classList.remove('hidden');
+ imgEl.src = spoilerUri;
+ imgOverlay.innerHTML = reason;
+ imgOverlay.classList.remove('hidden');
+
+ clearEl(vidEl);
+ vidEl.classList.add('hidden');
+ vidEl.pause();
+}
+
+function hideThumb(img, spoilerUri, reason) {
+ const picEl = img.querySelector('picture');
+ if (!picEl) return hideVideoThumb(img, spoilerUri, reason);
+
+ const imgEl = picEl.querySelector('img');
+ const imgOverlay = img.querySelector('.js-spoiler-info-overlay');
+
+ if (!imgEl || imgEl.src.indexOf(spoilerUri) !== -1) return;
+
+ imgEl.srcset = '';
+ imgEl.src = spoilerUri;
+ imgOverlay.innerHTML = reason;
+ imgOverlay.classList.remove('hidden');
+}
+
+function spoilerThumb(img, spoilerUri, reason) {
+ hideThumb(img, spoilerUri, reason);
+
+ switch (window.booru.spoilerType) {
+ case 'click':
+ img.addEventListener('click', event => { if (showThumb(img)) event.preventDefault(); });
+ img.addEventListener('mouseleave', () => hideThumb(img, spoilerUri, reason));
+ break;
+ case 'hover':
+ img.addEventListener('mouseenter', () => showThumb(img));
+ img.addEventListener('mouseleave', () => hideThumb(img, spoilerUri, reason));
+ break;
+ default:
+ break;
+ }
+}
+
+function spoilerBlock(img, spoilerUri, reason) {
+ const imgEl = img.querySelector('.image-filtered img');
+ const imgReason = img.querySelector('.filter-explanation');
+
+ if (!imgEl) return;
+
+ imgEl.src = spoilerUri;
+ imgReason.innerHTML = reason;
+
+ img.querySelector('.image-show').classList.add('hidden');
+ img.querySelector('.image-filtered').classList.remove('hidden');
+}
+
+export { showThumb, showBlock, spoilerThumb, spoilerBlock, hideThumb };
diff --git a/assets/js/utils/requests.js b/assets/js/utils/requests.js
new file mode 100644
index 00000000..3981dd68
--- /dev/null
+++ b/assets/js/utils/requests.js
@@ -0,0 +1,40 @@
+/**
+ * Request Utils
+ */
+
+function fetchJson(verb, endpoint, body) {
+ const data = {
+ method: verb,
+ credentials: 'same-origin',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-CSRF-Token': window.booru.csrfToken,
+ },
+ };
+
+ if (body) {
+ body._method = verb;
+ data.body = JSON.stringify(body);
+ }
+
+ return fetch(endpoint, data);
+}
+
+function fetchHtml(endpoint) {
+ return fetch(endpoint, {
+ credentials: 'same-origin',
+ headers: {
+ 'X-Requested-With': 'XMLHttpRequest',
+ 'X-CSRF-Token': window.booru.csrfToken,
+ },
+ });
+}
+
+function handleError(response) {
+ if (!response.ok) {
+ throw new Error('Received error from server');
+ }
+ return response;
+}
+
+export { fetchJson, fetchHtml, handleError };
diff --git a/assets/js/utils/store.js b/assets/js/utils/store.js
new file mode 100644
index 00000000..8681c0cf
--- /dev/null
+++ b/assets/js/utils/store.js
@@ -0,0 +1,66 @@
+/**
+ * localStorage utils
+ */
+
+const lastUpdatedSuffix = '__lastUpdated';
+
+export default {
+
+ set(key, value) {
+ try {
+ localStorage.setItem(key, JSON.stringify(value));
+ return true;
+ }
+ catch (err) {
+ return false;
+ }
+ },
+
+ get(key) {
+ const value = localStorage.getItem(key);
+ try {
+ return JSON.parse(value);
+ }
+ catch (err) {
+ return value;
+ }
+ },
+
+ remove(key) {
+ try {
+ localStorage.removeItem(key);
+ return true;
+ }
+ catch (err) {
+ return false;
+ }
+ },
+
+ // Watch changes to a specified key - returns value on change
+ watch(key, callback) {
+ window.addEventListener('storage', event => {
+ if (event.key === key) callback(this.get(key));
+ });
+ },
+
+ // set() with an additional key containing the current time + expiration time
+ setWithExpireTime(key, value, maxAge) {
+ const lastUpdatedKey = key + lastUpdatedSuffix;
+ const lastUpdatedTime = Date.now() + maxAge;
+
+ this.set(key, value) && this.set(lastUpdatedKey, lastUpdatedTime);
+ },
+
+ // Whether the value of a key set with setWithExpireTime() has expired
+ hasExpired(key) {
+ const lastUpdatedKey = key + lastUpdatedSuffix;
+ const lastUpdatedTime = this.get(lastUpdatedKey);
+
+ if (Date.now() > lastUpdatedTime) {
+ return true;
+ }
+
+ return false;
+ },
+
+};
diff --git a/assets/js/utils/tag.js b/assets/js/utils/tag.js
new file mode 100644
index 00000000..699b0e57
--- /dev/null
+++ b/assets/js/utils/tag.js
@@ -0,0 +1,58 @@
+import { escapeHtml } from './dom';
+import { getTag } from '../booru';
+
+function unique(array) {
+ return array.filter((a, b, c) => c.indexOf(a) === b);
+}
+
+function sortTags(hidden, a, b) {
+ // If both tags have a spoiler image, sort by images count desc (hidden) or asc (spoilered)
+ if (a.spoiler_image_uri && b.spoiler_image_uri) {
+ return hidden ? b.images - a.images : a.images - b.images;
+ }
+ // If neither has a spoiler image, sort by images count desc
+ else if (!a.spoiler_image_uri && !b.spoiler_image_uri) {
+ return b.images - a.images;
+ }
+
+ // Tag with spoiler image takes precedence
+ return a.spoiler_image_uri ? -1 : 1;
+}
+
+function getHiddenTags() {
+ return unique(window.booru.hiddenTagList)
+ .map(tagId => getTag(tagId))
+ .sort(sortTags.bind(null, true));
+}
+
+function getSpoileredTags() {
+ if (window.booru.spoilerType === 'off') return [];
+
+ return unique(window.booru.spoileredTagList)
+ .filter(tagId => window.booru.ignoredTagList.indexOf(tagId) === -1)
+ .map(tagId => getTag(tagId))
+ .sort(sortTags.bind(null, false));
+}
+
+function imageHitsTags(img, matchTags) {
+ const imageTags = JSON.parse(img.dataset.imageTags);
+ return matchTags.filter(t => imageTags.indexOf(t.id) !== -1);
+}
+
+function imageHitsComplex(img, matchComplex) {
+ return matchComplex.hitsImage(img);
+}
+
+function displayTags(tags) {
+ const mainTag = tags[0], otherTags = tags.slice(1);
+ let list = escapeHtml(mainTag.name), extras;
+
+ if (otherTags.length > 0) {
+ extras = otherTags.map(tag => escapeHtml(tag.name)).join(', ');
+ list += `, ${extras}`;
+ }
+
+ return list;
+}
+
+export { getHiddenTags, getSpoileredTags, imageHitsTags, imageHitsComplex, displayTags };
diff --git a/assets/js/vendor/closest.polyfill.js b/assets/js/vendor/closest.polyfill.js
new file mode 100644
index 00000000..2c60a6d5
--- /dev/null
+++ b/assets/js/vendor/closest.polyfill.js
@@ -0,0 +1,31 @@
+// element-closest | CC0-1.0 | github.com/jonathantneal/closest
+
+if (typeof Element.prototype.matches !== 'function') {
+ Element.prototype.matches = Element.prototype.msMatchesSelector || Element.prototype.mozMatchesSelector || Element.prototype.webkitMatchesSelector || function matches(selector) {
+ var element = this;
+ var elements = (element.document || element.ownerDocument).querySelectorAll(selector);
+ var index = 0;
+
+ while (elements[index] && elements[index] !== element) {
+ ++index;
+ }
+
+ return Boolean(elements[index]);
+ };
+}
+
+if (typeof Element.prototype.closest !== 'function') {
+ Element.prototype.closest = function closest(selector) {
+ var element = this;
+
+ while (element && element.nodeType === 1) {
+ if (element.matches(selector)) {
+ return element;
+ }
+
+ element = element.parentNode;
+ }
+
+ return null;
+ };
+}
diff --git a/assets/js/vendor/customevent.polyfill.js b/assets/js/vendor/customevent.polyfill.js
new file mode 100644
index 00000000..7d13f9cf
--- /dev/null
+++ b/assets/js/vendor/customevent.polyfill.js
@@ -0,0 +1,17 @@
+// https://developer.mozilla.org/en-US/docs/Web/API/CustomEvent/CustomEvent
+
+(function () {
+
+ if ( typeof window.CustomEvent === "function" ) return false;
+
+ function CustomEvent ( event, params ) {
+ params = params || { bubbles: false, cancelable: false, detail: undefined };
+ var evt = document.createEvent( 'CustomEvent' );
+ evt.initCustomEvent( event, params.bubbles, params.cancelable, params.detail );
+ return evt;
+ }
+
+ CustomEvent.prototype = window.Event.prototype;
+
+ window.CustomEvent = CustomEvent;
+})();
diff --git a/assets/js/vendor/es6.polyfill.js b/assets/js/vendor/es6.polyfill.js
new file mode 100644
index 00000000..51c1c423
--- /dev/null
+++ b/assets/js/vendor/es6.polyfill.js
@@ -0,0 +1,104 @@
+/**
+ * ES6 methods polyfill
+ * Sourced from their respective articles on MDN
+ */
+
+if (!Array.prototype.find) {
+ Array.prototype.find = function(predicate) {
+ 'use strict';
+ if (this == null) {
+ throw new TypeError('Array.prototype.find called on null or undefined');
+ }
+ if (typeof predicate !== 'function') {
+ throw new TypeError('predicate must be a function');
+ }
+ var list = Object(this);
+ var length = list.length >>> 0;
+ var thisArg = arguments[1];
+ var value;
+
+ for (var i = 0; i < length; i++) {
+ value = list[i];
+ if (predicate.call(thisArg, value, i, list)) {
+ return value;
+ }
+ }
+ return undefined;
+ };
+}
+
+if (!Array.prototype.findIndex) {
+ Array.prototype.findIndex = function(predicate) {
+ 'use strict';
+ if (this == null) {
+ throw new TypeError('Array.prototype.findIndex called on null or undefined');
+ }
+ if (typeof predicate !== 'function') {
+ throw new TypeError('predicate must be a function');
+ }
+ var list = Object(this);
+ var length = list.length >>> 0;
+ var thisArg = arguments[1];
+ var value;
+
+ for (var i = 0; i < length; i++) {
+ value = list[i];
+ if (predicate.call(thisArg, value, i, list)) {
+ return i;
+ }
+ }
+ return -1;
+ };
+}
+
+if (!Array.prototype.includes) {
+ Array.prototype.includes = function(searchElement /*, fromIndex*/) {
+ 'use strict';
+ if (this == null) {
+ throw new TypeError('Array.prototype.includes called on null or undefined');
+ }
+
+ var O = Object(this);
+ var len = parseInt(O.length, 10) || 0;
+ if (len === 0) {
+ return false;
+ }
+ var n = parseInt(arguments[1], 10) || 0;
+ var k;
+ if (n >= 0) {
+ k = n;
+ } else {
+ k = len + n;
+ if (k < 0) {k = 0;}
+ }
+ var currentElement;
+ while (k < len) {
+ currentElement = O[k];
+ if (searchElement === currentElement ||
+ (searchElement !== searchElement && currentElement !== currentElement)) { // NaN !== NaN
+ return true;
+ }
+ k++;
+ }
+ return false;
+ };
+}
+
+if (!String.prototype.startsWith) {
+ String.prototype.startsWith = function(searchString, position){
+ position = position || 0;
+ return this.substr(position, searchString.length) === searchString;
+ };
+}
+
+if (!String.prototype.endsWith) {
+ String.prototype.endsWith = function(searchString, position) {
+ var subjectString = this.toString();
+ if (typeof position !== 'number' || !isFinite(position) || Math.floor(position) !== position || position > subjectString.length) {
+ position = subjectString.length;
+ }
+ position -= searchString.length;
+ var lastIndex = subjectString.lastIndexOf(searchString, position);
+ return lastIndex !== -1 && lastIndex === position;
+ };
+}
diff --git a/assets/js/vendor/fetch.polyfill.js b/assets/js/vendor/fetch.polyfill.js
new file mode 100644
index 00000000..456f8c1f
--- /dev/null
+++ b/assets/js/vendor/fetch.polyfill.js
@@ -0,0 +1,53 @@
+if (typeof window.fetch !== 'function') {
+ window.fetch = function fetch(url, options) {
+ return new Promise((resolve, reject) => {
+ let request = new XMLHttpRequest();
+
+ options = options || {};
+ request.open(options.method || 'GET', url);
+
+ for (const i in options.headers) {
+ request.setRequestHeader(i, options.headers[i]);
+ }
+
+ request.withCredentials = options.credentials === 'include' || options.credentials === 'same-origin';
+ request.onload = () => resolve(response());
+ request.onerror = reject;
+
+ // IE11 hack: don't send null/undefined
+ if (options.body != null)
+ request.send(options.body);
+ else
+ request.send();
+
+ function response() {
+ const keys = [], all = [], headers = {};
+ let header;
+
+ request.getAllResponseHeaders().replace(/^(.*?):\s*([\s\S]*?)$/gm, (m, key, value) => {
+ keys.push(key = key.toLowerCase());
+ all.push([key, value]);
+ header = headers[key];
+ headers[key] = header ? `${header},${value}` : value;
+ });
+
+ return {
+ ok: (request.status/200|0) === 1,
+ status: request.status,
+ statusText: request.statusText,
+ url: request.responseURL,
+ clone: response,
+ text: () => Promise.resolve(request.responseText),
+ json: () => Promise.resolve(request.responseText).then(JSON.parse),
+ blob: () => Promise.resolve(new Blob([request.response])),
+ headers: {
+ keys: () => keys,
+ entries: () => all,
+ get: n => headers[n.toLowerCase()],
+ has: n => n.toLowerCase() in headers
+ }
+ };
+ }
+ });
+ };
+}
diff --git a/assets/js/vendor/promise.polyfill.js b/assets/js/vendor/promise.polyfill.js
new file mode 100644
index 00000000..c1cfac74
--- /dev/null
+++ b/assets/js/vendor/promise.polyfill.js
@@ -0,0 +1,230 @@
+(function (root) {
+
+ // Store setTimeout reference so promise-polyfill will be unaffected by
+ // other code modifying setTimeout (like sinon.useFakeTimers())
+ var setTimeoutFunc = setTimeout;
+
+ function noop() {
+ }
+
+ // Use polyfill for setImmediate for performance gains
+ var asap = (typeof setImmediate === 'function' && setImmediate) ||
+ function (fn) {
+ setTimeoutFunc(fn, 1);
+ };
+
+ var onUnhandledRejection = function onUnhandledRejection(err) {
+ console.warn('Possible Unhandled Promise Rejection:', err); // eslint-disable-line no-console
+ };
+
+ // Polyfill for Function.prototype.bind
+ function bind(fn, thisArg) {
+ return function () {
+ fn.apply(thisArg, arguments);
+ };
+ }
+
+ var isArray = Array.isArray || function (value) {
+ return Object.prototype.toString.call(value) === '[object Array]';
+ };
+
+ function Promise(fn) {
+ if (typeof this !== 'object') throw new TypeError('Promises must be constructed via new');
+ if (typeof fn !== 'function') throw new TypeError('not a function');
+ this._state = 0;
+ this._handled = false;
+ this._value = undefined;
+ this._deferreds = [];
+
+ doResolve(fn, this);
+ }
+
+ function handle(self, deferred) {
+ while (self._state === 3) {
+ self = self._value;
+ }
+ if (self._state === 0) {
+ self._deferreds.push(deferred);
+ return;
+ }
+ self._handled = true;
+ asap(function () {
+ var cb = self._state === 1 ? deferred.onFulfilled : deferred.onRejected;
+ if (cb === null) {
+ (self._state === 1 ? resolve : reject)(deferred.promise, self._value);
+ return;
+ }
+ var ret;
+ try {
+ ret = cb(self._value);
+ } catch (e) {
+ reject(deferred.promise, e);
+ return;
+ }
+ resolve(deferred.promise, ret);
+ });
+ }
+
+ function resolve(self, newValue) {
+ try {
+ // Promise Resolution Procedure: https://github.com/promises-aplus/promises-spec#the-promise-resolution-procedure
+ if (newValue === self) throw new TypeError('A promise cannot be resolved with itself.');
+ if (newValue && (typeof newValue === 'object' || typeof newValue === 'function')) {
+ var then = newValue.then;
+ if (newValue instanceof Promise) {
+ self._state = 3;
+ self._value = newValue;
+ finale(self);
+ return;
+ } else if (typeof then === 'function') {
+ doResolve(bind(then, newValue), self);
+ return;
+ }
+ }
+ self._state = 1;
+ self._value = newValue;
+ finale(self);
+ } catch (e) {
+ reject(self, e);
+ }
+ }
+
+ function reject(self, newValue) {
+ self._state = 2;
+ self._value = newValue;
+ finale(self);
+ }
+
+ function finale(self) {
+ if (self._state === 2 && self._deferreds.length === 0) {
+ setTimeout(function() {
+ if (!self._handled) {
+ onUnhandledRejection(self._value);
+ }
+ }, 1);
+ }
+
+ for (var i = 0, len = self._deferreds.length; i < len; i++) {
+ handle(self, self._deferreds[i]);
+ }
+ self._deferreds = null;
+ }
+
+ function Handler(onFulfilled, onRejected, promise) {
+ this.onFulfilled = typeof onFulfilled === 'function' ? onFulfilled : null;
+ this.onRejected = typeof onRejected === 'function' ? onRejected : null;
+ this.promise = promise;
+ }
+
+ /**
+ * Take a potentially misbehaving resolver function and make sure
+ * onFulfilled and onRejected are only called once.
+ *
+ * Makes no guarantees about asynchrony.
+ */
+ function doResolve(fn, self) {
+ var done = false;
+ try {
+ fn(function (value) {
+ if (done) return;
+ done = true;
+ resolve(self, value);
+ }, function (reason) {
+ if (done) return;
+ done = true;
+ reject(self, reason);
+ });
+ } catch (ex) {
+ if (done) return;
+ done = true;
+ reject(self, ex);
+ }
+ }
+
+ Promise.prototype['catch'] = function (onRejected) {
+ return this.then(null, onRejected);
+ };
+
+ Promise.prototype.then = function (onFulfilled, onRejected) {
+ var prom = new Promise(noop);
+ handle(this, new Handler(onFulfilled, onRejected, prom));
+ return prom;
+ };
+
+ Promise.all = function () {
+ var args = Array.prototype.slice.call(arguments.length === 1 && isArray(arguments[0]) ? arguments[0] : arguments);
+
+ return new Promise(function (resolve, reject) {
+ if (args.length === 0) return resolve([]);
+ var remaining = args.length;
+
+ function res(i, val) {
+ try {
+ if (val && (typeof val === 'object' || typeof val === 'function')) {
+ var then = val.then;
+ if (typeof then === 'function') {
+ then.call(val, function (val) {
+ res(i, val);
+ }, reject);
+ return;
+ }
+ }
+ args[i] = val;
+ if (--remaining === 0) {
+ resolve(args);
+ }
+ } catch (ex) {
+ reject(ex);
+ }
+ }
+
+ for (var i = 0; i < args.length; i++) {
+ res(i, args[i]);
+ }
+ });
+ };
+
+ Promise.resolve = function (value) {
+ if (value && typeof value === 'object' && value.constructor === Promise) {
+ return value;
+ }
+
+ return new Promise(function (resolve) {
+ resolve(value);
+ });
+ };
+
+ Promise.reject = function (value) {
+ return new Promise(function (resolve, reject) {
+ reject(value);
+ });
+ };
+
+ Promise.race = function (values) {
+ return new Promise(function (resolve, reject) {
+ for (var i = 0, len = values.length; i < len; i++) {
+ values[i].then(resolve, reject);
+ }
+ });
+ };
+
+ /**
+ * Set the immediate function to execute callbacks
+ * @param fn {function} Function to execute
+ * @private
+ */
+ Promise._setImmediateFn = function _setImmediateFn(fn) {
+ asap = fn;
+ };
+
+ Promise._setUnhandledRejectionFn = function _setUnhandledRejectionFn(fn) {
+ onUnhandledRejection = fn;
+ };
+
+ if (typeof module !== 'undefined' && module.exports) {
+ module.exports = Promise;
+ } else if (!root.Promise) {
+ root.Promise = Promise;
+ }
+
+})(window);
diff --git a/assets/js/when-ready.js b/assets/js/when-ready.js
new file mode 100644
index 00000000..4f52a875
--- /dev/null
+++ b/assets/js/when-ready.js
@@ -0,0 +1,70 @@
+/**
+ * Functions to execute when the DOM is ready
+ */
+
+import { whenReady } from './utils/dom';
+
+import { showOwnedComments } from './communications/comment';
+import { showOwnedPosts } from './communications/post';
+
+import { listenAutocomplete } from './autocomplete';
+import { loadBooruData } from './booru';
+import { registerEvents } from './boorujs';
+import { setupBurgerMenu } from './burger';
+import { bindCaptchaLinks } from './captcha';
+import { setupComments } from './comment';
+import { setupDupeReports } from './duplicate_reports.js';
+import { setFingerprintCookie } from './fingerprint';
+import { setupGalleryEditing } from './galleries';
+import { initImagesClientside } from './imagesclientside';
+import { bindImageTarget } from './image_expansion';
+import { setupEvents } from './misc';
+import { setupNotifications } from './notifications';
+import { setupPreviews } from './preview';
+import { setupQuickTag } from './quick-tag';
+import { initializeListener } from './resizablemedia';
+import { setupSettings } from './settings';
+import { listenForKeys } from './shortcuts';
+import { initTagDropdown } from './tags';
+import { setupTagListener } from './tagsinput';
+import { setupTagEvents } from './tagsmisc';
+import { setupTimestamps } from './timeago';
+import { setupImageUpload } from './upload';
+import { setupSearch } from './search';
+import { setupToolbar } from './textiletoolbar';
+import { hideStaffTools } from './staffhider';
+import { pollOptionCreator } from './poll';
+
+whenReady(() => {
+
+ showOwnedComments();
+ showOwnedPosts();
+ loadBooruData();
+ listenAutocomplete();
+ registerEvents();
+ setupBurgerMenu();
+ bindCaptchaLinks();
+ initImagesClientside();
+ setupComments();
+ setupDupeReports();
+ setFingerprintCookie();
+ setupGalleryEditing();
+ bindImageTarget();
+ setupEvents();
+ setupNotifications();
+ setupPreviews();
+ setupQuickTag();
+ initializeListener();
+ setupSettings();
+ listenForKeys();
+ initTagDropdown();
+ setupTagListener();
+ setupTagEvents();
+ setupTimestamps();
+ setupImageUpload();
+ setupSearch();
+ setupToolbar();
+ hideStaffTools();
+ pollOptionCreator();
+
+});