From ecc084546ca136bcab4bfce25e8291c3de565e67 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Wed, 15 Nov 2023 18:06:33 -0400 Subject: content: generatePageLayout: sidebar column & section class slots --- src/content/dependencies/generatePageLayout.js | 29 ++++++++++++++++---------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/src/content/dependencies/generatePageLayout.js b/src/content/dependencies/generatePageLayout.js index 5fa6e751..3bf0c9f0 100644 --- a/src/content/dependencies/generatePageLayout.js +++ b/src/content/dependencies/generatePageLayout.js @@ -6,12 +6,19 @@ function sidebarSlots(side) { // if specified. [side + 'Content']: {type: 'html'}, - // Multiple is an array of {content: (HTML)} objects. Each of these - // will generate one sidebar section. + // A single class to apply to the whole sidebar. If specifying multiple + // sections, this be added to the containing sidebar-column - specify a + // class on each section if that's more suitable. + [side + 'Class']: {type: 'string'}, + + // Multiple is an array of objects, each specifying content (HTML) and + // optionally class (a string). Each of these will generate one sidebar + // section. [side + 'Multiple']: { validate: v => v.sparseArrayOf( v.validateProperties({ + class: v.optional(v.isString), content: v.isHTML, })), }, @@ -27,6 +34,7 @@ function sidebarSlots(side) { // the whole section's containing box (or the sidebar column as a whole). [side + 'StickyMode']: { validate: v => v.is('last', 'column', 'static'), + default: 'static', }, // Collapsing sidebars disappear when the viewport is sufficiently @@ -354,6 +362,7 @@ export default { const generateSidebarHTML = (side, id) => { const content = slots[side + 'Content']; + const topClass = slots[side + 'Class']; const multiple = slots[side + 'Multiple']; const stickyMode = slots[side + 'StickyMode']; const wide = slots[side + 'Wide']; @@ -363,20 +372,18 @@ export default { let sidebarContent = html.blank(); if (!html.isBlank(content)) { - sidebarClasses = ['sidebar']; + sidebarClasses = ['sidebar', topClass]; sidebarContent = content; } else if (multiple) { - sidebarClasses = ['sidebar-multiple']; + sidebarClasses = ['sidebar-multiple', topClass]; sidebarContent = multiple .filter(Boolean) - .map(({content}) => - html.tag('div', - { - [html.onlyIfContent]: true, - class: 'sidebar', - }, - content)); + .map(box => + html.tag('div', { + [html.onlyIfContent]: true, + class: ['sidebar', box.class], + }, box.content)); } if (html.isBlank(sidebarContent)) { -- cgit 1.3.0-6-gf8a5 From e9d4cd7fb8bb9c55f3dd90b36eec7e246bc2589e Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Wed, 15 Nov 2023 18:15:37 -0400 Subject: content: add classes to various sidebar boxes --- .../dependencies/generateAlbumCommentaryPage.js | 1 + src/content/dependencies/generateAlbumSidebar.js | 8 ++++++-- .../dependencies/generateFlashActSidebar.js | 24 +++++++++++++--------- src/content/dependencies/generateGroupSidebar.js | 1 + src/content/dependencies/generateListingSidebar.js | 1 + .../dependencies/generateWikiHomeNewsBox.js | 1 + src/content/dependencies/generateWikiHomePage.js | 1 + 7 files changed, 25 insertions(+), 12 deletions(-) diff --git a/src/content/dependencies/generateAlbumCommentaryPage.js b/src/content/dependencies/generateAlbumCommentaryPage.js index 3ad1549e..e2415516 100644 --- a/src/content/dependencies/generateAlbumCommentaryPage.js +++ b/src/content/dependencies/generateAlbumCommentaryPage.js @@ -201,6 +201,7 @@ export default { ], leftSidebarStickyMode: 'column', + leftSidebarClass: 'commentary-track-list-sidebar-box', leftSidebarContent: [ html.tag('h1', relations.sidebarAlbumLink), relations.sidebarTrackSections.map(section => diff --git a/src/content/dependencies/generateAlbumSidebar.js b/src/content/dependencies/generateAlbumSidebar.js index a84f4357..5ef4501b 100644 --- a/src/content/dependencies/generateAlbumSidebar.js +++ b/src/content/dependencies/generateAlbumSidebar.js @@ -30,6 +30,7 @@ export default { generate(data, relations, {html}) { const trackListBox = { + class: 'track-list-sidebar-box', content: html.tags([ html.tag('h1', relations.albumLink), @@ -40,8 +41,10 @@ export default { if (data.isAlbumPage) { const groupBoxes = relations.groupBoxes - .map(content => content.slot('mode', 'album')) - .map(content => ({content})); + .map(content => ({ + class: 'individual-group-sidebar-box', + content: content.slot('mode', 'album'), + })); return { leftSidebarMultiple: [ @@ -52,6 +55,7 @@ export default { } const conjoinedGroupBox = { + class: 'conjoined-group-sidebar-box', content: relations.groupBoxes .flatMap((content, i, {length}) => [ diff --git a/src/content/dependencies/generateFlashActSidebar.js b/src/content/dependencies/generateFlashActSidebar.js index 80072483..29379644 100644 --- a/src/content/dependencies/generateFlashActSidebar.js +++ b/src/content/dependencies/generateFlashActSidebar.js @@ -137,7 +137,7 @@ export default { }), generate(data, relations, {getColors, html, language}) { - const currentActBox = html.tags([ + const currentActBoxContent = html.tags([ html.tag('h1', relations.currentActLink), html.tag('details', @@ -160,7 +160,7 @@ export default { ]), ]); - const sideMapBox = html.tags([ + const sideMapBoxContent = html.tags([ html.tag('h1', relations.flashIndexLink), stitchArrays({ @@ -188,17 +188,21 @@ export default { ])), ]); + const sideMapBox = { + class: 'flash-act-map-sidebar-box', + content: sideMapBoxContent, + }; + + const currentActBox = { + class: 'flash-current-act-sidebar-box', + content: currentActBoxContent, + }; + return { leftSidebarMultiple: (data.isFlashActPage - ? [ - {content: sideMapBox}, - {content: currentActBox}, - ] - : [ - {content: currentActBox}, - {content: sideMapBox}, - ]), + ? [sideMapBox, currentActBox] + : [currentActBox, sideMapBox]), }; }, }; diff --git a/src/content/dependencies/generateGroupSidebar.js b/src/content/dependencies/generateGroupSidebar.js index 6baf37f4..98b288fa 100644 --- a/src/content/dependencies/generateGroupSidebar.js +++ b/src/content/dependencies/generateGroupSidebar.js @@ -22,6 +22,7 @@ export default { generate(relations, slots, {html, language}) { return { + leftSidebarClass: 'category-map-sidebar-box', leftSidebarContent: [ html.tag('h1', language.$('groupSidebar.title')), diff --git a/src/content/dependencies/generateListingSidebar.js b/src/content/dependencies/generateListingSidebar.js index fe2a08fa..1cdd236b 100644 --- a/src/content/dependencies/generateListingSidebar.js +++ b/src/content/dependencies/generateListingSidebar.js @@ -11,6 +11,7 @@ export default { generate(relations, {html}) { return { + leftSidebarClass: 'listing-map-sidebar-box', leftSidebarContent: [ html.tag('h1', relations.listingIndexLink), relations.listingIndexList.slot('mode', 'sidebar'), diff --git a/src/content/dependencies/generateWikiHomeNewsBox.js b/src/content/dependencies/generateWikiHomeNewsBox.js index 8acd426c..0d8303f1 100644 --- a/src/content/dependencies/generateWikiHomeNewsBox.js +++ b/src/content/dependencies/generateWikiHomeNewsBox.js @@ -42,6 +42,7 @@ export default { } return { + class: 'latest-news-sidebar-box', content: [ html.tag('h1', language.$('homepage.news.title')), diff --git a/src/content/dependencies/generateWikiHomePage.js b/src/content/dependencies/generateWikiHomePage.js index 40a6b1c5..36fcc6f2 100644 --- a/src/content/dependencies/generateWikiHomePage.js +++ b/src/content/dependencies/generateWikiHomePage.js @@ -75,6 +75,7 @@ export default { leftSidebarMultiple: [ (relations.customSidebarContent ? { + class: 'custom-content-sidebar-box', content: relations.customSidebarContent .slot('mode', 'multiline'), -- cgit 1.3.0-6-gf8a5 From c399b00ccea8280032e0576a99eab2d34a04355c Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Wed, 15 Nov 2023 18:17:15 -0400 Subject: content, client: use 'track-in-sidebar' for random link in nav --- src/content/dependencies/generateAlbumNavAccent.js | 2 +- src/static/client2.js | 17 ++++++++++++++--- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/src/content/dependencies/generateAlbumNavAccent.js b/src/content/dependencies/generateAlbumNavAccent.js index 7eb1dac0..01c88bf7 100644 --- a/src/content/dependencies/generateAlbumNavAccent.js +++ b/src/content/dependencies/generateAlbumNavAccent.js @@ -92,7 +92,7 @@ export default { html.tag('a', { href: '#', - 'data-random': 'track-in-album', + 'data-random': 'track-in-sidebar', id: 'random-button', }, (data.isTrackPage diff --git a/src/static/client2.js b/src/static/client2.js index 0ec052bd..b72933d0 100644 --- a/src/static/client2.js +++ b/src/static/client2.js @@ -187,6 +187,19 @@ function addRandomLinkListeners() { break; } + case 'track-in-sidebar': { + // Note that the container for track links may be
    or
      , and + // they can't be identified by href, since links from one track to + // another don't include "track" in the href. + const trackLinks = + Array.from(document + .querySelector('.track-list-sidebar-box') + .querySelectorAll('li a')); + + a.href = pick(trackLinks).href; + break; + } + /* Legacy links, for old versions * * of generateListRandomPageLinksGroupSection */ @@ -255,9 +268,7 @@ function addNavigationKeyPressListeners() { } else if (event.charCode === 'P'.charCodeAt(0)) { scriptedLinkInfo.previousNavLink?.click(); } else if (event.charCode === 'R'.charCodeAt(0)) { - if (ready) { - scriptedLinkInfo.randomNavLink?.click(); - } + scriptedLinkInfo.randomNavLink?.click(); } } }); -- cgit 1.3.0-6-gf8a5 From 43141f1fc41768679b63e154ac21203e928b17c7 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Wed, 15 Nov 2023 18:19:00 -0400 Subject: client, content: client2.js -> client3.js --- src/content/dependencies/generatePageLayout.js | 2 +- src/static/client2.js | 1492 ------------------------ src/static/client3.js | 1453 +++++++++++++++++++++++ 3 files changed, 1454 insertions(+), 1493 deletions(-) delete mode 100644 src/static/client2.js create mode 100644 src/static/client3.js diff --git a/src/content/dependencies/generatePageLayout.js b/src/content/dependencies/generatePageLayout.js index 3bf0c9f0..95551f3e 100644 --- a/src/content/dependencies/generatePageLayout.js +++ b/src/content/dependencies/generatePageLayout.js @@ -655,7 +655,7 @@ export default { html.tag('script', { type: 'module', - src: to('shared.staticFile', 'client2.js', cachebust), + src: to('shared.staticFile', 'client3.js', cachebust), }), ]), ]) diff --git a/src/static/client2.js b/src/static/client2.js deleted file mode 100644 index b72933d0..00000000 --- a/src/static/client2.js +++ /dev/null @@ -1,1492 +0,0 @@ -/* eslint-env browser */ - -// This is the JS file that gets loaded on the client! It's only really used for -// the random track feature right now - the idea is we only use it for stuff -// that cannot 8e done at static-site compile time, 8y its fundamentally -// ephemeral nature. - -import {getColors} from '../util/colors.js'; -import {empty, stitchArrays} from '../util/sugar.js'; - -import { - filterMultipleArrays, - getArtistNumContributions, -} from '../util/wiki-data.js'; - -let albumData, artistData; -let officialAlbumData, fandomAlbumData, beyondAlbumData; - -let ready = false; - -const clientInfo = window.hsmusicClientInfo = Object.create(null); - -const clientSteps = { - getPageReferences: [], - addInternalListeners: [], - mutatePageContent: [], - initializeState: [], - addPageListeners: [], -}; - -// Localiz8tion nonsense ---------------------------------- - -const language = document.documentElement.getAttribute('lang'); - -let list; -if (typeof Intl === 'object' && typeof Intl.ListFormat === 'function') { - const getFormat = (type) => { - const formatter = new Intl.ListFormat(language, {type}); - return formatter.format.bind(formatter); - }; - - list = { - conjunction: getFormat('conjunction'), - disjunction: getFormat('disjunction'), - unit: getFormat('unit'), - }; -} else { - // Not a gr8 mock we've got going here, 8ut it's *mostly* language-free. - // We use the same mock for every list 'cuz we don't have any of the - // necessary CLDR info to appropri8tely distinguish 8etween them. - const arbitraryMock = (array) => array.join(', '); - - list = { - conjunction: arbitraryMock, - disjunction: arbitraryMock, - unit: arbitraryMock, - }; -} - -// Miscellaneous helpers ---------------------------------- - -function rebase(href, rebaseKey = 'rebaseLocalized') { - const relative = (document.documentElement.dataset[rebaseKey] || '.') + '/'; - if (relative) { - return relative + href; - } else { - return href; - } -} - -function pick(array) { - return array[Math.floor(Math.random() * array.length)]; -} - -function cssProp(el, key) { - return getComputedStyle(el).getPropertyValue(key).trim(); -} - -function getRefDirectory(ref) { - return ref.split(':')[1]; -} - -function getAlbum(el) { - const directory = cssProp(el, '--album-directory'); - return albumData.find((album) => album.directory === directory); -} - -// TODO: These should pro8a8ly access some shared urlSpec path. We'd need to -// separ8te the tooling around that into common-shared code too. -const getLinkHref = (type, directory) => rebase(`${type}/${directory}`); -const openAlbum = (d) => rebase(`album/${d}`); -const openTrack = (d) => rebase(`track/${d}`); -const openArtist = (d) => rebase(`artist/${d}`); - -// TODO: This should also use urlSpec. -function fetchData(type, directory) { - return fetch(rebase(`${type}/${directory}/data.json`, 'rebaseData')).then( - (res) => res.json() - ); -} - -// JS-based links ----------------------------------------- - -const scriptedLinkInfo = clientInfo.scriptedLinkInfo = { - randomLinks: null, - revealLinks: null, - - nextLink: null, - previousLink: null, - randomLink: null, -}; - -function getScriptedLinkReferences() { - scriptedLinkInfo.randomLinks = - document.querySelectorAll('[data-random]'); - - scriptedLinkInfo.revealLinks = - document.getElementsByClassName('reveal'); - - scriptedLinkInfo.nextNavLink = - document.getElementById('next-button'); - - scriptedLinkInfo.previousNavLink = - document.getElementById('previous-button'); - - scriptedLinkInfo.randomNavLink = - document.getElementById('random-button'); -} - -function addRandomLinkListeners() { - for (const a of scriptedLinkInfo.randomLinks ?? []) { - a.addEventListener('click', evt => { - if (!ready) { - evt.preventDefault(); - return; - } - - const tracks = albumData => - albumData - .map(album => album.tracks) - .reduce((acc, tracks) => acc.concat(tracks), []); - - setTimeout(() => { - a.href = rebase('js-disabled'); - }); - - switch (a.dataset.random) { - case 'album': - a.href = openAlbum(pick(albumData).directory); - break; - - case 'track': - a.href = openTrack(getRefDirectory(pick(tracks(albumData)))); - break; - - case 'album-in-group-dl': { - const albumLinks = - Array.from(a - .closest('dt') - .nextElementSibling - .querySelectorAll('li a')) - - const albumDirectories = - albumLinks.map(a => - getComputedStyle(a).getPropertyValue('--album-directory')); - - a.href = openAlbum(pick(albumDirectories)); - break; - } - - case 'track-in-group-dl': { - const albumLinks = - Array.from(a - .closest('dt') - .nextElementSibling - .querySelectorAll('li a')) - - const albumDirectories = - albumLinks.map(a => - getComputedStyle(a).getPropertyValue('--album-directory')); - - const filteredAlbumData = - albumData.filter(album => - albumDirectories.includes(album.directory)); - - a.href = openTrack(getRefDirectory(pick(tracks(filteredAlbumData)))); - break; - } - - case 'track-in-sidebar': { - // Note that the container for track links may be
        or
          , and - // they can't be identified by href, since links from one track to - // another don't include "track" in the href. - const trackLinks = - Array.from(document - .querySelector('.track-list-sidebar-box') - .querySelectorAll('li a')); - - a.href = pick(trackLinks).href; - break; - } - - /* Legacy links, for old versions * - * of generateListRandomPageLinksGroupSection */ - - case 'album-in-official': - a.href = openAlbum(pick(officialAlbumData).directory); - break; - - case 'album-in-fandom': - a.href = openAlbum(pick(fandomAlbumData).directory); - break; - - case 'album-in-beyond': - a.href = openAlbum(pick(beyondAlbumData).directory); - break; - - /* End legacy links */ - - case 'track-in-album': - a.href = openTrack(getRefDirectory(pick(getAlbum(a).tracks))); - break; - - case 'track-in-official': - a.href = openTrack(getRefDirectory(pick(tracks(officialAlbumData)))); - break; - - case 'track-in-fandom': - a.href = openTrack(getRefDirectory(pick(tracks(fandomAlbumData)))); - break; - - case 'track-in-beyond': - a.href = openTrack(getRefDirectory(pick(tracks(beyondAlbumData)))); - break; - - case 'artist': - a.href = openArtist(pick(artistData).directory); - break; - - case 'artist-more-than-one-contrib': - a.href = - openArtist( - pick(artistData.filter((artist) => getArtistNumContributions(artist) > 1)) - .directory); - break; - } - }); - } -} - -function mutateNavigationLinkContent() { - const prependTitle = (el, prepend) => - el?.setAttribute('title', - (el.hasAttribute('title') - ? prepend + ' ' + el.getAttribute('title') - : prepend)); - - prependTitle(scriptedLinkInfo.nextNavLink, '(Shift+N)'); - prependTitle(scriptedLinkInfo.previousNavLink, '(Shift+P)'); - prependTitle(scriptedLinkInfo.randomNavLink, '(Shift+R)'); -} - -function addNavigationKeyPressListeners() { - document.addEventListener('keypress', (event) => { - if (event.shiftKey) { - if (event.charCode === 'N'.charCodeAt(0)) { - scriptedLinkInfo.nextNavLink?.click(); - } else if (event.charCode === 'P'.charCodeAt(0)) { - scriptedLinkInfo.previousNavLink?.click(); - } else if (event.charCode === 'R'.charCodeAt(0)) { - scriptedLinkInfo.randomNavLink?.click(); - } - } - }); -} - -function addRevealLinkClickListeners() { - for (const reveal of scriptedLinkInfo.revealLinks ?? []) { - reveal.addEventListener('click', (event) => { - if (!reveal.classList.contains('revealed')) { - reveal.classList.add('revealed'); - event.preventDefault(); - event.stopPropagation(); - reveal.dispatchEvent(new CustomEvent('hsmusic-reveal')); - } - }); - } -} - -clientSteps.getPageReferences.push(getScriptedLinkReferences); -clientSteps.addPageListeners.push(addRandomLinkListeners); -clientSteps.addPageListeners.push(addNavigationKeyPressListeners); -clientSteps.addPageListeners.push(addRevealLinkClickListeners); -clientSteps.mutatePageContent.push(mutateNavigationLinkContent); - -const elements1 = document.getElementsByClassName('js-hide-once-data'); -const elements2 = document.getElementsByClassName('js-show-once-data'); - -for (const element of elements1) element.style.display = 'block'; - -fetch(rebase('data.json', 'rebaseShared')) - .then((data) => data.json()) - .then((data) => { - albumData = data.albumData; - artistData = data.artistData; - - const albumsInGroup = directory => - albumData - .filter(album => - album.groups.includes(`group:${directory}`)); - - officialAlbumData = albumsInGroup('official'); - fandomAlbumData = albumsInGroup('fandom'); - beyondAlbumData = albumsInGroup('beyond'); - - for (const element of elements1) element.style.display = 'none'; - for (const element of elements2) element.style.display = 'block'; - - ready = true; - }); - -// Data & info card --------------------------------------- - -/* -const NORMAL_HOVER_INFO_DELAY = 750; -const FAST_HOVER_INFO_DELAY = 250; -const END_FAST_HOVER_DELAY = 500; -const HIDE_HOVER_DELAY = 250; - -let fastHover = false; -let endFastHoverTimeout = null; - -function colorLink(a, color) { - console.warn('Info card link colors temporarily disabled: chroma.js required, no dependency linking for client.js yet'); - return; - - // eslint-disable-next-line no-unreachable - const chroma = {}; - - if (color) { - const {primary, dim} = getColors(color, {chroma}); - a.style.setProperty('--primary-color', primary); - a.style.setProperty('--dim-color', dim); - } -} - -function link(a, type, {name, directory, color}) { - colorLink(a, color); - a.innerText = name; - a.href = getLinkHref(type, directory); -} - -function joinElements(type, elements) { - // We can't use the Intl APIs with elements, 8ecuase it only oper8tes on - // strings. So instead, we'll pass the element's outer HTML's (which means - // the entire HTML of that element). - // - // That does mean this function returns a string, so always 8e sure to - // set innerHTML when using it (not appendChild). - - return list[type](elements.map((el) => el.outerHTML)); -} - -const infoCard = (() => { - const container = document.getElementById('info-card-container'); - - let cancelShow = false; - let hideTimeout = null; - let showing = false; - - container.addEventListener('mouseenter', cancelHide); - container.addEventListener('mouseleave', readyHide); - - function show(type, target) { - cancelShow = false; - - fetchData(type, target.dataset[type]).then((data) => { - // Manual DOM 'cuz we're laaaazy. - - if (cancelShow) { - return; - } - - showing = true; - - const rect = target.getBoundingClientRect(); - - container.style.setProperty('--primary-color', data.color); - - container.style.top = window.scrollY + rect.bottom + 'px'; - container.style.left = window.scrollX + rect.left + 'px'; - - // Use a short timeout to let a currently hidden (or not yet shown) - // info card teleport to the position set a8ove. (If it's currently - // shown, it'll transition to that position.) - setTimeout(() => { - container.classList.remove('hide'); - container.classList.add('show'); - }, 50); - - // 8asic details. - - const nameLink = container.querySelector('.info-card-name a'); - link(nameLink, 'track', data); - - const albumLink = container.querySelector('.info-card-album a'); - link(albumLink, 'album', data.album); - - const artistSpan = container.querySelector('.info-card-artists span'); - artistSpan.innerHTML = joinElements( - 'conjunction', - data.artists.map(({artist}) => { - const a = document.createElement('a'); - a.href = getLinkHref('artist', artist.directory); - a.innerText = artist.name; - return a; - }) - ); - - const coverArtistParagraph = container.querySelector( - '.info-card-cover-artists' - ); - const coverArtistSpan = coverArtistParagraph.querySelector('span'); - if (data.coverArtists.length) { - coverArtistParagraph.style.display = 'block'; - coverArtistSpan.innerHTML = joinElements( - 'conjunction', - data.coverArtists.map(({artist}) => { - const a = document.createElement('a'); - a.href = getLinkHref('artist', artist.directory); - a.innerText = artist.name; - return a; - }) - ); - } else { - coverArtistParagraph.style.display = 'none'; - } - - // Cover art. - - const [containerNoReveal, containerReveal] = [ - container.querySelector('.info-card-art-container.no-reveal'), - container.querySelector('.info-card-art-container.reveal'), - ]; - - const [containerShow, containerHide] = data.cover.warnings.length - ? [containerReveal, containerNoReveal] - : [containerNoReveal, containerReveal]; - - containerHide.style.display = 'none'; - containerShow.style.display = 'block'; - - const img = containerShow.querySelector('.info-card-art'); - img.src = rebase(data.cover.paths.small, 'rebaseMedia'); - - const imgLink = containerShow.querySelector('a'); - colorLink(imgLink, data.color); - imgLink.href = rebase(data.cover.paths.original, 'rebaseMedia'); - - if (containerShow === containerReveal) { - const cw = containerShow.querySelector('.info-card-art-warnings'); - cw.innerText = list.unit(data.cover.warnings); - - const reveal = containerShow.querySelector('.reveal'); - reveal.classList.remove('revealed'); - } - }); - } - - function hide() { - container.classList.remove('show'); - container.classList.add('hide'); - cancelShow = true; - showing = false; - } - - function readyHide() { - if (!hideTimeout && showing) { - hideTimeout = setTimeout(hide, HIDE_HOVER_DELAY); - } - } - - function cancelHide() { - if (hideTimeout) { - clearTimeout(hideTimeout); - hideTimeout = null; - } - } - - return { - show, - hide, - readyHide, - cancelHide, - }; -})(); - -function makeInfoCardLinkHandlers(type) { - let hoverTimeout = null; - - return { - mouseenter(evt) { - hoverTimeout = setTimeout( - () => { - fastHover = true; - infoCard.show(type, evt.target); - }, - fastHover ? FAST_HOVER_INFO_DELAY : NORMAL_HOVER_INFO_DELAY); - - clearTimeout(endFastHoverTimeout); - endFastHoverTimeout = null; - - infoCard.cancelHide(); - }, - - mouseleave() { - clearTimeout(hoverTimeout); - - if (fastHover && !endFastHoverTimeout) { - endFastHoverTimeout = setTimeout(() => { - endFastHoverTimeout = null; - fastHover = false; - }, END_FAST_HOVER_DELAY); - } - - infoCard.readyHide(); - }, - }; -} - -const infoCardLinkHandlers = { - track: makeInfoCardLinkHandlers('track'), -}; - -function addInfoCardLinkHandlers(type) { - for (const a of document.querySelectorAll(`a[data-${type}]`)) { - for (const [eventName, handler] of Object.entries( - infoCardLinkHandlers[type] - )) { - a.addEventListener(eventName, handler); - } - } -} - -// Info cards are disa8led for now since they aren't quite ready for release, -// 8ut you can try 'em out 8y setting this localStorage flag! -// -// localStorage.tryInfoCards = true; -// -if (localStorage.tryInfoCards) { - addInfoCardLinkHandlers('track'); -} -*/ - -// Custom hash links -------------------------------------- - -const hashLinkInfo = clientInfo.hashLinkInfo = { - links: null, - hrefs: null, - targets: null, - - state: { - highlightedTarget: null, - scrollingAfterClick: false, - concludeScrollingStateInterval: null, - }, - - event: { - whenHashLinkClicked: [], - }, -}; - -function getHashLinkReferences() { - const info = hashLinkInfo; - - info.links = - Array.from(document.querySelectorAll('a[href^="#"]:not([href="#"])')); - - info.hrefs = - info.links - .map(link => link.getAttribute('href')); - - info.targets = - info.hrefs - .map(href => document.getElementById(href.slice(1))); - - filterMultipleArrays( - info.links, - info.hrefs, - info.targets, - (_link, _href, target) => target); -} - -function processScrollingAfterHashLinkClicked() { - const {state} = hashLinkInfo; - - if (state.concludeScrollingStateInterval) return; - - let lastScroll = window.scrollY; - state.scrollingAfterClick = true; - state.concludeScrollingStateInterval = setInterval(() => { - if (Math.abs(window.scrollY - lastScroll) < 10) { - clearInterval(state.concludeScrollingStateInterval); - state.scrollingAfterClick = false; - state.concludeScrollingStateInterval = null; - } else { - lastScroll = window.scrollY; - } - }, 200); -} - -function addHashLinkListeners() { - // Instead of defining a scroll offset (to account for the sticky heading) - // in JavaScript, we interface with the CSS property 'scroll-margin-top'. - // This lets the scroll offset be consolidated where it makes sense, and - // sets an appropriate offset when (re)loading a page with hash for free! - - const info = hashLinkInfo; - const {state, event} = info; - - for (const {hashLink, href, target} of stitchArrays({ - hashLink: info.links, - href: info.hrefs, - target: info.targets, - })) { - hashLink.addEventListener('click', evt => { - if (evt.metaKey || evt.shiftKey || evt.ctrlKey || evt.altKey) { - return; - } - - // Hide skipper box right away, so the layout is updated on time for the - // math operations coming up next. - const skipper = document.getElementById('skippers'); - skipper.style.display = 'none'; - setTimeout(() => skipper.style.display = ''); - - const box = target.getBoundingClientRect(); - const style = window.getComputedStyle(target); - - const scrollY = - window.scrollY - + box.top - - style['scroll-margin-top'].replace('px', ''); - - evt.preventDefault(); - history.pushState({}, '', href); - window.scrollTo({top: scrollY, behavior: 'smooth'}); - target.focus({preventScroll: true}); - - const maxScroll = - document.body.scrollHeight - - window.innerHeight; - - if (scrollY > maxScroll && target.classList.contains('content-heading')) { - if (state.highlightedTarget) { - state.highlightedTarget.classList.remove('highlight-hash-link'); - } - - target.classList.add('highlight-hash-link'); - state.highlightedTarget = target; - } - - processScrollingAfterHashLinkClicked(); - - for (const handler of event.whenHashLinkClicked) { - handler({ - link: hashLink, - }); - } - }); - } - - for (const target of info.targets) { - target.addEventListener('animationend', evt => { - if (evt.animationName !== 'highlight-hash-link') return; - target.classList.remove('highlight-hash-link'); - if (target !== state.highlightedTarget) return; - state.highlightedTarget = null; - }); - } -} - -clientSteps.getPageReferences.push(getHashLinkReferences); -clientSteps.addPageListeners.push(addHashLinkListeners); - -// Sticky content heading --------------------------------- - -const stickyHeadingInfo = clientInfo.stickyHeadingInfo = { - stickyContainers: null, - - stickySubheadingRows: null, - stickySubheadings: null, - - stickyCoverContainers: null, - stickyCoverTextAreas: null, - stickyCovers: null, - - contentContainers: null, - contentHeadings: null, - contentCovers: null, - contentCoversReveal: null, - - state: { - displayedHeading: null, - }, - - event: { - whenDisplayedHeadingChanges: [], - }, -}; - -function getStickyHeadingReferences() { - const info = stickyHeadingInfo; - - info.stickyContainers = - Array.from(document.getElementsByClassName('content-sticky-heading-container')); - - info.stickyCoverContainers = - info.stickyContainers - .map(el => el.querySelector('.content-sticky-heading-cover-container')); - - info.stickyCovers = - info.stickyCoverContainers - .map(el => el?.querySelector('.content-sticky-heading-cover')); - - info.stickyCoverTextAreas = - info.stickyCovers - .map(el => el?.querySelector('.image-text-area')); - - info.stickySubheadingRows = - info.stickyContainers - .map(el => el.querySelector('.content-sticky-subheading-row')); - - info.stickySubheadings = - info.stickySubheadingRows - .map(el => el.querySelector('h2')); - - info.contentContainers = - info.stickyContainers - .map(el => el.parentElement); - - info.contentCovers = - info.contentContainers - .map(el => el.querySelector('#cover-art-container')); - - info.contentCoversReveal = - info.contentCovers - .map(el => el ? !!el.querySelector('.reveal') : null); - - info.contentHeadings = - info.contentContainers - .map(el => Array.from(el.querySelectorAll('.content-heading'))); -} - -function removeTextPlaceholderStickyHeadingCovers() { - const info = stickyHeadingInfo; - - const hasTextArea = - info.stickyCoverTextAreas.map(el => !!el); - - const coverContainersWithTextArea = - info.stickyCoverContainers - .filter((_el, index) => hasTextArea[index]); - - for (const el of coverContainersWithTextArea) { - el.remove(); - } - - info.stickyCoverContainers = - info.stickyCoverContainers - .map((el, index) => hasTextArea[index] ? null : el); - - info.stickyCovers = - info.stickyCovers - .map((el, index) => hasTextArea[index] ? null : el); - - info.stickyCoverTextAreas = - info.stickyCoverTextAreas - .slice() - .fill(null); -} - -function addRevealClassToStickyHeadingCovers() { - const info = stickyHeadingInfo; - - const stickyCoversWhichReveal = - info.stickyCovers - .filter((_el, index) => info.contentCoversReveal[index]); - - for (const el of stickyCoversWhichReveal) { - el.classList.add('content-sticky-heading-cover-needs-reveal'); - } -} - -function addRevealListenersForStickyHeadingCovers() { - const info = stickyHeadingInfo; - - const stickyCovers = info.stickyCovers.slice(); - const contentCovers = info.contentCovers.slice(); - - filterMultipleArrays( - stickyCovers, - contentCovers, - (_stickyCover, _contentCover, index) => info.contentCoversReveal[index]); - - for (const {stickyCover, contentCover} of stitchArrays({ - stickyCover: stickyCovers, - contentCover: contentCovers, - })) { - // TODO: Janky - should use internal event instead of DOM event - contentCover.querySelector('.reveal').addEventListener('hsmusic-reveal', () => { - stickyCover.classList.remove('content-sticky-heading-cover-needs-reveal'); - }); - } -} - -function topOfViewInside(el, scroll = window.scrollY) { - return ( - scroll > el.offsetTop && - scroll < el.offsetTop + el.offsetHeight); -} - -function updateStickyCoverVisibility(index) { - const info = stickyHeadingInfo; - - const stickyCoverContainer = info.stickyCoverContainers[index]; - const contentCover = info.contentCovers[index]; - - if (contentCover && stickyCoverContainer) { - if (contentCover.getBoundingClientRect().bottom < 0) { - stickyCoverContainer.classList.add('visible'); - } else { - stickyCoverContainer.classList.remove('visible'); - } - } -} - -function getContentHeadingClosestToStickySubheading(index) { - const info = stickyHeadingInfo; - - const contentContainer = info.contentContainers[index]; - - if (!topOfViewInside(contentContainer)) { - return null; - } - - const stickySubheading = info.stickySubheadings[index]; - - if (stickySubheading.childNodes.length === 0) { - // Supply a non-breaking space to ensure correct basic line height. - stickySubheading.appendChild(document.createTextNode('\xA0')); - } - - const stickyContainer = info.stickyContainers[index]; - const stickyRect = stickyContainer.getBoundingClientRect(); - - // TODO: Should this compute with the subheading row instead of h2? - const subheadingRect = stickySubheading.getBoundingClientRect(); - - const stickyBottom = stickyRect.bottom + subheadingRect.height; - - // Iterate from bottom to top of the content area. - const contentHeadings = info.contentHeadings[index]; - for (const heading of contentHeadings.slice().reverse()) { - const headingRect = heading.getBoundingClientRect(); - if (headingRect.y + headingRect.height / 1.5 < stickyBottom + 20) { - return heading; - } - } - - return null; -} - -function updateStickySubheadingContent(index) { - const info = stickyHeadingInfo; - const {event, state} = info; - - const closestHeading = getContentHeadingClosestToStickySubheading(index); - - if (state.displayedHeading === closestHeading) return; - - const stickySubheadingRow = info.stickySubheadingRows[index]; - - if (closestHeading) { - const stickySubheading = info.stickySubheadings[index]; - - // Array.from needed to iterate over a live array with for..of - for (const child of Array.from(stickySubheading.childNodes)) { - child.remove(); - } - - for (const child of closestHeading.childNodes) { - if (child.classList?.contains('content-heading-accent')) { - continue; - } - - if (child.tagName === 'A') { - for (const grandchild of child.childNodes) { - stickySubheading.appendChild(grandchild.cloneNode(true)); - } - } else { - stickySubheading.appendChild(child.cloneNode(true)); - } - } - - stickySubheadingRow.classList.add('visible'); - } else { - stickySubheadingRow.classList.remove('visible'); - } - - const oldDisplayedHeading = state.displayedHeading; - - state.displayedHeading = closestHeading; - - for (const handler of event.whenDisplayedHeadingChanges) { - handler(index, { - oldHeading: oldDisplayedHeading, - newHeading: closestHeading, - }); - } -} - -function updateStickyHeadings(index) { - updateStickyCoverVisibility(index); - updateStickySubheadingContent(index); -} - -function initializeStateForStickyHeadings() { - for (let i = 0; i < stickyHeadingInfo.stickyContainers.length; i++) { - updateStickyHeadings(i); - } -} - -function addScrollListenerForStickyHeadings() { - document.addEventListener('scroll', () => { - for (let i = 0; i < stickyHeadingInfo.stickyContainers.length; i++) { - updateStickyHeadings(i); - } - }); -} - -clientSteps.getPageReferences.push(getStickyHeadingReferences); -clientSteps.mutatePageContent.push(removeTextPlaceholderStickyHeadingCovers); -clientSteps.mutatePageContent.push(addRevealClassToStickyHeadingCovers); -clientSteps.initializeState.push(initializeStateForStickyHeadings); -clientSteps.addPageListeners.push(addRevealListenersForStickyHeadingCovers); -clientSteps.addPageListeners.push(addScrollListenerForStickyHeadings); - -// Image overlay ------------------------------------------ - -function addImageOverlayClickHandlers() { - const container = document.getElementById('image-overlay-container'); - - if (!container) { - console.warn(`#image-overlay-container missing, image overlay module disabled.`); - return; - } - - for (const link of document.querySelectorAll('.image-link')) { - if (link.querySelector('img').hasAttribute('data-no-image-preview')) { - continue; - } - - link.addEventListener('click', handleImageLinkClicked); - } - - const actionContainer = document.getElementById('image-overlay-action-container'); - - container.addEventListener('click', handleContainerClicked); - document.body.addEventListener('keydown', handleKeyDown); - - function handleContainerClicked(evt) { - // Only hide the image overlay if actually clicking the background. - if (evt.target !== container) { - return; - } - - // If you clicked anything close to or beneath the action bar, don't hide - // the image overlay. - const rect = actionContainer.getBoundingClientRect(); - if (evt.clientY >= rect.top - 40) { - return; - } - - container.classList.remove('visible'); - } - - function handleKeyDown(evt) { - if (evt.key === 'Escape' || evt.key === 'Esc' || evt.keyCode === 27) { - container.classList.remove('visible'); - } - } -} - -function handleImageLinkClicked(evt) { - if (evt.metaKey || evt.shiftKey || evt.altKey) { - return; - } - evt.preventDefault(); - - const container = document.getElementById('image-overlay-container'); - container.classList.add('visible'); - container.classList.remove('loaded'); - container.classList.remove('errored'); - - const allViewOriginal = document.getElementsByClassName('image-overlay-view-original'); - const mainImage = document.getElementById('image-overlay-image'); - const thumbImage = document.getElementById('image-overlay-image-thumb'); - - const {href: originalSrc} = evt.target.closest('a'); - - const { - src: embeddedSrc, - dataset: { - originalSize: originalFileSize, - thumbs: availableThumbList, - }, - } = evt.target.closest('a').querySelector('img'); - - updateFileSizeInformation(originalFileSize); - - let mainSrc = null; - let thumbSrc = null; - - if (availableThumbList) { - const {thumb: mainThumb, length: mainLength} = getPreferredThumbSize(availableThumbList); - const {thumb: smallThumb, length: smallLength} = getSmallestThumbSize(availableThumbList); - mainSrc = embeddedSrc.replace(/\.[a-z]+\.(jpg|png)$/, `.${mainThumb}.jpg`); - thumbSrc = embeddedSrc.replace(/\.[a-z]+\.(jpg|png)$/, `.${smallThumb}.jpg`); - // Show the thumbnail size on each element's data attributes. - // Y'know, just for debugging convenience. - mainImage.dataset.displayingThumb = `${mainThumb}:${mainLength}`; - thumbImage.dataset.displayingThumb = `${smallThumb}:${smallLength}`; - } else { - mainSrc = originalSrc; - thumbSrc = null; - mainImage.dataset.displayingThumb = ''; - thumbImage.dataset.displayingThumb = ''; - } - - if (thumbSrc) { - thumbImage.src = thumbSrc; - thumbImage.style.display = null; - } else { - thumbImage.src = ''; - thumbImage.style.display = 'none'; - } - - for (const viewOriginal of allViewOriginal) { - viewOriginal.href = originalSrc; - } - - mainImage.addEventListener('load', handleMainImageLoaded); - mainImage.addEventListener('error', handleMainImageErrored); - - container.style.setProperty('--download-progress', '0%'); - loadImage(mainSrc, progress => { - container.style.setProperty('--download-progress', (20 + 0.8 * progress) + '%'); - }).then( - blobUrl => { - mainImage.src = blobUrl; - container.style.setProperty('--download-progress', '100%'); - }, - handleMainImageErrored); - - function handleMainImageLoaded() { - mainImage.removeEventListener('load', handleMainImageLoaded); - mainImage.removeEventListener('error', handleMainImageErrored); - container.classList.add('loaded'); - } - - function handleMainImageErrored() { - mainImage.removeEventListener('load', handleMainImageLoaded); - mainImage.removeEventListener('error', handleMainImageErrored); - container.classList.add('errored'); - } -} - -function parseThumbList(availableThumbList) { - // Parse all the available thumbnail sizes! These are provided by the actual - // content generation on each image. - const defaultThumbList = 'huge:1400 semihuge:1200 large:800 medium:400 small:250' - const availableSizes = - (availableThumbList || defaultThumbList) - .split(' ') - .map(part => part.split(':')) - .map(([thumb, length]) => ({thumb, length: parseInt(length)})) - .sort((a, b) => a.length - b.length); - - return availableSizes; -} - -function getPreferredThumbSize(availableThumbList) { - // Assuming a square, the image will be constrained to the lesser window - // dimension. Coefficient here matches CSS dimensions for image overlay. - const constrainedLength = Math.floor(Math.min( - 0.80 * window.innerWidth, - 0.80 * window.innerHeight)); - - // Match device pixel ratio, which is 2x for "retina" displays and certain - // device configurations. - const visualLength = window.devicePixelRatio * constrainedLength; - - const availableSizes = parseThumbList(availableThumbList); - - // Starting from the smallest dimensions, find (and return) the first - // available length which hits a "good enough" threshold - it's got to be - // at least that percent of the way to the actual displayed dimensions. - const goodEnoughThreshold = 0.90; - - // (The last item is skipped since we'd be falling back to it anyway.) - for (const {thumb, length} of availableSizes.slice(0, -1)) { - if (Math.floor(visualLength * goodEnoughThreshold) <= length) { - return {thumb, length}; - } - } - - // If none of the items in the list were big enough to hit the "good enough" - // threshold, just use the largest size available. - return availableSizes[availableSizes.length - 1]; -} - -function getSmallestThumbSize(availableThumbList) { - // Just snag the smallest size. This'll be used for displaying the "preview" - // as the bigger one is loading. - const availableSizes = parseThumbList(availableThumbList); - return availableSizes[0]; -} - -function updateFileSizeInformation(fileSize) { - const fileSizeWarningThreshold = 8 * 10 ** 6; - - const actionContentWithoutSize = document.getElementById('image-overlay-action-content-without-size'); - const actionContentWithSize = document.getElementById('image-overlay-action-content-with-size'); - - if (!fileSize) { - actionContentWithSize.classList.remove('visible'); - actionContentWithoutSize.classList.add('visible'); - return; - } - - actionContentWithoutSize.classList.remove('visible'); - actionContentWithSize.classList.add('visible'); - - const megabytesContainer = document.getElementById('image-overlay-file-size-megabytes'); - const kilobytesContainer = document.getElementById('image-overlay-file-size-kilobytes'); - const megabytesContent = megabytesContainer.querySelector('.image-overlay-file-size-count'); - const kilobytesContent = kilobytesContainer.querySelector('.image-overlay-file-size-count'); - const fileSizeWarning = document.getElementById('image-overlay-file-size-warning'); - - fileSize = parseInt(fileSize); - const round = (exp) => Math.round(fileSize / 10 ** (exp - 1)) / 10; - - if (fileSize > fileSizeWarningThreshold) { - fileSizeWarning.classList.add('visible'); - } else { - fileSizeWarning.classList.remove('visible'); - } - - if (fileSize > 10 ** 6) { - megabytesContainer.classList.add('visible'); - kilobytesContainer.classList.remove('visible'); - megabytesContent.innerText = round(6); - } else { - megabytesContainer.classList.remove('visible'); - kilobytesContainer.classList.add('visible'); - kilobytesContent.innerText = round(3); - } - - void fileSizeWarning; -} - -addImageOverlayClickHandlers(); - -/** - * Credits: Parziphal, Feb 13, 2017 - * https://stackoverflow.com/a/42196770 - * - * Loads an image with progress callback. - * - * The `onprogress` callback will be called by XMLHttpRequest's onprogress - * event, and will receive the loading progress ratio as an whole number. - * However, if it's not possible to compute the progress ratio, `onprogress` - * will be called only once passing -1 as progress value. This is useful to, - * for example, change the progress animation to an undefined animation. - * - * @param {string} imageUrl The image to load - * @param {Function} onprogress - * @return {Promise} - */ -function loadImage(imageUrl, onprogress) { - return new Promise((resolve, reject) => { - var xhr = new XMLHttpRequest(); - var notifiedNotComputable = false; - - xhr.open('GET', imageUrl, true); - xhr.responseType = 'arraybuffer'; - - xhr.onprogress = function(ev) { - if (ev.lengthComputable) { - onprogress(parseInt((ev.loaded / ev.total) * 1000) / 10); - } else { - if (!notifiedNotComputable) { - notifiedNotComputable = true; - onprogress(-1); - } - } - } - - xhr.onloadend = function() { - if (!xhr.status.toString().match(/^2/)) { - reject(xhr); - } else { - if (!notifiedNotComputable) { - onprogress(100); - } - - var options = {} - var headers = xhr.getAllResponseHeaders(); - var m = headers.match(/^Content-Type:\s*(.*?)$/mi); - - if (m && m[1]) { - options.type = m[1]; - } - - var blob = new Blob([this.response], options); - - resolve(window.URL.createObjectURL(blob)); - } - } - - xhr.send(); - }); -} - -// Group contributions table ------------------------------ - -const groupContributionsTableInfo = - Array.from(document.querySelectorAll('#content dl')) - .filter(dl => dl.querySelector('a.group-contributions-sort-button')) - .map(dl => ({ - sortingByCountLink: dl.querySelector('dt.group-contributions-sorted-by-count a.group-contributions-sort-button'), - sortingByDurationLink: dl.querySelector('dt.group-contributions-sorted-by-duration a.group-contributions-sort-button'), - sortingByCountElements: dl.querySelectorAll('.group-contributions-sorted-by-count'), - sortingByDurationElements: dl.querySelectorAll('.group-contributions-sorted-by-duration'), - })); - -function sortGroupContributionsTableBy(info, sort) { - const [showThese, hideThese] = - (sort === 'count' - ? [info.sortingByCountElements, info.sortingByDurationElements] - : [info.sortingByDurationElements, info.sortingByCountElements]); - - for (const element of showThese) element.classList.add('visible'); - for (const element of hideThese) element.classList.remove('visible'); -} - -for (const info of groupContributionsTableInfo) { - info.sortingByCountLink.addEventListener('click', evt => { - evt.preventDefault(); - sortGroupContributionsTableBy(info, 'duration'); - }); - - info.sortingByDurationLink.addEventListener('click', evt => { - evt.preventDefault(); - sortGroupContributionsTableBy(info, 'count'); - }); -} - -// Sticky commentary sidebar ------------------------------ - -const albumCommentarySidebarInfo = clientInfo.albumCommentarySidebarInfo = { - sidebar: null, - - sidebarTrackLinks: null, - sidebarTrackDirectories: null, - - sidebarTrackSections: null, - sidebarTrackSectionStartIndices: null, - - state: { - currentTrackSection: null, - currentTrackLink: null, - justChangedTrackSection: false, - }, -}; - -function getAlbumCommentarySidebarReferences() { - const info = albumCommentarySidebarInfo; - - info.sidebar = - document.getElementById('sidebar-left'); - - info.sidebarHeading = - info.sidebar.querySelector('h1'); - - info.sidebarTrackLinks = - Array.from(info.sidebar.querySelectorAll('li a')); - - info.sidebarTrackDirectories = - info.sidebarTrackLinks - .map(el => el.getAttribute('href')?.slice(1) ?? null); - - info.sidebarTrackSections = - Array.from(info.sidebar.getElementsByTagName('details')); - - info.sidebarTrackSectionStartIndices = - info.sidebarTrackSections - .map(details => details.querySelector('ol, ul')) - .reduce( - (accumulator, _list, index, array) => - (empty(accumulator) - ? [0] - : [ - ...accumulator, - (accumulator[accumulator.length - 1] + - array[index - 1].querySelectorAll('li a').length), - ]), - []); -} - -function scrollAlbumCommentarySidebar() { - const info = albumCommentarySidebarInfo; - const {state} = info; - const {currentTrackLink, currentTrackSection} = state; - - if (!currentTrackLink) { - return; - } - - const {sidebar, sidebarHeading} = info; - - const scrollTop = sidebar.scrollTop; - - const headingRect = sidebarHeading.getBoundingClientRect(); - const sidebarRect = sidebar.getBoundingClientRect(); - - const stickyPadding = headingRect.height; - const sidebarViewportHeight = sidebarRect.height - stickyPadding; - - const linkRect = currentTrackLink.getBoundingClientRect(); - const sectionRect = currentTrackSection.getBoundingClientRect(); - - const sectionTopEdge = - sectionRect.top - (sidebarRect.top - scrollTop); - - const sectionHeight = - sectionRect.height; - - const sectionScrollTop = - sectionTopEdge - stickyPadding - 10; - - const linkTopEdge = - linkRect.top - (sidebarRect.top - scrollTop); - - const linkBottomEdge = - linkRect.bottom - (sidebarRect.top - scrollTop); - - const linkScrollTop = - linkTopEdge - stickyPadding - 5; - - const linkDistanceFromSection = - linkScrollTop - sectionTopEdge; - - const linkVisibleFromTopOfSection = - linkBottomEdge - sectionTopEdge > sidebarViewportHeight; - - const linkScrollBottom = - linkScrollTop - sidebarViewportHeight + linkRect.height + 20; - - const maxScrollInViewport = - scrollTop + stickyPadding + sidebarViewportHeight; - - const minScrollInViewport = - scrollTop + stickyPadding; - - if (linkBottomEdge > maxScrollInViewport) { - if (linkVisibleFromTopOfSection) { - sidebar.scrollTo({top: linkScrollBottom, behavior: 'smooth'}); - } else { - sidebar.scrollTo({top: sectionScrollTop, behavior: 'smooth'}); - } - } else if (linkTopEdge < minScrollInViewport) { - if (linkVisibleFromTopOfSection) { - sidebar.scrollTo({top: linkScrollTop, behavior: 'smooth'}); - } else { - sidebar.scrollTo({top: sectionScrollTop, behavior: 'smooth'}); - } - } else if (state.justChangedTrackSection) { - if (sectionHeight < sidebarViewportHeight) { - sidebar.scrollTo({top: sectionScrollTop, behavior: 'smooth'}); - } - } -} - -function markDirectoryAsCurrentForAlbumCommentary(trackDirectory) { - const info = albumCommentarySidebarInfo; - const {state} = info; - - const trackIndex = - (trackDirectory - ? info.sidebarTrackDirectories - .indexOf(trackDirectory) - : -1); - - const sectionIndex = - (trackIndex >= 0 - ? info.sidebarTrackSectionStartIndices - .findIndex((start, index, array) => - (index === array.length - 1 - ? true - : trackIndex < array[index + 1])) - : -1); - - const sidebarTrackLink = - (trackIndex >= 0 - ? info.sidebarTrackLinks[trackIndex] - : null); - - const sidebarTrackSection = - (sectionIndex >= 0 - ? info.sidebarTrackSections[sectionIndex] - : null); - - state.currentTrackLink?.classList?.remove('current'); - state.currentTrackLink = sidebarTrackLink; - state.currentTrackLink?.classList?.add('current'); - - if (sidebarTrackSection !== state.currentTrackSection) { - if (sidebarTrackSection && !sidebarTrackSection.open) { - if (state.currentTrackSection) { - state.currentTrackSection.open = false; - } - - sidebarTrackSection.open = true; - } - - state.currentTrackSection?.classList?.remove('current'); - state.currentTrackSection = sidebarTrackSection; - state.currentTrackSection?.classList?.add('current'); - state.justChangedTrackSection = true; - } else { - state.justChangedTrackSection = false; - } -} - -function addAlbumCommentaryInternalListeners() { - const info = albumCommentarySidebarInfo; - - const mainContentIndex = - (stickyHeadingInfo.contentContainers ?? []) - .findIndex(({id}) => id === 'content'); - - if (mainContentIndex === -1) return; - - stickyHeadingInfo.event.whenDisplayedHeadingChanges.push((index, {newHeading}) => { - if (index !== mainContentIndex) return; - if (hashLinkInfo.state.scrollingAfterClick) return; - - const trackDirectory = - (newHeading - ? newHeading.id - : null); - - markDirectoryAsCurrentForAlbumCommentary(trackDirectory); - scrollAlbumCommentarySidebar(); - }); - - hashLinkInfo.event.whenHashLinkClicked.push(({link}) => { - const hash = link.getAttribute('href').slice(1); - if (!info.sidebarTrackDirectories.includes(hash)) return; - markDirectoryAsCurrentForAlbumCommentary(hash); - }); -} - -if (document.documentElement.dataset.urlKey === 'localized.albumCommentary') { - clientSteps.getPageReferences.push(getAlbumCommentarySidebarReferences); - clientSteps.addInternalListeners.push(addAlbumCommentaryInternalListeners); -} - -// Run setup steps ---------------------------------------- - -for (const [key, steps] of Object.entries(clientSteps)) { - for (const step of steps) { - try { - step(); - } catch (error) { - console.warn(`During ${key}, failed to run ${step.name}`); - console.debug(error); - } - } -} diff --git a/src/static/client3.js b/src/static/client3.js new file mode 100644 index 00000000..94d4c4e2 --- /dev/null +++ b/src/static/client3.js @@ -0,0 +1,1453 @@ +/* eslint-env browser */ + +// This is the JS file that gets loaded on the client! It's only really used for +// the random track feature right now - the idea is we only use it for stuff +// that cannot 8e done at static-site compile time, 8y its fundamentally +// ephemeral nature. + +import {getColors} from '../util/colors.js'; +import {empty, stitchArrays} from '../util/sugar.js'; + +import { + filterMultipleArrays, + getArtistNumContributions, +} from '../util/wiki-data.js'; + +let albumData, artistData; + +let ready = false; + +const clientInfo = window.hsmusicClientInfo = Object.create(null); + +const clientSteps = { + getPageReferences: [], + addInternalListeners: [], + mutatePageContent: [], + initializeState: [], + addPageListeners: [], +}; + +// Localiz8tion nonsense ---------------------------------- + +const language = document.documentElement.getAttribute('lang'); + +let list; +if (typeof Intl === 'object' && typeof Intl.ListFormat === 'function') { + const getFormat = (type) => { + const formatter = new Intl.ListFormat(language, {type}); + return formatter.format.bind(formatter); + }; + + list = { + conjunction: getFormat('conjunction'), + disjunction: getFormat('disjunction'), + unit: getFormat('unit'), + }; +} else { + // Not a gr8 mock we've got going here, 8ut it's *mostly* language-free. + // We use the same mock for every list 'cuz we don't have any of the + // necessary CLDR info to appropri8tely distinguish 8etween them. + const arbitraryMock = (array) => array.join(', '); + + list = { + conjunction: arbitraryMock, + disjunction: arbitraryMock, + unit: arbitraryMock, + }; +} + +// Miscellaneous helpers ---------------------------------- + +function rebase(href, rebaseKey = 'rebaseLocalized') { + const relative = (document.documentElement.dataset[rebaseKey] || '.') + '/'; + if (relative) { + return relative + href; + } else { + return href; + } +} + +function pick(array) { + return array[Math.floor(Math.random() * array.length)]; +} + +function cssProp(el, key) { + return getComputedStyle(el).getPropertyValue(key).trim(); +} + +function getRefDirectory(ref) { + return ref.split(':')[1]; +} + +function getAlbum(el) { + const directory = cssProp(el, '--album-directory'); + return albumData.find((album) => album.directory === directory); +} + +// TODO: These should pro8a8ly access some shared urlSpec path. We'd need to +// separ8te the tooling around that into common-shared code too. +const getLinkHref = (type, directory) => rebase(`${type}/${directory}`); +const openAlbum = (d) => rebase(`album/${d}`); +const openTrack = (d) => rebase(`track/${d}`); +const openArtist = (d) => rebase(`artist/${d}`); + +// TODO: This should also use urlSpec. +function fetchData(type, directory) { + return fetch(rebase(`${type}/${directory}/data.json`, 'rebaseData')).then( + (res) => res.json() + ); +} + +// JS-based links ----------------------------------------- + +const scriptedLinkInfo = clientInfo.scriptedLinkInfo = { + randomLinks: null, + revealLinks: null, + + nextLink: null, + previousLink: null, + randomLink: null, +}; + +function getScriptedLinkReferences() { + scriptedLinkInfo.randomLinks = + document.querySelectorAll('[data-random]'); + + scriptedLinkInfo.revealLinks = + document.getElementsByClassName('reveal'); + + scriptedLinkInfo.nextNavLink = + document.getElementById('next-button'); + + scriptedLinkInfo.previousNavLink = + document.getElementById('previous-button'); + + scriptedLinkInfo.randomNavLink = + document.getElementById('random-button'); +} + +function addRandomLinkListeners() { + for (const a of scriptedLinkInfo.randomLinks ?? []) { + a.addEventListener('click', evt => { + if (!ready) { + evt.preventDefault(); + return; + } + + const tracks = albumData => + albumData + .map(album => album.tracks) + .reduce((acc, tracks) => acc.concat(tracks), []); + + setTimeout(() => { + a.href = rebase('js-disabled'); + }); + + switch (a.dataset.random) { + case 'album': + a.href = openAlbum(pick(albumData).directory); + break; + + case 'track': + a.href = openTrack(getRefDirectory(pick(tracks(albumData)))); + break; + + case 'album-in-group-dl': { + const albumLinks = + Array.from(a + .closest('dt') + .nextElementSibling + .querySelectorAll('li a')) + + const albumDirectories = + albumLinks.map(a => + getComputedStyle(a).getPropertyValue('--album-directory')); + + a.href = openAlbum(pick(albumDirectories)); + break; + } + + case 'track-in-group-dl': { + const albumLinks = + Array.from(a + .closest('dt') + .nextElementSibling + .querySelectorAll('li a')) + + const albumDirectories = + albumLinks.map(a => + getComputedStyle(a).getPropertyValue('--album-directory')); + + const filteredAlbumData = + albumData.filter(album => + albumDirectories.includes(album.directory)); + + a.href = openTrack(getRefDirectory(pick(tracks(filteredAlbumData)))); + break; + } + + case 'track-in-sidebar': { + // Note that the container for track links may be
            or
              , and + // they can't be identified by href, since links from one track to + // another don't include "track" in the href. + const trackLinks = + Array.from(document + .querySelector('.track-list-sidebar-box') + .querySelectorAll('li a')); + + a.href = pick(trackLinks).href; + break; + } + + case 'track-in-album': + a.href = openTrack(getRefDirectory(pick(getAlbum(a).tracks))); + break; + + case 'artist': + a.href = openArtist(pick(artistData).directory); + break; + + case 'artist-more-than-one-contrib': + a.href = + openArtist( + pick(artistData.filter((artist) => getArtistNumContributions(artist) > 1)) + .directory); + break; + } + }); + } +} + +function mutateNavigationLinkContent() { + const prependTitle = (el, prepend) => + el?.setAttribute('title', + (el.hasAttribute('title') + ? prepend + ' ' + el.getAttribute('title') + : prepend)); + + prependTitle(scriptedLinkInfo.nextNavLink, '(Shift+N)'); + prependTitle(scriptedLinkInfo.previousNavLink, '(Shift+P)'); + prependTitle(scriptedLinkInfo.randomNavLink, '(Shift+R)'); +} + +function addNavigationKeyPressListeners() { + document.addEventListener('keypress', (event) => { + if (event.shiftKey) { + if (event.charCode === 'N'.charCodeAt(0)) { + scriptedLinkInfo.nextNavLink?.click(); + } else if (event.charCode === 'P'.charCodeAt(0)) { + scriptedLinkInfo.previousNavLink?.click(); + } else if (event.charCode === 'R'.charCodeAt(0)) { + scriptedLinkInfo.randomNavLink?.click(); + } + } + }); +} + +function addRevealLinkClickListeners() { + for (const reveal of scriptedLinkInfo.revealLinks ?? []) { + reveal.addEventListener('click', (event) => { + if (!reveal.classList.contains('revealed')) { + reveal.classList.add('revealed'); + event.preventDefault(); + event.stopPropagation(); + reveal.dispatchEvent(new CustomEvent('hsmusic-reveal')); + } + }); + } +} + +clientSteps.getPageReferences.push(getScriptedLinkReferences); +clientSteps.addPageListeners.push(addRandomLinkListeners); +clientSteps.addPageListeners.push(addNavigationKeyPressListeners); +clientSteps.addPageListeners.push(addRevealLinkClickListeners); +clientSteps.mutatePageContent.push(mutateNavigationLinkContent); + +const elements1 = document.getElementsByClassName('js-hide-once-data'); +const elements2 = document.getElementsByClassName('js-show-once-data'); + +for (const element of elements1) element.style.display = 'block'; + +fetch(rebase('data.json', 'rebaseShared')) + .then((data) => data.json()) + .then((data) => { + albumData = data.albumData; + artistData = data.artistData; + + for (const element of elements1) element.style.display = 'none'; + for (const element of elements2) element.style.display = 'block'; + + ready = true; + }); + +// Data & info card --------------------------------------- + +/* +const NORMAL_HOVER_INFO_DELAY = 750; +const FAST_HOVER_INFO_DELAY = 250; +const END_FAST_HOVER_DELAY = 500; +const HIDE_HOVER_DELAY = 250; + +let fastHover = false; +let endFastHoverTimeout = null; + +function colorLink(a, color) { + console.warn('Info card link colors temporarily disabled: chroma.js required, no dependency linking for client.js yet'); + return; + + // eslint-disable-next-line no-unreachable + const chroma = {}; + + if (color) { + const {primary, dim} = getColors(color, {chroma}); + a.style.setProperty('--primary-color', primary); + a.style.setProperty('--dim-color', dim); + } +} + +function link(a, type, {name, directory, color}) { + colorLink(a, color); + a.innerText = name; + a.href = getLinkHref(type, directory); +} + +function joinElements(type, elements) { + // We can't use the Intl APIs with elements, 8ecuase it only oper8tes on + // strings. So instead, we'll pass the element's outer HTML's (which means + // the entire HTML of that element). + // + // That does mean this function returns a string, so always 8e sure to + // set innerHTML when using it (not appendChild). + + return list[type](elements.map((el) => el.outerHTML)); +} + +const infoCard = (() => { + const container = document.getElementById('info-card-container'); + + let cancelShow = false; + let hideTimeout = null; + let showing = false; + + container.addEventListener('mouseenter', cancelHide); + container.addEventListener('mouseleave', readyHide); + + function show(type, target) { + cancelShow = false; + + fetchData(type, target.dataset[type]).then((data) => { + // Manual DOM 'cuz we're laaaazy. + + if (cancelShow) { + return; + } + + showing = true; + + const rect = target.getBoundingClientRect(); + + container.style.setProperty('--primary-color', data.color); + + container.style.top = window.scrollY + rect.bottom + 'px'; + container.style.left = window.scrollX + rect.left + 'px'; + + // Use a short timeout to let a currently hidden (or not yet shown) + // info card teleport to the position set a8ove. (If it's currently + // shown, it'll transition to that position.) + setTimeout(() => { + container.classList.remove('hide'); + container.classList.add('show'); + }, 50); + + // 8asic details. + + const nameLink = container.querySelector('.info-card-name a'); + link(nameLink, 'track', data); + + const albumLink = container.querySelector('.info-card-album a'); + link(albumLink, 'album', data.album); + + const artistSpan = container.querySelector('.info-card-artists span'); + artistSpan.innerHTML = joinElements( + 'conjunction', + data.artists.map(({artist}) => { + const a = document.createElement('a'); + a.href = getLinkHref('artist', artist.directory); + a.innerText = artist.name; + return a; + }) + ); + + const coverArtistParagraph = container.querySelector( + '.info-card-cover-artists' + ); + const coverArtistSpan = coverArtistParagraph.querySelector('span'); + if (data.coverArtists.length) { + coverArtistParagraph.style.display = 'block'; + coverArtistSpan.innerHTML = joinElements( + 'conjunction', + data.coverArtists.map(({artist}) => { + const a = document.createElement('a'); + a.href = getLinkHref('artist', artist.directory); + a.innerText = artist.name; + return a; + }) + ); + } else { + coverArtistParagraph.style.display = 'none'; + } + + // Cover art. + + const [containerNoReveal, containerReveal] = [ + container.querySelector('.info-card-art-container.no-reveal'), + container.querySelector('.info-card-art-container.reveal'), + ]; + + const [containerShow, containerHide] = data.cover.warnings.length + ? [containerReveal, containerNoReveal] + : [containerNoReveal, containerReveal]; + + containerHide.style.display = 'none'; + containerShow.style.display = 'block'; + + const img = containerShow.querySelector('.info-card-art'); + img.src = rebase(data.cover.paths.small, 'rebaseMedia'); + + const imgLink = containerShow.querySelector('a'); + colorLink(imgLink, data.color); + imgLink.href = rebase(data.cover.paths.original, 'rebaseMedia'); + + if (containerShow === containerReveal) { + const cw = containerShow.querySelector('.info-card-art-warnings'); + cw.innerText = list.unit(data.cover.warnings); + + const reveal = containerShow.querySelector('.reveal'); + reveal.classList.remove('revealed'); + } + }); + } + + function hide() { + container.classList.remove('show'); + container.classList.add('hide'); + cancelShow = true; + showing = false; + } + + function readyHide() { + if (!hideTimeout && showing) { + hideTimeout = setTimeout(hide, HIDE_HOVER_DELAY); + } + } + + function cancelHide() { + if (hideTimeout) { + clearTimeout(hideTimeout); + hideTimeout = null; + } + } + + return { + show, + hide, + readyHide, + cancelHide, + }; +})(); + +function makeInfoCardLinkHandlers(type) { + let hoverTimeout = null; + + return { + mouseenter(evt) { + hoverTimeout = setTimeout( + () => { + fastHover = true; + infoCard.show(type, evt.target); + }, + fastHover ? FAST_HOVER_INFO_DELAY : NORMAL_HOVER_INFO_DELAY); + + clearTimeout(endFastHoverTimeout); + endFastHoverTimeout = null; + + infoCard.cancelHide(); + }, + + mouseleave() { + clearTimeout(hoverTimeout); + + if (fastHover && !endFastHoverTimeout) { + endFastHoverTimeout = setTimeout(() => { + endFastHoverTimeout = null; + fastHover = false; + }, END_FAST_HOVER_DELAY); + } + + infoCard.readyHide(); + }, + }; +} + +const infoCardLinkHandlers = { + track: makeInfoCardLinkHandlers('track'), +}; + +function addInfoCardLinkHandlers(type) { + for (const a of document.querySelectorAll(`a[data-${type}]`)) { + for (const [eventName, handler] of Object.entries( + infoCardLinkHandlers[type] + )) { + a.addEventListener(eventName, handler); + } + } +} + +// Info cards are disa8led for now since they aren't quite ready for release, +// 8ut you can try 'em out 8y setting this localStorage flag! +// +// localStorage.tryInfoCards = true; +// +if (localStorage.tryInfoCards) { + addInfoCardLinkHandlers('track'); +} +*/ + +// Custom hash links -------------------------------------- + +const hashLinkInfo = clientInfo.hashLinkInfo = { + links: null, + hrefs: null, + targets: null, + + state: { + highlightedTarget: null, + scrollingAfterClick: false, + concludeScrollingStateInterval: null, + }, + + event: { + whenHashLinkClicked: [], + }, +}; + +function getHashLinkReferences() { + const info = hashLinkInfo; + + info.links = + Array.from(document.querySelectorAll('a[href^="#"]:not([href="#"])')); + + info.hrefs = + info.links + .map(link => link.getAttribute('href')); + + info.targets = + info.hrefs + .map(href => document.getElementById(href.slice(1))); + + filterMultipleArrays( + info.links, + info.hrefs, + info.targets, + (_link, _href, target) => target); +} + +function processScrollingAfterHashLinkClicked() { + const {state} = hashLinkInfo; + + if (state.concludeScrollingStateInterval) return; + + let lastScroll = window.scrollY; + state.scrollingAfterClick = true; + state.concludeScrollingStateInterval = setInterval(() => { + if (Math.abs(window.scrollY - lastScroll) < 10) { + clearInterval(state.concludeScrollingStateInterval); + state.scrollingAfterClick = false; + state.concludeScrollingStateInterval = null; + } else { + lastScroll = window.scrollY; + } + }, 200); +} + +function addHashLinkListeners() { + // Instead of defining a scroll offset (to account for the sticky heading) + // in JavaScript, we interface with the CSS property 'scroll-margin-top'. + // This lets the scroll offset be consolidated where it makes sense, and + // sets an appropriate offset when (re)loading a page with hash for free! + + const info = hashLinkInfo; + const {state, event} = info; + + for (const {hashLink, href, target} of stitchArrays({ + hashLink: info.links, + href: info.hrefs, + target: info.targets, + })) { + hashLink.addEventListener('click', evt => { + if (evt.metaKey || evt.shiftKey || evt.ctrlKey || evt.altKey) { + return; + } + + // Hide skipper box right away, so the layout is updated on time for the + // math operations coming up next. + const skipper = document.getElementById('skippers'); + skipper.style.display = 'none'; + setTimeout(() => skipper.style.display = ''); + + const box = target.getBoundingClientRect(); + const style = window.getComputedStyle(target); + + const scrollY = + window.scrollY + + box.top + - style['scroll-margin-top'].replace('px', ''); + + evt.preventDefault(); + history.pushState({}, '', href); + window.scrollTo({top: scrollY, behavior: 'smooth'}); + target.focus({preventScroll: true}); + + const maxScroll = + document.body.scrollHeight + - window.innerHeight; + + if (scrollY > maxScroll && target.classList.contains('content-heading')) { + if (state.highlightedTarget) { + state.highlightedTarget.classList.remove('highlight-hash-link'); + } + + target.classList.add('highlight-hash-link'); + state.highlightedTarget = target; + } + + processScrollingAfterHashLinkClicked(); + + for (const handler of event.whenHashLinkClicked) { + handler({ + link: hashLink, + }); + } + }); + } + + for (const target of info.targets) { + target.addEventListener('animationend', evt => { + if (evt.animationName !== 'highlight-hash-link') return; + target.classList.remove('highlight-hash-link'); + if (target !== state.highlightedTarget) return; + state.highlightedTarget = null; + }); + } +} + +clientSteps.getPageReferences.push(getHashLinkReferences); +clientSteps.addPageListeners.push(addHashLinkListeners); + +// Sticky content heading --------------------------------- + +const stickyHeadingInfo = clientInfo.stickyHeadingInfo = { + stickyContainers: null, + + stickySubheadingRows: null, + stickySubheadings: null, + + stickyCoverContainers: null, + stickyCoverTextAreas: null, + stickyCovers: null, + + contentContainers: null, + contentHeadings: null, + contentCovers: null, + contentCoversReveal: null, + + state: { + displayedHeading: null, + }, + + event: { + whenDisplayedHeadingChanges: [], + }, +}; + +function getStickyHeadingReferences() { + const info = stickyHeadingInfo; + + info.stickyContainers = + Array.from(document.getElementsByClassName('content-sticky-heading-container')); + + info.stickyCoverContainers = + info.stickyContainers + .map(el => el.querySelector('.content-sticky-heading-cover-container')); + + info.stickyCovers = + info.stickyCoverContainers + .map(el => el?.querySelector('.content-sticky-heading-cover')); + + info.stickyCoverTextAreas = + info.stickyCovers + .map(el => el?.querySelector('.image-text-area')); + + info.stickySubheadingRows = + info.stickyContainers + .map(el => el.querySelector('.content-sticky-subheading-row')); + + info.stickySubheadings = + info.stickySubheadingRows + .map(el => el.querySelector('h2')); + + info.contentContainers = + info.stickyContainers + .map(el => el.parentElement); + + info.contentCovers = + info.contentContainers + .map(el => el.querySelector('#cover-art-container')); + + info.contentCoversReveal = + info.contentCovers + .map(el => el ? !!el.querySelector('.reveal') : null); + + info.contentHeadings = + info.contentContainers + .map(el => Array.from(el.querySelectorAll('.content-heading'))); +} + +function removeTextPlaceholderStickyHeadingCovers() { + const info = stickyHeadingInfo; + + const hasTextArea = + info.stickyCoverTextAreas.map(el => !!el); + + const coverContainersWithTextArea = + info.stickyCoverContainers + .filter((_el, index) => hasTextArea[index]); + + for (const el of coverContainersWithTextArea) { + el.remove(); + } + + info.stickyCoverContainers = + info.stickyCoverContainers + .map((el, index) => hasTextArea[index] ? null : el); + + info.stickyCovers = + info.stickyCovers + .map((el, index) => hasTextArea[index] ? null : el); + + info.stickyCoverTextAreas = + info.stickyCoverTextAreas + .slice() + .fill(null); +} + +function addRevealClassToStickyHeadingCovers() { + const info = stickyHeadingInfo; + + const stickyCoversWhichReveal = + info.stickyCovers + .filter((_el, index) => info.contentCoversReveal[index]); + + for (const el of stickyCoversWhichReveal) { + el.classList.add('content-sticky-heading-cover-needs-reveal'); + } +} + +function addRevealListenersForStickyHeadingCovers() { + const info = stickyHeadingInfo; + + const stickyCovers = info.stickyCovers.slice(); + const contentCovers = info.contentCovers.slice(); + + filterMultipleArrays( + stickyCovers, + contentCovers, + (_stickyCover, _contentCover, index) => info.contentCoversReveal[index]); + + for (const {stickyCover, contentCover} of stitchArrays({ + stickyCover: stickyCovers, + contentCover: contentCovers, + })) { + // TODO: Janky - should use internal event instead of DOM event + contentCover.querySelector('.reveal').addEventListener('hsmusic-reveal', () => { + stickyCover.classList.remove('content-sticky-heading-cover-needs-reveal'); + }); + } +} + +function topOfViewInside(el, scroll = window.scrollY) { + return ( + scroll > el.offsetTop && + scroll < el.offsetTop + el.offsetHeight); +} + +function updateStickyCoverVisibility(index) { + const info = stickyHeadingInfo; + + const stickyCoverContainer = info.stickyCoverContainers[index]; + const contentCover = info.contentCovers[index]; + + if (contentCover && stickyCoverContainer) { + if (contentCover.getBoundingClientRect().bottom < 0) { + stickyCoverContainer.classList.add('visible'); + } else { + stickyCoverContainer.classList.remove('visible'); + } + } +} + +function getContentHeadingClosestToStickySubheading(index) { + const info = stickyHeadingInfo; + + const contentContainer = info.contentContainers[index]; + + if (!topOfViewInside(contentContainer)) { + return null; + } + + const stickySubheading = info.stickySubheadings[index]; + + if (stickySubheading.childNodes.length === 0) { + // Supply a non-breaking space to ensure correct basic line height. + stickySubheading.appendChild(document.createTextNode('\xA0')); + } + + const stickyContainer = info.stickyContainers[index]; + const stickyRect = stickyContainer.getBoundingClientRect(); + + // TODO: Should this compute with the subheading row instead of h2? + const subheadingRect = stickySubheading.getBoundingClientRect(); + + const stickyBottom = stickyRect.bottom + subheadingRect.height; + + // Iterate from bottom to top of the content area. + const contentHeadings = info.contentHeadings[index]; + for (const heading of contentHeadings.slice().reverse()) { + const headingRect = heading.getBoundingClientRect(); + if (headingRect.y + headingRect.height / 1.5 < stickyBottom + 20) { + return heading; + } + } + + return null; +} + +function updateStickySubheadingContent(index) { + const info = stickyHeadingInfo; + const {event, state} = info; + + const closestHeading = getContentHeadingClosestToStickySubheading(index); + + if (state.displayedHeading === closestHeading) return; + + const stickySubheadingRow = info.stickySubheadingRows[index]; + + if (closestHeading) { + const stickySubheading = info.stickySubheadings[index]; + + // Array.from needed to iterate over a live array with for..of + for (const child of Array.from(stickySubheading.childNodes)) { + child.remove(); + } + + for (const child of closestHeading.childNodes) { + if (child.classList?.contains('content-heading-accent')) { + continue; + } + + if (child.tagName === 'A') { + for (const grandchild of child.childNodes) { + stickySubheading.appendChild(grandchild.cloneNode(true)); + } + } else { + stickySubheading.appendChild(child.cloneNode(true)); + } + } + + stickySubheadingRow.classList.add('visible'); + } else { + stickySubheadingRow.classList.remove('visible'); + } + + const oldDisplayedHeading = state.displayedHeading; + + state.displayedHeading = closestHeading; + + for (const handler of event.whenDisplayedHeadingChanges) { + handler(index, { + oldHeading: oldDisplayedHeading, + newHeading: closestHeading, + }); + } +} + +function updateStickyHeadings(index) { + updateStickyCoverVisibility(index); + updateStickySubheadingContent(index); +} + +function initializeStateForStickyHeadings() { + for (let i = 0; i < stickyHeadingInfo.stickyContainers.length; i++) { + updateStickyHeadings(i); + } +} + +function addScrollListenerForStickyHeadings() { + document.addEventListener('scroll', () => { + for (let i = 0; i < stickyHeadingInfo.stickyContainers.length; i++) { + updateStickyHeadings(i); + } + }); +} + +clientSteps.getPageReferences.push(getStickyHeadingReferences); +clientSteps.mutatePageContent.push(removeTextPlaceholderStickyHeadingCovers); +clientSteps.mutatePageContent.push(addRevealClassToStickyHeadingCovers); +clientSteps.initializeState.push(initializeStateForStickyHeadings); +clientSteps.addPageListeners.push(addRevealListenersForStickyHeadingCovers); +clientSteps.addPageListeners.push(addScrollListenerForStickyHeadings); + +// Image overlay ------------------------------------------ + +function addImageOverlayClickHandlers() { + const container = document.getElementById('image-overlay-container'); + + if (!container) { + console.warn(`#image-overlay-container missing, image overlay module disabled.`); + return; + } + + for (const link of document.querySelectorAll('.image-link')) { + if (link.querySelector('img').hasAttribute('data-no-image-preview')) { + continue; + } + + link.addEventListener('click', handleImageLinkClicked); + } + + const actionContainer = document.getElementById('image-overlay-action-container'); + + container.addEventListener('click', handleContainerClicked); + document.body.addEventListener('keydown', handleKeyDown); + + function handleContainerClicked(evt) { + // Only hide the image overlay if actually clicking the background. + if (evt.target !== container) { + return; + } + + // If you clicked anything close to or beneath the action bar, don't hide + // the image overlay. + const rect = actionContainer.getBoundingClientRect(); + if (evt.clientY >= rect.top - 40) { + return; + } + + container.classList.remove('visible'); + } + + function handleKeyDown(evt) { + if (evt.key === 'Escape' || evt.key === 'Esc' || evt.keyCode === 27) { + container.classList.remove('visible'); + } + } +} + +function handleImageLinkClicked(evt) { + if (evt.metaKey || evt.shiftKey || evt.altKey) { + return; + } + evt.preventDefault(); + + const container = document.getElementById('image-overlay-container'); + container.classList.add('visible'); + container.classList.remove('loaded'); + container.classList.remove('errored'); + + const allViewOriginal = document.getElementsByClassName('image-overlay-view-original'); + const mainImage = document.getElementById('image-overlay-image'); + const thumbImage = document.getElementById('image-overlay-image-thumb'); + + const {href: originalSrc} = evt.target.closest('a'); + + const { + src: embeddedSrc, + dataset: { + originalSize: originalFileSize, + thumbs: availableThumbList, + }, + } = evt.target.closest('a').querySelector('img'); + + updateFileSizeInformation(originalFileSize); + + let mainSrc = null; + let thumbSrc = null; + + if (availableThumbList) { + const {thumb: mainThumb, length: mainLength} = getPreferredThumbSize(availableThumbList); + const {thumb: smallThumb, length: smallLength} = getSmallestThumbSize(availableThumbList); + mainSrc = embeddedSrc.replace(/\.[a-z]+\.(jpg|png)$/, `.${mainThumb}.jpg`); + thumbSrc = embeddedSrc.replace(/\.[a-z]+\.(jpg|png)$/, `.${smallThumb}.jpg`); + // Show the thumbnail size on each element's data attributes. + // Y'know, just for debugging convenience. + mainImage.dataset.displayingThumb = `${mainThumb}:${mainLength}`; + thumbImage.dataset.displayingThumb = `${smallThumb}:${smallLength}`; + } else { + mainSrc = originalSrc; + thumbSrc = null; + mainImage.dataset.displayingThumb = ''; + thumbImage.dataset.displayingThumb = ''; + } + + if (thumbSrc) { + thumbImage.src = thumbSrc; + thumbImage.style.display = null; + } else { + thumbImage.src = ''; + thumbImage.style.display = 'none'; + } + + for (const viewOriginal of allViewOriginal) { + viewOriginal.href = originalSrc; + } + + mainImage.addEventListener('load', handleMainImageLoaded); + mainImage.addEventListener('error', handleMainImageErrored); + + container.style.setProperty('--download-progress', '0%'); + loadImage(mainSrc, progress => { + container.style.setProperty('--download-progress', (20 + 0.8 * progress) + '%'); + }).then( + blobUrl => { + mainImage.src = blobUrl; + container.style.setProperty('--download-progress', '100%'); + }, + handleMainImageErrored); + + function handleMainImageLoaded() { + mainImage.removeEventListener('load', handleMainImageLoaded); + mainImage.removeEventListener('error', handleMainImageErrored); + container.classList.add('loaded'); + } + + function handleMainImageErrored() { + mainImage.removeEventListener('load', handleMainImageLoaded); + mainImage.removeEventListener('error', handleMainImageErrored); + container.classList.add('errored'); + } +} + +function parseThumbList(availableThumbList) { + // Parse all the available thumbnail sizes! These are provided by the actual + // content generation on each image. + const defaultThumbList = 'huge:1400 semihuge:1200 large:800 medium:400 small:250' + const availableSizes = + (availableThumbList || defaultThumbList) + .split(' ') + .map(part => part.split(':')) + .map(([thumb, length]) => ({thumb, length: parseInt(length)})) + .sort((a, b) => a.length - b.length); + + return availableSizes; +} + +function getPreferredThumbSize(availableThumbList) { + // Assuming a square, the image will be constrained to the lesser window + // dimension. Coefficient here matches CSS dimensions for image overlay. + const constrainedLength = Math.floor(Math.min( + 0.80 * window.innerWidth, + 0.80 * window.innerHeight)); + + // Match device pixel ratio, which is 2x for "retina" displays and certain + // device configurations. + const visualLength = window.devicePixelRatio * constrainedLength; + + const availableSizes = parseThumbList(availableThumbList); + + // Starting from the smallest dimensions, find (and return) the first + // available length which hits a "good enough" threshold - it's got to be + // at least that percent of the way to the actual displayed dimensions. + const goodEnoughThreshold = 0.90; + + // (The last item is skipped since we'd be falling back to it anyway.) + for (const {thumb, length} of availableSizes.slice(0, -1)) { + if (Math.floor(visualLength * goodEnoughThreshold) <= length) { + return {thumb, length}; + } + } + + // If none of the items in the list were big enough to hit the "good enough" + // threshold, just use the largest size available. + return availableSizes[availableSizes.length - 1]; +} + +function getSmallestThumbSize(availableThumbList) { + // Just snag the smallest size. This'll be used for displaying the "preview" + // as the bigger one is loading. + const availableSizes = parseThumbList(availableThumbList); + return availableSizes[0]; +} + +function updateFileSizeInformation(fileSize) { + const fileSizeWarningThreshold = 8 * 10 ** 6; + + const actionContentWithoutSize = document.getElementById('image-overlay-action-content-without-size'); + const actionContentWithSize = document.getElementById('image-overlay-action-content-with-size'); + + if (!fileSize) { + actionContentWithSize.classList.remove('visible'); + actionContentWithoutSize.classList.add('visible'); + return; + } + + actionContentWithoutSize.classList.remove('visible'); + actionContentWithSize.classList.add('visible'); + + const megabytesContainer = document.getElementById('image-overlay-file-size-megabytes'); + const kilobytesContainer = document.getElementById('image-overlay-file-size-kilobytes'); + const megabytesContent = megabytesContainer.querySelector('.image-overlay-file-size-count'); + const kilobytesContent = kilobytesContainer.querySelector('.image-overlay-file-size-count'); + const fileSizeWarning = document.getElementById('image-overlay-file-size-warning'); + + fileSize = parseInt(fileSize); + const round = (exp) => Math.round(fileSize / 10 ** (exp - 1)) / 10; + + if (fileSize > fileSizeWarningThreshold) { + fileSizeWarning.classList.add('visible'); + } else { + fileSizeWarning.classList.remove('visible'); + } + + if (fileSize > 10 ** 6) { + megabytesContainer.classList.add('visible'); + kilobytesContainer.classList.remove('visible'); + megabytesContent.innerText = round(6); + } else { + megabytesContainer.classList.remove('visible'); + kilobytesContainer.classList.add('visible'); + kilobytesContent.innerText = round(3); + } + + void fileSizeWarning; +} + +addImageOverlayClickHandlers(); + +/** + * Credits: Parziphal, Feb 13, 2017 + * https://stackoverflow.com/a/42196770 + * + * Loads an image with progress callback. + * + * The `onprogress` callback will be called by XMLHttpRequest's onprogress + * event, and will receive the loading progress ratio as an whole number. + * However, if it's not possible to compute the progress ratio, `onprogress` + * will be called only once passing -1 as progress value. This is useful to, + * for example, change the progress animation to an undefined animation. + * + * @param {string} imageUrl The image to load + * @param {Function} onprogress + * @return {Promise} + */ +function loadImage(imageUrl, onprogress) { + return new Promise((resolve, reject) => { + var xhr = new XMLHttpRequest(); + var notifiedNotComputable = false; + + xhr.open('GET', imageUrl, true); + xhr.responseType = 'arraybuffer'; + + xhr.onprogress = function(ev) { + if (ev.lengthComputable) { + onprogress(parseInt((ev.loaded / ev.total) * 1000) / 10); + } else { + if (!notifiedNotComputable) { + notifiedNotComputable = true; + onprogress(-1); + } + } + } + + xhr.onloadend = function() { + if (!xhr.status.toString().match(/^2/)) { + reject(xhr); + } else { + if (!notifiedNotComputable) { + onprogress(100); + } + + var options = {} + var headers = xhr.getAllResponseHeaders(); + var m = headers.match(/^Content-Type:\s*(.*?)$/mi); + + if (m && m[1]) { + options.type = m[1]; + } + + var blob = new Blob([this.response], options); + + resolve(window.URL.createObjectURL(blob)); + } + } + + xhr.send(); + }); +} + +// Group contributions table ------------------------------ + +const groupContributionsTableInfo = + Array.from(document.querySelectorAll('#content dl')) + .filter(dl => dl.querySelector('a.group-contributions-sort-button')) + .map(dl => ({ + sortingByCountLink: dl.querySelector('dt.group-contributions-sorted-by-count a.group-contributions-sort-button'), + sortingByDurationLink: dl.querySelector('dt.group-contributions-sorted-by-duration a.group-contributions-sort-button'), + sortingByCountElements: dl.querySelectorAll('.group-contributions-sorted-by-count'), + sortingByDurationElements: dl.querySelectorAll('.group-contributions-sorted-by-duration'), + })); + +function sortGroupContributionsTableBy(info, sort) { + const [showThese, hideThese] = + (sort === 'count' + ? [info.sortingByCountElements, info.sortingByDurationElements] + : [info.sortingByDurationElements, info.sortingByCountElements]); + + for (const element of showThese) element.classList.add('visible'); + for (const element of hideThese) element.classList.remove('visible'); +} + +for (const info of groupContributionsTableInfo) { + info.sortingByCountLink.addEventListener('click', evt => { + evt.preventDefault(); + sortGroupContributionsTableBy(info, 'duration'); + }); + + info.sortingByDurationLink.addEventListener('click', evt => { + evt.preventDefault(); + sortGroupContributionsTableBy(info, 'count'); + }); +} + +// Sticky commentary sidebar ------------------------------ + +const albumCommentarySidebarInfo = clientInfo.albumCommentarySidebarInfo = { + sidebar: null, + + sidebarTrackLinks: null, + sidebarTrackDirectories: null, + + sidebarTrackSections: null, + sidebarTrackSectionStartIndices: null, + + state: { + currentTrackSection: null, + currentTrackLink: null, + justChangedTrackSection: false, + }, +}; + +function getAlbumCommentarySidebarReferences() { + const info = albumCommentarySidebarInfo; + + info.sidebar = + document.getElementById('sidebar-left'); + + info.sidebarHeading = + info.sidebar.querySelector('h1'); + + info.sidebarTrackLinks = + Array.from(info.sidebar.querySelectorAll('li a')); + + info.sidebarTrackDirectories = + info.sidebarTrackLinks + .map(el => el.getAttribute('href')?.slice(1) ?? null); + + info.sidebarTrackSections = + Array.from(info.sidebar.getElementsByTagName('details')); + + info.sidebarTrackSectionStartIndices = + info.sidebarTrackSections + .map(details => details.querySelector('ol, ul')) + .reduce( + (accumulator, _list, index, array) => + (empty(accumulator) + ? [0] + : [ + ...accumulator, + (accumulator[accumulator.length - 1] + + array[index - 1].querySelectorAll('li a').length), + ]), + []); +} + +function scrollAlbumCommentarySidebar() { + const info = albumCommentarySidebarInfo; + const {state} = info; + const {currentTrackLink, currentTrackSection} = state; + + if (!currentTrackLink) { + return; + } + + const {sidebar, sidebarHeading} = info; + + const scrollTop = sidebar.scrollTop; + + const headingRect = sidebarHeading.getBoundingClientRect(); + const sidebarRect = sidebar.getBoundingClientRect(); + + const stickyPadding = headingRect.height; + const sidebarViewportHeight = sidebarRect.height - stickyPadding; + + const linkRect = currentTrackLink.getBoundingClientRect(); + const sectionRect = currentTrackSection.getBoundingClientRect(); + + const sectionTopEdge = + sectionRect.top - (sidebarRect.top - scrollTop); + + const sectionHeight = + sectionRect.height; + + const sectionScrollTop = + sectionTopEdge - stickyPadding - 10; + + const linkTopEdge = + linkRect.top - (sidebarRect.top - scrollTop); + + const linkBottomEdge = + linkRect.bottom - (sidebarRect.top - scrollTop); + + const linkScrollTop = + linkTopEdge - stickyPadding - 5; + + const linkDistanceFromSection = + linkScrollTop - sectionTopEdge; + + const linkVisibleFromTopOfSection = + linkBottomEdge - sectionTopEdge > sidebarViewportHeight; + + const linkScrollBottom = + linkScrollTop - sidebarViewportHeight + linkRect.height + 20; + + const maxScrollInViewport = + scrollTop + stickyPadding + sidebarViewportHeight; + + const minScrollInViewport = + scrollTop + stickyPadding; + + if (linkBottomEdge > maxScrollInViewport) { + if (linkVisibleFromTopOfSection) { + sidebar.scrollTo({top: linkScrollBottom, behavior: 'smooth'}); + } else { + sidebar.scrollTo({top: sectionScrollTop, behavior: 'smooth'}); + } + } else if (linkTopEdge < minScrollInViewport) { + if (linkVisibleFromTopOfSection) { + sidebar.scrollTo({top: linkScrollTop, behavior: 'smooth'}); + } else { + sidebar.scrollTo({top: sectionScrollTop, behavior: 'smooth'}); + } + } else if (state.justChangedTrackSection) { + if (sectionHeight < sidebarViewportHeight) { + sidebar.scrollTo({top: sectionScrollTop, behavior: 'smooth'}); + } + } +} + +function markDirectoryAsCurrentForAlbumCommentary(trackDirectory) { + const info = albumCommentarySidebarInfo; + const {state} = info; + + const trackIndex = + (trackDirectory + ? info.sidebarTrackDirectories + .indexOf(trackDirectory) + : -1); + + const sectionIndex = + (trackIndex >= 0 + ? info.sidebarTrackSectionStartIndices + .findIndex((start, index, array) => + (index === array.length - 1 + ? true + : trackIndex < array[index + 1])) + : -1); + + const sidebarTrackLink = + (trackIndex >= 0 + ? info.sidebarTrackLinks[trackIndex] + : null); + + const sidebarTrackSection = + (sectionIndex >= 0 + ? info.sidebarTrackSections[sectionIndex] + : null); + + state.currentTrackLink?.classList?.remove('current'); + state.currentTrackLink = sidebarTrackLink; + state.currentTrackLink?.classList?.add('current'); + + if (sidebarTrackSection !== state.currentTrackSection) { + if (sidebarTrackSection && !sidebarTrackSection.open) { + if (state.currentTrackSection) { + state.currentTrackSection.open = false; + } + + sidebarTrackSection.open = true; + } + + state.currentTrackSection?.classList?.remove('current'); + state.currentTrackSection = sidebarTrackSection; + state.currentTrackSection?.classList?.add('current'); + state.justChangedTrackSection = true; + } else { + state.justChangedTrackSection = false; + } +} + +function addAlbumCommentaryInternalListeners() { + const info = albumCommentarySidebarInfo; + + const mainContentIndex = + (stickyHeadingInfo.contentContainers ?? []) + .findIndex(({id}) => id === 'content'); + + if (mainContentIndex === -1) return; + + stickyHeadingInfo.event.whenDisplayedHeadingChanges.push((index, {newHeading}) => { + if (index !== mainContentIndex) return; + if (hashLinkInfo.state.scrollingAfterClick) return; + + const trackDirectory = + (newHeading + ? newHeading.id + : null); + + markDirectoryAsCurrentForAlbumCommentary(trackDirectory); + scrollAlbumCommentarySidebar(); + }); + + hashLinkInfo.event.whenHashLinkClicked.push(({link}) => { + const hash = link.getAttribute('href').slice(1); + if (!info.sidebarTrackDirectories.includes(hash)) return; + markDirectoryAsCurrentForAlbumCommentary(hash); + }); +} + +if (document.documentElement.dataset.urlKey === 'localized.albumCommentary') { + clientSteps.getPageReferences.push(getAlbumCommentarySidebarReferences); + clientSteps.addInternalListeners.push(addAlbumCommentaryInternalListeners); +} + +// Run setup steps ---------------------------------------- + +for (const [key, steps] of Object.entries(clientSteps)) { + for (const step of steps) { + try { + step(); + } catch (error) { + console.warn(`During ${key}, failed to run ${step.name}`); + console.debug(error); + } + } +} -- cgit 1.3.0-6-gf8a5 From 91f00a36b33d13630ea7a9ac6fcd03110b0f1a73 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Wed, 15 Nov 2023 18:55:22 -0400 Subject: client: tidy & better isolate random links code in client info --- src/content/dependencies/listRandomPageLinks.js | 6 +- src/static/client3.js | 218 +++++++++++++----------- src/static/site5.css | 5 + 3 files changed, 128 insertions(+), 101 deletions(-) diff --git a/src/content/dependencies/listRandomPageLinks.js b/src/content/dependencies/listRandomPageLinks.js index 0b904019..5e2972ad 100644 --- a/src/content/dependencies/listRandomPageLinks.js +++ b/src/content/dependencies/listRandomPageLinks.js @@ -114,12 +114,10 @@ export default { language.$('listingPage.other.randomPages.chooseLinkLine.browserSupportPart'), })), - html.tag('p', - {class: 'js-hide-once-data'}, + html.tag('p', {id: 'data-loading-line'}, language.$('listingPage.other.randomPages.dataLoadingLine')), - html.tag('p', - {class: 'js-show-once-data'}, + html.tag('p', {id: 'data-loaded-line'}, language.$('listingPage.other.randomPages.dataLoadedLine')), ], diff --git a/src/static/client3.js b/src/static/client3.js index 94d4c4e2..31eddfe9 100644 --- a/src/static/client3.js +++ b/src/static/client3.js @@ -13,10 +13,6 @@ import { getArtistNumContributions, } from '../util/wiki-data.js'; -let albumData, artistData; - -let ready = false; - const clientInfo = window.hsmusicClientInfo = Object.create(null); const clientSteps = { @@ -79,11 +75,6 @@ function getRefDirectory(ref) { return ref.split(':')[1]; } -function getAlbum(el) { - const directory = cssProp(el, '--album-directory'); - return albumData.find((album) => album.directory === directory); -} - // TODO: These should pro8a8ly access some shared urlSpec path. We'd need to // separ8te the tooling around that into common-shared code too. const getLinkHref = (type, directory) => rebase(`${type}/${directory}`); @@ -107,6 +98,11 @@ const scriptedLinkInfo = clientInfo.scriptedLinkInfo = { nextLink: null, previousLink: null, randomLink: null, + + state: { + albumData: null, + artistData: null, + }, }; function getScriptedLinkReferences() { @@ -128,93 +124,115 @@ function getScriptedLinkReferences() { function addRandomLinkListeners() { for (const a of scriptedLinkInfo.randomLinks ?? []) { - a.addEventListener('click', evt => { - if (!ready) { - evt.preventDefault(); - return; - } + a.addEventListener('click', domEvent => { + handleRandomLinkClicked(a, domEvent); + }); + } +} + +function handleRandomLinkClicked(a, domEvent) { + const href = determineRandomLinkHref(a); + + if (!href) { + domEvent.preventDefault(); + return; + } + + setTimeout(() => { + a.href = '#' + }); + + a.href = href; +} + +function determineRandomLinkHref(a) { + const {state} = scriptedLinkInfo; + const {albumData, artistData} = state; + + const tracksFromAlbums = albums => + albums + .map(album => album.tracks) + .reduce((acc, tracks) => acc.concat(tracks), []); + + switch (a.dataset.random) { + case 'album': + if (!albumData) return null; + return openAlbum(pick(albumData).directory); + + case 'track': + if (!albumData) return null; + return openTrack(getRefDirectory(pick(tracksFromAlbums(albumData)))); - const tracks = albumData => + case 'album-in-group-dl': { + const albumLinks = + Array.from(a + .closest('dt') + .nextElementSibling + .querySelectorAll('li a')) + + const albumDirectories = + albumLinks + .map(a => cssProp(a, '--album-directory')); + + return openAlbum(pick(albumDirectories)); + } + + case 'track-in-group-dl': { + if (!albumData) return null; + + const albumLinks = + Array.from(a + .closest('dt') + .nextElementSibling + .querySelectorAll('li a')) + + const albumDirectories = + albumLinks + .map(a => cssProp(a, '--album-directory')); + + const filteredAlbumData = albumData - .map(album => album.tracks) - .reduce((acc, tracks) => acc.concat(tracks), []); + .filter(album => + albumDirectories.includes(album.directory)); - setTimeout(() => { - a.href = rebase('js-disabled'); - }); - - switch (a.dataset.random) { - case 'album': - a.href = openAlbum(pick(albumData).directory); - break; - - case 'track': - a.href = openTrack(getRefDirectory(pick(tracks(albumData)))); - break; - - case 'album-in-group-dl': { - const albumLinks = - Array.from(a - .closest('dt') - .nextElementSibling - .querySelectorAll('li a')) - - const albumDirectories = - albumLinks.map(a => - getComputedStyle(a).getPropertyValue('--album-directory')); - - a.href = openAlbum(pick(albumDirectories)); - break; - } + return openTrack(getRefDirectory(pick(tracksFromAlbums(filteredAlbumData)))); + } - case 'track-in-group-dl': { - const albumLinks = - Array.from(a - .closest('dt') - .nextElementSibling - .querySelectorAll('li a')) + case 'track-in-sidebar': { + // Note that the container for track links may be
                or
                  , and + // they can't be identified by href, since links from one track to + // another don't include "track" in the href. + const trackLinks = + Array.from(document + .querySelector('.track-list-sidebar-box') + .querySelectorAll('li a')); - const albumDirectories = - albumLinks.map(a => - getComputedStyle(a).getPropertyValue('--album-directory')); + return pick(trackLinks).href; + } - const filteredAlbumData = - albumData.filter(album => - albumDirectories.includes(album.directory)); + case 'track-in-album': { + if (!albumData) return null; - a.href = openTrack(getRefDirectory(pick(tracks(filteredAlbumData)))); - break; - } + const directory = cssProp(a, '--album-directory'); + const {tracks} = albumData.find(album => album.directory === directory); - case 'track-in-sidebar': { - // Note that the container for track links may be
                    or
                      , and - // they can't be identified by href, since links from one track to - // another don't include "track" in the href. - const trackLinks = - Array.from(document - .querySelector('.track-list-sidebar-box') - .querySelectorAll('li a')); - - a.href = pick(trackLinks).href; - break; - } + return openTrack(getRefDirectory(pick(tracks))); + } + + case 'artist': { + if (!artistData) return null; + return openArtist(pick(artistData).directory); + } - case 'track-in-album': - a.href = openTrack(getRefDirectory(pick(getAlbum(a).tracks))); - break; + case 'artist-more-than-one-contrib': { + if (!artistData) return null; - case 'artist': - a.href = openArtist(pick(artistData).directory); - break; + const artists = + artistData + .filter(artist => getArtistNumContributions(artist) > 1); - case 'artist-more-than-one-contrib': - a.href = - openArtist( - pick(artistData.filter((artist) => getArtistNumContributions(artist) > 1)) - .directory); - break; - } - }); + return openArtist(pick(artists).directory); + } } } @@ -263,22 +281,28 @@ clientSteps.addPageListeners.push(addNavigationKeyPressListeners); clientSteps.addPageListeners.push(addRevealLinkClickListeners); clientSteps.mutatePageContent.push(mutateNavigationLinkContent); -const elements1 = document.getElementsByClassName('js-hide-once-data'); -const elements2 = document.getElementsByClassName('js-show-once-data'); +if ( + document.documentElement.dataset.urlKey === 'localized.listing' && + document.documentElement.dataset.urlValue0 === 'random' +) { + const dataLoadingLine = document.getElementById('data-loading-line'); + const dataLoadedLine = document.getElementById('data-loaded-line'); -for (const element of elements1) element.style.display = 'block'; + dataLoadingLine.style.display = 'block'; -fetch(rebase('data.json', 'rebaseShared')) - .then((data) => data.json()) - .then((data) => { - albumData = data.albumData; - artistData = data.artistData; + fetch(rebase('data.json', 'rebaseShared')) + .then((data) => data.json()) + .then((data) => { + const {state} = scriptedLinkInfo; - for (const element of elements1) element.style.display = 'none'; - for (const element of elements2) element.style.display = 'block'; + state.albumData = data.albumData; + state.artistData = data.artistData; - ready = true; - }); + dataLoadingLine.style.display = 'none'; + dataLoadedLine.style.display = 'block'; + }); + +} // Data & info card --------------------------------------- diff --git a/src/static/site5.css b/src/static/site5.css index 014e6d25..9111f3a6 100644 --- a/src/static/site5.css +++ b/src/static/site5.css @@ -792,6 +792,11 @@ html[data-url-key="localized.albumCommentary"] li.no-commentary { opacity: 0.7; } +html[data-url-key="localized.listing"][data-url-value0="random"] #data-loading-line, +html[data-url-key="localized.listing"][data-url-value0="random"] #data-loaded-line { + display: none; +} + /* Images */ .image-container { -- cgit 1.3.0-6-gf8a5 From 28371f6e029fb86ba536bf4c20e787dba44d202b Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Wed, 15 Nov 2023 18:56:10 -0400 Subject: content, client: handle random data failing to load --- src/content/dependencies/listRandomPageLinks.js | 3 +++ src/static/client3.js | 15 ++++++++++++++- src/static/site5.css | 7 ++++++- src/strings-default.yaml | 5 ++++- 4 files changed, 27 insertions(+), 3 deletions(-) diff --git a/src/content/dependencies/listRandomPageLinks.js b/src/content/dependencies/listRandomPageLinks.js index 5e2972ad..18585696 100644 --- a/src/content/dependencies/listRandomPageLinks.js +++ b/src/content/dependencies/listRandomPageLinks.js @@ -119,6 +119,9 @@ export default { html.tag('p', {id: 'data-loaded-line'}, language.$('listingPage.other.randomPages.dataLoadedLine')), + + html.tag('p', {id: 'data-error-line'}, + language.$('listingPage.other.randomPages.dataErrorLine')), ], showSkipToSection: true, diff --git a/src/static/client3.js b/src/static/client3.js index 31eddfe9..d2f2bd17 100644 --- a/src/static/client3.js +++ b/src/static/client3.js @@ -287,6 +287,7 @@ if ( ) { const dataLoadingLine = document.getElementById('data-loading-line'); const dataLoadedLine = document.getElementById('data-loaded-line'); + const dataErrorLine = document.getElementById('data-error-line'); dataLoadingLine.style.display = 'block'; @@ -300,8 +301,20 @@ if ( dataLoadingLine.style.display = 'none'; dataLoadedLine.style.display = 'block'; - }); + }) + .catch(() => { + const info = scriptedLinkInfo; + + for (const a of info.randomLinks) { + const href = determineRandomLinkHref(a); + if (!href) { + a.removeAttribute('href'); + } + } + dataLoadingLine.style.display = 'none'; + dataErrorLine.style.display = 'block'; + }); } // Data & info card --------------------------------------- diff --git a/src/static/site5.css b/src/static/site5.css index 9111f3a6..bb83fe67 100644 --- a/src/static/site5.css +++ b/src/static/site5.css @@ -793,10 +793,15 @@ html[data-url-key="localized.albumCommentary"] li.no-commentary { } html[data-url-key="localized.listing"][data-url-value0="random"] #data-loading-line, -html[data-url-key="localized.listing"][data-url-value0="random"] #data-loaded-line { +html[data-url-key="localized.listing"][data-url-value0="random"] #data-loaded-line, +html[data-url-key="localized.listing"][data-url-value0="random"] #data-error-line { display: none; } +html[data-url-key="localized.listing"][data-url-value0="random"] #content a:not([href]) { + opacity: 0.7; +} + /* Images */ .image-container { diff --git a/src/strings-default.yaml b/src/strings-default.yaml index a21758e7..e6b8d6db 100644 --- a/src/strings-default.yaml +++ b/src/strings-default.yaml @@ -1556,7 +1556,7 @@ listingPage: If your browser doesn't support relatively modern JavaScript or you've disabled it, these links won't work - sorry. - # dataLoadingLine, dataLoadedLine: + # dataLoadingLine, dataLoadedLine, dataErrorLine: # Since the links on this page depend on access to a fairly # large data file that is downloaded separately and in the # background, these messages indicate the status of that @@ -1568,6 +1568,9 @@ listingPage: dataLoadedLine: >- (Data files have finished being downloaded. The links should work!) + dataErrorLine: >- + (Data files failed to download. Sorry, some of these links won't work right now!) + chunk: title: -- cgit 1.3.0-6-gf8a5 From 84c367c27ca82938c61b696ef24601d1cff9b2b3 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Wed, 15 Nov 2023 19:16:52 -0400 Subject: client, write: data.json -> random-link-data.json --- src/static/client3.js | 120 +++++++++++++++++-------------- src/write/build-modes/live-dev-server.js | 11 +-- src/write/build-modes/static-build.js | 18 ++--- src/write/common-templates.js | 40 ++++++----- 4 files changed, 100 insertions(+), 89 deletions(-) diff --git a/src/static/client3.js b/src/static/client3.js index d2f2bd17..8372a268 100644 --- a/src/static/client3.js +++ b/src/static/client3.js @@ -7,11 +7,7 @@ import {getColors} from '../util/colors.js'; import {empty, stitchArrays} from '../util/sugar.js'; - -import { - filterMultipleArrays, - getArtistNumContributions, -} from '../util/wiki-data.js'; +import {filterMultipleArrays} from '../util/wiki-data.js'; const clientInfo = window.hsmusicClientInfo = Object.create(null); @@ -71,10 +67,6 @@ function cssProp(el, key) { return getComputedStyle(el).getPropertyValue(key).trim(); } -function getRefDirectory(ref) { - return ref.split(':')[1]; -} - // TODO: These should pro8a8ly access some shared urlSpec path. We'd need to // separ8te the tooling around that into common-shared code too. const getLinkHref = (type, directory) => rebase(`${type}/${directory}`); @@ -100,8 +92,10 @@ const scriptedLinkInfo = clientInfo.scriptedLinkInfo = { randomLink: null, state: { - albumData: null, - artistData: null, + albumDirectories: null, + albumTrackDirectories: null, + artistDirectories: null, + artistNumContributions: null, }, }; @@ -147,21 +141,31 @@ function handleRandomLinkClicked(a, domEvent) { function determineRandomLinkHref(a) { const {state} = scriptedLinkInfo; - const {albumData, artistData} = state; - const tracksFromAlbums = albums => - albums - .map(album => album.tracks) - .reduce((acc, tracks) => acc.concat(tracks), []); + const trackDirectoriesFromAlbumDirectories = albumDirectories => + albumDirectories + .map(directory => state.albumDirectories.indexOf(directory)) + .map(index => state.albumTrackDirectories[index]) + .reduce((acc, trackDirectories) => acc.concat(trackDirectories, [])); switch (a.dataset.random) { - case 'album': - if (!albumData) return null; - return openAlbum(pick(albumData).directory); + case 'album': { + const {albumDirectories} = state; + if (!albumDirectories) return null; - case 'track': - if (!albumData) return null; - return openTrack(getRefDirectory(pick(tracksFromAlbums(albumData)))); + return openAlbum(pick(albumDirectories)); + } + + case 'track': { + const {albumDirectories} = state; + if (!albumDirectories) return null; + + const trackDirectories = + trackDirectoriesFromAlbumDirectories( + albumDirectories); + + return openTrack(pick(trackDirectories)); + } case 'album-in-group-dl': { const albumLinks = @@ -170,15 +174,16 @@ function determineRandomLinkHref(a) { .nextElementSibling .querySelectorAll('li a')) - const albumDirectories = + const listAlbumDirectories = albumLinks .map(a => cssProp(a, '--album-directory')); - return openAlbum(pick(albumDirectories)); + return openAlbum(pick(listAlbumDirectories)); } case 'track-in-group-dl': { - if (!albumData) return null; + const {albumDirectories} = state; + if (!albumDirectories) return null; const albumLinks = Array.from(a @@ -186,16 +191,15 @@ function determineRandomLinkHref(a) { .nextElementSibling .querySelectorAll('li a')) - const albumDirectories = + const listAlbumDirectories = albumLinks .map(a => cssProp(a, '--album-directory')); - const filteredAlbumData = - albumData - .filter(album => - albumDirectories.includes(album.directory)); + const trackDirectories = + trackDirectoriesFromAlbumDirectories( + listAlbumDirectories); - return openTrack(getRefDirectory(pick(tracksFromAlbums(filteredAlbumData)))); + return openTrack(pick(trackDirectories)); } case 'track-in-sidebar': { @@ -211,27 +215,32 @@ function determineRandomLinkHref(a) { } case 'track-in-album': { - if (!albumData) return null; + const {albumDirectories, albumTrackDirectories} = state; + if (!albumDirectories || !albumTrackDirectories) return null; - const directory = cssProp(a, '--album-directory'); - const {tracks} = albumData.find(album => album.directory === directory); + const albumDirectory = cssProp(a, '--album-directory'); + const albumIndex = albumDirectories.indexOf(albumDirectory); + const trackDirectories = albumTrackDirectories[albumIndex]; - return openTrack(getRefDirectory(pick(tracks))); + return openTrack(pick(trackDirectories)); } case 'artist': { - if (!artistData) return null; - return openArtist(pick(artistData).directory); + const {artistDirectories} = state; + if (!artistDirectories) return null; + + return openArtist(pick(artistDirectories)); } case 'artist-more-than-one-contrib': { - if (!artistData) return null; + const {artistDirectories, artistNumContributions} = state; + if (!artistDirectories || !artistNumContributions) return null; - const artists = - artistData - .filter(artist => getArtistNumContributions(artist) > 1); + const filteredArtistDirectories = + artistDirectories + .filter((_artist, index) => artistNumContributions[index] > 1); - return openArtist(pick(artists).directory); + return openArtist(pick(filteredArtistDirectories)); } } } @@ -291,29 +300,32 @@ if ( dataLoadingLine.style.display = 'block'; - fetch(rebase('data.json', 'rebaseShared')) - .then((data) => data.json()) - .then((data) => { + fetch(rebase('random-link-data.json', 'rebaseShared')) + .then(data => data.json()) + .then(data => { const {state} = scriptedLinkInfo; - state.albumData = data.albumData; - state.artistData = data.artistData; + Object.assign(state, { + albumDirectories: data.albumDirectories, + albumTrackDirectories: data.albumTrackDirectories, + artistDirectories: data.artistDirectories, + artistNumContributions: data.artistNumContributions, + }); dataLoadingLine.style.display = 'none'; dataLoadedLine.style.display = 'block'; + }, () => { + dataLoadingLine.style.display = 'none'; + dataErrorLine.style.display = 'block'; }) - .catch(() => { - const info = scriptedLinkInfo; - - for (const a of info.randomLinks) { + .then(() => { + const {randomLinks} = scriptedLinkInfo; + for (const a of randomLinks) { const href = determineRandomLinkHref(a); if (!href) { a.removeAttribute('href'); } } - - dataLoadingLine.style.display = 'none'; - dataErrorLine.style.display = 'block'; }); } diff --git a/src/write/build-modes/live-dev-server.js b/src/write/build-modes/live-dev-server.js index ab6ceecb..8828a5bd 100644 --- a/src/write/build-modes/live-dev-server.js +++ b/src/write/build-modes/live-dev-server.js @@ -16,7 +16,7 @@ import { } from '#urls'; import {bindUtilities} from '../bind-utilities.js'; -import {generateGlobalWikiDataJSON, generateRedirectHTML} from '../common-templates.js'; +import {generateRandomLinkDataJSON, generateRedirectHTML} from '../common-templates.js'; const defaultHost = '0.0.0.0'; const defaultPort = 8002; @@ -157,19 +157,20 @@ export async function go({ // Specialized routes - if (pathname === '/data.json') { + if (pathname === '/random-link-data.json') { try { - const json = generateGlobalWikiDataJSON({ + const json = generateRandomLinkDataJSON({ serializeThings, wikiData, }); + response.writeHead(200, contentTypeJSON); response.end(json); - if (loudResponses) console.log(`${requestHead} [200] /data.json`); + if (loudResponses) console.log(`${requestHead} [200] ${pathname}`); } catch (error) { response.writeHead(500, contentTypeJSON); response.end(`Internal error serializing wiki JSON`); - console.error(`${requestHead} [500] /data.json`); + console.error(`${requestHead} [500] ${pathname}`); showError(error); } return; diff --git a/src/write/build-modes/static-build.js b/src/write/build-modes/static-build.js index b6dc9643..a8e0eb23 100644 --- a/src/write/build-modes/static-build.js +++ b/src/write/build-modes/static-build.js @@ -31,7 +31,7 @@ import { } from '#urls'; import {bindUtilities} from '../bind-utilities.js'; -import {generateRedirectHTML, generateGlobalWikiDataJSON} from '../common-templates.js'; +import {generateRedirectHTML, generateRandomLinkDataJSON} from '../common-templates.js'; const pageFlags = Object.keys(pageSpecs); @@ -145,14 +145,8 @@ export async function go({ }); await writeSharedFilesAndPages({ - language: defaultLanguage, outputPath, - urls, - wikiData, - wikiDataJSON: generateGlobalWikiDataJSON({ - serializeThings, - wikiData, - }), + randomLinkDataJSON: generateRandomLinkDataJSON({wikiData}), }); const buildSteps = writeAll @@ -477,12 +471,12 @@ async function writeFavicon({ async function writeSharedFilesAndPages({ outputPath, - wikiDataJSON, + randomLinkDataJSON, }) { return progressPromiseAll(`Writing files & pages shared across languages.`, [ - wikiDataJSON && + randomLinkDataJSON && writeFile( - path.join(outputPath, 'data.json'), - wikiDataJSON), + path.join(outputPath, 'random-link-data.json'), + randomLinkDataJSON), ].filter(Boolean)); } diff --git a/src/write/common-templates.js b/src/write/common-templates.js index 2dd4c924..d897a73b 100644 --- a/src/write/common-templates.js +++ b/src/write/common-templates.js @@ -1,4 +1,5 @@ import * as html from '#html'; +import {getArtistNumContributions} from '#wiki-data'; export function generateRedirectHTML(title, target, {language}) { return `\n` + html.tag('html', [ @@ -30,22 +31,25 @@ export function generateRedirectHTML(title, target, {language}) { ]); } -export function generateGlobalWikiDataJSON({ - serializeThings, - wikiData, -}) { - const stringifyThings = thingData => - JSON.stringify(serializeThings(thingData)); - - return '{\n' + - ([ - `"albumData": ${stringifyThings(wikiData.albumData)},`, - wikiData.wikiInfo.enableFlashesAndGames && - `"flashData": ${stringifyThings(wikiData.flashData)},`, - `"artistData": ${stringifyThings(wikiData.artistData)}`, - ] - .filter(Boolean) - .map(line => ' ' + line) - .join('\n')) + - '\n}'; +export function generateRandomLinkDataJSON({wikiData}) { + const {albumData, artistData} = wikiData; + + return JSON.stringify({ + albumDirectories: + albumData + .map(album => album.directory), + + albumTrackDirectories: + albumData + .map(album => album.tracks + .map(track => track.directory)), + + artistDirectories: + artistData + .map(artist => artist.directory), + + artistNumContributions: + artistData + .map(artist => getArtistNumContributions(artist)), + }); } -- cgit 1.3.0-6-gf8a5