add scripts

This commit is contained in:
byte[] 2019-10-04 20:09:52 -04:00
parent 509d53dbee
commit a40d31e9cd
48 changed files with 4456 additions and 13 deletions

View file

@ -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';

156
assets/js/autocomplete.js Normal file
View file

@ -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 };

114
assets/js/booru.js Normal file
View file

@ -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 };

109
assets/js/boorujs.js Normal file
View file

@ -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 };

73
assets/js/burger.js Normal file
View file

@ -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 };

11
assets/js/cable.js Normal file
View file

@ -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 };

33
assets/js/captcha.js Normal file
View file

@ -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', '<p class="block block--danger">Failed to fetch challenge from server!</p>');
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 };

197
assets/js/comment.js Normal file
View file

@ -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 = '<div>Comment failed to load!</div>';
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 };

View file

@ -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 };

View file

@ -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 };

View file

@ -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 };

51
assets/js/fingerprint.js Normal file
View file

@ -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 };

45
assets/js/galleries.js Normal file
View file

@ -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());
});
}

View file

@ -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',
`<video controls autoplay loop muted playsinline preload="auto" id="image-display"
width="${imageWidth}" height="${imageHeight}">
<source src="${uris.mp4}" type="video/mp4">
<source src="${uris.webm}" type="video/webm">
<p class="block block--fixed block--warning">
Your browser supports neither MP4/H264 nor
WebM/VP8! Please update it to the latest version.
</p>
</video>`
);
}
else if (imageFormat === 'webm') {
elem.insertAdjacentHTML('afterbegin',
`<video controls autoplay loop muted playsinline id="image-display">
<source src="${uri}" type="video/webm">
<source src="${uri.replace(/webm$/, 'mp4')}" type="video/mp4">
<p class="block block--fixed block--warning">
Your browser supports neither MP4/H264 nor
WebM/VP8! Please update it to the latest version.
</p>
</video>`
);
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 = `<picture><img id="image-display" src="${uri}" class="image-scaled"></picture>`;
}
else if (scaled === 'partscaled') {
image = `<picture><img id="image-display" src="${uri}" class="image-partscaled"></picture>`;
}
else {
image = `<picture><img id="image-display" src="${uri}" width="${imageWidth}" height="${imageHeight}"></picture>`;
}
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 };

View file

@ -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] <i>(Complex Filter)</i>'); }
function spoilerThumbComplex(img) { spoilerThumb(img, window.booru.hiddenTag, '<i>(Complex Filter)</i>'); }
function filterBlockSimple(img, tagsHit) { spoilerBlock(img, tagsHit[0].spoiler_image_uri || window.booru.hiddenTag, `This image is tagged <code>${escapeHtml(tagsHit[0].name)}</code>, which is hidden by `); }
function spoilerBlockSimple(img, tagsHit) { spoilerBlock(img, tagsHit[0].spoiler_image_uri || window.booru.hiddenTag, `This image is tagged <code>${escapeHtml(tagsHit[0].name)}</code>, 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 };

201
assets/js/interactions.js Normal file
View file

@ -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 };

871
assets/js/match_query.js Normal file
View file

@ -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;

84
assets/js/misc.js Normal file
View file

@ -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 };

View file

@ -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 };

36
assets/js/poll.js Normal file
View file

@ -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 };

88
assets/js/preview.js Normal file
View file

@ -0,0 +1,88 @@
/**
* Textile previews (posts, comments, messages)
*/
import { fetchJson } from './utils/requests';
import { filterNode } from './imagesclientside';
function handleError(response) {
const errorMessage = '<div>Preview failed to load!</div>';
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 };

113
assets/js/quick-tag.js Normal file
View file

@ -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 };

View file

@ -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 };

45
assets/js/search.js Normal file
View file

@ -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 };

29
assets/js/settings.js Normal file
View file

@ -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;
});
}

44
assets/js/shortcuts.js Normal file
View file

@ -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 };

17
assets/js/staffhider.js Normal file
View file

@ -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 };

61
assets/js/tags.js Normal file
View file

@ -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 };

141
assets/js/tagsinput.js Normal file
View file

@ -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 = `<span class="tag">${escapeHtml(name)} <a href="#" data-click-focus=".js-taginput-input" data-tag-name="${escapeHtml(name)}">x</a></span>`;
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 };

56
assets/js/tagsmisc.js Normal file
View file

@ -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 };

172
assets/js/textiletoolbar.js Normal file
View file

@ -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 };

68
assets/js/timeago.js Normal file
View file

@ -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;

106
assets/js/ujs.js Normal file
View file

@ -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
});

105
assets/js/upload.js Normal file
View file

@ -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 };

13
assets/js/utils/array.js Normal file
View file

@ -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 };

69
assets/js/utils/dom.js Normal file
View file

@ -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, '&amp;')
.replace(/>/g, '&gt;')
.replace(/</g, '&lt;')
.replace(/"/g, '&quot;');
}
function findFirstTextNode(of) {
return Array.prototype.filter.call(of.childNodes, el => el.nodeType === Node.TEXT_NODE)[0];
}
export { $, $$, showEl, hideEl, toggleEl, clearEl, removeEl, makeEl, insertBefore, onLeftClick, whenReady, escapeHtml, findFirstTextNode };

View file

@ -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));
}

20
assets/js/utils/events.js Normal file
View file

@ -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;
}
});
}

128
assets/js/utils/image.js Normal file
View file

@ -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 = `
<source src="${thumbUri}" type="video/webm"/>
<source src="${thumbUri.replace(/webm$/, 'mp4')}" type="video/mp4"/>
`;
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 };

View file

@ -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 };

66
assets/js/utils/store.js Normal file
View file

@ -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;
},
};

58
assets/js/utils/tag.js Normal file
View file

@ -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 += `<span title="${extras}">, ${extras}</span>`;
}
return list;
}
export { getHiddenTags, getSpoileredTags, imageHitsTags, imageHitsComplex, displayTags };

31
assets/js/vendor/closest.polyfill.js vendored Normal file
View file

@ -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;
};
}

View file

@ -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;
})();

104
assets/js/vendor/es6.polyfill.js vendored Normal file
View file

@ -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;
};
}

53
assets/js/vendor/fetch.polyfill.js vendored Normal file
View file

@ -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
}
};
}
});
};
}

230
assets/js/vendor/promise.polyfill.js vendored Normal file
View file

@ -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);

70
assets/js/when-ready.js Normal file
View file

@ -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();
});