« get me outta code hell

hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/common-util/search-spec.js120
-rw-r--r--src/content/dependencies/generateCoverGrid.js37
-rw-r--r--src/content/dependencies/generateExpandableGallerySection.js92
-rw-r--r--src/content/dependencies/generateGridExpando.js39
-rw-r--r--src/content/dependencies/generateGroupGalleryPageSeriesSection.js147
-rw-r--r--src/content/dependencies/generateLyricsEntry.js31
-rw-r--r--src/content/dependencies/generateSearchSidebarBox.js17
-rw-r--r--src/content/dependencies/index.js7
-rw-r--r--src/static/css/site.css102
-rw-r--r--src/static/js/client-util.js22
-rw-r--r--src/static/js/client/expandable-gallery-section.js77
-rw-r--r--src/static/js/client/expandable-grid-section.js85
-rw-r--r--src/static/js/client/index.js4
-rw-r--r--src/static/js/client/quick-description.js2
-rw-r--r--src/static/js/client/sidebar-search.js56
-rw-r--r--src/static/js/search-worker.js3
-rw-r--r--src/strings-default.yaml17
17 files changed, 494 insertions, 364 deletions
diff --git a/src/common-util/search-spec.js b/src/common-util/search-spec.js
index af5ec201..731e5495 100644
--- a/src/common-util/search-spec.js
+++ b/src/common-util/search-spec.js
@@ -1,57 +1,19 @@
 // Index structures shared by client and server, and relevant interfaces.
 
