diff options
author | (quasar) nebula <qznebula@protonmail.com> | 2024-03-11 19:34:40 -0300 |
---|---|---|
committer | (quasar) nebula <qznebula@protonmail.com> | 2025-07-11 08:23:19 -0300 |
commit | f8b24147722763117c3bc3a2368c7b4ac2826ea4 (patch) | |
tree | a9a2af89b3aa9e46b430f9a860898f0a797c621e | |
parent | df8ddccb30f86f1f67f8099ca5368783bea47088 (diff) |
content, client, css: generateArtistRollingWindowPage
-rw-r--r-- | src/content/dependencies/generateArtistNavLinks.js | 13 | ||||
-rw-r--r-- | src/content/dependencies/generateArtistRollingWindowPage.js | 440 | ||||
-rw-r--r-- | src/content/dependencies/linkArtistRollingWindow.js | 8 | ||||
-rw-r--r-- | src/page/artist.js | 10 | ||||
-rw-r--r-- | src/static/css/site.css | 50 | ||||
-rw-r--r-- | src/static/js/client-util.js | 7 | ||||
-rw-r--r-- | src/static/js/client/artist-rolling-window.js | 573 | ||||
-rw-r--r-- | src/static/js/client/index.js | 2 | ||||
-rw-r--r-- | src/strings-default.yaml | 37 | ||||
-rw-r--r-- | src/urls-default.yaml | 1 |
10 files changed, 1140 insertions, 1 deletions
diff --git a/src/content/dependencies/generateArtistNavLinks.js b/src/content/dependencies/generateArtistNavLinks.js index 1b4b6eca..1a520e84 100644 --- a/src/content/dependencies/generateArtistNavLinks.js +++ b/src/content/dependencies/generateArtistNavLinks.js @@ -5,6 +5,7 @@ export default { 'generateInterpageDotSwitcher', 'linkArtist', 'linkArtistGallery', + 'linkArtistRollingWindow', ], extraDependencies: ['html', 'language', 'wikiData'], @@ -34,6 +35,9 @@ export default { (query.hasGallery ? relation('linkArtistGallery', artist) : null), + + artistRollingWindowLink: + relation('linkArtistRollingWindow', artist), }), data: (_query, sprawl) => ({ @@ -45,7 +49,7 @@ export default { showExtraLinks: {type: 'boolean', default: false}, currentExtra: { - validate: v => v.is('gallery'), + validate: v => v.is('gallery', 'rolling-window'), }, }, @@ -79,6 +83,7 @@ export default { }), slots.showExtraLinks && + slots.currentExtra !== 'rolling-window' && relations.artistGalleryLink?.slots({ attributes: [ slots.currentExtra === 'gallery' && @@ -87,6 +92,12 @@ export default { content: language.$('misc.nav.gallery'), }), + + slots.currentExtra === 'rolling-window' && + relations.artistRollingWindowLink.slots({ + attributes: {class: 'current'}, + content: language.$('misc.nav.rollingWindow'), + }), ], }), }, diff --git a/src/content/dependencies/generateArtistRollingWindowPage.js b/src/content/dependencies/generateArtistRollingWindowPage.js new file mode 100644 index 00000000..bfc780e5 --- /dev/null +++ b/src/content/dependencies/generateArtistRollingWindowPage.js @@ -0,0 +1,440 @@ +import {sortAlbumsTracksChronologically} from '#sort'; +import Thing from '#thing'; + +import { + chunkByConditions, + filterMultipleArrays, + empty, + sortMultipleArrays, + stitchArrays, + unique, +} from '#sugar'; + +export default { + contentDependencies: [ + 'image', + 'generateArtistNavLinks', + 'generateCoverGrid', + 'generatePageLayout', + 'linkAlbum', + 'linkFlash', + 'linkTrack', + ], + + extraDependencies: ['html', 'language', 'wikiData'], + + sprawl: ({groupCategoryData}) => ({ + groupCategoryData, + }), + + query(sprawl, artist) { + const query = {}; + + const musicContributions = + artist.musicContributions + .filter(contrib => contrib.date); + + const artworkContributions = + artist.artworkContributions + .filter(contrib => + contrib.date && + contrib.thingProperty !== 'wallpaperArtistContribs' && + contrib.thingProperty !== 'bannerArtistContribs'); + + const musicThings = + musicContributions + .map(contrib => contrib.thing); + + const artworkThings = + artworkContributions + .map(contrib => contrib.thing.thing); + + const musicContributionDates = + musicContributions + .map(contrib => contrib.date); + + const artworkContributionDates = + artworkContributions + .map(contrib => contrib.date); + + const musicContributionKinds = + musicContributions + .map(() => 'music'); + + const artworkContributionKinds = + artworkContributions + .map(() => 'artwork'); + + const allThings = [ + ...artworkThings, + ...musicThings, + ]; + + const allContributionDates = [ + ...artworkContributionDates, + ...musicContributionDates, + ]; + + const allContributionKinds = [ + ...artworkContributionKinds, + ...musicContributionKinds, + ]; + + const sortedThings = + sortAlbumsTracksChronologically(allThings.slice(), {latestFirst: true}); + + sortMultipleArrays( + allThings, + allContributionDates, + allContributionKinds, + (thing1, thing2) => + sortedThings.indexOf(thing1) - + sortedThings.indexOf(thing2)); + + const sourceIndices = + Array.from({length: allThings.length}, (_, i) => i); + + const sourceChunks = + chunkByConditions(sourceIndices, [ + (index1, index2) => + allThings[index1] !== + allThings[index2], + ]); + + const indicesTo = array => index => array[index]; + + query.things = + sourceChunks + .map(chunks => allThings[chunks[0]]); + + query.thingGroups = + query.things.map(thing => + (thing.constructor[Thing.referenceType] === 'album' + ? thing.groups + : thing.constructor[Thing.referenceType] === 'track' + ? thing.album.groups + : null)); + + query.thingContributionDates = + sourceChunks + .map(indices => indices + .map(indicesTo(allContributionDates))); + + query.thingContributionKinds = + sourceChunks + .map(indices => indices + .map(indicesTo(allContributionKinds))); + + // Matches the "kind" dropdown. + const kinds = ['artwork', 'music', 'flash']; + + const allKinds = + unique(query.thingContributionKinds.flat(2)); + + query.kinds = + kinds + .filter(kind => allKinds.includes(kind)); + + query.firstKind = + query.kinds.at(0); + + const allGroups = + unique(query.thingGroups.flat()); + + query.groupCategories = + sprawl.groupCategoryData.slice(); + + query.groupCategoryGroups = + sprawl.groupCategoryData + .map(category => category.groups + .filter(group => allGroups.includes(group))); + + filterMultipleArrays( + query.groupCategories, + query.groupCategoryGroups, + (_category, groups) => !empty(groups)); + + const groupsMatchingFirstKind = + unique( + stitchArrays({ + thing: query.things, + groups: query.thingGroups, + kinds: query.thingContributionKinds, + }).filter(({kinds}) => kinds.includes(query.firstKind)) + .flatMap(({groups}) => groups)); + + query.firstGroup = + sprawl.groupCategoryData + .flatMap(category => category.groups) + .find(group => groupsMatchingFirstKind.includes(group)); + + query.firstGroupCategory = + query.firstGroup.category; + + return query; + }, + + relations: (relation, query, sprawl, artist) => ({ + layout: + relation('generatePageLayout'), + + artistNavLinks: + relation('generateArtistNavLinks', artist), + + sourceGrid: + relation('generateCoverGrid'), + + sourceGridImages: + query.things.map(() => relation('image')), + + sourceGridLinks: + query.things.map(thing => + (thing.constructor[Thing.referenceType] === 'album' + ? relation('linkAlbum', thing) + : thing.constructor[Thing.referenceType] === 'track' + ? relation('linkTrack', thing) + : thing.constructor[Thing.referenceType] === 'flash' + ? relation('linkFlash', thing) + : null)), + }), + + data: (query, sprawl, artist) => ({ + name: + artist.name, + + categoryGroupDirectories: + query.groupCategoryGroups + .map(groups => groups + .map(group => group.directory)), + + categoryGroupNames: + query.groupCategoryGroups + .map(groups => groups + .map(group => group.name)), + + firstGroupCategoryIndex: + query.groupCategories + .indexOf(query.firstGroupCategory), + + firstGroupIndex: + stitchArrays({ + category: query.groupCategories, + groups: query.groupCategoryGroups, + }).find(({category}) => category === query.firstGroupCategory) + .groups + .indexOf(query.firstGroup), + + kinds: + query.kinds, + + sourceGridNames: + query.things + .map(thing => thing.name), + + sourceGridGroupDirectories: + query.thingGroups + .map(groups => groups + .map(group => group.directory)), + + sourceGridGroupNames: + query.thingGroups + .map(groups => groups + .map(group => group.name)), + + sourceGridPaths: + query.things.map(thing => + (thing.constructor[Thing.referenceType] === 'album' && thing.hasCoverArt + ? ['media.albumCover', thing.directory, thing.coverArtFileExtension] + : thing.constructor[Thing.referenceType] === 'track' + ? (thing.hasUniqueCoverArt + ? ['media.trackCover', thing.album.directory, thing.directory, thing.coverArtFileExtension] + : thing.album.hasCoverArt + ? ['media.albumCover', thing.album.directory, thing.album.coverArtFileExtension] + : null) + : thing.constructor[Thing.referenceType] === 'flash' + ? ['media.flashCover', thing.directory, thing.coverArtFileExtension] + : null)), + + sourceGridContributionKinds: + query.thingContributionKinds, + + sourceGridContributionDates: + query.thingContributionDates, + }), + + generate: (data, relations, {html, language}) => + relations.layout.slots({ + title: + language.$('artistRollingWindowPage.title', { + artist: data.name, + }), + + mainClasses: ['top-index'], + mainContent: [ + html.tag('p', {id: 'timeframe-configuration'}, + language.$('artistRollingWindowPage.windowConfigurationLine', { + timeBefore: + language.$('artistRollingWindowPage.timeframe.months', { + input: + html.tag('input', {id: 'timeframe-months-before'}, + {type: 'number'}, + {value: 3, min: 0}), + }), + + timeAfter: + language.$('artistRollingWindowPage.timeframe.months', { + input: + html.tag('input', {id: 'timeframe-months-after'}, + {type: 'number'}, + {value: 3, min: 1}), + }), + + peek: + language.$('artistRollingWindowPage.timeframe.months', { + input: + html.tag('input', {id: 'timeframe-months-peek'}, + {type: 'number'}, + {value: 1, min: 0}), + }), + })), + + html.tag('p', {id: 'contribution-configuration'}, + language.$('artistRollingWindowPage.contributionConfigurationLine', { + kind: + html.tag('select', {id: 'contribution-kind'}, + data.kinds.map(kind => + html.tag('option', {value: kind}, + language.$('artistRollingWindowPage.contributionKind', kind)))), + + group: + html.tag('select', {id: 'contribution-group'}, [ + html.tag('option', {value: '-'}, + language.$('artistRollingWindowPage.contributionGroup.all')), + + stitchArrays({ + names: data.categoryGroupNames, + directories: data.categoryGroupDirectories, + }).map(({names, directories}, categoryIndex) => [ + html.tag('hr'), + + stitchArrays({name: names, directory: directories}) + .map(({name, directory}, groupIndex) => + html.tag('option', {value: directory}, + categoryIndex === data.firstGroupCategoryIndex && + groupIndex === data.firstGroupIndex && + {selected: true}, + + language.$('artistRollingWindowPage.contributionGroup.group', { + group: name, + }))), + ]), + ]), + })), + + html.tag('p', {id: 'timeframe-selection-info'}, [ + html.tag('span', {id: 'timeframe-selection-some'}, + {style: 'display: none'}, + + language.$('artistRollingWindowPage.timeframeSelectionLine', { + contributions: + html.tag('b', {id: 'timeframe-selection-contribution-count'}), + + timeframes: + html.tag('b', {id: 'timeframe-selection-timeframe-count'}), + + firstDate: + html.tag('b', {id: 'timeframe-selection-first-date'}), + + lastDate: + html.tag('b', {id: 'timeframe-selection-last-date'}), + })), + + html.tag('span', {id: 'timeframe-selection-none'}, + {style: 'display: none'}, + language.$('artistRollingWindowPage.timeframeSelectionLine.none')), + ]), + + html.tag('p', {id: 'timeframe-selection-control'}, + {style: 'display: none'}, + + language.$('artistRollingWindowPage.timeframeSelectionControl', { + timeframes: + html.tag('select', {id: 'timeframe-selection-menu'}), + + previous: + html.tag('a', {id: 'timeframe-selection-previous'}, + {href: '#'}, + language.$('artistRollingWindowPage.timeframeSelectionControl.previous')), + + next: + html.tag('a', {id: 'timeframe-selection-next'}, + {href: '#'}, + language.$('artistRollingWindowPage.timeframeSelectionControl.next')), + })), + + html.tag('div', {id: 'timeframe-source-area'}, [ + html.tag('p', {id: 'timeframe-empty'}, + {style: 'display: none'}, + language.$('artistRollingWindowPage.emptyTimeframeLine')), + + relations.sourceGrid.slots({ + attributes: {style: 'display: none'}, + + lazy: true, + + links: + relations.sourceGridLinks.map(link => + link.slot('attributes', {target: '_blank'})), + + names: + data.sourceGridNames, + + images: + stitchArrays({ + image: relations.sourceGridImages, + path: data.sourceGridPaths, + }).map(({image, path}) => + image.slot('path', path)), + + info: + stitchArrays({ + contributionKinds: data.sourceGridContributionKinds, + contributionDates: data.sourceGridContributionDates, + groupDirectories: data.sourceGridGroupDirectories, + groupNames: data.sourceGridGroupNames, + }).map(({ + contributionKinds, + contributionDates, + groupDirectories, + groupNames, + }) => [ + stitchArrays({ + directory: groupDirectories, + name: groupNames, + }).map(({directory, name}) => + html.tag('data', {class: 'contribution-group'}, + {value: directory}, + name)), + + stitchArrays({ + kind: contributionKinds, + date: contributionDates, + }).map(({kind, date}) => + html.tag('time', {class: `${kind}-contribution-date`}, + {datetime: date.toUTCString()}, + language.formatDate(date))), + ]), + }), + ]), + ], + + navLinkStyle: 'hierarchical', + navLinks: + relations.artistNavLinks + .slots({ + showExtraLinks: true, + currentExtra: 'rolling-window', + }) + .content, + }), +} diff --git a/src/content/dependencies/linkArtistRollingWindow.js b/src/content/dependencies/linkArtistRollingWindow.js new file mode 100644 index 00000000..e94b8ec5 --- /dev/null +++ b/src/content/dependencies/linkArtistRollingWindow.js @@ -0,0 +1,8 @@ +export default { + contentDependencies: ['linkThing'], + + relations: (relation, artist) => + ({link: relation('linkThing', 'localized.artistRollingWindow', artist)}), + + generate: (relations) => relations.link, +}; diff --git a/src/page/artist.js b/src/page/artist.js index 257e060d..bd8b8988 100644 --- a/src/page/artist.js +++ b/src/page/artist.js @@ -32,6 +32,16 @@ export function pathsForTarget(artist) { args: [artist], }, }, + + { + type: 'page', + path: ['artistRollingWindow', artist.directory], + + contentFunction: { + name: 'generateArtistRollingWindowPage', + args: [artist], + }, + }, ]; } diff --git a/src/static/css/site.css b/src/static/css/site.css index bfbe1e56..de089777 100644 --- a/src/static/css/site.css +++ b/src/static/css/site.css @@ -2416,6 +2416,55 @@ html[data-url-key="localized.albumCommentary"] p.track-info { margin-left: 20px; } +html[data-url-key="localized.artistRollingWindow"] #content p { + text-align: center; +} + +html[data-url-key="localized.artistRollingWindow"] #content input[type=number] { + width: 3em; + margin: 0 0.25em; + background: black; + color: white; + border: 1px dotted var(--primary-color); + padding: 4px; + border-radius: 3px; +} + +html[data-url-key="localized.artistRollingWindow"] #timeframe-selection-control a { + text-decoration: underline; + text-decoration-style: dotted; +} + +html[data-url-key="localized.artistRollingWindow"] #timeframe-selection-control a:not([href]) { + text-decoration: none; + opacity: 0.7; +} + +html[data-url-key="localized.artistRollingWindow"] #timeframe-source-area { + border: 1px dashed #ffffff42; + border-top-style: solid; + border-bottom-style: solid; + + display: flex; + flex-direction: column; + justify-content: center; + min-height: calc(100vh - 260px); +} + +html[data-url-key="localized.artistRollingWindow"] #timeframe-source-area .grid-listing { + width: 100%; +} + +html[data-url-key="localized.artistRollingWindow"] .grid-item.peeking { + opacity: 0.8; + background: #ffffff24; +} + +/* This is a pretty snazzy selector. */ +html[data-url-key="localized.artistRollingWindow"] .grid-item > span:not(:first-of-type) > *:not([style*="display: none"]) ~ *::before { + content: '\0020\00b7\0020'; +} + html[data-url-key="localized.groupInfo"] .by a { color: var(--page-primary-color); } @@ -2963,6 +3012,7 @@ video.pixelate, .pixelate video { justify-content: center; align-items: flex-start; padding: 5px 15px; + box-sizing: border-box; } .grid-item { diff --git a/src/static/js/client-util.js b/src/static/js/client-util.js index 71112313..5a35bcf2 100644 --- a/src/static/js/client-util.js +++ b/src/static/js/client-util.js @@ -127,3 +127,10 @@ export function dispatchInternalEvent(event, eventName, ...args) { return results; } + +const languageCode = document.documentElement.getAttribute('lang'); + +export function formatDate(inputDate) { + const date = new Date(inputDate); + return date.toLocaleDateString(languageCode); +} diff --git a/src/static/js/client/artist-rolling-window.js b/src/static/js/client/artist-rolling-window.js new file mode 100644 index 00000000..b201e7df --- /dev/null +++ b/src/static/js/client/artist-rolling-window.js @@ -0,0 +1,573 @@ +/* eslint-env browser */ + +import {cssProp, formatDate} from '../client-util.js'; + +import {sortByDate} from '../../shared-util/sort.js'; +import {chunkByConditions, chunkByProperties, empty, stitchArrays} + from '../../shared-util/sugar.js'; + +export const info = { + id: 'artistRollingWindowInfo', + + timeframeMonthsBefore: null, + timeframeMonthsAfter: null, + timeframeMonthsPeek: null, + + contributionKind: null, + contributionGroup: null, + + timeframeSelectionSomeLine: null, + timeframeSelectionNoneLine: null, + + timeframeSelectionContributionCount: null, + timeframeSelectionTimeframeCount: null, + timeframeSelectionFirstDate: null, + timeframeSelectionLastDate: null, + + timeframeSelectionControl: null, + timeframeSelectionMenu: null, + timeframeSelectionPrevious: null, + timeframeSelectionNext: null, + + timeframeEmptyLine: null, + + sourceArea: null, + sourceGrid: null, + sources: null, +}; + +export function getPageReferences() { + if (document.documentElement.dataset.urlKey !== 'localized.artistRollingWindow') { + return; + } + + info.timeframeMonthsBefore = + document.getElementById('timeframe-months-before'); + + info.timeframeMonthsAfter = + document.getElementById('timeframe-months-after'); + + info.timeframeMonthsPeek = + document.getElementById('timeframe-months-peek'); + + info.contributionKind = + document.getElementById('contribution-kind'); + + info.contributionGroup = + document.getElementById('contribution-group'); + + info.timeframeSelectionSomeLine = + document.getElementById('timeframe-selection-some'); + + info.timeframeSelectionNoneLine = + document.getElementById('timeframe-selection-none'); + + info.timeframeSelectionContributionCount = + document.getElementById('timeframe-selection-contribution-count'); + + info.timeframeSelectionTimeframeCount = + document.getElementById('timeframe-selection-timeframe-count'); + + info.timeframeSelectionFirstDate = + document.getElementById('timeframe-selection-first-date'); + + info.timeframeSelectionLastDate = + document.getElementById('timeframe-selection-last-date'); + + info.timeframeSelectionControl = + document.getElementById('timeframe-selection-control'); + + info.timeframeSelectionMenu = + document.getElementById('timeframe-selection-menu'); + + info.timeframeSelectionPrevious = + document.getElementById('timeframe-selection-previous'); + + info.timeframeSelectionNext = + document.getElementById('timeframe-selection-next'); + + info.timeframeEmptyLine = + document.getElementById('timeframe-empty'); + + info.sourceArea = + document.getElementById('timeframe-source-area'); + + info.sourceGrid = + info.sourceArea.querySelector('.grid-listing'); + + info.sources = + info.sourceGrid.getElementsByClassName('grid-item'); +} + +export function addPageListeners() { + if (!info.sourceArea) { + return; + } + + for (const input of [ + info.timeframeMonthsBefore, + info.timeframeMonthsAfter, + info.timeframeMonthsPeek, + info.contributionKind, + info.contributionGroup, + ]) { + input.addEventListener('change', () => { + updateArtistRollingWindow() + }); + } + + info.timeframeSelectionMenu.addEventListener('change', () => { + updateRollingWindowTimeframeSelection(); + }); + + const eatClicks = (element, callback) => { + element.addEventListener('click', domEvent => { + domEvent.preventDefault(); + callback(); + }); + + element.addEventListener('mousedown', domEvent => { + if (domEvent.detail > 1) { + domEvent.preventDefault(); + } + }); + }; + + eatClicks(info.timeframeSelectionNext, nextRollingTimeframeSelection); + eatClicks(info.timeframeSelectionPrevious, previousRollingTimeframeSelection); +} + +export function mutatePageContent() { + if (!info.sourceArea) { + return; + } + + updateArtistRollingWindow(); +} + +function previousRollingTimeframeSelection() { + const menu = info.timeframeSelectionMenu; + + if (menu.selectedIndex > 0) { + menu.selectedIndex--; + } + + updateRollingWindowTimeframeSelection(); +} + +function nextRollingTimeframeSelection() { + const menu = info.timeframeSelectionMenu; + + if (menu.selectedIndex < menu.length - 1) { + menu.selectedIndex++; + } + + updateRollingWindowTimeframeSelection(); +} + +function getArtistRollingWindowSourceInfo() { + const sourceElements = + Array.from(info.sources); + + const sourceTimeElements = + sourceElements + .map(el => Array.from(el.getElementsByTagName('time'))); + + const sourceTimeClasses = + sourceTimeElements + .map(times => times + .map(time => Array.from(time.classList))); + + const sourceKinds = + sourceTimeClasses + .map(times => times + .map(classes => classes + .find(cl => cl.endsWith('-contribution-date')) + .slice(0, -'-contribution-date'.length))); + + const sourceGroups = + sourceElements + .map(el => + Array.from(el.querySelectorAll('.contribution-group')) + .map(data => data.value)); + + const sourceDates = + sourceTimeElements + .map(times => times + .map(time => new Date(time.getAttribute('datetime')))); + + return stitchArrays({ + element: sourceElements, + kinds: sourceKinds, + groups: sourceGroups, + dates: sourceDates, + }); +} + +function getArtistRollingWindowTimeframeInfo() { + const contributionKind = + info.contributionKind.value; + + const contributionGroup = + info.contributionGroup.value; + + const sourceInfo = + getArtistRollingWindowSourceInfo(); + + const principalSources = + sourceInfo.filter(source => { + if (!source.kinds.includes(contributionKind)) { + return false; + } + + if (contributionGroup !== '-') { + if (!source.groups.includes(contributionGroup)) { + return false; + } + } + + return true; + }); + + const principalSourceDates = + principalSources.map(source => + stitchArrays({ + kind: source.kinds, + date: source.dates, + }).find(({kind}) => kind === contributionKind) + .date); + + const getPeekDate = inputDate => { + const date = new Date(inputDate); + + date.setMonth( + (date.getMonth() + - parseInt(info.timeframeMonthsBefore.value) + - parseInt(info.timeframeMonthsPeek.value))); + + return date; + }; + + const getEntranceDate = inputDate => { + const date = new Date(inputDate); + + date.setMonth( + (date.getMonth() + - parseInt(info.timeframeMonthsBefore.value))); + + return date; + }; + + const getExitDate = inputDate => { + const date = new Date(inputDate); + + date.setMonth( + (date.getMonth() + + parseInt(info.timeframeMonthsAfter.value))); + + return date; + }; + + const principalSourceIndices = + Array.from({length: principalSources.length}, (_, i) => i); + + const timeframeSourceChunks = + chunkByConditions(principalSourceIndices, [ + (previous, next) => + +principalSourceDates[previous] !== + +principalSourceDates[next], + ]); + + const timeframeSourceChunkDates = + timeframeSourceChunks + .map(indices => indices[0]) + .map(index => principalSourceDates[index]); + + const timeframeSourceChunkPeekDates = + timeframeSourceChunkDates + .map(getPeekDate); + + const timeframeSourceChunkEntranceDates = + timeframeSourceChunkDates + .map(getEntranceDate); + + const timeframeSourceChunkExitDates = + timeframeSourceChunkDates + .map(getExitDate); + + const peekDateInfo = + stitchArrays({ + peek: timeframeSourceChunkPeekDates, + indices: timeframeSourceChunks, + }).map(({peek, indices}) => ({ + date: peek, + peek: indices, + })); + + const entranceDateInfo = + stitchArrays({ + entrance: timeframeSourceChunkEntranceDates, + indices: timeframeSourceChunks, + }).map(({entrance, indices}) => ({ + date: entrance, + entrance: indices, + })); + + const exitDateInfo = + stitchArrays({ + exit: timeframeSourceChunkExitDates, + indices: timeframeSourceChunks, + }).map(({exit, indices}) => ({ + date: exit, + exit: indices, + })); + + const dateInfoChunks = + chunkByProperties( + sortByDate([ + ...peekDateInfo, + ...entranceDateInfo, + ...exitDateInfo, + ]), + ['date']); + + const dateInfo = + dateInfoChunks + .map(({chunk}) => + Object.assign({ + peek: null, + entrance: null, + exit: null, + }, ...chunk)); + + const timeframeInfo = + dateInfo.reduce( + (accumulator, {date, peek, entrance, exit}) => { + const previous = accumulator.at(-1); + + // These mustn't be mutated! + let peeking = (previous ? previous.peeking : []); + let tracking = (previous ? previous.tracking : []); + + if (peek) { + peeking = + peeking.concat(peek); + } + + if (entrance) { + peeking = + peeking.filter(index => !entrance.includes(index)); + + tracking = + tracking.concat(entrance); + } + + if (exit) { + tracking = + tracking.filter(index => !exit.includes(index)); + } + + return [...accumulator, { + date, + peeking, + tracking, + peek, + entrance, + exit, + }]; + }, + []); + + const indicesToSources = indices => + (indices + ? indices.map(index => principalSources[index]) + : null); + + const finalizedTimeframeInfo = + timeframeInfo.map(({ + date, + peeking, + tracking, + peek, + entrance, + exit, + }) => ({ + date, + peeking: indicesToSources(peeking), + tracking: indicesToSources(tracking), + peek: indicesToSources(peek), + entrance: indicesToSources(entrance), + exit: indicesToSources(exit), + })); + + return finalizedTimeframeInfo; +} + +function updateArtistRollingWindow() { + const timeframeInfo = + getArtistRollingWindowTimeframeInfo(); + + if (empty(timeframeInfo)) { + cssProp(info.timeframeSelectionControl, 'display', 'none'); + cssProp(info.timeframeSelectionSomeLine, 'display', 'none'); + cssProp(info.timeframeSelectionNoneLine, 'display', null); + + updateRollingWindowTimeframeSelection(timeframeInfo); + + return; + } + + cssProp(info.timeframeSelectionControl, 'display', null); + cssProp(info.timeframeSelectionSomeLine, 'display', null); + cssProp(info.timeframeSelectionNoneLine, 'display', 'none'); + + // The last timeframe is just the exit of the final tracked sources, + // so we aren't going to display a menu option for it, and will just use + // it as the end of the final option's date range. + + const usedTimeframes = timeframeInfo.slice(0, -1); + const firstTimeframe = timeframeInfo.at(0); + const lastTimeframe = timeframeInfo.at(-1); + + const sourceCount = + timeframeInfo + .flatMap(({entrance}) => entrance ?? []) + .length; + + const timeframeCount = + usedTimeframes.length; + + info.timeframeSelectionContributionCount.innerText = sourceCount; + info.timeframeSelectionTimeframeCount.innerText = timeframeCount; + + const firstDate = firstTimeframe.date; + const lastDate = lastTimeframe.date; + + info.timeframeSelectionFirstDate.innerText = formatDate(firstDate); + info.timeframeSelectionLastDate.innerText = formatDate(lastDate); + + while (info.timeframeSelectionMenu.firstChild) { + info.timeframeSelectionMenu.firstChild.remove(); + } + + for (const [index, timeframe] of usedTimeframes.entries()) { + const nextTimeframe = timeframeInfo[index + 1]; + + const option = document.createElement('option'); + + option.appendChild(document.createTextNode( + `${formatDate(timeframe.date)} – ${formatDate(nextTimeframe.date)}`)); + + info.timeframeSelectionMenu.appendChild(option); + } + + updateRollingWindowTimeframeSelection(timeframeInfo); +} + +function updateRollingWindowTimeframeSelection(timeframeInfo) { + timeframeInfo ??= getArtistRollingWindowTimeframeInfo(); + + updateRollingWindowTimeframeSelectionControls(timeframeInfo); + updateRollingWindowTimeframeSelectionSources(timeframeInfo); +} + +function updateRollingWindowTimeframeSelectionControls(timeframeInfo) { + const currentIndex = + info.timeframeSelectionMenu.selectedIndex; + + const atFirstTimeframe = + currentIndex === 0; + + // The last actual timeframe is empty and not displayed as a menu option. + const atLastTimeframe = + currentIndex === timeframeInfo.length - 2; + + if (atFirstTimeframe) { + info.timeframeSelectionPrevious.removeAttribute('href'); + } else { + info.timeframeSelectionPrevious.setAttribute('href', '#'); + } + + if (atLastTimeframe) { + info.timeframeSelectionNext.removeAttribute('href'); + } else { + info.timeframeSelectionNext.setAttribute('href', '#'); + } +} + +function updateRollingWindowTimeframeSelectionSources(timeframeInfo) { + const currentIndex = + info.timeframeSelectionMenu.selectedIndex; + + const contributionGroup = + info.contributionGroup.value; + + cssProp(info.sourceGrid, 'display', null); + + const {peeking: peekingSources, tracking: trackingSources} = + (empty(timeframeInfo) + ? {peeking: [], tracking: []} + : timeframeInfo[currentIndex]); + + const peekingElements = + peekingSources.map(source => source.element); + + const trackingElements = + trackingSources.map(source => source.element); + + const showingElements = + [...trackingElements, ...peekingElements]; + + const hidingElements = + Array.from(info.sources) + .filter(element => + !peekingElements.includes(element) && + !trackingElements.includes(element)); + + for (const element of peekingElements) { + element.classList.add('peeking'); + element.classList.remove('tracking'); + } + + for (const element of trackingElements) { + element.classList.remove('peeking'); + element.classList.add('tracking'); + } + + for (const element of hidingElements) { + element.classList.remove('peeking'); + element.classList.remove('tracking'); + cssProp(element, 'display', 'none'); + } + + for (const element of showingElements) { + cssProp(element, 'display', null); + + for (const time of element.getElementsByTagName('time')) { + for (const className of time.classList) { + if (!className.endsWith('-contribution-date')) continue; + + const kind = className.slice(0, -'-contribution-date'.length); + if (kind === info.contributionKind.value) { + cssProp(time, 'display', null); + } else { + cssProp(time, 'display', 'none'); + } + } + } + + for (const data of element.getElementsByClassName('contribution-group')) { + if (contributionGroup === '-' || data.value !== contributionGroup) { + cssProp(data, 'display', null); + } else { + cssProp(data, 'display', 'none'); + } + } + } + + if (empty(peekingElements) && empty(trackingElements)) { + cssProp(info.timeframeEmptyLine, 'display', null); + } else { + cssProp(info.timeframeEmptyLine, 'display', 'none'); + } +} diff --git a/src/static/js/client/index.js b/src/static/js/client/index.js index aeb9264a..9d7eae86 100644 --- a/src/static/js/client/index.js +++ b/src/static/js/client/index.js @@ -7,6 +7,7 @@ import * as albumCommentarySidebarModule from './album-commentary-sidebar.js'; import * as artTagGalleryFilterModule from './art-tag-gallery-filter.js'; import * as artTagNetworkModule from './art-tag-network.js'; import * as artistExternalLinkTooltipModule from './artist-external-link-tooltip.js'; +import * as artistRollingWindowModule from './artist-rolling-window.js'; import * as cssCompatibilityAssistantModule from './css-compatibility-assistant.js'; import * as datetimestampTooltipModule from './datetimestamp-tooltip.js'; import * as draggedLinkModule from './dragged-link.js'; @@ -30,6 +31,7 @@ export const modules = [ artTagGalleryFilterModule, artTagNetworkModule, artistExternalLinkTooltipModule, + artistRollingWindowModule, cssCompatibilityAssistantModule, datetimestampTooltipModule, draggedLinkModule, diff --git a/src/strings-default.yaml b/src/strings-default.yaml index b0a8b6e4..16daf0fe 100644 --- a/src/strings-default.yaml +++ b/src/strings-default.yaml @@ -794,8 +794,10 @@ misc: nav: previous: "Previous" next: "Next" + info: "Info" gallery: "Gallery" + rollingWindow: "Rolling Window" # pageTitle: # Title set under the page's <title> HTML element, which is @@ -1422,6 +1424,41 @@ artistGalleryPage: infoLine: >- Contributed to {COVER_ARTS}. +artistRollingWindowPage: + title: "{ARTIST} - Rolling Window" + + windowConfigurationLine: >- + With a rolling window of {TIME_BEFORE} before a given date, and {TIME_AFTER} after, peeking ahead {PEEK}... + + contributionConfigurationLine: >- + Selecting {KIND} contributions from group {GROUP}... + + timeframeSelectionLine: + _: >- + There are {CONTRIBUTIONS} contributions, making {TIMEFRAMES} timeframes between {FIRST_DATE} and {LAST_DATE}. + none: >- + There aren't any matching contributions, or those which do aren't dated, so there are no timeframes. + + emptyTimeframeLine: >- + This timeframe is empty, since no contributions are in range. + + timeframeSelectionControl: + _: "{PREVIOUS} {TIMEFRAMES} {NEXT}" + previous: "(← Previous)" + next: "(Next →)" + + contributionKind: + artwork: "Artwork" + music: "Music" + flash: "Flash" + + contributionGroup: + all: "All groups" + group: "{GROUP}" + + timeframe: + months: "{INPUT} months" + # # artTagPage: # Stuff that's common between art tag pages. diff --git a/src/urls-default.yaml b/src/urls-default.yaml index cbdd8a23..667f7d8b 100644 --- a/src/urls-default.yaml +++ b/src/urls-default.yaml @@ -41,6 +41,7 @@ localized: artist: 'artist/<>/' artistGallery: 'artist/<>/gallery/' + artistRollingWindow: 'artist/<>/rolling-window/' commentaryIndex: 'commentary/' |