« get me outta code hell

content: generateArtistGroupContributionsInfo: table layout 👻 - hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
diff options
context:
space:
mode:
author(quasar) nebula <qznebula@protonmail.com>2023-06-30 20:45:00 -0300
committer(quasar) nebula <qznebula@protonmail.com>2023-06-30 20:45:00 -0300
commitda5ba89f4171c395f5e7fa2c764272e7d2de93f3 (patch)
tree51252bb8e85fc6e2867c40393798ef8b9304734e
parent84e1f947c9ac17ab075348ea386d43c17af66435 (diff)
content: generateArtistGroupContributionsInfo: table layout 👻
-rw-r--r--src/content/dependencies/generateArtistGroupContributionsInfo.js161
-rw-r--r--src/content/dependencies/generateArtistInfoPage.js47
-rw-r--r--src/content/dependencies/generateArtistInfoPageArtworksChunkedList.js112
-rw-r--r--src/content/dependencies/generateArtistInfoPageChunkedList.js16
-rw-r--r--src/content/dependencies/generateArtistInfoPageTracksChunkedList.js128
-rw-r--r--src/data/things/language.js1
-rw-r--r--src/static/client.js34
-rw-r--r--src/static/site4.css19
-rw-r--r--src/strings-default.json17
9 files changed, 371 insertions, 164 deletions
diff --git a/src/content/dependencies/generateArtistGroupContributionsInfo.js b/src/content/dependencies/generateArtistGroupContributionsInfo.js
index 0ecfaa3..1e7086e 100644
--- a/src/content/dependencies/generateArtistGroupContributionsInfo.js
+++ b/src/content/dependencies/generateArtistGroupContributionsInfo.js
@@ -1,4 +1,9 @@
-import {stitchArrays, unique} from '../../util/sugar.js';
+import {
+  empty,
+  filterProperties,
+  stitchArrays,
+  unique,
+} from '../../util/sugar.js';
 
 export default {
   contentDependencies: ['linkGroup'],
@@ -47,29 +52,48 @@ export default {
       allGroupsOrdered
         .sort((a, b) => groupToCountMap.get(b) - groupToCountMap.get(a));
 
+    // The filter here ensures all displayed groups have at least some duration
+    // when sorting by duration.
     const groupsSortedByDuration =
       allGroupsOrdered
         .filter(group => groupToDurationMap.get(group) > 0)
         .sort((a, b) => groupToDurationMap.get(b) - groupToDurationMap.get(a));
 
-    const groupCounts =
+    const groupCountsSortedByCount =
       groupsSortedByCount
         .map(group => groupToCountMap.get(group));
 
-    const groupDurations =
+    const groupDurationsSortedByCount =
+      groupsSortedByCount
+        .map(group => groupToDurationMap.get(group));
+
+    const groupDurationsApproximateSortedByCount =
+      groupsSortedByCount
+        .map(group => groupToDurationCountMap.get(group) > 1);
+
+    const groupCountsSortedByDuration =
+      groupsSortedByDuration
+        .map(group => groupToCountMap.get(group));
+
+    const groupDurationsSortedByDuration =
       groupsSortedByDuration
         .map(group => groupToDurationMap.get(group));
 
-    const groupDurationsApproximate =
+    const groupDurationsApproximateSortedByDuration =
       groupsSortedByDuration
         .map(group => groupToDurationCountMap.get(group) > 1);
 
     return {
       groupsSortedByCount,
       groupsSortedByDuration,
-      groupCounts,
-      groupDurations,
-      groupDurationsApproximate,
+
+      groupCountsSortedByCount,
+      groupDurationsSortedByCount,
+      groupDurationsApproximateSortedByCount,
+
+      groupCountsSortedByDuration,
+      groupDurationsSortedByDuration,
+      groupDurationsApproximateSortedByDuration,
     };
   },
 
@@ -86,39 +110,104 @@ export default {
   },
 
   data(query) {
-    return {
-      groupCounts: query.groupCounts,
-      groupDurations: query.groupDurations,
-      groupDurationsApproximate: query.groupDurationsApproximate,
-    };
+    return filterProperties(query, [
+      'groupCountsSortedByCount',
+      'groupDurationsSortedByCount',
+      'groupDurationsApproximateSortedByCount',
+
+      'groupCountsSortedByDuration',
+      'groupDurationsSortedByDuration',
+      'groupDurationsApproximateSortedByDuration',
+    ]);
   },
 
   slots: {
-    mode: {
-      validate: v => v.is('count', 'duration'),
-    },
+    title: {type: 'html'},
+    showBothColumns: {type: 'boolean'},
+    showSortButton: {type: 'boolean'},
+    visible: {type: 'boolean', default: true},
+
+    sort: {validate: v => v.is('count', 'duration')},
+    countUnit: {validate: v => v.is('tracks', 'artworks')},
   },
 
-  generate(data, relations, slots, {language}) {
-    return (
-      language.formatUnitList(
-        (slots.mode === 'count'
-          ? stitchArrays({
-              groupLink: relations.groupLinksSortedByCount,
-              count: data.groupCounts,
-            }).map(({groupLink, count}) =>
-                language.$('artistPage.groupsLine.item.withCount', {
-                  group: groupLink,
-                  count,
-                }))
-          : stitchArrays({
-              groupLink: relations.groupLinksSortedByDuration,
-              duration: data.groupDurations,
-              approximate: data.groupDurationsApproximate,
-            }).map(({groupLink, duration, approximate}) =>
-                language.$('artistPage.groupsLine.item.withDuration', {
-                  group: groupLink,
-                  duration: language.formatDuration(duration, {approximate}),
-                })))));
+  generate(data, relations, slots, {html, language}) {
+    if (slots.sort === 'count' && empty(relations.groupLinksSortedByCount)) {
+      return html.blank();
+    } else if (slots.sort === 'duration' && empty(relations.groupLinksSortedByDuration)) {
+      return html.blank();
+    }
+
+    const getCounts = counts =>
+      counts.map(count => {
+        switch (slots.countUnit) {
+          case 'tracks': return language.countTracks(count, {unit: true});
+          case 'artworks': return language.countArtworks(count, {unit: true});
+        }
+      });
+
+    // We aren't displaying the "~" approximate symbol here for now.
+    // The general notion that these sums aren't going to be 100% accurate
+    // is made clear by the "XYZ has contributed ~1:23:45 hours of music..."
+    // line that's always displayed above this table.
+    const getDurations = (durations, approximate) =>
+      stitchArrays({
+        duration: durations,
+        approximate: approximate,
+      }).map(({duration}) => language.formatDuration(duration));
+
+    const topLevelClasses = [
+      'group-contributions-sorted-by-' + slots.sort,
+      slots.visible && 'visible',
+    ];
+
+    return html.tags([
+      html.tag('dt', {class: topLevelClasses},
+        (slots.showSortButton
+          ? language.$('artistPage.groupContributions.title.withSortButton', {
+              title: slots.title,
+              sort:
+                html.tag('a', {href: '#', class: 'group-contributions-sort-button'},
+                  (slots.sort === 'count'
+                    ? language.$('artistPage.groupContributions.title.sorting.count')
+                    : language.$('artistPage.groupContributions.title.sorting.duration'))),
+            })
+          : slots.title)),
+
+      html.tag('dd', {class: topLevelClasses},
+        html.tag('ul', {class: 'group-contributions-table', role: 'list'},
+          (slots.sort === 'count'
+            ? stitchArrays({
+                group: relations.groupLinksSortedByCount,
+                count: getCounts(data.groupCountsSortedByCount),
+                duration: getDurations(data.groupDurationsSortedByCount, data.groupDurationsApproximateSortedByCount),
+              }).map(({group, count, duration}) =>
+                html.tag('li',
+                  html.tag('div', {class: 'group-contributions-row'}, [
+                    group,
+                    html.tag('span', {class: 'group-contributions-metrics'},
+                      // When sorting by count, duration details aren't necessarily
+                      // available for all items.
+                      (slots.showBothColumns && duration
+                        ? language.$('artistPage.groupContributions.item.countDurationAccent', {count, duration})
+                        : language.$('artistPage.groupContributions.item.countAccent', {count}))),
+                  ])))
+            : stitchArrays({
+                group: relations.groupLinksSortedByDuration,
+                count: getCounts(data.groupCountsSortedByDuration),
+                duration: getDurations(data.groupDurationsSortedByDuration, data.groupDurationsApproximateSortedByCount),
+              }).map(({group, count, duration}) =>
+                html.tag('li',
+                  html.tag('div', {class: 'group-contributions-row'}, [
+                    group,
+                    html.tag('span', {class: 'group-contributions-metrics'},
+                      // Count details are always available, since they're just the
+                      // number of contributions directly. And duration details are
+                      // guaranteed for every item when sorting by duration.
+                      (slots.showBothColumns
+                        ? language.$('artistPage.groupContributions.item.durationCountAccent', {duration, count})
+                        : language.$('artistPage.groupContributions.item.durationAccent', {duration}))),
+                  ])))))),
+    ]);
   },
 };
diff --git a/src/content/dependencies/generateArtistInfoPage.js b/src/content/dependencies/generateArtistInfoPage.js
index 1f6c66f..7f79a60 100644
--- a/src/content/dependencies/generateArtistInfoPage.js
+++ b/src/content/dependencies/generateArtistInfoPage.js
@@ -218,14 +218,30 @@ export default {
                     }),
                 })),
 
