« get me outta code hell

hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
path: root/src/static/client.js
diff options
context:
space:
mode:
Diffstat (limited to 'src/static/client.js')
-rw-r--r--src/static/client.js415
1 files changed, 415 insertions, 0 deletions
diff --git a/src/static/client.js b/src/static/client.js
new file mode 100644
index 0000000..c12ff35
--- /dev/null
+++ b/src/static/client.js
@@ -0,0 +1,415 @@
+// 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.
+//
+// Upd8: As of 04/02/2021, it's now used for info cards too! Nice.
+
+import {
+    getColors
+} from '../util/colors.js';
+
+let albumData, artistData, flashData;
+let officialAlbumData, fandomAlbumData, artistNames;
+
+let ready = false;
+
+// 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);
+}
+
+function getFlash(el) {
+    const directory = cssProp(el, '--flash-directory');
+    return flashData.find(flash => flash.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}`);
+const openFlash = d => rebase(`flash/${d}`);
+
+function getTrackListAndIndex() {
+    const album = getAlbum(document.body);
+    const directory = cssProp(document.body, '--track-directory');
+    if (!directory && !album) return {};
+    if (!directory) return {list: album.tracks};
+    const trackIndex = album.tracks.findIndex(track => track.directory === directory);
+    return {list: album.tracks, index: trackIndex};
+}
+
+function openRandomTrack() {
+    const { list } = getTrackListAndIndex();
+    if (!list) return;
+    return openTrack(pick(list));
+}
+
+function getFlashListAndIndex() {
+    const list = flashData.filter(flash => !flash.act8r8k)
+    const flash = getFlash(document.body);
+    if (!flash) return {list};
+    const flashIndex = list.indexOf(flash);
+    return {list, index: flashIndex};
+}
+
+// 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 -----------------------------------------
+
+for (const a of document.body.querySelectorAll('[data-random]')) {
+    a.addEventListener('click', evt => {
+        if (!ready) {
+            evt.preventDefault();
+            return;
+        }
+
+        setTimeout(() => {
+            a.href = rebase('js-disabled');
+        });
+        switch (a.dataset.random) {
+            case 'album': return a.href = openAlbum(pick(albumData).directory);
+            case 'album-in-fandom': return a.href = openAlbum(pick(fandomAlbumData).directory);
+            case 'album-in-official': return a.href = openAlbum(pick(officialAlbumData).directory);
+            case 'track': return a.href = openTrack(getRefDirectory(pick(albumData.map(a => a.tracks).reduce((a, b) => a.concat(b), []))));
+            case 'track-in-album': return a.href = openTrack(getRefDirectory(pick(getAlbum(a).tracks)));
+            case 'track-in-fandom': return a.href = openTrack(getRefDirectory(pick(fandomAlbumData.reduce((acc, album) => acc.concat(album.tracks), []))));
+            case 'track-in-official': return a.href = openTrack(getRefDirectory(pick(officialAlbumData.reduce((acc, album) => acc.concat(album.tracks), []))));
+            case 'artist': return a.href = openArtist(pick(artistData).directory);
+            case 'artist-more-than-one-contrib': return a.href = openArtist(pick(artistData.filter(artist => C.getArtistNumContributions(artist) > 1)).directory);
+        }
+    });
+}
+
+const next = document.getElementById('next-button');
+const previous = document.getElementById('previous-button');
+const random = document.getElementById('random-button');
+
+const prependTitle = (el, prepend) => {
+    const existing = el.getAttribute('title');
+    if (existing) {
+        el.setAttribute('title', prepend + ' ' + existing);
+    } else {
+        el.setAttribute('title', prepend);
+    }
+};
+
+if (next) prependTitle(next, '(Shift+N)');
+if (previous) prependTitle(previous, '(Shift+P)');
+if (random) prependTitle(random, '(Shift+R)');
+
+document.addEventListener('keypress', event => {
+    if (event.shiftKey) {
+        if (event.charCode === 'N'.charCodeAt(0)) {
+            if (next) next.click();
+        } else if (event.charCode === 'P'.charCodeAt(0)) {
+            if (previous) previous.click();
+        } else if (event.charCode === 'R'.charCodeAt(0)) {
+            if (random && ready) random.click();
+        }
+    }
+});
+
+for (const reveal of document.querySelectorAll('.reveal')) {
+    reveal.addEventListener('click', event => {
+        if (!reveal.classList.contains('revealed')) {
+            reveal.classList.add('revealed');
+            event.preventDefault();
+            event.stopPropagation();
+        }
+    });
+}
+
+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;
+    flashData = data.flashData;
+
+    officialAlbumData = albumData.filter(album => album.groups.includes('group:official'));
+    fandomAlbumData = albumData.filter(album => !album.groups.includes('group:official'));
+    artistNames = artistData.filter(artist => !artist.alias).map(artist => artist.name);
+
+    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) {
+    if (color) {
+        const { primary, dim } = getColors(color);
+        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(evt) {
+            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');
+}