« get me outta code hell

hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
path: root/src/content
diff options
context:
space:
mode:
Diffstat (limited to 'src/content')
-rw-r--r--src/content/dependencies/generateArtistInfoPageFirstReleaseTooltip.js4
-rw-r--r--src/content/dependencies/generateArtistInfoPageRereleaseTooltip.js4
-rw-r--r--src/content/dependencies/generateArtistInfoPageTracksChunkItem.js4
-rw-r--r--src/content/dependencies/generateArtistNavLinks.js13
-rw-r--r--src/content/dependencies/generateArtistRollingWindowPage.js428
-rw-r--r--src/content/dependencies/generateContributionTooltip.js153
-rw-r--r--src/content/dependencies/generateContributionTooltipChronologySection.js30
-rw-r--r--src/content/dependencies/generateCoverGrid.js5
-rw-r--r--src/content/dependencies/generateTrackList.js8
-rw-r--r--src/content/dependencies/linkArtistRollingWindow.js8
10 files changed, 611 insertions, 46 deletions
diff --git a/src/content/dependencies/generateArtistInfoPageFirstReleaseTooltip.js b/src/content/dependencies/generateArtistInfoPageFirstReleaseTooltip.js
index f86dead7..31a223f5 100644
--- a/src/content/dependencies/generateArtistInfoPageFirstReleaseTooltip.js
+++ b/src/content/dependencies/generateArtistInfoPageFirstReleaseTooltip.js
@@ -1,4 +1,4 @@
-import {sortChronologically} from '#sort';
+import {sortAlbumsTracksChronologically} from '#sort';
 import {stitchArrays} from '#sugar';
 
 export default {
@@ -12,7 +12,7 @@ export default {
 
   query: (track) => ({
     rereleases:
-      sortChronologically(track.allReleases).slice(1),
+      sortAlbumsTracksChronologically(track.allReleases).slice(1),
   }),
 
   relations: (relation, query, track, artist) => ({
diff --git a/src/content/dependencies/generateArtistInfoPageRereleaseTooltip.js b/src/content/dependencies/generateArtistInfoPageRereleaseTooltip.js
index 1d849919..853edcb7 100644
--- a/src/content/dependencies/generateArtistInfoPageRereleaseTooltip.js
+++ b/src/content/dependencies/generateArtistInfoPageRereleaseTooltip.js
@@ -1,4 +1,4 @@
-import {sortChronologically} from '#sort';
+import {sortAlbumsTracksChronologically} from '#sort';
 
 export default {
   contentDependencies: [
@@ -11,7 +11,7 @@ export default {
 
   query: (track) => ({
     firstRelease:
-      sortChronologically(track.allReleases)[0],
+      sortAlbumsTracksChronologically(track.allReleases)[0],
   }),
 
   relations: (relation, query, track, artist) => ({
diff --git a/src/content/dependencies/generateArtistInfoPageTracksChunkItem.js b/src/content/dependencies/generateArtistInfoPageTracksChunkItem.js
index 927d892c..877b2fe9 100644
--- a/src/content/dependencies/generateArtistInfoPageTracksChunkItem.js
+++ b/src/content/dependencies/generateArtistInfoPageTracksChunkItem.js
@@ -1,4 +1,4 @@
-import {sortChronologically} from '#sort';
+import {sortAlbumsTracksChronologically} from '#sort';
 import {empty} from '#sugar';
 
 export default {
@@ -73,7 +73,7 @@ export default {
     // different - and it's the latter that determines whether the
     // track is a rerelease!
     const allReleasesChronologically =
-      sortChronologically(query.track.allReleases);
+      sortAlbumsTracksChronologically(query.track.allReleases);
 
     query.isFirstRelease =
       allReleasesChronologically[0] === query.track;
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..33b1501e
--- /dev/null
+++ b/src/content/dependencies/generateArtistRollingWindowPage.js
@@ -0,0 +1,428 @@
+import {sortAlbumsTracksChronologically} from '#sort';
+import Thing from '#thing';
+
+import {
+  chunkByConditions,
+  filterMultipleArrays,
+  empty,
+  sortMultipleArrays,
+  stitchArrays,
+  unique,
+} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'image',
+    'generateArtistNavLinks',
+    'generateCoverGrid',
+    'generatePageLayout',
+    'linkAnythingMan',
+  ],
+
+  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);
+
+    query.thingArtworks =
+      stitchArrays({
+        thing: query.things,
+        kinds: query.thingContributionKinds,
+      }).map(({thing, kinds}) =>
+          (kinds.includes('artwork')
+            ? (thing.coverArtworks ?? thing.trackArtworks ?? [])
+                .find(artwork => artwork.artistContribs
+                  .some(contrib => contrib.artist === artist))
+            : (thing.coverArtworks ?? thing.trackArtworks)?.[0] ??
+              thing.album?.coverArtworks[0] ??
+              null));
+
+    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.thingArtworks
+        .map(artwork => relation('image', artwork)),
+
+    sourceGridLinks:
+      query.things
+        .map(thing => relation('linkAnythingMan', thing)),
+  }),
+
+  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)),
+
+    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:
+              relations.sourceGridImages,
+
+            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/generateContributionTooltip.js b/src/content/dependencies/generateContributionTooltip.js
index 3a31014d..70641ddb 100644
--- a/src/content/dependencies/generateContributionTooltip.js
+++ b/src/content/dependencies/generateContributionTooltip.js
@@ -1,3 +1,36 @@
+function compareReleaseContributions(a, b) {
+  if (a === b) {
+    return true;
+  }
+
+  const {previous: aPrev, next: aNext} = getSiblings(a);
+  const {previous: bPrev, next: bNext} = getSiblings(b);
+
+  const effective = contrib =>
+    (contrib?.thing.isAlbum && contrib.thing.style === 'single'
+      ? contrib.thing.tracks[0]
+      : contrib?.thing);
+
+  return (
+    effective(aPrev) === effective(bPrev) &&
+    effective(aNext) === effective(bNext)
+  );
+}
+
+function getSiblings(contribution) {
+  let previous = contribution;
+  while (previous && previous.thing === contribution.thing) {
+    previous = previous.previousBySameArtist;
+  }
+
+  let next = contribution;
+  while (next && next.thing === contribution.thing) {
+    next = next.nextBySameArtist;
+  }
+
+  return {previous, next};
+}
+
 export default {
   contentDependencies: [
     'generateContributionTooltipChronologySection',
@@ -5,17 +38,51 @@ export default {
     'generateTooltip',
   ],
 
-  extraDependencies: ['html'],
+  extraDependencies: ['html', 'language'],
 
-  relations: (relation, contribution) => ({
+  query: (contribution) => ({
+    albumArtistContribution:
+      (contribution.thing.isTrack
+        ? contribution.thing.album.artistContribs
+            .find(artistContrib => artistContrib.artist === contribution.artist)
+        : null),
+  }),
+
+  relations: (relation, query, contribution) => ({
     tooltip:
       relation('generateTooltip'),
 
     externalLinkSection:
       relation('generateContributionTooltipExternalLinkSection', contribution),
 
-    chronologySection:
+    ownChronologySection:
       relation('generateContributionTooltipChronologySection', contribution),
+
+    artistReleaseChronologySection:
+      (query.albumArtistContribution
+        ? relation('generateContributionTooltipChronologySection',
+            query.albumArtistContribution)
+        : null),
+  }),
+
+  data: (query, contribution) => ({
+    artistName:
+      contribution.artist.name,
+
+    isAlbumArtistContribution:
+      contribution.thing.isAlbum &&
+      contribution.thingProperty === 'artistContribs',
+
+    isSingleFirstTrackArtistContribution:
+      contribution.thing.isTrack &&
+      contribution.thingProperty === 'artistContribs' &&
+      contribution.thing.album.style === 'single' &&
+      contribution.thing.album.tracks[0] === contribution.thing,
+
+    artistReleaseChronologySectionDiffers:
+      (query.albumArtistContribution
+        ? !compareReleaseContributions(contribution, query.albumArtistContribution)
+        : null),
   }),
 
   slots: {
@@ -25,24 +92,64 @@ export default {
     chronologyKind: {type: 'string'},
   },
 
-  generate: (relations, slots, {html}) =>
-    relations.tooltip.slots({
-      attributes:
-        {class: 'contribution-tooltip'},
-
-      contentAttributes: {
-        [html.joinChildren]:
-          html.tag('span', {class: 'tooltip-divider'}),
-      },
-
-      content: [
-        slots.showExternalLinks &&
-          relations.externalLinkSection,
-
-        slots.showChronology &&
-          relations.chronologySection.slots({
-            kind: slots.chronologyKind,
-          }),
-      ],
-    }),
+  generate: (data, relations, slots, {html, language}) =>
+    language.encapsulate('misc.artistLink', capsule =>
+      relations.tooltip.slots({
+        attributes:
+          {class: 'contribution-tooltip'},
+
+        contentAttributes: {
+          [html.joinChildren]:
+            html.tag('span', {class: 'tooltip-divider'}),
+        },
+
+        content: [
+          slots.showExternalLinks &&
+            relations.externalLinkSection,
+
+          slots.showChronology &&
+            language.encapsulate(capsule, 'chronology', capsule => {
+              const chronologySections = [];
+
+              if (data.isAlbumArtistContribution) {
+                relations.ownChronologySection.setSlots({
+                  kind: 'release',
+                  heading:
+                    language.$(capsule, 'heading.artistReleases', {
+                      artist: data.artistName,
+                    }),
+                });
+              } else {
+                relations.ownChronologySection.setSlot('kind', slots.chronologyKind);
+              }
+
+              if (
+                data.isSingleFirstTrackArtistContribution &&
+                !html.isBlank(relations.artistReleaseChronologySection)
+              ) {
+                relations.artistReleaseChronologySection.setSlot('kind', 'release');
+
+                relations.artistReleaseChronologySection.setSlot('heading',
+                  language.$(capsule, 'heading.artistReleases', {
+                    artist: data.artistName,
+                  }));
+
+                chronologySections.push(relations.artistReleaseChronologySection);
+
+                if (data.artistReleaseChronologySectionDiffers) {
+                  relations.ownChronologySection.setSlot('heading',
+                    language.$(capsule, 'heading.artistTracks', {
+                      artist: data.artistName,
+                    }));
+
+                  chronologySections.push(relations.ownChronologySection);
+                }
+              } else {
+                chronologySections.push(relations.ownChronologySection);
+              }
+
+              return chronologySections;
+            }),
+        ],
+      })),
 };
diff --git a/src/content/dependencies/generateContributionTooltipChronologySection.js b/src/content/dependencies/generateContributionTooltipChronologySection.js
index fb668844..4ee9bb35 100644
--- a/src/content/dependencies/generateContributionTooltipChronologySection.js
+++ b/src/content/dependencies/generateContributionTooltipChronologySection.js
@@ -10,23 +10,27 @@ function getName(thing) {
   return thing.name;
 }
 
+function getSiblings(contribution) {
+  let previous = contribution;
+  while (previous && previous.thing === contribution.thing) {
+    previous = previous.previousBySameArtist;
+  }
+
+  let next = contribution;
+  while (next && next.thing === contribution.thing) {
+    next = next.nextBySameArtist;
+  }
+
+  return {previous, next};
+}
+
 export default {
   contentDependencies: ['linkAnythingMan'],
   extraDependencies: ['html', 'language'],
 
-  query(contribution) {
-    let previous = contribution;
-    while (previous && previous.thing === contribution.thing) {
-      previous = previous.previousBySameArtist;
-    }
-
-    let next = contribution;
-    while (next && next.thing === contribution.thing) {
-      next = next.nextBySameArtist;
-    }
-
-    return {previous, next};
-  },
+  query: (contribution) => ({
+    ...getSiblings(contribution),
+  }),
 
   relations: (relation, query, _contribution) => ({
     previousLink:
diff --git a/src/content/dependencies/generateCoverGrid.js b/src/content/dependencies/generateCoverGrid.js
index e4dfd905..e1f13af3 100644
--- a/src/content/dependencies/generateCoverGrid.js
+++ b/src/content/dependencies/generateCoverGrid.js
@@ -11,6 +11,8 @@ export default {
   },
 
   slots: {
+    attributes: {type: 'attributes', mutable: false},
+
     images: {validate: v => v.strictArrayOf(v.isHTML)},
     links: {validate: v => v.strictArrayOf(v.isHTML)},
     names: {validate: v => v.strictArrayOf(v.isHTML)},
@@ -36,6 +38,7 @@ export default {
 
   generate: (relations, slots, {html, language}) =>
     html.tag('div', {class: 'grid-listing'},
+      slots.attributes,
       {[html.onlyIfContent]: true},
 
       [
@@ -59,6 +62,8 @@ export default {
           }, index) =>
             link.slots({
               attributes: [
+                link.getSlotValue('attributes'),
+
                 {class: ['grid-item', 'box']},
 
                 (classes
diff --git a/src/content/dependencies/generateTrackList.js b/src/content/dependencies/generateTrackList.js
index f3ada092..ff7659b5 100644
--- a/src/content/dependencies/generateTrackList.js
+++ b/src/content/dependencies/generateTrackList.js
@@ -4,9 +4,11 @@ export default {
 
   query: (tracks, contextTrack) => ({
     presentedTracks:
-      tracks.map(track =>
-        track.otherReleases.find(({album}) => album === contextTrack.album) ??
-        track),
+      (contextTrack
+        ? tracks.map(track =>
+            track.otherReleases.find(({album}) => album === contextTrack.album) ??
+            track)
+        : tracks),
   }),
 
   relations: (relation, query, _tracks, _contextTrack) => ({
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,
+};