-            // TODO: How to check if a template is blank!?
-            // !html.isBlank(sec.tracks.groupInfo.content) &&
-              html.tag('p',
-                language.$('artistPage.musicGroupsLine', {
-                  groups: sec.tracks.groupInfo.slot('mode', 'duration'),
-                })),
+            sec.tracks.list
+              .slots({
+                groupInfo: [
+                  sec.tracks.groupInfo
+                    .clone()
+                    .slots({
+                      title: language.$('artistPage.groupContributions.title.music'),
+                      showSortButton: true,
+                      sort: 'count',
+                      countUnit: 'tracks',
+                      visible: true,
+                    }),
 
-            sec.tracks.list,
+                  sec.tracks.groupInfo
+                    .clone()
+                    .slots({
+                      title: language.$('artistPage.groupContributions.title.music'),
+                      showSortButton: true,
+                      sort: 'duration',
+                      countUnit: 'tracks',
+                      visible: false,
+                    }),
+                ],
+              }),
           ],
 
           sec.artworks && [
@@ -236,11 +252,6 @@ export default {
                 title: language.$('artistPage.artList.title'),
               }),
 
-            html.tag('p',
-              language.$('artistPage.artGroupsLine', {
-                groups: sec.artworks.groupInfo.slot('mode', 'count'),
-              })),
-
             sec.artworks.artistGalleryLink &&
               html.tag('p',
                 language.$('artistPage.viewArtGallery.orBrowseList', {
@@ -249,7 +260,17 @@ export default {
                   }),
                 })),
 
