From 8ca39020059e6e195eea4e7e99d56e4fddf2bb63 Mon Sep 17 00:00:00 2001 From: "Luna D." Date: Thu, 6 Jun 2024 18:48:16 +0200 Subject: [PATCH] port a bunch of stuff to typescript (untested) --- assets/js/{app.js => app.ts} | 3 +- assets/js/{captcha.js => captcha.ts} | 12 +- assets/js/comment.js | 23 +-- assets/js/communications/comment.js | 10 -- assets/js/communications/post.js | 10 -- assets/js/duplicate_reports.js | 42 ------ assets/js/duplicate_reports.ts | 45 ++++++ assets/js/fp.ts | 10 +- assets/js/{galleries.js => galleries.ts} | 24 ++-- assets/js/imagesclientside.js | 76 ---------- assets/js/imagesclientside.ts | 131 ++++++++++++++++++ assets/js/{pmwarning.js => pmwarning.ts} | 4 +- assets/js/{poll.js => poll.ts} | 4 +- assets/js/search.js | 45 ------ assets/js/search.ts | 50 +++++++ assets/js/{settings.js => settings.ts} | 14 +- assets/js/shortcuts.js | 62 --------- assets/js/shortcuts.ts | 76 ++++++++++ assets/js/{sources.js => sources.ts} | 14 +- assets/js/tagsmisc.js | 56 -------- assets/js/tagsmisc.ts | 70 ++++++++++ assets/js/utils/tag.ts | 4 +- assets/js/{when-ready.js => when-ready.ts} | 5 - assets/test/vitest-setup.ts | 3 +- assets/types/booru-object.d.ts | 4 + assets/vite.config.ts | 2 +- .../templates/layout/app.html.slime | 2 +- 27 files changed, 431 insertions(+), 370 deletions(-) rename assets/js/{app.js => app.ts} (91%) rename assets/js/{captcha.js => captcha.ts} (57%) delete mode 100644 assets/js/communications/comment.js delete mode 100644 assets/js/communications/post.js delete mode 100644 assets/js/duplicate_reports.js create mode 100644 assets/js/duplicate_reports.ts rename assets/js/{galleries.js => galleries.ts} (51%) delete mode 100644 assets/js/imagesclientside.js create mode 100644 assets/js/imagesclientside.ts rename assets/js/{pmwarning.js => pmwarning.ts} (80%) rename assets/js/{poll.js => poll.ts} (81%) delete mode 100644 assets/js/search.js create mode 100644 assets/js/search.ts rename assets/js/{settings.js => settings.ts} (50%) delete mode 100644 assets/js/shortcuts.js create mode 100644 assets/js/shortcuts.ts rename assets/js/{sources.js => sources.ts} (58%) delete mode 100644 assets/js/tagsmisc.js create mode 100644 assets/js/tagsmisc.ts rename assets/js/{when-ready.js => when-ready.ts} (93%) diff --git a/assets/js/app.js b/assets/js/app.ts similarity index 91% rename from assets/js/app.js rename to assets/js/app.ts index 4f23d655..863f4ff2 100644 --- a/assets/js/app.js +++ b/assets/js/app.ts @@ -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 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'; diff --git a/assets/js/captcha.js b/assets/js/captcha.ts similarity index 57% rename from assets/js/captcha.js rename to assets/js/captcha.ts index ec0b4f32..eef2f0b2 100644 --- a/assets/js/captcha.js +++ b/assets/js/captcha.ts @@ -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() { diff --git a/assets/js/comment.js b/assets/js/comment.js index de245fa8..feb05b59 100644 --- a/assets/js/comment.js +++ b/assets/js/comment.js @@ -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 = '
Comment failed to load!
'; 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(); } } diff --git a/assets/js/communications/comment.js b/assets/js/communications/comment.js deleted file mode 100644 index a4661c61..00000000 --- a/assets/js/communications/comment.js +++ /dev/null @@ -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 }; diff --git a/assets/js/communications/post.js b/assets/js/communications/post.js deleted file mode 100644 index d2172220..00000000 --- a/assets/js/communications/post.js +++ /dev/null @@ -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 }; diff --git a/assets/js/duplicate_reports.js b/assets/js/duplicate_reports.js deleted file mode 100644 index a6794ec4..00000000 --- a/assets/js/duplicate_reports.js +++ /dev/null @@ -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 }; diff --git a/assets/js/duplicate_reports.ts b/assets/js/duplicate_reports.ts new file mode 100644 index 00000000..12eb8c2c --- /dev/null +++ b/assets/js/duplicate_reports.ts @@ -0,0 +1,45 @@ +/** + * Interactive behavior for duplicate reports. + */ + +import { $, $$ } from './utils/dom'; + +function setupDupeReports() { + const onion = $('.onion-skin__image'); + const slider = $('.onion-skin__slider'); + const swipe = $('.swipe__image'); + + if (swipe) setupSwipe(swipe); + if (onion && slider) setupOnionSkin(onion, slider); +} + +function setupSwipe(swipe: SVGSVGElement) { + const [ clip, divider ] = $$('#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 = $('#target', onion); + + function setOpacity() { + if (target) { + target.setAttribute('opacity', slider.value); + } + } + + setOpacity(); + slider.addEventListener('input', setOpacity); +} + +export { setupDupeReports }; diff --git a/assets/js/fp.ts b/assets/js/fp.ts index 84a1557b..49182bc9 100644 --- a/assets/js/fp.ts +++ b/assets/js/fp.ts @@ -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 { } let width: string | null = store.get('cached_rem_size'); - const body = $('body'); + const body = $('body'); if (!width && body) { const testElement = document.createElement('span'); @@ -127,7 +127,7 @@ async function createFp(): Promise { /** * 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. diff --git a/assets/js/galleries.js b/assets/js/galleries.ts similarity index 51% rename from assets/js/galleries.js rename to assets/js/galleries.ts index e881fa65..7866de74 100644 --- a/assets/js/galleries.js +++ b/assets/js/galleries.ts @@ -8,11 +8,13 @@ import { initDraggables } from './utils/draggable'; import { fetchJson } from './utils/requests'; export function setupGalleryEditing() { - if (!$('.rearrange-button')) return; + if (!$('.rearrange-button')) return; - const [ rearrangeEl, saveEl ] = $$('.rearrange-button'); - const sortableEl = $('#sortable'); - const containerEl = $('.media-list'); + const [ rearrangeEl, saveEl ] = $$('.rearrange-button'); + const sortableEl = $('#sortable'); + const containerEl = $('.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); + $$('.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 = $$('.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()); + } }); } diff --git a/assets/js/imagesclientside.js b/assets/js/imagesclientside.js deleted file mode 100644 index 7dc3fb31..00000000 --- a/assets/js/imagesclientside.js +++ /dev/null @@ -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] (Complex Filter)'); } -function spoilerThumbComplex(img) { spoilerThumb(img, window.booru.hiddenTag, '(Complex Filter)'); } - -function filterBlockSimple(img, tagsHit) { spoilerBlock(img, tagsHit[0].spoiler_image_uri || window.booru.hiddenTag, `This image is tagged ${escapeHtml(tagsHit[0].name)}, which is hidden by `); } -function spoilerBlockSimple(img, tagsHit) { spoilerBlock(img, tagsHit[0].spoiler_image_uri || window.booru.hiddenTag, `This image is tagged ${escapeHtml(tagsHit[0].name)}, which is spoilered by `); } -function filterBlockComplex(img) { spoilerBlock(img, window.booru.hiddenTag, 'This image was hidden by a complex tag expression in '); } -function spoilerBlockComplex(img) { spoilerBlock(img, window.booru.hiddenTag, 'This image was spoilered by a complex tag expression in '); } - -// --- - -function thumbTagFilter(tags, img) { return runFilter(img, imageHitsTags(img, tags), filterThumbSimple); } -function thumbComplexFilter(complex, img) { return runFilter(img, imageHitsComplex(img, complex), filterThumbComplex); } -function thumbTagSpoiler(tags, img) { return runFilter(img, imageHitsTags(img, tags), spoilerThumbSimple); } -function thumbComplexSpoiler(complex, img) { return runFilter(img, imageHitsComplex(img, complex), spoilerThumbComplex); } - -function blockTagFilter(tags, img) { return runFilter(img, imageHitsTags(img, tags), filterBlockSimple); } -function blockComplexFilter(complex, img) { return runFilter(img, imageHitsComplex(img, complex), filterBlockComplex); } -function blockTagSpoiler(tags, img) { return runFilter(img, imageHitsTags(img, tags), spoilerBlockSimple); } -function blockComplexSpoiler(complex, img) { return runFilter(img, imageHitsComplex(img, complex), spoilerBlockComplex); } - -// --- - -function filterNode(node = document) { - const hiddenTags = getHiddenTags(), spoileredTags = getSpoileredTags(); - const { hiddenFilter, spoileredFilter } = window.booru; - - // Image thumb boxes with vote and fave buttons on them - $$('.image-container', node) - .filter(img => !thumbTagFilter(hiddenTags, img)) - .filter(img => !thumbComplexFilter(hiddenFilter, img)) - .filter(img => !thumbTagSpoiler(spoileredTags, img)) - .filter(img => !thumbComplexSpoiler(spoileredFilter, img)) - .forEach(img => showThumb(img)); - - // Individual image pages and images in posts/comments - $$('.image-show-container', node) - .filter(img => !blockTagFilter(hiddenTags, img)) - .filter(img => !blockComplexFilter(hiddenFilter, img)) - .filter(img => !blockTagSpoiler(spoileredTags, img)) - .filter(img => !blockComplexSpoiler(spoileredFilter, img)) - .forEach(img => showBlock(img)); -} - -function initImagesClientside() { - window.booru.imagesWithDownvotingDisabled = []; - // This fills the imagesWithDownvotingDisabled array - filterNode(document); - // Once the array is populated, we can initialize interactions - setupInteractions(); -} - -export { initImagesClientside, filterNode }; diff --git a/assets/js/imagesclientside.ts b/assets/js/imagesclientside.ts new file mode 100644 index 00000000..47fef0e9 --- /dev/null +++ b/assets/js/imagesclientside.ts @@ -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] (Complex Filter)'); +} + +function spoilerThumbComplex(img: HTMLDivElement) { + spoilerThumb(img, window.booru.hiddenTag, '(Complex Filter)'); +} + +function filterBlockSimple(img: HTMLDivElement, tagsHit: TagData[]) { + spoilerBlock( + img, + tagsHit[0].spoiler_image_uri || window.booru.hiddenTag, + `This image is tagged ${escapeHtml(tagsHit[0].name)}, which is hidden by ` + ); +} + +function spoilerBlockSimple(img: HTMLDivElement, tagsHit: TagData[]) { + spoilerBlock( + img, + tagsHit[0].spoiler_image_uri || window.booru.hiddenTag, + `This image is tagged ${escapeHtml(tagsHit[0].name)}, which is spoilered by ` + ); +} + +function filterBlockComplex(img: 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) { + const hiddenTags = getHiddenTags(), spoileredTags = getSpoileredTags(); + const { hiddenFilter, spoileredFilter } = window.booru; + + // Image thumb boxes with vote and fave buttons on them + $$('.image-container', node) + .filter(img => !thumbTagFilter(hiddenTags, img)) + .filter(img => !thumbComplexFilter(hiddenFilter, img)) + .filter(img => !thumbTagSpoiler(spoileredTags, img)) + .filter(img => !thumbComplexSpoiler(spoileredFilter, img)) + .forEach(img => showThumb(img)); + + // Individual image pages and images in posts/comments + $$('.image-show-container', node) + .filter(img => !blockTagFilter(hiddenTags, img)) + .filter(img => !blockComplexFilter(hiddenFilter, img)) + .filter(img => !blockTagSpoiler(spoileredTags, img)) + .filter(img => !blockComplexSpoiler(spoileredFilter, img)) + .forEach(img => showBlock(img)); +} + +function initImagesClientside() { + window.booru.imagesWithDownvotingDisabled = []; + // This fills the imagesWithDownvotingDisabled array + filterNode(document); + // Once the array is populated, we can initialize interactions + setupInteractions(); +} + +export { initImagesClientside, filterNode }; diff --git a/assets/js/pmwarning.js b/assets/js/pmwarning.ts similarity index 80% rename from assets/js/pmwarning.js rename to assets/js/pmwarning.ts index 104bd09c..524f0f12 100644 --- a/assets/js/pmwarning.js +++ b/assets/js/pmwarning.ts @@ -7,8 +7,8 @@ import { $ } from './utils/dom'; function warnAboutPMs() { - const textarea = $('.js-toolbar-input'); - const warning = $('.js-hidden-warning'); + const textarea = $('.js-toolbar-input'); + const warning = $('.js-hidden-warning'); const imageEmbedRegex = /!+\[/g; if (!warning || !textarea) return; diff --git a/assets/js/poll.js b/assets/js/poll.ts similarity index 81% rename from assets/js/poll.js rename to assets/js/poll.ts index 68debf5d..38c74907 100644 --- a/assets/js/poll.js +++ b/assets/js/poll.ts @@ -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 }; diff --git a/assets/js/search.js b/assets/js/search.js deleted file mode 100644 index 864ea231..00000000 --- a/assets/js/search.js +++ /dev/null @@ -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 }; diff --git a/assets/js/search.ts b/assets/js/search.ts new file mode 100644 index 00000000..068ff068 --- /dev/null +++ b/assets/js/search.ts @@ -0,0 +1,50 @@ +import { $, $$ } from './utils/dom'; +import { addTag } from './tagsinput'; + +function showHelp(subject: string, type: string | null) { + $$('[data-search-help]').forEach(helpBox => { + if (helpBox.getAttribute('data-search-help') === type) { + const searchSubject = $('.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 = $('.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 = $('.js-search-form'); + + form && form.addEventListener('click', executeFormHelper as EventListener); +} diff --git a/assets/js/settings.js b/assets/js/settings.ts similarity index 50% rename from assets/js/settings.js rename to assets/js/settings.ts index 360228ee..d6b71d44 100644 --- a/assets/js/settings.js +++ b/assets/js/settings.ts @@ -6,12 +6,11 @@ import { $, $$ } from './utils/dom'; import store from './utils/store'; export function setupSettings() { + if (!$('#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 = $$('[data-tab="local"] input[type="checkbox"]'); + const themeSelect = $('#user_theme'); + const styleSheet = $('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 || '#'; + } }); - } diff --git a/assets/js/shortcuts.js b/assets/js/shortcuts.js deleted file mode 100644 index 67d1acbd..00000000 --- a/assets/js/shortcuts.js +++ /dev/null @@ -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 }; diff --git a/assets/js/shortcuts.ts b/assets/js/shortcuts.ts new file mode 100644 index 00000000..c76a0458 --- /dev/null +++ b/assets/js/shortcuts.ts @@ -0,0 +1,76 @@ +/** + * Keyboard shortcuts + */ + +import { $ } from './utils/dom'; + +interface ShortcutKeycodes { + [key: string]: () => void +} + +function getHover(): string | null { + const thumbBoxHover = $('.media-box:hover'); + + return thumbBoxHover && (thumbBoxHover.dataset.imageId || null); +} + +function openFullView() { + const imageHover = $('[data-uris]:hover'); + + if (!imageHover || !imageHover.dataset.uris) return; + + window.location = JSON.parse(imageHover.dataset.uris).full; +} + +function openFullViewNewTab() { + const imageHover = $('[data-uris]:hover'); + + if (!imageHover || !imageHover.dataset.uris) return; + + window.open(JSON.parse(imageHover.dataset.uris).full); +} + +function click(selector: string) { + const el = $(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 }; diff --git a/assets/js/sources.js b/assets/js/sources.ts similarity index 58% rename from assets/js/sources.js rename to assets/js/sources.ts index bb4ae3ad..b2c29c45 100644 --- a/assets/js/sources.js +++ b/assets/js/sources.ts @@ -1,5 +1,10 @@ +import { $ } from './utils/dom'; import { inputDuplicatorCreator } from './input-duplicator'; +export interface SourcesEvent extends CustomEvent { + 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 = $('.js-sourcesauce'); + + if (sourceSauce && target && target.matches('#source-form')) { detail.text().then(text => { sourceSauce.outerHTML = text; setupInputs(); }); } - }); + }) as EventListener); } export { imageSourcesCreator }; diff --git a/assets/js/tagsmisc.js b/assets/js/tagsmisc.js deleted file mode 100644 index 29dc9dbe..00000000 --- a/assets/js/tagsmisc.js +++ /dev/null @@ -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 }; diff --git a/assets/js/tagsmisc.ts b/assets/js/tagsmisc.ts new file mode 100644 index 00000000..ea5f4976 --- /dev/null +++ b/assets/js/tagsmisc.ts @@ -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]($('image_tag_input')); + } + } +} + +function setupTags() { + $$('.js-tag-block').forEach(el => { + setupTagsInput(el); + el.classList.remove('js-tag-block'); + }); +} + +function updateTagSauce({target, detail}: SourcesEvent) { + const tagSauce = $('.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 }; diff --git a/assets/js/utils/tag.ts b/assets/js/utils/tag.ts index ea104213..26c6bdf9 100644 --- a/assets/js/utils/tag.ts +++ b/assets/js/utils/tag.ts @@ -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) diff --git a/assets/js/when-ready.js b/assets/js/when-ready.ts similarity index 93% rename from assets/js/when-ready.js rename to assets/js/when-ready.ts index f39cec69..03cfee2a 100644 --- a/assets/js/when-ready.js +++ b/assets/js/when-ready.ts @@ -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(); diff --git a/assets/test/vitest-setup.ts b/assets/test/vitest-setup.ts index e4cd1314..446beac6 100644 --- a/assets/test/vitest-setup.ts +++ b/assets/test/vitest-setup.ts @@ -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 diff --git a/assets/types/booru-object.d.ts b/assets/types/booru-object.d.ts index 2a5949c0..e23e7bce 100644 --- a/assets/types/booru-object.d.ts +++ b/assets/types/booru-object.d.ts @@ -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 { diff --git a/assets/vite.config.ts b/assets/vite.config.ts index e31cc1ef..c677a7ff 100644 --- a/assets/vite.config.ts +++ b/assets/vite.config.ts @@ -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) }, diff --git a/lib/philomena_web/templates/layout/app.html.slime b/lib/philomena_web/templates/layout/app.html.slime index e529f4f5..a4a0e66c 100644 --- a/lib/philomena_web/templates/layout/app.html.slime +++ b/lib/philomena_web/templates/layout/app.html.slime @@ -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