mirror of
https://github.com/philomena-dev/philomena.git
synced 2024-11-27 21:47:59 +01:00
port a bunch of stuff to typescript (untested)
This commit is contained in:
parent
b6973eb437
commit
8ca3902005
27 changed files with 431 additions and 370 deletions
|
@ -14,6 +14,7 @@ import './when-ready';
|
|||
// Would typically be either the theme file, or any additional file
|
||||
// you later intend to put in the <link> tag.
|
||||
|
||||
// import '../css/themes/default.scss';
|
||||
import '../css/application.css';
|
||||
import '../css/themes/dark-blue.css';
|
||||
// import '../css/themes/dark.scss';
|
||||
// import '../css/themes/red.scss';
|
|
@ -1,18 +1,20 @@
|
|||
import { delegate, leftClick } from './utils/events';
|
||||
import { clearEl, makeEl } from './utils/dom';
|
||||
|
||||
function insertCaptcha(_event, target) {
|
||||
const { parentNode, dataset: { sitekey } } = target;
|
||||
function insertCaptcha(_event: Event, target: HTMLInputElement) {
|
||||
const { parentElement, dataset: { sitekey } } = target;
|
||||
|
||||
if (!parentElement) { return; }
|
||||
|
||||
const script = makeEl('script', {src: 'https://hcaptcha.com/1/api.js', async: true, defer: true});
|
||||
const frame = makeEl('div', {className: 'h-captcha'});
|
||||
|
||||
frame.dataset.sitekey = sitekey;
|
||||
|
||||
clearEl(parentNode);
|
||||
clearEl(parentElement);
|
||||
|
||||
parentNode.insertAdjacentElement('beforeend', frame);
|
||||
parentNode.insertAdjacentElement('beforeend', script);
|
||||
parentElement.insertAdjacentElement('beforeend', frame);
|
||||
parentElement.insertAdjacentElement('beforeend', script);
|
||||
}
|
||||
|
||||
export function bindCaptchaLinks() {
|
|
@ -3,24 +3,21 @@
|
|||
*/
|
||||
|
||||
import { $ } from './utils/dom';
|
||||
import { showOwnedComments } from './communications/comment';
|
||||
import { filterNode } from './imagesclientside';
|
||||
import { fetchHtml } from './utils/requests';
|
||||
import { timeAgo } from './timeago';
|
||||
|
||||
function handleError(response) {
|
||||
|
||||
const errorMessage = '<div>Comment failed to load!</div>';
|
||||
|
||||
if (!response.ok) {
|
||||
return errorMessage;
|
||||
}
|
||||
return response.text();
|
||||
|
||||
return response.text();
|
||||
}
|
||||
|
||||
function commentPosted(response) {
|
||||
|
||||
const commentEditTab = $('#js-comment-form a[data-click-tab="write"]'),
|
||||
commentEditForm = $('#js-comment-form'),
|
||||
container = document.getElementById('comments'),
|
||||
|
@ -43,11 +40,9 @@ function commentPosted(response) {
|
|||
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'),
|
||||
|
@ -62,7 +57,6 @@ function loadParentPost(event) {
|
|||
}
|
||||
|
||||
if (commentMatches) {
|
||||
|
||||
// If the regex matched, get the image and comment ID
|
||||
const [ , imageId, commentId ] = commentMatches;
|
||||
|
||||
|
@ -74,13 +68,10 @@ function loadParentPost(event) {
|
|||
});
|
||||
|
||||
return true;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
function insertParentPost(data, clickedLink, fullComment) {
|
||||
|
||||
// Add the 'subthread' class to the comment with the clicked link
|
||||
fullComment.classList.add('subthread');
|
||||
|
||||
|
@ -99,11 +90,9 @@ function insertParentPost(data, clickedLink, fullComment) {
|
|||
|
||||
// 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);
|
||||
|
@ -118,11 +107,9 @@ function clearParentPost(clickedLink, fullComment) {
|
|||
if (!fullComment.classList.contains('fetched-comment')) {
|
||||
fullComment.classList.remove('subthread');
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
function displayComments(container, commentsHtml) {
|
||||
|
||||
container.innerHTML = commentsHtml;
|
||||
|
||||
// Execute timeago on comments
|
||||
|
@ -130,14 +117,9 @@ function displayComments(container, commentsHtml) {
|
|||
|
||||
// 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]+)/),
|
||||
|
@ -147,7 +129,6 @@ function loadComments(event) {
|
|||
fetchHtml(getURL)
|
||||
.then(handleError)
|
||||
.then(data => {
|
||||
|
||||
displayComments(container, data);
|
||||
|
||||
// Make sure the :target CSS selector applies to the inserted content
|
||||
|
@ -159,7 +140,6 @@ function loadComments(event) {
|
|||
});
|
||||
|
||||
return true;
|
||||
|
||||
}
|
||||
|
||||
function setupComments() {
|
||||
|
@ -175,7 +155,6 @@ function setupComments() {
|
|||
}
|
||||
else {
|
||||
filterNode(comments);
|
||||
showOwnedComments();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,10 +0,0 @@
|
|||
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 };
|
|
@ -1,10 +0,0 @@
|
|||
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 };
|
|
@ -1,42 +0,0 @@
|
|||
/**
|
||||
* 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 };
|
45
assets/js/duplicate_reports.ts
Normal file
45
assets/js/duplicate_reports.ts
Normal file
|
@ -0,0 +1,45 @@
|
|||
/**
|
||||
* Interactive behavior for duplicate reports.
|
||||
*/
|
||||
|
||||
import { $, $$ } from './utils/dom';
|
||||
|
||||
function setupDupeReports() {
|
||||
const onion = $<SVGSVGElement>('.onion-skin__image');
|
||||
const slider = $<HTMLInputElement>('.onion-skin__slider');
|
||||
const swipe = $<SVGSVGElement>('.swipe__image');
|
||||
|
||||
if (swipe) setupSwipe(swipe);
|
||||
if (onion && slider) setupOnionSkin(onion, slider);
|
||||
}
|
||||
|
||||
function setupSwipe(swipe: SVGSVGElement) {
|
||||
const [ clip, divider ] = $$<SVGRectElement>('#clip rect, #divider', swipe);
|
||||
const { width } = swipe.viewBox.baseVal;
|
||||
|
||||
function moveDivider({ clientX }: MouseEvent) {
|
||||
// Move center to cursor
|
||||
const rect = swipe.getBoundingClientRect();
|
||||
const newX = (clientX - rect.left) * (width / rect.width);
|
||||
|
||||
divider.setAttribute('x', newX.toString());
|
||||
clip.setAttribute('width', newX.toString());
|
||||
}
|
||||
|
||||
swipe.addEventListener('mousemove', moveDivider);
|
||||
}
|
||||
|
||||
function setupOnionSkin(onion: SVGSVGElement, slider: HTMLInputElement) {
|
||||
const target = $<HTMLImageElement>('#target', onion);
|
||||
|
||||
function setOpacity() {
|
||||
if (target) {
|
||||
target.setAttribute('opacity', slider.value);
|
||||
}
|
||||
}
|
||||
|
||||
setOpacity();
|
||||
slider.addEventListener('input', setOpacity);
|
||||
}
|
||||
|
||||
export { setupDupeReports };
|
|
@ -19,9 +19,9 @@ interface RealUserAgentData {
|
|||
}
|
||||
|
||||
interface RealNavigator extends Navigator {
|
||||
deviceMemory: number,
|
||||
keyboard: RealKeyboard,
|
||||
userAgentData: RealUserAgentData,
|
||||
deviceMemory: number | null,
|
||||
keyboard: RealKeyboard | null,
|
||||
userAgentData: RealUserAgentData | null,
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -81,7 +81,7 @@ async function createFp(): Promise<string> {
|
|||
}
|
||||
|
||||
let width: string | null = store.get('cached_rem_size');
|
||||
const body = $('body');
|
||||
const body = $<HTMLBodyElement>('body');
|
||||
|
||||
if (!width && body) {
|
||||
const testElement = document.createElement('span');
|
||||
|
@ -127,7 +127,7 @@ async function createFp(): Promise<string> {
|
|||
/**
|
||||
* Sets the `_ses` cookie.
|
||||
*
|
||||
* If `cached_ses_value` is present in local storage, uses that instead.
|
||||
* If `cached_ses_value` is present in local storage, uses it to set the `_ses` cookie.
|
||||
* Otherwise if the `_ses` cookie already exits, uses its value instead.
|
||||
* Otherwise attempts to generate a new value for the `_ses` cookie
|
||||
* based on various browser attributes.
|
||||
|
|
|
@ -8,11 +8,13 @@ import { initDraggables } from './utils/draggable';
|
|||
import { fetchJson } from './utils/requests';
|
||||
|
||||
export function setupGalleryEditing() {
|
||||
if (!$('.rearrange-button')) return;
|
||||
if (!$<HTMLElement>('.rearrange-button')) return;
|
||||
|
||||
const [ rearrangeEl, saveEl ] = $$('.rearrange-button');
|
||||
const sortableEl = $('#sortable');
|
||||
const containerEl = $('.media-list');
|
||||
const [ rearrangeEl, saveEl ] = $$<HTMLElement>('.rearrange-button');
|
||||
const sortableEl = $<HTMLDivElement>('#sortable');
|
||||
const containerEl = $<HTMLDivElement>('.media-list');
|
||||
|
||||
if (!sortableEl || !containerEl || !saveEl || !rearrangeEl) { return; }
|
||||
|
||||
// Copy array
|
||||
let oldImages = window.booru.galleryImages.slice();
|
||||
|
@ -20,7 +22,7 @@ export function setupGalleryEditing() {
|
|||
|
||||
initDraggables();
|
||||
|
||||
$$('.media-box', containerEl).forEach(i => i.draggable = true);
|
||||
$$<HTMLDivElement>('.media-box', containerEl).forEach(i => i.draggable = true);
|
||||
|
||||
rearrangeEl.addEventListener('click', () => {
|
||||
sortableEl.classList.add('editing');
|
||||
|
@ -31,15 +33,17 @@ export function setupGalleryEditing() {
|
|||
sortableEl.classList.remove('editing');
|
||||
containerEl.classList.remove('drag-container');
|
||||
|
||||
newImages = $$('.image-container', containerEl).map(i => parseInt(i.dataset.imageId, 10));
|
||||
newImages = $$<HTMLDivElement>('.image-container', containerEl).map(i => parseInt(i.dataset.imageId || '-1', 10));
|
||||
|
||||
// If nothing changed, don't bother.
|
||||
if (arraysEqual(newImages, oldImages)) return;
|
||||
|
||||
fetchJson('PATCH', saveEl.dataset.reorderPath, {
|
||||
image_ids: newImages,
|
||||
if (saveEl.dataset.reorderPath) {
|
||||
fetchJson('PATCH', saveEl.dataset.reorderPath, {
|
||||
image_ids: newImages,
|
||||
|
||||
// copy the array again so that we have the newly updated set
|
||||
}).then(() => oldImages = newImages.slice());
|
||||
// copy the array again so that we have the newly updated set
|
||||
}).then(() => oldImages = newImages.slice());
|
||||
}
|
||||
});
|
||||
}
|
|
@ -1,76 +0,0 @@
|
|||
/**
|
||||
* 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 };
|
131
assets/js/imagesclientside.ts
Normal file
131
assets/js/imagesclientside.ts
Normal file
|
@ -0,0 +1,131 @@
|
|||
/**
|
||||
* Client-side image filtering/spoilering.
|
||||
*/
|
||||
|
||||
import { $$, escapeHtml } from './utils/dom';
|
||||
import { setupInteractions } from './interactions';
|
||||
import { showThumb, showBlock, spoilerThumb, spoilerBlock, hideThumb } from './utils/image';
|
||||
import { TagData, getHiddenTags, getSpoileredTags, imageHitsTags, imageHitsComplex, displayTags } from './utils/tag';
|
||||
import { AstMatcher } from './query/types';
|
||||
|
||||
type RunFilterCallback = (img: HTMLDivElement, test: TagData[]) => void;
|
||||
|
||||
function runFilter(img: HTMLDivElement, test: TagData[] | boolean, runCallback: RunFilterCallback) {
|
||||
if (!test || typeof test !== 'boolean' && test.length === 0) { return false; }
|
||||
|
||||
runCallback(img, test as TagData[]);
|
||||
|
||||
// I don't like this.
|
||||
img.dataset.imageId && window.booru.imagesWithDownvotingDisabled.push(img.dataset.imageId);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---
|
||||
|
||||
function filterThumbSimple(img: HTMLDivElement, tagsHit: TagData[]) {
|
||||
hideThumb(img, tagsHit[0].spoiler_image_uri || window.booru.hiddenTag, `[HIDDEN] ${displayTags(tagsHit)}`);
|
||||
}
|
||||
|
||||
function spoilerThumbSimple(img: HTMLDivElement, tagsHit: TagData[]) {
|
||||
spoilerThumb(img, tagsHit[0].spoiler_image_uri || window.booru.hiddenTag, displayTags(tagsHit));
|
||||
}
|
||||
|
||||
function filterThumbComplex(img: HTMLDivElement) {
|
||||
hideThumb(img, window.booru.hiddenTag, '[HIDDEN] <i>(Complex Filter)</i>');
|
||||
}
|
||||
|
||||
function spoilerThumbComplex(img: HTMLDivElement) {
|
||||
spoilerThumb(img, window.booru.hiddenTag, '<i>(Complex Filter)</i>');
|
||||
}
|
||||
|
||||
function filterBlockSimple(img: HTMLDivElement, tagsHit: TagData[]) {
|
||||
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: HTMLDivElement, tagsHit: TagData[]) {
|
||||
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: HTMLDivElement) {
|
||||
spoilerBlock(img, window.booru.hiddenTag, 'This image was hidden by a complex tag expression in ');
|
||||
}
|
||||
|
||||
function spoilerBlockComplex(img: HTMLDivElement) {
|
||||
spoilerBlock(img, window.booru.hiddenTag, 'This image was spoilered by a complex tag expression in ');
|
||||
}
|
||||
|
||||
// ---
|
||||
|
||||
function thumbTagFilter(tags: TagData[], img: HTMLDivElement) {
|
||||
return runFilter(img, imageHitsTags(img, tags), filterThumbSimple);
|
||||
}
|
||||
|
||||
function thumbComplexFilter(complex: AstMatcher, img: HTMLDivElement) {
|
||||
return runFilter(img, imageHitsComplex(img, complex), filterThumbComplex);
|
||||
}
|
||||
|
||||
function thumbTagSpoiler(tags: TagData[], img: HTMLDivElement) {
|
||||
return runFilter(img, imageHitsTags(img, tags), spoilerThumbSimple);
|
||||
}
|
||||
|
||||
function thumbComplexSpoiler(complex: AstMatcher, img: HTMLDivElement) {
|
||||
return runFilter(img, imageHitsComplex(img, complex), spoilerThumbComplex);
|
||||
}
|
||||
|
||||
function blockTagFilter(tags: TagData[], img: HTMLDivElement) {
|
||||
return runFilter(img, imageHitsTags(img, tags), filterBlockSimple);
|
||||
}
|
||||
|
||||
function blockComplexFilter(complex: AstMatcher, img: HTMLDivElement) {
|
||||
return runFilter(img, imageHitsComplex(img, complex), filterBlockComplex);
|
||||
}
|
||||
|
||||
function blockTagSpoiler(tags: TagData[], img: HTMLDivElement) {
|
||||
return runFilter(img, imageHitsTags(img, tags), spoilerBlockSimple);
|
||||
}
|
||||
|
||||
function blockComplexSpoiler(complex: AstMatcher, img: HTMLDivElement) {
|
||||
return runFilter(img, imageHitsComplex(img, complex), spoilerBlockComplex);
|
||||
}
|
||||
|
||||
// ---
|
||||
|
||||
function filterNode(node: Pick<Document, 'querySelectorAll'>) {
|
||||
const hiddenTags = getHiddenTags(), spoileredTags = getSpoileredTags();
|
||||
const { hiddenFilter, spoileredFilter } = window.booru;
|
||||
|
||||
// Image thumb boxes with vote and fave buttons on them
|
||||
$$<HTMLDivElement>('.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
|
||||
$$<HTMLDivElement>('.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 };
|
|
@ -7,8 +7,8 @@
|
|||
import { $ } from './utils/dom';
|
||||
|
||||
function warnAboutPMs() {
|
||||
const textarea = $('.js-toolbar-input');
|
||||
const warning = $('.js-hidden-warning');
|
||||
const textarea = $<HTMLTextAreaElement>('.js-toolbar-input');
|
||||
const warning = $<HTMLDivElement>('.js-hidden-warning');
|
||||
const imageEmbedRegex = /!+\[/g;
|
||||
|
||||
if (!warning || !textarea) return;
|
|
@ -1,6 +1,6 @@
|
|||
import { inputDuplicatorCreator } from './input-duplicator';
|
||||
|
||||
function pollOptionCreator() {
|
||||
export function pollOptionCreator() {
|
||||
inputDuplicatorCreator({
|
||||
addButtonSelector: '.js-poll-add-option',
|
||||
fieldSelector: '.js-poll-option',
|
||||
|
@ -8,5 +8,3 @@ function pollOptionCreator() {
|
|||
removeButtonSelector: '.js-option-remove',
|
||||
});
|
||||
}
|
||||
|
||||
export { pollOptionCreator };
|
|
@ -1,45 +0,0 @@
|
|||
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 };
|
50
assets/js/search.ts
Normal file
50
assets/js/search.ts
Normal file
|
@ -0,0 +1,50 @@
|
|||
import { $, $$ } from './utils/dom';
|
||||
import { addTag } from './tagsinput';
|
||||
|
||||
function showHelp(subject: string, type: string | null) {
|
||||
$$<HTMLElement>('[data-search-help]').forEach(helpBox => {
|
||||
if (helpBox.getAttribute('data-search-help') === type) {
|
||||
const searchSubject = $<HTMLElement>('.js-search-help-subject', helpBox);
|
||||
|
||||
if (searchSubject) {
|
||||
searchSubject.textContent = subject;
|
||||
}
|
||||
|
||||
helpBox.classList.remove('hidden');
|
||||
}
|
||||
else {
|
||||
helpBox.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function prependToLast(field: HTMLInputElement, value: string) {
|
||||
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: HTMLInputElement, characterCount: number) {
|
||||
field.focus();
|
||||
|
||||
field.selectionStart = field.value.length - characterCount;
|
||||
field.selectionEnd = field.value.length;
|
||||
}
|
||||
|
||||
function executeFormHelper(e: PointerEvent) {
|
||||
if (!e.target) { return; }
|
||||
|
||||
const searchField = $<HTMLInputElement>('.js-search-field');
|
||||
const attr = (name: string) => e.target && (e.target as HTMLElement).getAttribute(name);
|
||||
|
||||
attr('data-search-add') && addTag(searchField, attr('data-search-add'));
|
||||
attr('data-search-show-help') && showHelp((e.target as Node).textContent || '', attr('data-search-show-help'));
|
||||
attr('data-search-select-last') && searchField && selectLast(searchField, parseInt(attr('data-search-select-last') || '', 10));
|
||||
attr('data-search-prepend') && searchField && prependToLast(searchField, attr('data-search-prepend') || '');
|
||||
}
|
||||
|
||||
export function setupSearch() {
|
||||
const form = $<HTMLInputElement>('.js-search-form');
|
||||
|
||||
form && form.addEventListener('click', executeFormHelper as EventListener);
|
||||
}
|
|
@ -6,12 +6,11 @@ import { $, $$ } from './utils/dom';
|
|||
import store from './utils/store';
|
||||
|
||||
export function setupSettings() {
|
||||
if (!$<HTMLElement>('#js-setting-table')) return;
|
||||
|
||||
if (!$('#js-setting-table')) return;
|
||||
|
||||
const localCheckboxes = $$('[data-tab="local"] input[type="checkbox"]');
|
||||
const themeSelect = $('#user_theme');
|
||||
const styleSheet = $('head link[rel="stylesheet"]');
|
||||
const localCheckboxes = $$<HTMLInputElement>('[data-tab="local"] input[type="checkbox"]');
|
||||
const themeSelect = $<HTMLSelectElement>('#user_theme');
|
||||
const styleSheet = $<HTMLLinkElement>('head link[rel="stylesheet"]');
|
||||
|
||||
// Local settings
|
||||
localCheckboxes.forEach(checkbox => {
|
||||
|
@ -22,7 +21,8 @@ export function setupSettings() {
|
|||
|
||||
// Theme preview
|
||||
themeSelect && themeSelect.addEventListener('change', () => {
|
||||
styleSheet.href = themeSelect.options[themeSelect.selectedIndex].dataset.themePath;
|
||||
if (styleSheet) {
|
||||
styleSheet.href = themeSelect.options[themeSelect.selectedIndex].dataset.themePath || '#';
|
||||
}
|
||||
});
|
||||
|
||||
}
|
|
@ -1,62 +0,0 @@
|
|||
/**
|
||||
* Keyboard shortcuts
|
||||
*/
|
||||
|
||||
import { $ } from './utils/dom';
|
||||
|
||||
function getHover() {
|
||||
const thumbBoxHover = $('.media-box:hover');
|
||||
if (thumbBoxHover) return thumbBoxHover.dataset.imageId;
|
||||
}
|
||||
|
||||
function openFullView() {
|
||||
const imageHover = $('[data-uris]:hover');
|
||||
if (!imageHover) return;
|
||||
|
||||
window.location = JSON.parse(imageHover.dataset.uris).full;
|
||||
}
|
||||
|
||||
function openFullViewNewTab() {
|
||||
const imageHover = $('[data-uris]:hover');
|
||||
if (!imageHover) return;
|
||||
|
||||
window.open(JSON.parse(imageHover.dataset.uris).full);
|
||||
}
|
||||
|
||||
function click(selector) {
|
||||
const el = $(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
|
||||
79() { openFullView(); }, // O - open original
|
||||
86() { openFullViewNewTab(); }, // V - open original in a new tab
|
||||
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 };
|
76
assets/js/shortcuts.ts
Normal file
76
assets/js/shortcuts.ts
Normal file
|
@ -0,0 +1,76 @@
|
|||
/**
|
||||
* Keyboard shortcuts
|
||||
*/
|
||||
|
||||
import { $ } from './utils/dom';
|
||||
|
||||
interface ShortcutKeycodes {
|
||||
[key: string]: () => void
|
||||
}
|
||||
|
||||
function getHover(): string | null {
|
||||
const thumbBoxHover = $<HTMLDivElement>('.media-box:hover');
|
||||
|
||||
return thumbBoxHover && (thumbBoxHover.dataset.imageId || null);
|
||||
}
|
||||
|
||||
function openFullView() {
|
||||
const imageHover = $<HTMLDivElement>('[data-uris]:hover');
|
||||
|
||||
if (!imageHover || !imageHover.dataset.uris) return;
|
||||
|
||||
window.location = JSON.parse(imageHover.dataset.uris).full;
|
||||
}
|
||||
|
||||
function openFullViewNewTab() {
|
||||
const imageHover = $<HTMLDivElement>('[data-uris]:hover');
|
||||
|
||||
if (!imageHover || !imageHover.dataset.uris) return;
|
||||
|
||||
window.open(JSON.parse(imageHover.dataset.uris).full);
|
||||
}
|
||||
|
||||
function click(selector: string) {
|
||||
const el = $<HTMLElement>(selector);
|
||||
|
||||
if (el) {
|
||||
el.click();
|
||||
}
|
||||
}
|
||||
|
||||
function isOK(event: KeyboardEvent): boolean {
|
||||
return !event.altKey && !event.ctrlKey && !event.metaKey &&
|
||||
document.activeElement !== null &&
|
||||
document.activeElement.tagName !== 'INPUT' &&
|
||||
document.activeElement.tagName !== 'TEXTAREA';
|
||||
}
|
||||
|
||||
const keyCodes: ShortcutKeycodes = {
|
||||
KeyJ() { click('.js-prev'); }, // J - go to previous image
|
||||
KeyI() { click('.js-up'); }, // I - go to index page
|
||||
KeyK() { click('.js-next'); }, // K - go to next image
|
||||
KeyR() { click('.js-rand'); }, // R - go to random image
|
||||
KeyS() { click('.js-source-link'); }, // S - go to image source
|
||||
KeyL() { click('.js-tag-sauce-toggle'); }, // L - edit tags
|
||||
KeyO() { openFullView(); }, // O - open original
|
||||
KeyV() { openFullViewNewTab(); }, // V - open original in a new tab
|
||||
KeyF() { // F - favourite image
|
||||
getHover() ? click(`a.interaction--fave[data-image-id="${getHover()}"]`)
|
||||
: click('.block__header a.interaction--fave');
|
||||
},
|
||||
KeyU() { // U - upvote image
|
||||
getHover() ? click(`a.interaction--upvote[data-image-id="${getHover()}"]`)
|
||||
: click('.block__header a.interaction--upvote');
|
||||
},
|
||||
};
|
||||
|
||||
function listenForKeys() {
|
||||
document.addEventListener('keydown', (event: KeyboardEvent) => {
|
||||
if (isOK(event) && keyCodes[event.code]) {
|
||||
keyCodes[event.code]();
|
||||
event.preventDefault();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export { listenForKeys };
|
|
@ -1,5 +1,10 @@
|
|||
import { $ } from './utils/dom';
|
||||
import { inputDuplicatorCreator } from './input-duplicator';
|
||||
|
||||
export interface SourcesEvent extends CustomEvent<Response> {
|
||||
target: HTMLElement,
|
||||
}
|
||||
|
||||
function setupInputs() {
|
||||
inputDuplicatorCreator({
|
||||
addButtonSelector: '.js-image-add-source',
|
||||
|
@ -11,16 +16,17 @@ function setupInputs() {
|
|||
|
||||
function imageSourcesCreator() {
|
||||
setupInputs();
|
||||
document.addEventListener('fetchcomplete', ({ target, detail }) => {
|
||||
const sourceSauce = document.querySelector('.js-sourcesauce');
|
||||
|
||||
if (target.matches('#source-form')) {
|
||||
document.addEventListener('fetchcomplete', (({ target, detail }: SourcesEvent) => {
|
||||
const sourceSauce = $<HTMLElement>('.js-sourcesauce');
|
||||
|
||||
if (sourceSauce && target && target.matches('#source-form')) {
|
||||
detail.text().then(text => {
|
||||
sourceSauce.outerHTML = text;
|
||||
setupInputs();
|
||||
});
|
||||
}
|
||||
});
|
||||
}) as EventListener);
|
||||
}
|
||||
|
||||
export { imageSourcesCreator };
|
|
@ -1,56 +0,0 @@
|
|||
/**
|
||||
* 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 };
|
70
assets/js/tagsmisc.ts
Normal file
70
assets/js/tagsmisc.ts
Normal file
|
@ -0,0 +1,70 @@
|
|||
/**
|
||||
* Tags Misc
|
||||
*/
|
||||
|
||||
import { $, $$ } from './utils/dom';
|
||||
import store from './utils/store';
|
||||
import { initTagDropdown } from './tags';
|
||||
import { setupTagsInput, reloadTagsInput } from './tagsinput';
|
||||
import { SourcesEvent } from './sources';
|
||||
|
||||
type TagInputActionFunction = (tagInput: HTMLTextAreaElement | null) => void
|
||||
type TagInputActionList = {
|
||||
save: TagInputActionFunction,
|
||||
load: TagInputActionFunction,
|
||||
clear: TagInputActionFunction,
|
||||
}
|
||||
|
||||
function tagInputButtons({target}: PointerEvent) {
|
||||
const actions: TagInputActionList = {
|
||||
save(tagInput: HTMLTextAreaElement | null) {
|
||||
tagInput && store.set('tag_input', tagInput.value);
|
||||
},
|
||||
load(tagInput: HTMLTextAreaElement | null) {
|
||||
if (!tagInput) { return; }
|
||||
|
||||
// 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: HTMLTextAreaElement | null) {
|
||||
if (!tagInput) { return; }
|
||||
|
||||
tagInput.value = '';
|
||||
reloadTagsInput(tagInput);
|
||||
},
|
||||
};
|
||||
|
||||
for (const action in actions) {
|
||||
if (target && (target as HTMLElement).matches(`#tagsinput-${action}`)) {
|
||||
actions[action as keyof TagInputActionList]($<HTMLTextAreaElement>('image_tag_input'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setupTags() {
|
||||
$$<HTMLDivElement>('.js-tag-block').forEach(el => {
|
||||
setupTagsInput(el);
|
||||
el.classList.remove('js-tag-block');
|
||||
});
|
||||
}
|
||||
|
||||
function updateTagSauce({target, detail}: SourcesEvent) {
|
||||
const tagSauce = $<HTMLDivElement>('.js-tagsauce');
|
||||
|
||||
if (tagSauce && target.matches('#tags-form')) {
|
||||
detail.text().then(text => {
|
||||
tagSauce.outerHTML = text;
|
||||
setupTags();
|
||||
initTagDropdown();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function setupTagEvents() {
|
||||
setupTags();
|
||||
document.addEventListener('fetchcomplete', updateTagSauce as EventListener);
|
||||
document.addEventListener('click', tagInputButtons as EventListener);
|
||||
}
|
||||
|
||||
export { setupTagEvents };
|
|
@ -28,13 +28,13 @@ function sortTags(hidden: boolean, a: TagData, b: TagData): number {
|
|||
return a.spoiler_image_uri ? -1 : 1;
|
||||
}
|
||||
|
||||
export function getHiddenTags() {
|
||||
export function getHiddenTags(): TagData[] {
|
||||
return unique(window.booru.hiddenTagList)
|
||||
.map(tagId => getTag(tagId))
|
||||
.sort(sortTags.bind(null, true));
|
||||
}
|
||||
|
||||
export function getSpoileredTags() {
|
||||
export function getSpoileredTags(): TagData[] {
|
||||
if (window.booru.spoilerType === 'off') return [];
|
||||
|
||||
return unique(window.booru.spoileredTagList)
|
||||
|
|
|
@ -4,9 +4,6 @@
|
|||
|
||||
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';
|
||||
|
@ -40,8 +37,6 @@ import { setupSliders } from './slider';
|
|||
|
||||
whenReady(() => {
|
||||
|
||||
showOwnedComments();
|
||||
showOwnedPosts();
|
||||
loadBooruData();
|
||||
listenAutocomplete();
|
||||
registerEvents();
|
|
@ -21,7 +21,8 @@ window.booru = {
|
|||
spoileredFilter: matchNone(),
|
||||
interactions: [],
|
||||
tagsVersion: 5,
|
||||
hideStaffTools: 'false'
|
||||
hideStaffTools: 'false',
|
||||
galleryImages: []
|
||||
};
|
||||
|
||||
// https://github.com/jsdom/jsdom/issues/1721#issuecomment-1484202038
|
||||
|
|
4
assets/types/booru-object.d.ts
vendored
4
assets/types/booru-object.d.ts
vendored
|
@ -69,6 +69,10 @@ interface BooruObject {
|
|||
* Indicates whether sensitive staff-only info should be hidden or not.
|
||||
*/
|
||||
hideStaffTools: string;
|
||||
/**
|
||||
* List of image IDs in the current gallery.
|
||||
*/
|
||||
galleryImages: number[]
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
|
|
@ -45,7 +45,7 @@ export default defineConfig(({ command, mode }: ConfigEnv): UserConfig => {
|
|||
cssCodeSplit: true,
|
||||
rollupOptions: {
|
||||
input: {
|
||||
'js/app': './js/app.js',
|
||||
'js/app': './js/app.ts',
|
||||
'css/application': './css/application.css',
|
||||
...Object.fromEntries(targets)
|
||||
},
|
||||
|
|
|
@ -24,7 +24,7 @@ html lang="en"
|
|||
|
||||
= vite_hmr? do
|
||||
script type="module" src="http://localhost:5173/@vite/client"
|
||||
script type="module" src="http://localhost:5173/js/app.js"
|
||||
script type="module" src="http://localhost:5173/js/app.ts"
|
||||
- else
|
||||
script type="text/javascript" src=Routes.static_path(@conn, "/js/app.js") async="async"
|
||||
= render PhilomenaWeb.LayoutView, "_opengraph.html", assigns
|
||||
|
|
Loading…
Reference in a new issue