-function getArtworkPath(thing) {
-  switch (thing.constructor[Symbol.for('Thing.referenceType')]) {
-    case 'album': {
-      return [
-        'media.albumCover',
-        thing.directory,
-        thing.coverArtFileExtension,
-      ];
-    }
-
-    case 'flash': {
-      return [
-        'media.flashArt',
-        thing.directory,
-        thing.coverArtFileExtension,
-      ];
-    }
-
-    case 'track': {
-      if (thing.hasUniqueCoverArt) {
-        return [
-          'media.trackCover',
-          thing.album.directory,
-          thing.directory,
-          thing.coverArtFileExtension,
-        ];
-      } else if (thing.album.hasCoverArt) {
-        return [
-          'media.albumCover',
-          thing.album.directory,
-          thing.album.coverArtFileExtension,
-        ];
-      } else {
-        return null;
-      }
-    }
-
-    default:
-      return null;
-  }
-}
-
-function prepareArtwork(thing, {
+function prepareArtwork(artwork, thing, {
   checkIfImagePathHasCachedThumbnails,
   getThumbnailEqualOrSmaller,
   urls,
 }) {
+  if (!artwork) {
+    return undefined;
+  }
+
   const hasWarnings =
-    thing.artTags?.some(artTag => artTag.isContentWarning);
+    artwork.artTags?.some(artTag => artTag.isContentWarning);
 
   const artworkPath =
-    getArtworkPath(thing);
+    artwork.path;
 
   if (!artworkPath) {
     return undefined;
@@ -92,23 +54,48 @@ function baselineProcess(thing, opts) {
     thing.name;
 
   fields.artwork =
-    prepareArtwork(thing, opts);
+    null;
 
   fields.color =
     thing.color;
 
+  fields.disambiguator =
+    null;
+
   return fields;
 }
 
 const baselineStore = [
   'primaryName',
+  'disambiguator',
   'artwork',
   'color',
 ];
 
 function genericQuery(wikiData) {
+  const groupOrder =
+    wikiData.wikiInfo.divideTrackListsByGroups;
+
+  const getGroupRank = thing => {
+    const relevantRanks =
+      Array.from(groupOrder.entries())
+        .filter(({1: group}) => thing.groups.includes(group))
+        .map(({0: index}) => index);
+
+    if (relevantRanks.length === 0) {
+      return Infinity;
+    } else if (relevantRanks.length === 1) {
+      return relevantRanks[0];
+    } else {
+      return relevantRanks[0] + 0.5;
+    }
+  }
+
+  const sortByGroupRank = things =>
+    things.sort((a, b) => getGroupRank(a) - getGroupRank(b));
+
   return [
-    wikiData.albumData,
+    sortByGroupRank(wikiData.albumData.slice()),
 
     wikiData.artTagData,
 
@@ -119,10 +106,9 @@ function genericQuery(wikiData) {
 
     wikiData.groupData,
 
-    wikiData.trackData
-      // Exclude rereleases - there's no reasonable way to differentiate
-      // them from the main release as part of this query.
-      .filter(track => !track.mainReleaseTrack),
+    sortByGroupRank(
+      wikiData.trackData
+        .filter(track => !track.mainReleaseTrack)),
   ].flat();
 }
 
@@ -132,6 +118,20 @@ function genericProcess(thing, opts) {
   const kind =
     thing.constructor[Symbol.for('Thing.referenceType')];
 
+  const boundPrepareArtwork = artwork =>
+    prepareArtwork(artwork, thing, opts);
+
+  fields.artwork =
+    (kind === 'track' && thing.hasUniqueCoverArt
+      ? boundPrepareArtwork(thing.trackArtworks[0])
+   : kind === 'track'
+      ? boundPrepareArtwork(thing.album.coverArtworks[0])
+   : kind === 'album'
+      ? boundPrepareArtwork(thing.coverArtworks[0])
+   : kind === 'flash'
+      ? boundPrepareArtwork(thing.coverArtwork)
+      : null);
+
   fields.parentName =
     (kind === 'track'
       ? thing.album.name
@@ -141,10 +141,18 @@ function genericProcess(thing, opts) {
       ? thing.act.name
       : null);
 
+  fields.disambiguator =
+    fields.parentName;
+
   fields.artTags =
-    (thing.constructor.hasPropertyDescriptor('artTags')
-      ? thing.artTags.map(artTag => artTag.nameShort)
-      : []);
+    (Array.from(new Set(
+      (kind === 'track'
+        ? thing.trackArtworks.flatMap(artwork => artwork.artTags)
+     : kind === 'album'
+        ? thing.coverArtworks.flatMap(artwork => artwork.artTags)
+        : []))))
+
+      .map(artTag => artTag.nameShort);
 
   fields.additionalNames =
     (thing.constructor.hasPropertyDescriptor('additionalNames')
@@ -230,6 +238,10 @@ export function makeSearchIndex(descriptor, {FlexSearch}) {
     id: 'reference',
     index: descriptor.index,
     store: descriptor.store,
+
+    // Disable scoring, always return results according to provided order
+    // (specified above in `genericQuery`, etc).
+    resolution: 1,
   });
 }
 
diff --git a/src/content/dependencies/generateCoverGrid.js b/src/content/dependencies/generateCoverGrid.js
index 89371015..53b2b8b8 100644
--- a/src/content/dependencies/generateCoverGrid.js
+++ b/src/content/dependencies/generateCoverGrid.js
@@ -1,14 +1,16 @@
 import {empty, stitchArrays, unique} from '#sugar';
 
 export default {
-  contentDependencies: ['generateGridActionLinks'],
+  contentDependencies: ['generateGridActionLinks', 'generateGridExpando'],
   extraDependencies: ['html', 'language'],
 
-  relations(relation) {
-    return {
-      actionLinks: relation('generateGridActionLinks'),
-    };
-  },
+  relations: (relation) => ({
+    actionLinks:
+      relation('generateGridActionLinks'),
+
+    expando:
+      relation('generateGridExpando'),
+  }),
 
   slots: {
     attributes: {type: 'attributes', mutable: false},
@@ -45,6 +47,13 @@ export default {
     revealAllWarnings: {
       validate: v => v.looseArrayOf(v.isString),
     },
+
+    bottomCaption: {
+      type: 'html',
+      mutable: false,
+    },
+
+    cutIndex: {validate: v => v.isWholeNumber},
   },
 
   generate: (relations, slots, {html, language}) =>
@@ -121,6 +130,10 @@ export default {
                 (classes
                   ? {class: classes}
                   : null),
+
+                slots.cutIndex >= 1 &&
+                index >= slots.cutIndex &&
+                  {class: 'hidden-by-expandable-cut'},
               ],
 
               colorContext: 'image-box',
@@ -168,5 +181,17 @@ export default {
 
         relations.actionLinks
           .slot('actionLinks', slots.actionLinks),
+
+        (slots.cutIndex >= 1 &&
+         slots.cutIndex < slots.links.length
+          ? relations.expando.slots({
+              caption: slots.bottomCaption,
+            })
+
+       : !html.isBlank(relations.bottomCaption)
+          ? html.tag('p', {class: 'grid-caption'},
+              slots.caption)
+
+          : html.blank()),
       ]),
 };
diff --git a/src/content/dependencies/generateExpandableGallerySection.js b/src/content/dependencies/generateExpandableGallerySection.js
deleted file mode 100644
index 122ca4b1..00000000
--- a/src/content/dependencies/generateExpandableGallerySection.js
+++ /dev/null
@@ -1,92 +0,0 @@
-export default {
-  contentDependencies: ['generateContentHeading'],
-  extraDependencies: ['html', 'language'],
-
-  relations: (relation) => ({
-    contentHeading:
-      relation('generateContentHeading'),
-  }),
-
-  slots: {
-    title: {
-      type: 'html',
-      mutable: false,
-    },
-
-    contentAboveCut: {
-      type: 'html',
-      mutable: false,
-    },
-
-    contentBelowCut: {
-      type: 'html',
-      mutable: false,
-    },
-
-    caption: {
-      type: 'html',
-      mutable: false,
-    },
-
-    expandCue: {
-      type: 'html',
-      mutable: false,
-    },
-
-    collapseCue: {
-      type: 'html',
-      mutable: false,
-    },
-  },
-
-  generate: (relations, slots, {html, language}) =>
-    html.tag('section', {class: 'expandable-gallery-section'}, [
-      relations.contentHeading.slots({
-        tag: 'h2',
-        title: slots.title,
-      }),
-
-      html.tag('div', {class: 'section-content-above-cut'},
-        {[html.onlyIfContent]: true},
-
-        slots.contentAboveCut),
-
-      html.tag('div', {class: 'section-content-below-cut'},
-        {[html.onlyIfContent]: true},
-
-        !html.isBlank(slots.contentBelowCut) &&
-          {style: 'display: none'},
-
-        slots.contentBelowCut),
-
-      html.tag('div', {class: 'section-expando'},
-        {[html.onlyIfSiblings]: true},
-
-        html.tag('div', {class: 'section-expando-content'},
-          {[html.joinChildren]: html.tag('br')},
-
-          [
-            html.tag('span', {class: 'section-caption'},
-              slots.caption),
-
-            !html.isBlank(slots.contentBelowCut) &&
-              language.$('misc.coverGrid.expandCollapseCue', {
-                cue:
-                  html.tag('a', {class: 'section-expando-toggle'},
-                    {href: '#'},
-
-                    {[html.joinChildren]: ''},
-                    {[html.noEdgeWhitespace]: true},
-
-                    [
-                      html.tag('span', {class: 'section-expand-cue'},
-                        slots.expandCue),
-
-                      html.tag('span', {class: 'section-collapse-cue'},
-                        {style: 'display: none'},
-                        slots.collapseCue),
-                    ]),
-              }),
-          ])),
-    ]),
-};
diff --git a/src/content/dependencies/generateGridExpando.js b/src/content/dependencies/generateGridExpando.js
new file mode 100644
index 00000000..71c2f970
--- /dev/null
+++ b/src/content/dependencies/generateGridExpando.js
@@ -0,0 +1,39 @@
+export default {
+  extraDependencies: ['html', 'language'],
+
+  slots: {
+    caption: {type: 'html', mutable: false},
+  },
+
+  generate: (slots, {html, language}) =>
+    language.encapsulate('misc.coverGrid', capsule =>
+      html.tag('div', {class: 'grid-expando'},
+        {[html.onlyIfSiblings]: true},
+
+        html.tag('p', {class: 'grid-expando-content'},
+          {[html.joinChildren]: html.tag('br')},
+
+          [
+            html.tag('span', {class: 'grid-caption'},
+              slots.caption),
+
+            !html.isBlank(slots.contentBelowCut) &&
+              language.$(capsule, 'expandCollapseCue', {
+                cue:
+                  html.tag('a', {class: 'grid-expando-toggle'},
+                    {href: '#'},
+
+                    {[html.joinChildren]: ''},
+                    {[html.noEdgeWhitespace]: true},
+
+                    [
+                      html.tag('span', {class: 'grid-expand-cue'},
+                        language.$(capsule, 'expand')),
+
+                      html.tag('span', {class: 'grid-collapse-cue'},
+                        {style: 'display: none'},
+                        language.$(capsule, 'collapse')),
+                    ]),
+              }),
+          ]))),
+};
diff --git a/src/content/dependencies/generateGroupGalleryPageSeriesSection.js b/src/content/dependencies/generateGroupGalleryPageSeriesSection.js
index 2ccead5d..b88adfa3 100644
--- a/src/content/dependencies/generateGroupGalleryPageSeriesSection.js
+++ b/src/content/dependencies/generateGroupGalleryPageSeriesSection.js
@@ -2,7 +2,7 @@ import {sortChronologically} from '#sort';
 
 export default {
   contentDependencies: [
-    'generateExpandableGallerySection',
+    'generateContentHeading',
     'generateGroupGalleryPageAlbumGrid',
   ],
 
@@ -11,12 +11,8 @@ export default {
   query(series) {
     const query = {};
 
-    // Includes undated albums.
-    const albumsLatestFirst =
-      sortChronologically(series.albums, {latestFirst: true});
-
-    query.albumsAboveCut = albumsLatestFirst.slice(0, 4);
-    query.albumsBelowCut = albumsLatestFirst.slice(4);
+    query.albums =
+      sortChronologically(series.albums.slice(), {latestFirst: true});
 
     query.allAlbumsDated =
       series.albums.every(album => album.date);
@@ -25,13 +21,13 @@ export default {
       series.albums.some(album => !album.groups.includes(series.group));
 
     query.latestAlbum =
-      albumsLatestFirst
+      query.albums
         .filter(album => album.date)
         .at(0) ??
       null;
 
     query.earliestAlbum =
-      albumsLatestFirst
+      query.albums
         .filter(album => album.date)
         .at(-1) ??
       null;
@@ -40,17 +36,12 @@ export default {
   },
 
   relations: (relation, query, series) => ({
-    gallerySection:
-      relation('generateExpandableGallerySection'),
+    contentHeading:
+      relation('generateContentHeading'),
 
-    gridAboveCut:
+    grid:
       relation('generateGroupGalleryPageAlbumGrid',
-        query.albumsAboveCut,
-        series.group),
-
-    gridBelowCut:
-      relation('generateGroupGalleryPageAlbumGrid',
-        query.albumsBelowCut,
+        query.albums,
         series.group),
   }),
 
@@ -88,69 +79,67 @@ export default {
 
   generate: (data, relations, {html, language}) =>
     language.encapsulate('groupGalleryPage.albumSection', capsule =>
-      relations.gallerySection.slots({
-        title: data.name,
-
-        contentAboveCut: relations.gridAboveCut,
-        contentBelowCut: relations.gridBelowCut,
-
-        caption:
-          language.encapsulate(capsule, 'caption', captionCapsule =>
-            html.tags([
-              data.anyAlbumNotFromThisGroup &&
-                language.$(captionCapsule, 'seriesAlbumsNotFromGroup', {
-                  marker:
-                    language.$('misc.coverGrid.details.notFromThisGroup.marker'),
-
-                  series:
-                    html.tag('i', data.name),
-
-                  group: data.groupName,
-                }),
-
-              language.encapsulate(captionCapsule, workingCapsule => {
-                const workingOptions = {};
-
-                workingOptions.tracks =
-                  html.tag('b',
-                    language.countTracks(data.tracks, {unit: true}));
-
-                workingOptions.albums =
-                  html.tag('b',
-                    language.countAlbums(data.albums, {unit: true}));
-
-                if (data.allAlbumsDated) {
-                  const earliestDate = data.earliestAlbumDate;
-                  const latestDate = data.latestAlbumDate;
-
-                  const earliestYear = earliestDate.getFullYear();
-                  const latestYear = latestDate.getFullYear();
-
-                  if (earliestYear === latestYear) {
-                    if (data.albums === 1) {
-                      workingCapsule += '.withDate';
-                      workingOptions.date =
-                        language.formatDate(earliestDate);
+      html.tags([
+        relations.contentHeading.slots({
+          tag: 'h2',
+          title: language.sanitize(data.name),
+        }),
+
+        relations.grid.slots({
+          cutIndex: 4,
+
+          bottomCaption:
+            language.encapsulate(capsule, 'caption', captionCapsule =>
+              html.tags([
+                data.anyAlbumNotFromThisGroup &&
+                  language.$(captionCapsule, 'seriesAlbumsNotFromGroup', {
+                    marker:
+                      language.$('misc.coverGrid.details.notFromThisGroup.marker'),
+
+                    series:
+                      html.tag('i', data.name),
+
+                    group: data.groupName,
+                  }),
+
+                language.encapsulate(captionCapsule, workingCapsule => {
+                  const workingOptions = {};
+
+                  workingOptions.tracks =
+                    html.tag('b',
+                      language.countTracks(data.tracks, {unit: true}));
+
+                  workingOptions.albums =
+                    html.tag('b',
+                      language.countAlbums(data.albums, {unit: true}));
+
+                  if (data.allAlbumsDated) {
+                    const earliestDate = data.earliestAlbumDate;
+                    const latestDate = data.latestAlbumDate;
+
+                    const earliestYear = earliestDate.getFullYear();
+                    const latestYear = latestDate.getFullYear();
+
+                    if (earliestYear === latestYear) {
+                      if (data.albums === 1) {
+                        workingCapsule += '.withDate';
+                        workingOptions.date =
+                          language.formatDate(earliestDate);
+                      } else {
+                        workingCapsule += '.withYear';
+                        workingOptions.year =
+                          language.formatYear(earliestDate);
+                      }
                     } else {
-                      workingCapsule += '.withYear';
-                      workingOptions.year =
-                        language.formatYear(earliestDate);
+                      workingCapsule += '.withYearRange';
+                      workingOptions.yearRange =
+                        language.formatYearRange(earliestDate, latestDate);
                     }
-                  } else {
-                    workingCapsule += '.withYearRange';
-                    workingOptions.yearRange =
-                      language.formatYearRange(earliestDate, latestDate);
                   }
-                }
-
-                return language.$(workingCapsule, workingOptions);
-              }),
-            ], {[html.joinChildren]: html.tag('br')})),
 
-        expandCue:
-          language.$(capsule, 'expand'),
-
-        collapseCue:
-          language.$(capsule, 'collapse'),
-      })),
+                  return language.$(workingCapsule, workingOptions);
+                }),
+              ], {[html.joinChildren]: html.tag('br')})),
+        }),
+      ])),
 };
diff --git a/src/content/dependencies/generateLyricsEntry.js b/src/content/dependencies/generateLyricsEntry.js
index 0c91ce0c..1379ae06 100644
--- a/src/content/dependencies/generateLyricsEntry.js
+++ b/src/content/dependencies/generateLyricsEntry.js
@@ -28,6 +28,30 @@ export default {
 
     hasSquareBracketAnnotations:
       entry.hasSquareBracketAnnotations,
+
+    numStanzas:
+      1 +
+
+      (Array.from(
+        entry.body
+          .matchAll(/\n\n|<br><br>/g))
+
+        .length) +
+
+      (entry.body.includes('<br')
+        ? entry.body.split('\n').length
+        : 0),
+
+    numLines:
+      1 +
+
+      (Array.from(
+        entry.body
+          .replaceAll(/(<br>){1,}/g, '\n')
+          .replaceAll(/\n{2,}/g, '\n')
+          .matchAll(/\n/g))
+
+        .length),
   }),
 
   slots: {
@@ -42,6 +66,13 @@ export default {
       html.tag('div', {class: 'lyrics-entry'},
         slots.attributes,
 
+        {'data-stanzas': data.numStanzas},
+        {'data-lines': data.numLines},
+
+        (data.numStanzas > 1 ||
+         data.numLines > 8) &&
+          {class: 'long-lyrics'},
+
         [
           html.tag('p', {class: 'lyrics-details'},
             {[html.onlyIfContent]: true},
diff --git a/src/content/dependencies/generateSearchSidebarBox.js b/src/content/dependencies/generateSearchSidebarBox.js
index 308a1105..87785906 100644
--- a/src/content/dependencies/generateSearchSidebarBox.js
+++ b/src/content/dependencies/generateSearchSidebarBox.js
@@ -58,6 +58,23 @@ export default {
               language.$(capsule, 'artTag')),
           ]),
 
+          language.encapsulate(capsule, 'resultDisambiguator', capsule => [
+            html.tag('template', {class: 'wiki-search-group-result-disambiguator-string'},
+              language.$(capsule, 'group', {
+                disambiguator: html.tag('slot', {name: 'disambiguator'}),
+              })),
+
+            html.tag('template', {class: 'wiki-search-flash-result-disambiguator-string'},
+              language.$(capsule, 'flash', {
+                disambiguator: html.tag('slot', {name: 'disambiguator'}),
+              })),
+
+            html.tag('template', {class: 'wiki-search-track-result-disambiguator-string'},
+              language.$(capsule, 'track', {
+                disambiguator: html.tag('slot', {name: 'disambiguator'}),
+              })),
+          ]),
+
           language.encapsulate(capsule, 'resultFilter', capsule => [
             html.tag('template', {class: 'wiki-search-album-result-filter-string'},
               language.$(capsule, 'album')),
diff --git a/src/content/dependencies/index.js b/src/content/dependencies/index.js
index 25d7324f..cfa6346c 100644
--- a/src/content/dependencies/index.js
+++ b/src/content/dependencies/index.js
@@ -94,6 +94,8 @@ export function watchContentDependencies({
     const filePaths = files.map(file => path.join(watchPath, file));
     for (const filePath of filePaths) {
       if (filePath === metaPath) continue;
+      if (filePath.endsWith('.DS_Store')) continue;
+
       const functionName = getFunctionName(filePath);
       if (!isMocked(functionName)) {
         contentDependencies[functionName] = null;
@@ -105,8 +107,9 @@ export function watchContentDependencies({
     watcher.on('all', (event, filePath) => {
       if (!['add', 'change'].includes(event)) return;
       if (filePath === metaPath) return;
-      handlePathUpdated(filePath);
+      if (filePath.endsWith('.DS_Store')) return;
 
+      handlePathUpdated(filePath);
     });
 
     watcher.on('unlink', (filePath) => {
@@ -115,6 +118,8 @@ export function watchContentDependencies({
         return;
       }
 
+      if (filePath.endsWith('.DS_Store')) return;
+
       handlePathRemoved(filePath);
     });
 
diff --git a/src/static/css/site.css b/src/static/css/site.css
index 8872bde8..e584f918 100644
--- a/src/static/css/site.css
+++ b/src/static/css/site.css
@@ -918,6 +918,11 @@ summary.underline-white > span:hover a:not(:hover) {
   display: inline-block;
 }
 
+.wiki-search-result-disambiguator {
+  opacity: 0.9;
+  display: inline-block;
+}
+
 .wiki-search-result-image-container {
   align-self: flex-start;
   flex-shrink: 0;
@@ -1232,7 +1237,12 @@ label > input[type=checkbox]:not(:checked) + span {
   white-space: nowrap;
 }
 
-.isolate-tooltip-z-indexing > * {
+:where(.isolate-tooltip-z-indexing) {
+  position: relative;
+  z-index: 1;
+}
+
+:where(.isolate-tooltip-z-indexing > *) {
   position: relative;
   z-index: -1;
 }
@@ -1640,9 +1650,11 @@ hr.cute,
 }
 
 #artwork-column .cover-artwork {
+  --normal-shadow: 0 0 12px 12px #00000080;
+
   box-shadow:
     0 2px 14px -6px var(--primary-color),
-    0 0 12px 12px #00000080;
+    var(--normal-shadow);
 }
 
 #artwork-column .cover-artwork:not(:first-child),
@@ -1651,6 +1663,10 @@ hr.cute,
   margin-right: 5px;
 }
 
+#artwork-column .cover-artwork:not(:first-child) {
+  --normal-shadow: 0 0 9px 9px #00000068;
+}
+
 #artwork-column .cover-artwork:first-child + .cover-artwork-joiner,
 #artwork-column .cover-artwork.attached-artwork-is-main-artwork,
 #artwork-column .cover-artwork.attached-artwork-is-main-artwork + .cover-artwork-joiner {
@@ -1870,11 +1886,11 @@ p.image-details.origin-details .origin-details {
   margin-top: 0.25em;
 }
 
-.lyrics-entry {
+.lyrics-entry.long-lyrics {
   clip-path: inset(-15px -20px);
 }
 
-.lyrics-entry::after {
+.lyrics-entry.long-lyrics::after {
   content: "";
   pointer-events: none;
   display: block;
@@ -2076,39 +2092,6 @@ ul.quick-info li:not(:last-child)::after {
   margin-bottom: 1.5em;
 }
 
-.expandable-gallery-section .section-expando {
-  margin-top: 1em;
-  margin-bottom: 2em;
-
-  display: flex;
-  flex-direction: row;
-  justify-content: space-around;
-}
-
-.expandable-gallery-section .section-expando-content {
-  text-align: center;
-  line-height: 1.5;
-}
-
-.expandable-gallery-section .section-expando-toggle {
-  text-decoration: underline;
-  text-decoration-style: dotted;
-}
-
-.expandable-gallery-section.expanded .section-content-below-cut {
-  animation: expand-gallery-section 0.8s forwards;
-}
-
-@keyframes expand-gallery-section {
-  from {
-    opacity: 0;
-  }
-
-  to {
-    opacity: 1;
-  }
-}
-
 .quick-description:not(.has-external-links-only) {
   --clamped-padding-ratio: max(var(--responsive-padding-ratio), 0.06);
   margin-left: auto;
@@ -3251,6 +3234,47 @@ video.pixelate, .pixelate video {
   --dim-color: inherit !important;
 }
 
+.grid-caption {
+  flex-basis: 100%;
+  text-align: center;
+  line-height: 1.5;
+}
+
+.grid-expando {
+  margin-top: 1em;
+  margin-bottom: 2em;
+  flex-basis: 100%;
+
+  display: flex;
+  flex-direction: row;
+  justify-content: space-around;
+}
+
+.grid-expando-content {
+  margin: 0;
+  text-align: center;
+  line-height: 1.5;
+}
+
+.grid-expando-toggle {
+  text-decoration: underline;
+  text-decoration-style: dotted;
+}
+
+.grid-item.shown-by-expandable-cut {
+  animation: expand-cover-grid 0.8s forwards;
+}
+
+@keyframes expand-cover-grid {
+  from {
+    opacity: 0;
+  }
+
+  to {
+    opacity: 1;
+  }
+}
+
 /* Carousel */
 
 .carousel-container {
@@ -4193,6 +4217,10 @@ main.long-content .content-sticky-heading-container .content-sticky-subheading-r
     max-width: unset;
   }
 
+  #artwork-column .cover-artwork {
+    --normal-shadow: 0 0 transparent;
+  }
+
   #artwork-column .cover-artwork:not(:first-child),
   #artwork-column .cover-artwork-joiner {
     margin-left: 30px;
diff --git a/src/static/js/client-util.js b/src/static/js/client-util.js
index 5a35bcf2..396c4889 100644
--- a/src/static/js/client-util.js
+++ b/src/static/js/client-util.js
@@ -37,7 +37,7 @@ export function cssProp(el, ...args) {
   }
 }
 
-export function templateContent(el) {
+export function templateContent(el, slots = {}) {
   if (el === null) {
     return null;
   }
@@ -46,7 +46,25 @@ export function templateContent(el) {
     throw new Error(`Expected a <template> element`);
   }
 
-  return el.content.cloneNode(true);
+  const content = el.content.cloneNode(true);
+
+  for (const [key, value] of Object.entries(slots)) {
+    const slot = content.querySelector(`slot[name="${key}"]`);
+
+    if (!slot) {
+      console.warn(`Slot ${key} missing in template:`, el);
+      continue;
+    }
+
+    if (value === null || value === undefined) {
+      console.warn(`Valueless slot ${key} in template:`, el);
+      continue;
+    }
+
+    slot.replaceWith(value);
+  }
+
+  return content;
 }
 
 // Curry-style, so multiple points can more conveniently be tested at once.
diff --git a/src/static/js/client/expandable-gallery-section.js b/src/static/js/client/expandable-gallery-section.js
deleted file mode 100644
index dc83e8b7..00000000
--- a/src/static/js/client/expandable-gallery-section.js
+++ /dev/null
@@ -1,77 +0,0 @@
-/* eslint-env browser */
-
-// TODO: Combine this and quick-description.js
-
-import {cssProp} from '../client-util.js';
-
-import {stitchArrays} from '../../shared-util/sugar.js';
-
-export const info = {
-  id: 'expandableGallerySectionInfo',
-
-  sections: null,
-
-  sectionContentBelowCut: null,
-
-  sectionExpandoToggles: null,
-
-  sectionExpandCues: null,
-  sectionCollapseCues: null,
-};
-
-export function getPageReferences() {
-  info.sections =
-    Array.from(document.querySelectorAll('.expandable-gallery-section'))
-      .filter(section => section.querySelector('.section-expando-toggle'));
-
-  info.sectionContentBelowCut =
-    info.sections
-      .map(section => section.querySelector('.section-content-below-cut'));
-
-  info.sectionExpandoToggles =
-    info.sections
-      .map(section => section.querySelector('.section-expando-toggle'));
-
-  info.sectionExpandCues =
-    info.sections
-      .map(section => section.querySelector('.section-expand-cue'));
-
-  info.sectionCollapseCues =
-    info.sections
-      .map(section => section.querySelector('.section-collapse-cue'));
-}
-
-export function addPageListeners() {
-  for (const {
-    section,
-    contentBelowCut,
-    expandoToggle,
-    expandCue,
-    collapseCue,
-  } of stitchArrays({
-    section: info.sections,
-    contentBelowCut: info.sectionContentBelowCut,
-    expandoToggle: info.sectionExpandoToggles,
-    expandCue: info.sectionExpandCues,
-    collapseCue: info.sectionCollapseCues,
-  })) {
-    expandoToggle.addEventListener('click', domEvent => {
-      domEvent.preventDefault();
-
-      const collapsed =
-        cssProp(contentBelowCut, 'display') === 'none';
-
-      if (collapsed) {
-        section.classList.add('expanded');
-        cssProp(contentBelowCut, 'display', null);
-        cssProp(expandCue, 'display', 'none');
-        cssProp(collapseCue, 'display', null);
-      } else {
-        section.classList.remove('expanded');
-        cssProp(contentBelowCut, 'display', 'none');
-        cssProp(expandCue, 'display', null);
-        cssProp(collapseCue, 'display', 'none');
-      }
-    });
-  }
-}
diff --git a/src/static/js/client/expandable-grid-section.js b/src/static/js/client/expandable-grid-section.js
new file mode 100644
index 00000000..ce9a4c06
--- /dev/null
+++ b/src/static/js/client/expandable-grid-section.js
@@ -0,0 +1,85 @@
+/* eslint-env browser */
+
+import {cssProp} from '../client-util.js';
+
+import {stitchArrays} from '../../shared-util/sugar.js';
+
+export const info = {
+  id: 'expandableGallerySectionInfo',
+
+  items: null,
+  toggles: null,
+  expandCues: null,
+  collapseCues: null,
+};
+
+export function getPageReferences() {
+  const expandos =
+    Array.from(document.querySelectorAll('.grid-expando'));
+
+  const grids =
+    expandos
+      .map(expando => expando.closest('.grid-listing'));
+
+  info.items =
+    grids
+      .map(grid => grid.querySelectorAll('.grid-item'))
+      .map(items => Array.from(items));
+
+  info.toggles =
+    expandos
+      .map(expando => expando.querySelector('.grid-expando-toggle'));
+
+  info.expandCues =
+    info.toggles
+      .map(toggle => toggle.querySelector('.grid-expand-cue'));
+
+  info.collapseCues =
+    info.toggles
+      .map(toggle => toggle.querySelector('.grid-collapse-cue'));
+}
+
+export function addPageListeners() {
+  stitchArrays({
+    items: info.items,
+    toggle: info.toggles,
+    expandCue: info.expandCues,
+    collapseCue: info.collapseCues,
+  }).forEach(({
+      items,
+      toggle,
+      expandCue,
+      collapseCue,
+    }) => {
+      toggle.addEventListener('click', domEvent => {
+        domEvent.preventDefault();
+
+        const collapsed =
+          items.some(item =>
+            item.classList.contains('hidden-by-expandable-cut'));
+
+        for (const item of items) {
+          if (
+            !item.classList.contains('hidden-by-expandable-cut') &&
+            !item.classList.contains('shown-by-expandable-cut')
+          ) continue;
+
+          if (collapsed) {
+            item.classList.remove('hidden-by-expandable-cut');
+            item.classList.add('shown-by-expandable-cut');
+          } else {
+            item.classList.add('hidden-by-expandable-cut');
+            item.classList.remove('shown-by-expandable-cut');
+          }
+        }
+
+        if (collapsed) {
+          cssProp(expandCue, 'display', 'none');
+          cssProp(collapseCue, 'display', null);
+        } else {
+          cssProp(expandCue, 'display', null);
+          cssProp(collapseCue, 'display', 'none');
+        }
+      });
+    });
+}
diff --git a/src/static/js/client/index.js b/src/static/js/client/index.js
index 016ce9ad..86081b5d 100644
--- a/src/static/js/client/index.js
+++ b/src/static/js/client/index.js
@@ -11,7 +11,7 @@ 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';
-import * as expandableGallerySectionModule from './expandable-gallery-section.js';
+import * as expandableGridSectionModule from './expandable-grid-section.js';
 import * as galleryStyleSelectorModule from './gallery-style-selector.js';
 import * as hashLinkModule from './hash-link.js';
 import * as hoverableTooltipModule from './hoverable-tooltip.js';
@@ -37,7 +37,7 @@ export const modules = [
   cssCompatibilityAssistantModule,
   datetimestampTooltipModule,
   draggedLinkModule,
-  expandableGallerySectionModule,
+  expandableGridSectionModule,
   galleryStyleSelectorModule,
   hashLinkModule,
   hoverableTooltipModule,
diff --git a/src/static/js/client/quick-description.js b/src/static/js/client/quick-description.js
index 6a7a6023..cff82252 100644
--- a/src/static/js/client/quick-description.js
+++ b/src/static/js/client/quick-description.js
@@ -1,7 +1,5 @@
 /* eslint-env browser */
 
-// TODO: Combine this and expandable-gallery-section.js
-
 import {stitchArrays} from '../../shared-util/sugar.js';
 
 export const info = {
diff --git a/src/static/js/client/sidebar-search.js b/src/static/js/client/sidebar-search.js
index eae1e74e..4467766c 100644
--- a/src/static/js/client/sidebar-search.js
+++ b/src/static/js/client/sidebar-search.js
@@ -73,6 +73,10 @@ export const info = {
   groupResultKindString: null,
   tagResultKindString: null,
 
+  groupResultDisambiguatorString: null,
+  flashResultDisambiguatorString: null,
+  trackResultDisambiguatorString: null,
+
   albumResultFilterString: null,
   artistResultFilterString: null,
   flashResultFilterString: null,
@@ -196,6 +200,15 @@ export function getPageReferences() {
   info.tagResultKindString =
     findString('tag-result-kind');
 
+  info.groupResultDisambiguatorString =
+    findString('group-result-disambiguator');
+
+  info.flashResultDisambiguatorString =
+    findString('flash-result-disambiguator');
+
+  info.trackResultDisambiguatorString =
+    findString('track-result-disambiguator');
+
   info.albumResultFilterString =
     findString('album-result-filter');
 
@@ -841,7 +854,7 @@ function fillResultElements(results, {
   }
 
   for (const result of filteredResults) {
-    const el = generateSidebarSearchResult(result);
+    const el = generateSidebarSearchResult(result, filteredResults);
     if (!el) continue;
 
     info.results.appendChild(el);
@@ -890,13 +903,13 @@ function showFilterElements(results) {
   }
 }
 
-function generateSidebarSearchResult(result) {
+function generateSidebarSearchResult(result, results) {
   const preparedSlots = {
     color:
       result.data.color ?? null,
 
     name:
-      result.data.name ?? result.data.primaryName ?? null,
+      getSearchResultName(result),
 
     imageSource:
       getSearchResultImageSource(result),
@@ -961,9 +974,37 @@ function generateSidebarSearchResult(result) {
       return null;
   }
 
+  const compareReferenceType = otherResult =>
+    otherResult.referenceType === result.referenceType;
+
+  const compareName = otherResult =>
+    getSearchResultName(otherResult) === getSearchResultName(result);
+
+  const ambiguous =
+    results.some(otherResult =>
+      otherResult !== result &&
+      compareReferenceType(otherResult) &&
+      compareName(otherResult));
+
+  if (ambiguous) {
+    preparedSlots.disambiguate =
+      result.data.disambiguator;
+
+    preparedSlots.disambiguatorString =
+      info[result.referenceType + 'ResultDisambiguatorString'];
+  }
+
   return generateSidebarSearchResultTemplate(preparedSlots);
 }
 
+function getSearchResultName(result) {
+  return (
+    result.data.name ??
+    result.data.primaryName ??
+    null
+  );
+}
+
 function getSearchResultImageSource(result) {
   const {artwork} = result.data;
   if (!artwork) return null;
@@ -1039,6 +1080,15 @@ function generateSidebarSearchResultTemplate(slots) {
     }
   }
 
+  if (!accentSpan && slots.disambiguate) {
+    accentSpan = document.createElement('span');
+    accentSpan.classList.add('wiki-search-result-disambiguator');
+    accentSpan.appendChild(
+      templateContent(slots.disambiguatorString, {
+        disambiguator: slots.disambiguate,
+      }));
+  }
+
   if (!accentSpan && slots.kindString) {
     accentSpan = document.createElement('span');
     accentSpan.classList.add('wiki-search-result-kind');
diff --git a/src/static/js/search-worker.js b/src/static/js/search-worker.js
index e32b4ad5..3e9fbfca 100644
--- a/src/static/js/search-worker.js
+++ b/src/static/js/search-worker.js
@@ -391,6 +391,8 @@ function performSearchAction({query, options}) {
 }
 
 const interestingFieldCombinations = [
+  ['primaryName'],
+
   ['primaryName', 'parentName', 'groups'],
   ['primaryName', 'parentName'],
   ['primaryName', 'groups', 'contributors'],
@@ -412,7 +414,6 @@ const interestingFieldCombinations = [
   ['contributors', 'parentName'],
   ['contributors', 'groups'],
   ['primaryName', 'contributors'],
-  ['primaryName'],
 ];
 
 function queryGenericIndex(query, options) {
diff --git a/src/strings-default.yaml b/src/strings-default.yaml
index 4969bcff..93881aa2 100644
--- a/src/strings-default.yaml
+++ b/src/strings-default.yaml
@@ -857,6 +857,11 @@ misc:
       artist: "(artist)"
       group: "(group)"
 
+    resultDisambiguator:
+      group: "({DISAMBIGUATOR})"
+      flash: "(in {DISAMBIGUATOR})"
+      track: "(from {DISAMBIGUATOR})"
+
     resultFilter:
       album: "Albums"
       artTag: "Art Tags"
@@ -1007,6 +1012,10 @@ misc:
       conceal: "Conceal all artworks"
       warnings: "In this gallery: {WARNINGS}"
 
+    expandCollapseCue: "({CUE})"
+    expand: "Show the rest!"
+    collapse: "Collapse these"
+
     noCoverArt: "{ALBUM}"
 
     tab:
@@ -1028,8 +1037,6 @@ misc:
 
       otherCoverArtists: "With {ARTISTS}"
 
-    expandCollapseCue: "({CUE})"
-
   albumGalleryGrid:
     noCoverArt: "{NAME}"
 
@@ -1797,12 +1804,6 @@ groupGalleryPage:
     caption.seriesAlbumsNotFromGroup: >-
       Albums marked {MARKER} are part of {SERIES}, but not from {GROUP}.
 
-    expand: >-
-      Show the rest!
-
-    collapse: >-
-      Collapse these
-
 #
 # listingIndex:
 #   The listing index page shows all available listings on the wiki,