From 89e79008b02331b69660bb16b6ca737e37483e61 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Fri, 23 Jun 2023 19:41:15 -0300 Subject: content: generateCoverCarousel This also introduces a handy stitchArrays() utility, which probably has some uses not caught in this commit. --- src/content/dependencies/generateCoverCarousel.js | 54 +++++++++++ src/content/dependencies/generateCoverGrid.js | 45 +++++----- .../dependencies/generateGroupGalleryPage.js | 100 ++++++++++++++------- src/misc-templates.js | 83 ----------------- src/util/sugar.js | 32 +++++++ src/util/wiki-data.js | 62 +++++++++++++ 6 files changed, 239 insertions(+), 137 deletions(-) create mode 100644 src/content/dependencies/generateCoverCarousel.js diff --git a/src/content/dependencies/generateCoverCarousel.js b/src/content/dependencies/generateCoverCarousel.js new file mode 100644 index 0000000..2a2503a --- /dev/null +++ b/src/content/dependencies/generateCoverCarousel.js @@ -0,0 +1,54 @@ +import {empty, repeat, stitchArrays} from '../../util/sugar.js'; +import {getCarouselLayoutForNumberOfItems} from '../../util/wiki-data.js'; + +export default { + extraDependencies: ['html'], + + slots: { + images: {validate: v => v.arrayOf(v.isHTML)}, + links: {validate: v => v.arrayOf(v.isHTML)}, + + lazy: {validate: v => v.oneOf(v.isWholeNumber, v.isBoolean)}, + }, + + generate(slots, {html}) { + const stitched = + stitchArrays({ + image: slots.images, + link: slots.links, + }); + + if (empty(stitched)) { + return; + } + + const layout = getCarouselLayoutForNumberOfItems(stitched.length); + + return html.tag('div', + { + class: 'carousel-container', + 'data-carousel-rows': layout.rows, + 'data-carousel-columns': layout.columns, + }, + repeat(3, [ + html.tag('div', + {class: 'carousel-grid', 'aria-hidden': 'true'}, + stitched.map(({image, link}, index) => + html.tag('div', {class: 'carousel-item'}, + link.slots({ + attributes: {tabindex: '-1'}, + content: + image.slots({ + thumb: 'small', + square: true, + lazy: + (typeof slots.lazy === 'number' + ? index >= slots.lazy + : typeof slots.lazy === 'boolean' + ? slots.lazy + : false), + }), + })))), + ])); + }, +}; diff --git a/src/content/dependencies/generateCoverGrid.js b/src/content/dependencies/generateCoverGrid.js index a024ae2..970aa05 100644 --- a/src/content/dependencies/generateCoverGrid.js +++ b/src/content/dependencies/generateCoverGrid.js @@ -1,3 +1,5 @@ +import {stitchArrays} from '../../util/sugar.js'; + export default { extraDependencies: ['html'], @@ -12,27 +14,26 @@ export default { generate(slots, {html}) { return ( html.tag('div', {class: 'grid-listing'}, - slots.images.map((image, i) => { - const link = slots.links[i]; - const name = slots.names[i]; - return link.slots({ - content: [ - image.slots({ - thumb: 'medium', - lazy: - (typeof slots.lazy === 'number' - ? i >= slots.lazy - : typeof slots.lazy === 'boolean' - ? slots.lazy - : false), - square: true, - }), - html.tag('span', name), - ], - attributes: { - class: ['grid-item', 'box', /* large && 'large-grid-item' */], - }, - }); - }))); + stitchArrays({ + image: slots.images, + link: slots.links, + name: slots.names, + }).map(({image, link, name}, index) => + link.slots({ + attributes: {class: ['grid-item', 'box']}, + content: [ + image.slots({ + thumb: 'medium', + square: true, + lazy: + (typeof slots.lazy === 'number' + ? index >= slots.lazy + : typeof slots.lazy === 'boolean' + ? slots.lazy + : false), + }), + html.tag('span', name), + ], + })))); }, }; diff --git a/src/content/dependencies/generateGroupGalleryPage.js b/src/content/dependencies/generateGroupGalleryPage.js index 1ec53a0..168bf79 100644 --- a/src/content/dependencies/generateGroupGalleryPage.js +++ b/src/content/dependencies/generateGroupGalleryPage.js @@ -1,4 +1,7 @@ +import {empty, stitchArrays} from '../../util/sugar.js'; + import { + filterItemsForCarousel, getTotalDuration, sortChronologically, } from '../../util/wiki-data.js'; @@ -6,6 +9,7 @@ import { export default { contentDependencies: [ 'generateColorStyleRules', + 'generateCoverCarousel', 'generateCoverGrid', 'generateGroupNavLinks', 'generateGroupSidebar', @@ -55,14 +59,29 @@ export default { relation('linkListing', sprawl.groupsByCategoryListing); } + const carouselAlbums = filterItemsForCarousel(group.featuredAlbums); + + if (!empty(carouselAlbums)) { + relations.coverCarousel = + relation('generateCoverCarousel'); + + relations.carouselLinks = + carouselAlbums + .map(album => relation('linkAlbum', album)); + + relations.carouselImages = + carouselAlbums + .map(album => relation('image', album.artTags)); + } + relations.coverGrid = relation('generateCoverGrid'); - relations.links = + relations.gridLinks = albums .map(album => relation('linkAlbum', album)); - relations.images = + relations.gridImages = albums.map(album => (album.hasCoverArt ? relation('image', album.artTags) @@ -72,25 +91,35 @@ export default { }, data(sprawl, group) { - const albums = - sortChronologically(group.albums.slice(), {latestFirst: true}); + const data = {}; - const tracks = albums.flatMap((album) => album.tracks); - const totalDuration = getTotalDuration(tracks, {originalReleasesOnly: true}); + data.name = group.name; - return { - name: group.name, + const albums = sortChronologically(group.albums.slice(), {latestFirst: true}); + const tracks = albums.flatMap((album) => album.tracks); - numAlbums: albums.length, - numTracks: tracks.length, - totalDuration, + data.numAlbums = albums.length; + data.numTracks = tracks.length; + data.totalDuration = getTotalDuration(tracks, {originalReleasesOnly: true}); - names: albums.map(album => album.name), - paths: albums.map(album => + data.gridNames = albums.map(album => album.name); + data.gridPaths = + albums.map(album => (album.hasCoverArt ? ['media.albumCover', album.directory, album.coverArtFileExtension] - : null)), - }; + : null)); + + const carouselAlbums = filterItemsForCarousel(group.featuredAlbums); + + if (!empty(group.featuredAlbums)) { + data.carouselPaths = + carouselAlbums.map(album => + (album.hasCoverArt + ? ['media.albumCover', album.directory, album.coverArtFileExtension] + : null)); + } + + return data; }, generate(data, relations, {html, language}) { @@ -103,13 +132,16 @@ export default { mainClasses: ['top-index'], mainContent: [ - /* - getCarouselHTML({ - items: group.featuredAlbums.slice(0, 12 + 1), - srcFn: getAlbumCover, - linkFn: link.album, - }), - */ + relations.coverCarousel + ?.slots({ + links: relations.carouselLinks, + images: + stitchArrays({ + image: relations.carouselImages, + path: data.carouselPaths, + }).map(({image, path}) => + image.slot('path', path)), + }), html.tag('p', {class: 'quick-info'}, @@ -139,17 +171,21 @@ export default { relations.coverGrid .slots({ - links: relations.links, - names: data.names, + links: relations.gridLinks, + names: data.gridNames, images: - relations.images.map((image, i) => - image.slots({ - path: data.paths[i], - missingSourceContent: - language.$('misc.albumGrid.noCoverArt', { - album: data.names[i], - }), - })), + stitchArrays({ + image: relations.gridImages, + path: data.gridPaths, + name: data.gridNames, + }).map(({image, path, name}) => + image.slots({ + path, + missingSourceContent: + language.$('misc.albumGrid.noCoverArt', { + album: name, + }), + })), }), ], diff --git a/src/misc-templates.js b/src/misc-templates.js index dfff4d8..ba1a60f 100644 --- a/src/misc-templates.js +++ b/src/misc-templates.js @@ -99,48 +99,6 @@ function unbound_getFlashGridHTML({ // Carousel reels -// Layout constants: -// -// Carousels support fitting 4-18 items, with a few "dead" zones to watch out -// for, namely when a multiple of 6, 5, or 4 columns would drop the last tiles. -// -// Carousels are limited to 1-3 rows and 4-6 columns. -// Lower edge case: 1-3 items are treated as 4 items (with blank space). -// Upper edge case: all items past 18 are dropped (treated as 18 items). -// -// This is all done through JS instead of CSS because it's just... ANNOYING... -// to write a mapping like this in CSS lol. -const carouselLayoutMap = [ - // 0-3 - null, null, null, null, - - // 4-6 - {rows: 1, columns: 4}, // 4: 1x4, drop 0 - {rows: 1, columns: 5}, // 5: 1x5, drop 0 - {rows: 1, columns: 6}, // 6: 1x6, drop 0 - - // 7-12 - {rows: 1, columns: 6}, // 7: 1x6, drop 1 - {rows: 2, columns: 4}, // 8: 2x4, drop 0 - {rows: 2, columns: 4}, // 9: 2x4, drop 1 - {rows: 2, columns: 5}, // 10: 2x5, drop 0 - {rows: 2, columns: 5}, // 11: 2x5, drop 1 - {rows: 2, columns: 6}, // 12: 2x6, drop 0 - - // 13-18 - {rows: 2, columns: 6}, // 13: 2x6, drop 1 - {rows: 2, columns: 6}, // 14: 2x6, drop 2 - {rows: 3, columns: 5}, // 15: 3x5, drop 0 - {rows: 3, columns: 5}, // 16: 3x5, drop 1 - {rows: 3, columns: 5}, // 17: 3x5, drop 2 - {rows: 3, columns: 6}, // 18: 3x6, drop 0 -]; - -const minCarouselLayoutItems = carouselLayoutMap.findIndex(x => x !== null); -const maxCarouselLayoutItems = carouselLayoutMap.length - 1; -const shortestCarouselLayout = carouselLayoutMap[minCarouselLayoutItems]; -const longestCarouselLayout = carouselLayoutMap[maxCarouselLayoutItems]; - function unbound_getCarouselHTML({ html, img, @@ -152,47 +110,6 @@ function unbound_getCarouselHTML({ linkFn = (x, {text}) => text, srcFn, }) { - if (empty(items)) { - return; - } - - const {rows, columns} = ( - items.length < minCarouselLayoutItems ? shortestCarouselLayout : - items.length > maxCarouselLayoutItems ? longestCarouselLayout : - carouselLayoutMap[items.length]); - - items = items.slice(0, maxCarouselLayoutItems + 1); - - return html.tag('div', - { - class: 'carousel-container', - 'data-carousel-rows': rows, - 'data-carousel-columns': columns, - }, - repeat(3, - html.tag('div', - { - class: 'carousel-grid', - 'aria-hidden': 'true', - }, - items - .filter(item => srcFn(item)) - .filter(item => item.artTags.every(tag => !tag.isContentWarning)) - .map((item, i) => - html.tag('div', {class: 'carousel-item'}, - linkFn(item, { - attributes: { - tabindex: '-1', - }, - text: - img({ - src: srcFn(item), - alt: altFn(item), - thumb: 'small', - lazy: typeof lazy === 'number' ? i >= lazy : lazy, - square: true, - }), - })))))); } // Exports diff --git a/src/util/sugar.js b/src/util/sugar.js index 3a7e6f8..5f54c3b 100644 --- a/src/util/sugar.js +++ b/src/util/sugar.js @@ -73,6 +73,38 @@ export function accumulateSum(array, fn = x => x) { 0); } +// Stitches together the items of separate arrays into one array of objects +// whose keys are the corresponding items from each array at that index. +// This is mostly useful for iterating over multiple arrays at once! +export function stitchArrays(keyToArray) { + const errors = []; + + for (const [key, value] of Object.entries(keyToArray)) { + if (!Array.isArray(value)) { + errors.push(new TypeError(`(${key}) Expected array, got ${value}`)); + } + } + + if (!empty(errors)) { + throw new AggregateError(errors, `Expected all values to be arrays`); + } + + const keys = Object.keys(keyToArray); + const arrays = Object.values(keyToArray); + const length = Math.max(...arrays.map(({length}) => length)); + const results = []; + + for (let i = 0; i < length; i++) { + const object = {}; + for (const key of keys) { + object[key] = keyToArray[key][i]; + } + results.push(object); + } + + return results; +} + export const mapInPlace = (array, fn) => array.splice(0, array.length, ...array.map(fn)); diff --git a/src/util/wiki-data.js b/src/util/wiki-data.js index 89c621c..8a2897d 100644 --- a/src/util/wiki-data.js +++ b/src/util/wiki-data.js @@ -554,3 +554,65 @@ export function getNewReleases(numReleases, {wikiData}) { .slice(0, numReleases) .map((album) => ({item: album})); } + +// Carousel layout and utilities + +// Layout constants: +// +// Carousels support fitting 4-18 items, with a few "dead" zones to watch out +// for, namely when a multiple of 6, 5, or 4 columns would drop the last tiles. +// +// Carousels are limited to 1-3 rows and 4-6 columns. +// Lower edge case: 1-3 items are treated as 4 items (with blank space). +// Upper edge case: all items past 18 are dropped (treated as 18 items). +// +// This is all done through JS instead of CSS because it's just... ANNOYING... +// to write a mapping like this in CSS lol. +const carouselLayoutMap = [ + // 0-3 + null, null, null, null, + + // 4-6 + {rows: 1, columns: 4}, // 4: 1x4, drop 0 + {rows: 1, columns: 5}, // 5: 1x5, drop 0 + {rows: 1, columns: 6}, // 6: 1x6, drop 0 + + // 7-12 + {rows: 1, columns: 6}, // 7: 1x6, drop 1 + {rows: 2, columns: 4}, // 8: 2x4, drop 0 + {rows: 2, columns: 4}, // 9: 2x4, drop 1 + {rows: 2, columns: 5}, // 10: 2x5, drop 0 + {rows: 2, columns: 5}, // 11: 2x5, drop 1 + {rows: 2, columns: 6}, // 12: 2x6, drop 0 + + // 13-18 + {rows: 2, columns: 6}, // 13: 2x6, drop 1 + {rows: 2, columns: 6}, // 14: 2x6, drop 2 + {rows: 3, columns: 5}, // 15: 3x5, drop 0 + {rows: 3, columns: 5}, // 16: 3x5, drop 1 + {rows: 3, columns: 5}, // 17: 3x5, drop 2 + {rows: 3, columns: 6}, // 18: 3x6, drop 0 +]; + +const minCarouselLayoutItems = carouselLayoutMap.findIndex(x => x !== null); +const maxCarouselLayoutItems = carouselLayoutMap.length - 1; +const shortestCarouselLayout = carouselLayoutMap[minCarouselLayoutItems]; +const longestCarouselLayout = carouselLayoutMap[maxCarouselLayoutItems]; + +export function getCarouselLayoutForNumberOfItems(numItems) { + return ( + numItems < minCarouselLayoutItems ? shortestCarouselLayout : + numItems > maxCarouselLayoutItems ? longestCarouselLayout : + carouselLayoutMap[numItems]); +} + +export function filterItemsForCarousel(items) { + if (empty(items)) { + return []; + } + + return items + .filter(item => item.hasCoverArt) + .filter(item => item.artTags.every(tag => !tag.isContentWarning)) + .slice(0, maxCarouselLayoutItems + 1); +} -- cgit 1.3.0-6-gf8a5