From 453b0e9845d41c7a1049d4f0982b66121626766a Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Mon, 20 Nov 2023 19:28:06 -0400 Subject: data: use optional in definitions for more utility validators --- src/data/things/validators.js | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/src/data/things/validators.js b/src/data/things/validators.js index f60c363c..19ad1c0a 100644 --- a/src/data/things/validators.js +++ b/src/data/things/validators.js @@ -96,7 +96,10 @@ export function isStringNonEmpty(value) { } export function optional(validator) { - return value => value === null || value === undefined || validator(value); + return value => + value === null || + value === undefined || + validator(value); } // Complex types (non-primitives) @@ -285,20 +288,14 @@ export function validateProperties(spec) { export const isContribution = validateProperties({ who: isArtistRef, - what: (value) => - value === undefined || - value === null || - isStringNonEmpty(value), + what: optional(isStringNonEmpty), }); export const isContributionList = validateArrayItems(isContribution); export const isAdditionalFile = validateProperties({ title: isString, - description: (value) => - value === undefined || - value === null || - isString(value), + description: optional(isStringNonEmpty), files: validateArrayItems(isString), }); -- cgit 1.3.0-6-gf8a5 From 765e039ef94f7cd1365ba9f211bd686beab5e8ec Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Mon, 20 Nov 2023 19:31:05 -0400 Subject: data: move accent-parsing regex out of parseContributors Also use named capturing groups (and non-capturing groups) for generally better regex form. --- src/data/yaml.js | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/src/data/yaml.js b/src/data/yaml.js index 1d35bae8..67cd8db7 100644 --- a/src/data/yaml.js +++ b/src/data/yaml.js @@ -717,26 +717,28 @@ export function parseAdditionalFiles(array) { })); } -export function parseContributors(contributors) { +const extractAccentRegex = + /^(?
.*?)(?: \((?.*)\))?$/; + +export function parseContributors(contributionStrings) { // If this isn't something we can parse, just return it as-is. // The Thing object's validators will handle the data error better // than we're able to here. - if (!Array.isArray(contributors)) { - return contributors; + if (!Array.isArray(contributionStrings)) { + return contributionStrings; } - contributors = contributors.map((contrib) => { - if (typeof contrib !== 'string') return contrib; + return contributionStrings.map(contribString => { + if (typeof contribString !== 'string') return contribString; - const match = contrib.match(/^(.*?)( \((.*)\))?$/); - if (!match) return contrib; + const match = contribString.match(extractAccentRegex); + if (!match) return contribString; - const who = match[1]; - const what = match[3] || null; - return {who, what}; + return { + who: match.groups.main, + what: match.groups.accent ?? null, + }; }); - - return contributors; } function parseDimensions(string) { -- cgit 1.3.0-6-gf8a5 From bde469f4cede426bd9baa8981b876e82ae290972 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Mon, 20 Nov 2023 19:32:03 -0400 Subject: data: add additionalNames wiki property ("Additional Names") --- .../composite/wiki-properties/additionalNameList.js | 13 +++++++++++++ src/data/composite/wiki-properties/index.js | 1 + src/data/things/track.js | 2 ++ src/data/things/validators.js | 7 +++++++ src/data/yaml.js | 20 ++++++++++++++++++++ 5 files changed, 43 insertions(+) create mode 100644 src/data/composite/wiki-properties/additionalNameList.js diff --git a/src/data/composite/wiki-properties/additionalNameList.js b/src/data/composite/wiki-properties/additionalNameList.js new file mode 100644 index 00000000..d1302224 --- /dev/null +++ b/src/data/composite/wiki-properties/additionalNameList.js @@ -0,0 +1,13 @@ +// A list of additional names! These can be used for a variety of purposes, +// e.g. providing extra searchable titles, localizations, romanizations or +// original titles, and so on. Each item has a name and, optionally, a +// descriptive annotation. + +import {isAdditionalNameList} from '#validators'; + +export default function() { + return { + flags: {update: true, expose: true}, + update: {validate: isAdditionalNameList}, + }; +} diff --git a/src/data/composite/wiki-properties/index.js b/src/data/composite/wiki-properties/index.js index 2462b047..7607e361 100644 --- a/src/data/composite/wiki-properties/index.js +++ b/src/data/composite/wiki-properties/index.js @@ -1,4 +1,5 @@ export {default as additionalFiles} from './additionalFiles.js'; +export {default as additionalNameList} from './additionalNameList.js'; export {default as color} from './color.js'; export {default as commentary} from './commentary.js'; export {default as commentatorArtists} from './commentatorArtists.js'; diff --git a/src/data/things/track.js b/src/data/things/track.js index 8d310611..f6320677 100644 --- a/src/data/things/track.js +++ b/src/data/things/track.js @@ -24,6 +24,7 @@ import { import { additionalFiles, + additionalNameList, commentary, commentatorArtists, contributionList, @@ -63,6 +64,7 @@ export class Track extends Thing { name: name('Unnamed Track'), directory: directory(), + additionalNames: additionalNameList(), duration: duration(), urls: urls(), diff --git a/src/data/things/validators.js b/src/data/things/validators.js index 19ad1c0a..71570c5a 100644 --- a/src/data/things/validators.js +++ b/src/data/things/validators.js @@ -373,6 +373,13 @@ export function isURL(string) { return true; } +export const isAdditionalName = validateProperties({ + name: isName, + annotation: optional(isStringNonEmpty), +}); + +export const isAdditionalNameList = validateArrayItems(isAdditionalName); + export function validateReference(type = 'track') { return (ref) => { isStringNonEmpty(ref); diff --git a/src/data/yaml.js b/src/data/yaml.js index 67cd8db7..cde4413b 100644 --- a/src/data/yaml.js +++ b/src/data/yaml.js @@ -436,6 +436,7 @@ export const processTrackSectionDocument = makeProcessDocument(T.TrackSectionHel export const processTrackDocument = makeProcessDocument(T.Track, { fieldTransformations: { + 'Additional Names': parseAdditionalNames, 'Duration': parseDuration, 'Date First Released': (value) => new Date(value), @@ -457,6 +458,7 @@ export const processTrackDocument = makeProcessDocument(T.Track, { propertyFieldMapping: { name: 'Track', directory: 'Directory', + additionalNames: 'Additional Names', duration: 'Duration', color: 'Color', urls: 'URLs', @@ -741,6 +743,24 @@ export function parseContributors(contributionStrings) { }); } +export function parseAdditionalNames(additionalNameStrings) { + if (!Array.isArray(additionalNameStrings)) { + return additionalNameStrings; + } + + return additionalNameStrings.map(additionalNameString => { + if (typeof additionalNameString !== 'string') return additionalNameString; + + const match = additionalNameString.match(extractAccentRegex); + if (!match) return additionalNameString; + + return { + name: match.groups.main, + annotation: match.groups.accent ?? null, + }; + }); +} + function parseDimensions(string) { // It's technically possible to pass an array like [30, 40] through here. // That's not really an issue because if it isn't of the appropriate shape, -- cgit 1.3.0-6-gf8a5 From c9847299d6a31b5fb39f82f80c92e80d53c44234 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Tue, 21 Nov 2023 07:20:16 -0400 Subject: data: parse contribs & additional names from object shape --- src/data/yaml.js | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/src/data/yaml.js b/src/data/yaml.js index cde4413b..5da66c93 100644 --- a/src/data/yaml.js +++ b/src/data/yaml.js @@ -730,11 +730,14 @@ export function parseContributors(contributionStrings) { return contributionStrings; } - return contributionStrings.map(contribString => { - if (typeof contribString !== 'string') return contribString; + return contributionStrings.map(item => { + if (typeof item === 'object' && item['Who']) + return {who: item['Who'], what: item['What'] ?? null}; - const match = contribString.match(extractAccentRegex); - if (!match) return contribString; + if (typeof item !== 'string') return item; + + const match = item.match(extractAccentRegex); + if (!match) return item; return { who: match.groups.main, @@ -748,11 +751,14 @@ export function parseAdditionalNames(additionalNameStrings) { return additionalNameStrings; } - return additionalNameStrings.map(additionalNameString => { - if (typeof additionalNameString !== 'string') return additionalNameString; + return additionalNameStrings.map(item => { + if (typeof item === 'object' && item['Name']) + return {name: item['Name'], annotation: item['Annotation'] ?? null}; + + if (typeof item !== 'string') return item; - const match = additionalNameString.match(extractAccentRegex); - if (!match) return additionalNameString; + const match = item.match(extractAccentRegex); + if (!match) return item; return { name: match.groups.main, -- cgit 1.3.0-6-gf8a5 From 745eb45531afb233a49433b18e3095238eb41b84 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Tue, 21 Nov 2023 07:22:15 -0400 Subject: client: internal beforeHashLinkScrolls listener --- src/static/client3.js | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/static/client3.js b/src/static/client3.js index 8372a268..4a5dffc2 100644 --- a/src/static/client3.js +++ b/src/static/client3.js @@ -576,6 +576,7 @@ const hashLinkInfo = clientInfo.hashLinkInfo = { }, event: { + beforeHashLinkScrolls: [], whenHashLinkClicked: [], }, }; @@ -638,6 +639,21 @@ function addHashLinkListeners() { return; } + // Don't do anything if the target element isn't actually visible! + if (target.offsetParent === null) { + return; + } + + // Allow event handlers to prevent scrolling. + for (const handler of event.beforeHashLinkScrolls) { + if (handler({ + link: hashLink, + target, + }) === false) { + return; + } + } + // Hide skipper box right away, so the layout is updated on time for the // math operations coming up next. const skipper = document.getElementById('skippers'); @@ -675,6 +691,7 @@ function addHashLinkListeners() { for (const handler of event.whenHashLinkClicked) { handler({ link: hashLink, + target, }); } }); -- cgit 1.3.0-6-gf8a5 From bad238355e19c4fef5e5f3b41df88fa9b1b84aaa Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Tue, 21 Nov 2023 07:31:45 -0400 Subject: content, client, css: generateAdditionalNamesBox --- .../dependencies/generateAdditionalNamesBox.js | 48 ++++++++++++ src/content/dependencies/generatePageLayout.js | 38 +++++---- src/content/dependencies/generateTrackInfoPage.js | 8 ++ src/static/client3.js | 90 ++++++++++++++++++++++ src/static/site5.css | 66 ++++++++++++++++ src/strings-default.yaml | 15 ++++ 6 files changed, 249 insertions(+), 16 deletions(-) create mode 100644 src/content/dependencies/generateAdditionalNamesBox.js diff --git a/src/content/dependencies/generateAdditionalNamesBox.js b/src/content/dependencies/generateAdditionalNamesBox.js new file mode 100644 index 00000000..f7fa3b00 --- /dev/null +++ b/src/content/dependencies/generateAdditionalNamesBox.js @@ -0,0 +1,48 @@ +import {stitchArrays} from '#sugar'; + +export default { + contentDependencies: ['transformContent'], + extraDependencies: ['html', 'language'], + + relations: (relation, additionalNames) => ({ + names: + additionalNames.map(({name}) => + relation('transformContent', name)), + + annotations: + additionalNames.map(({annotation}) => + (annotation + ? relation('transformContent', annotation) + : null)), + }), + + generate: (relations, {html, language}) => { + const names = + relations.names.map(name => + html.tag('span', {class: 'additional-name'}, + name.slot('mode', 'inline'))); + + const annotations = + relations.annotations.map(annotation => + (annotation + ? html.tag('span', {class: 'annotation'}, + language.$('misc.additionalNames.item.annotation', { + annotation: + annotation.slot('mode', 'inline'), + })) + : null)); + + return html.tag('div', {id: 'additional-names-box'}, [ + html.tag('p', + language.$('misc.additionalNames.title')), + + html.tag('ul', + stitchArrays({name: names, annotation: annotations}) + .map(({name, annotation}) => + html.tag('li', + (annotation + ? language.$('misc.additionalNames.item.withAnnotation', {name, annotation}) + : language.$('misc.additionalNames.item', {name}))))), + ]); + }, +}; diff --git a/src/content/dependencies/generatePageLayout.js b/src/content/dependencies/generatePageLayout.js index 95551f3e..7dee8cc3 100644 --- a/src/content/dependencies/generatePageLayout.js +++ b/src/content/dependencies/generatePageLayout.js @@ -108,6 +108,8 @@ export default { title: {type: 'html'}, showWikiNameInTitle: {type: 'boolean', default: true}, + additionalNames: {type: 'html'}, + cover: {type: 'html'}, socialEmbed: {type: 'html'}, @@ -222,22 +224,25 @@ export default { const colors = getColors(slots.color ?? data.wikiColor); const hasSocialEmbed = !html.isBlank(slots.socialEmbed); - let titleHTML = null; - - if (!html.isBlank(slots.title)) { - switch (slots.headingMode) { - case 'sticky': - titleHTML = - relations.stickyHeadingContainer.slots({ - title: slots.title, - cover: slots.cover, - }); - break; - case 'static': - titleHTML = html.tag('h1', slots.title); - break; - } - } + const titleContentsHTML = + (html.isBlank(slots.title) + ? null + : html.isBlank(slots.additionalNames) + ? language.sanitize(slots.title) + : html.tag('a', { + href: '#additional-names-box', + title: language.$('misc.additionalNames.tooltip').toString(), + }, language.sanitize(slots.title))); + + const titleHTML = + (html.isBlank(slots.title) + ? null + : slots.headingMode === 'sticky' + ? relations.stickyHeadingContainer.slots({ + title: titleContentsHTML, + cover: slots.cover, + }) + : html.tag('h1', titleContentsHTML)); let footerContent = slots.footerContent; @@ -254,6 +259,7 @@ export default { titleHTML, slots.cover, + slots.additionalNames, html.tag('div', { diff --git a/src/content/dependencies/generateTrackInfoPage.js b/src/content/dependencies/generateTrackInfoPage.js index 93334948..180e5c29 100644 --- a/src/content/dependencies/generateTrackInfoPage.js +++ b/src/content/dependencies/generateTrackInfoPage.js @@ -6,6 +6,7 @@ import getChronologyRelations from '../util/getChronologyRelations.js'; export default { contentDependencies: [ 'generateAdditionalFilesShortcut', + 'generateAdditionalNamesBox', 'generateAlbumAdditionalFilesList', 'generateAlbumNavAccent', 'generateAlbumSidebar', @@ -106,6 +107,11 @@ export default { list: relation('generateAlbumAdditionalFilesList', album, additionalFiles), }); + if (!empty(track.additionalNames)) { + relations.additionalNamesBox = + relation('generateAdditionalNamesBox', track.additionalNames); + } + if (track.hasUniqueCoverArt || album.hasCoverArt) { relations.cover = relation('generateTrackCoverArtwork', track); @@ -300,6 +306,8 @@ export default { title: language.$('trackPage.title', {track: data.name}), headingMode: 'sticky', + additionalNames: relations.additionalNamesBox ?? null, + color: data.color, styleRules: [relations.albumStyleRules], diff --git a/src/static/client3.js b/src/static/client3.js index 4a5dffc2..94ba4a23 100644 --- a/src/static/client3.js +++ b/src/static/client3.js @@ -1260,6 +1260,96 @@ function loadImage(imageUrl, onprogress) { }); } +// "Additional names" box --------------------------------- + +const additionalNamesBoxInfo = clientInfo.additionalNamesBox = { + box: null, + links: null, + mainContentContainer: null, + + state: { + visible: false, + }, +}; + +function getAdditionalNamesBoxReferences() { + const info = additionalNamesBoxInfo; + + info.box = + document.getElementById('additional-names-box'); + + info.links = + document.querySelectorAll('a[href="#additional-names-box"]'); + + info.mainContentContainer = + document.querySelector('#content .main-content-container'); +} + +function addAdditionalNamesBoxInternalListeners() { + const info = additionalNamesBoxInfo; + + hashLinkInfo.event.beforeHashLinkScrolls.push(({target}) => { + if (target === info.box) { + return false; + } + }); +} + +function addAdditionalNamesBoxListeners() { + const info = additionalNamesBoxInfo; + + for (const link of info.links) { + link.addEventListener('click', domEvent => { + handleAdditionalNamesBoxLinkClicked(domEvent); + }); + } +} + +function handleAdditionalNamesBoxLinkClicked(domEvent) { + const info = additionalNamesBoxInfo; + const {state} = info; + + domEvent.preventDefault(); + + if (!info.box || !info.mainContentContainer) return; + + const margin = + +(cssProp(info.box, 'scroll-margin-top').replace('px', '')); + + const {top} = + (state.visible + ? info.box.getBoundingClientRect() + : info.mainContentContainer.getBoundingClientRect()); + + if (top + 20 < margin || top > 0.4 * window.innerHeight) { + if (!state.visible) { + toggleAdditionalNamesBox(); + } + + window.scrollTo({ + top: window.scrollY + top - margin, + behavior: 'smooth', + }); + } else { + toggleAdditionalNamesBox(); + } +} + +function toggleAdditionalNamesBox() { + const info = additionalNamesBoxInfo; + const {state} = info; + + state.visible = !state.visible; + info.box.style.display = + (state.visible + ? 'block' + : 'none'); +} + +clientSteps.getPageReferences.push(getAdditionalNamesBoxReferences); +clientSteps.addInternalListeners.push(addAdditionalNamesBoxInternalListeners); +clientSteps.addPageListeners.push(addAdditionalNamesBoxListeners); + // Group contributions table ------------------------------ const groupContributionsTableInfo = diff --git a/src/static/site5.css b/src/static/site5.css index ea27e35e..31b2995b 100644 --- a/src/static/site5.css +++ b/src/static/site5.css @@ -802,6 +802,68 @@ html[data-url-key="localized.listing"][data-url-value0="random"] #content a:not( opacity: 0.7; } +/* Additional names (heading and box) */ + +h1 a[href="#additional-names-box"] { + color: inherit; + text-decoration: underline; + text-decoration-style: dotted; +} + +h1 a[href="#additional-names-box"]:hover { + text-decoration-style: solid; +} + +#additional-names-box { + --custom-scroll-offset: calc(0.5em - 2px); + + margin: 1em 0 1em -10px; + padding: 15px 20px 10px 20px; + width: max-content; + max-width: min(60vw, 600px); + + border: 1px dotted var(--primary-color); + border-radius: 6px; + + background: + linear-gradient(var(--bg-color), var(--bg-color)), + linear-gradient(#000000bb, #000000bb), + var(--primary-color); + + box-shadow: 0 -2px 6px -1px var(--dim-color) inset; + + display: none; +} + +#additional-names-box > :first-child { margin-top: 0; } +#additional-names-box > :last-child { margin-bottom: 0; } + +#additional-names-box p { + padding-left: 10px; + padding-right: 10px; + margin-bottom: 0; + font-style: oblique; +} + +#additional-names-box ul { + padding-left: 10px; + margin-top: 0.5em; +} + +#additional-names-box li .additional-name { + margin-right: 0.25em; +} + +#additional-names-box li .additional-name .content-image { + margin-bottom: 0.25em; + margin-top: 0.5em; +} + +#additional-names-box li .annotation { + opacity: 0.8; + display: inline-block; +} + /* Images */ .image-container { @@ -1760,6 +1822,10 @@ html[data-language-code="preview-en"][data-url-key="localized.home"] #content max-width: unset; } + #additional-names-box { + max-width: unset; + } + /* Show sticky heading above cover art */ .content-sticky-heading-container { diff --git a/src/strings-default.yaml b/src/strings-default.yaml index e6b8d6db..7562af84 100644 --- a/src/strings-default.yaml +++ b/src/strings-default.yaml @@ -338,6 +338,21 @@ trackList: # misc: + # additionalNames: + # "Drop"-styled box that catalogues a variety of additional or + # alternate names for the current thing; toggled by clicking on the + # thing's title, which is styled interactively and gets a tooltip + # (hover text), since it isn't usually an interactive element. + + additionalNames: + title: "Additional or alternate names:" + tooltip: "Click to view additional or alternate names" + + item: + _: "{NAME}" + withAnnotation: "{NAME} {ANNOTATION}" + annotation: "({ANNOTATION})" + # alt: # Fallback text for the alt text of images and artworks - these # are read aloud by screen readers. -- cgit 1.3.0-6-gf8a5