-            sec.artworks.list,
+            sec.artworks.list
+              .slots({
+                groupInfo:
+                  sec.artworks.groupInfo
+                    .slots({
+                      title: language.$('artistPage.groupContributions.title.artworks'),
+                      showBothColumns: false,
+                      sort: 'count',
+                      countUnit: 'artworks',
+                    }),
+              }),
           ],
 
           sec.flashes && [
diff --git a/src/content/dependencies/generateArtistInfoPageArtworksChunkedList.js b/src/content/dependencies/generateArtistInfoPageArtworksChunkedList.js
index 8654859..656121c 100644
--- a/src/content/dependencies/generateArtistInfoPageArtworksChunkedList.js
+++ b/src/content/dependencies/generateArtistInfoPageArtworksChunkedList.js
@@ -9,6 +9,7 @@ import {
 export default {
   contentDependencies: [
     'generateArtistInfoPageChunk',
+    'generateArtistInfoPageChunkedList',
     'generateArtistInfoPageChunkItem',
     'generateArtistInfoPageOtherArtistLinks',
     'linkAlbum',
@@ -81,6 +82,9 @@ export default {
 
   relations(relation, query, artist) {
     return {
+      chunkedList:
+        relation('generateArtistInfoPageChunkedList'),
+
       chunks:
         query.chunks.map(() => relation('generateArtistInfoPageChunk')),
 
@@ -120,63 +124,65 @@ export default {
   },
 
   generate(data, relations, {html, language}) {
-    return html.tag('dl',
-      stitchArrays({
-        chunk: relations.chunks,
-        albumLink: relations.albumLinks,
-        date: data.chunkDates,
-
-        items: relations.items,
-        itemTrackLinks: relations.itemTrackLinks,
-        itemOtherArtistLinks: relations.itemOtherArtistLinks,
-        itemTypes: data.itemTypes,
-        itemContributions: data.itemContributions,
-      }).map(({
-          chunk,
-          albumLink,
-          date,
-
-          items,
-          itemTrackLinks,
-          itemOtherArtistLinks,
-          itemTypes,
-          itemContributions,
-        }) =>
-          chunk.slots({
-            mode: 'album',
+    return relations.chunkedList.slots({
+      chunks:
+        stitchArrays({
+          chunk: relations.chunks,
+          albumLink: relations.albumLinks,
+          date: data.chunkDates,
+
+          items: relations.items,
+          itemTrackLinks: relations.itemTrackLinks,
+          itemOtherArtistLinks: relations.itemOtherArtistLinks,
+          itemTypes: data.itemTypes,
+          itemContributions: data.itemContributions,
+        }).map(({
+            chunk,
             albumLink,
             date,
 
-            items:
-              stitchArrays({
-                item: items,
-                trackLink: itemTrackLinks,
-                otherArtistLinks: itemOtherArtistLinks,
-                type: itemTypes,
-                contribution: itemContributions,
-              }).map(({
-                  item,
-                  trackLink,
-                  otherArtistLinks,
-                  type,
-                  contribution,
-                }) =>
-                  item.slots({
+            items,
+            itemTrackLinks,
+            itemOtherArtistLinks,
+            itemTypes,
+            itemContributions,
+          }) =>
+            chunk.slots({
+              mode: 'album',
+              albumLink,
+              date,
+
+              items:
+                stitchArrays({
+                  item: items,
+                  trackLink: itemTrackLinks,
+                  otherArtistLinks: itemOtherArtistLinks,
+                  type: itemTypes,
+                  contribution: itemContributions,
+                }).map(({
+                    item,
+                    trackLink,
                     otherArtistLinks,
+                    type,
                     contribution,
-
-                    content:
-                      (type === 'trackCover'
-                        ? language.$('artistPage.creditList.entry.track', {
-                            track: trackLink,
-                          })
-                        : html.tag('i',
-                            language.$('artistPage.creditList.entry.album.' + {
-                              albumWallpaper: 'wallpaperArt',
-                              albumBanner: 'bannerArt',
-                              albumCover: 'coverArt',
-                            }[type]))),
-                  })),
-          })));
+                  }) =>
+                    item.slots({
+                      otherArtistLinks,
+                      contribution,
+
+                      content:
+                        (type === 'trackCover'
+                          ? language.$('artistPage.creditList.entry.track', {
+                              track: trackLink,
+                            })
+                          : html.tag('i',
+                              language.$('artistPage.creditList.entry.album.' + {
+                                albumWallpaper: 'wallpaperArt',
+                                albumBanner: 'bannerArt',
+                                albumCover: 'coverArt',
+                              }[type]))),
+                    })),
+            })),
+    });
   },
 };
diff --git a/src/content/dependencies/generateArtistInfoPageChunkedList.js b/src/content/dependencies/generateArtistInfoPageChunkedList.js
new file mode 100644
index 0000000..a0334cb
--- /dev/null
+++ b/src/content/dependencies/generateArtistInfoPageChunkedList.js
@@ -0,0 +1,16 @@
+export default {
+  extraDependencies: ['html'],
+
+  slots: {
+    groupInfo: {type: 'html'},
+    chunks: {type: 'html'},
+  },
+
+  generate(slots, {html}) {
+    return (
+      html.tag('dl', [
+        slots.groupInfo,
+        slots.chunks,
+      ]));
+  },
+};
diff --git a/src/content/dependencies/generateArtistInfoPageTracksChunkedList.js b/src/content/dependencies/generateArtistInfoPageTracksChunkedList.js
index 4dd4d46..d6ae9ae 100644
--- a/src/content/dependencies/generateArtistInfoPageTracksChunkedList.js
+++ b/src/content/dependencies/generateArtistInfoPageTracksChunkedList.js
@@ -9,13 +9,14 @@ import {
 export default {
   contentDependencies: [
     'generateArtistInfoPageChunk',
+    'generateArtistInfoPageChunkedList',
     'generateArtistInfoPageChunkItem',
     'generateArtistInfoPageOtherArtistLinks',
     'linkAlbum',
     'linkTrack',
   ],
 
-  extraDependencies: ['html', 'language'],
+  extraDependencies: ['language'],
 
   query(artist) {
     const entries = [
@@ -52,6 +53,9 @@ export default {
 
   relations(relation, query, artist) {
     return {
+      chunkedList:
+        relation('generateArtistInfoPageChunkedList'),
+
       chunks:
         query.chunks.map(() => relation('generateArtistInfoPageChunk')),
 
@@ -107,73 +111,75 @@ export default {
     };
   },
 
-  generate(data, relations, {html, language}) {
-    return html.tag('dl',
-      stitchArrays({
-        chunk: relations.chunks,
-        albumLink: relations.albumLinks,
-        date: data.chunkDates,
-        duration: data.chunkDurations,
-        durationApproximate: data.chunkDurationsApproximate,
-
-        items: relations.items,
-        trackLinks: relations.trackLinks,
-        trackOtherArtistLinks: relations.trackOtherArtistLinks,
-        trackDurations: data.trackDurations,
-        trackContributions: data.trackContributions,
-        trackRereleases: data.trackRereleases,
-      }).map(({
-          chunk,
-          albumLink,
-          date,
-          duration,
-          durationApproximate,
-
-          items,
-          trackLinks,
-          trackOtherArtistLinks,
-          trackDurations,
-          trackContributions,
-          trackRereleases,
-        }) =>
-          chunk.slots({
-            mode: 'album',
+  generate(data, relations, {language}) {
+    return relations.chunkedList.slots({
+      chunks:
+        stitchArrays({
+          chunk: relations.chunks,
+          albumLink: relations.albumLinks,
+          date: data.chunkDates,
+          duration: data.chunkDurations,
+          durationApproximate: data.chunkDurationsApproximate,
+
+          items: relations.items,
+          trackLinks: relations.trackLinks,
+          trackOtherArtistLinks: relations.trackOtherArtistLinks,
+          trackDurations: data.trackDurations,
+          trackContributions: data.trackContributions,
+          trackRereleases: data.trackRereleases,
+        }).map(({
+            chunk,
             albumLink,
             date,
             duration,
             durationApproximate,
 
-            items:
-              stitchArrays({
-                item: items,
-                trackLink: trackLinks,
-                otherArtistLinks: trackOtherArtistLinks,
-                duration: trackDurations,
-                contribution: trackContributions,
-                rerelease: trackRereleases,
-              }).map(({
-                  item,
-                  trackLink,
-                  otherArtistLinks,
-                  duration,
-                  contribution,
-                  rerelease,
-                }) =>
-                  item.slots({
+            items,
+            trackLinks,
+            trackOtherArtistLinks,
+            trackDurations,
+            trackContributions,
+            trackRereleases,
+          }) =>
+            chunk.slots({
+              mode: 'album',
+              albumLink,
+              date,
+              duration,
+              durationApproximate,
+
+              items:
+                stitchArrays({
+                  item: items,
+                  trackLink: trackLinks,
+                  otherArtistLinks: trackOtherArtistLinks,
+                  duration: trackDurations,
+                  contribution: trackContributions,
+                  rerelease: trackRereleases,
+                }).map(({
+                    item,
+                    trackLink,
                     otherArtistLinks,
+                    duration,
                     contribution,
                     rerelease,
-
-                    content:
-                      (duration
-                        ? language.$('artistPage.creditList.entry.track.withDuration', {
-                            track: trackLink,
-                            duration: language.formatDuration(duration),
-                          })
-                        : language.$('artistPage.creditList.entry.track', {
-                            track: trackLink,
-                          })),
-                  })),
-          })));
+                  }) =>
+                    item.slots({
+                      otherArtistLinks,
+                      contribution,
+                      rerelease,
+
+                      content:
+                        (duration
+                          ? language.$('artistPage.creditList.entry.track.withDuration', {
+                              track: trackLink,
+                              duration: language.formatDuration(duration),
+                            })
+                          : language.$('artistPage.creditList.entry.track', {
+                              track: trackLink,
+                            })),
+                    })),
+            })),
+    });
   },
 };
diff --git a/src/data/things/language.js b/src/data/things/language.js
index ec62de4..004678d 100644
--- a/src/data/things/language.js
+++ b/src/data/things/language.js
@@ -311,6 +311,7 @@ const countHelper = (stringKey, argName = stringKey) =>
 Object.assign(Language.prototype, {
   countAdditionalFiles: countHelper('additionalFiles', 'files'),
   countAlbums: countHelper('albums'),
+  countArtworks: countHelper('artworks'),
   countCommentaryEntries: countHelper('commentaryEntries', 'entries'),
   countContributions: countHelper('contributions'),
   countCoverArts: countHelper('coverArts'),
diff --git a/src/static/client.js b/src/static/client.js
index e75fbd9..2f0b6ae 100644
--- a/src/static/client.js
+++ b/src/static/client.js
@@ -870,3 +870,37 @@ function loadImage(imageUrl, onprogress) {
     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');
+  });
+}
diff --git a/src/static/site4.css b/src/static/site4.css
index d7801f2..c7ecc39 100644
--- a/src/static/site4.css
+++ b/src/static/site4.css
@@ -722,6 +722,25 @@ li > ul {
   margin-top: 5px;
 }
 
+.group-contributions-table {
+  display: inline-block;
+}
+
+.group-contributions-table .group-contributions-row {
+  display: flex;
+  justify-content: space-between;
+}
+
+.group-contributions-table .group-contributions-metrics {
+  margin-left: 1.5ch;
+  white-space: nowrap;
+}
+
+.group-contributions-sorted-by-count:not(.visible),
+.group-contributions-sorted-by-duration:not(.visible) {
+  display: none;
+}
+
 /* Images */
 
 .image-container {
diff --git a/src/strings-default.json b/src/strings-default.json
index 0a93990..4771dc4 100644
--- a/src/strings-default.json
+++ b/src/strings-default.json
@@ -19,10 +19,16 @@
   "count.albums.withUnit.zero": "",
   "count.albums.withUnit.one": "{ALBUMS} album",
   "count.albums.withUnit.two": "",
-  "count.albums.withUnit.two": "",
   "count.albums.withUnit.few": "",
   "count.albums.withUnit.many": "",
   "count.albums.withUnit.other": "{ALBUMS} albums",
+  "count.artworks": "{ARTWORKS}",
+  "count.artworks.withUnit.zero": "",
+  "count.artworks.withUnit.one": "{ARTWORKS} artwork",
+  "count.artworks.withUnit.two": "",
+  "count.artworks.withUnit.few": "",
+  "count.artworks.withUnit.many": "",
+  "count.artworks.withUnit.other": "{ARTWORKS} artworks",
   "count.commentaryEntries": "{ENTRIES}",
   "count.commentaryEntries.withUnit.zero": "",
   "count.commentaryEntries.withUnit.one": "{ENTRIES} entry",
@@ -279,6 +285,15 @@
   "artistPage.artGroupsLine": "Contributed art to groups: {GROUPS}",
   "artistPage.groupsLine.item.withCount": "{GROUP} ({COUNT})",
   "artistPage.groupsLine.item.withDuration": "{GROUP} ({DURATION})",
+  "artistPage.groupContributions.title.music": "Contributed music to groups:",
+  "artistPage.groupContributions.title.artworks": "Contributed artworks to groups:",
+  "artistPage.groupContributions.title.withSortButton": "{TITLE} ({SORT})",
+  "artistPage.groupContributions.title.sorting.count": "Sorting by count.",
+  "artistPage.groupContributions.title.sorting.duration": "Sorting by duration.",
+  "artistPage.groupContributions.item.countAccent": "({COUNT})",
+  "artistPage.groupContributions.item.durationAccent": "({DURATION})",
+  "artistPage.groupContributions.item.countDurationAccent": "({COUNT} — {DURATION})",
+  "artistPage.groupContributions.item.durationCountAccent": "({DURATION} — {COUNT})",
   "artistPage.trackList.title": "Tracks",
   "artistPage.artList.title": "Artworks",
   "artistPage.flashList.title": "Flashes & Games",