import truncateHtml from 'truncate-html' import { convert } from 'html-to-text' import { parseCountryField } from './countries.js' import { formatDate, md } from './formatters.js' import { ICON_MAP } from './icons.js' const warningBanner = `<div class="banner warning"><p>${ICON_MAP['alertTriangle']}There are probably spoilers after this banner — this is a warning about them.</p></div>` const generateAssociatedMediaHTML = (data, isGenre = false) => { const sections = [ { key: 'artists', icon: 'headphones', category: 'music', title: 'Related Artist(s)' }, { key: 'related_artists', icon: 'headphones', category: 'music', title: 'Related Artist(s)' }, { key: 'genres', icon: 'headphones', category: 'music', title: 'Related Genre(s)' }, { key: 'movies', icon: 'film', category: 'movies', title: 'Related Movie(s)' }, { key: 'shows', icon: 'deviceTvOld', category: 'tv', title: 'Related Show(s)' }, { key: 'related_shows', icon: 'deviceTvOld', category: 'tv', title: 'Related Show(s)' }, { key: 'posts', icon: 'article', category: 'article', title: 'Related Post(s)' }, { key: 'related_books', icon: 'books', category: 'books', title: 'Related Book(s)' } ] return sections .filter(({ key }) => !(isGenre && key === 'artists')) .map(({ key, category, icon, title }) => { const items = data[key] if (!items || items.length === 0) return '' return ` <div class="associated-media"> <p class="${category}">${ICON_MAP[icon]} ${title}</p> <ul> ${items .map(item => { const name = item.name || item.title const url = item.url const year = item.year ? ` (${item.year})` : '' const author = item.author ? ` by ${item.author}` : '' const totalPlays = item.total_plays ? ` <strong class="highlight-text">${item.total_plays}</strong> ${item.total_plays === 1 ? 'play' : 'plays'}` : '' let listItemContent = name if (key === 'artists' || key === 'related_artists') { return `<li><a href="${url}">${name}</a>${totalPlays}</li>` } else if (key === 'movies' || key === 'shows' || key === 'related_shows') { listItemContent = `${name}${year}` } else if (key === 'related_books') { listItemContent = `${name}${author}` } return `<li><a href="${url}">${listItemContent}</a></li>` }) .join('')} </ul> </div>` }) .join('') } const generateMediaLinks = (data, type, count = 10) => { if (!data || !type) return '' const dataSlice = data.slice(0, count) if (dataSlice.length === 0) return null const buildLink = (item) => { switch (type) { case 'genre': return `<a href="${item['genre_url']}">${item['genre_name']}</a>` case 'artist': return `<a href="${item['url']}">${item['name']}</a>` case 'book': return `<a href="${item['url']}">${item['title']}</a>` default: return '' } } if (dataSlice.length === 1) return buildLink(dataSlice[0]) const links = dataSlice.map(buildLink) const allButLast = links.slice(0, -1).join(', ') const last = links[links.length - 1] return `${allButLast} and ${last}` } export const generateArtistHTML = (artist, globals) => { const playLabel = artist?.['total_plays'] === 1 ? 'play' : 'plays' const concertsList = artist['concerts']?.length ? `<hr /> <p id="concerts" class="concerts"> ${ICON_MAP['deviceSpeaker']} I've seen this artist live! </p> <ul>${artist['concerts'].map(generateConcertModal).join('')}</ul>` : '' const albumsTable = artist['albums']?.length ? `<table> <tr><th>Album</th><th>Plays</th><th>Year</th></tr> ${artist['albums'].map(album => ` <tr> <td>${album['name']}</td> <td>${album['total_plays'] || 0}</td> <td>${album['release_year']}</td> </tr>`).join('')} </table> <p><em>These are the albums by this artist that are in my collection, not necessarily a comprehensive discography.</em></p> ` : '' return ` <a class="icon-link" href="/music">${ICON_MAP.arrowLeft} Back to music</a> <article class="artist-focus"> <div class="artist-display"> <img srcset=" ${globals['cdn_url']}${artist['image']}?class=w200&type=webp 200w, ${globals['cdn_url']}${artist['image']}?class=w600&type=webp 400w, ${globals['cdn_url']}${artist['image']}?class=w800&type=webp 800w " sizes="(max-width: 450px) 200px, (max-width: 850px) 400px, 800px" src="${globals['cdn_url']}${artist['image']}?class=w200&type=webp" alt="${artist['name']} / ${artist['country']}" loading="eager" decoding="async" width="200" height="200" /> <div class="artist-meta"> <p class="title"><strong>${artist['name']}</strong></p> <p class="sub-meta country">${ICON_MAP['mapPin']} ${parseCountryField(artist['country'])}</p> ${artist['favorite'] ? `<p class="sub-meta favorite">${ICON_MAP['heart']} This is one of my favorite artists!</p>` : ''} ${artist['tattoo'] ? `<p class="sub-meta tattoo">${ICON_MAP['needle']} I have a tattoo inspired by this artist!</p>` : ''} ${artist['total_plays'] ? `<p class="sub-meta"><strong class="highlight-text">${artist['total_plays']} ${playLabel}</strong></p>` : ''} <p class="sub-meta">${artist['genre'] ? `<a href="${artist['genre']['url']}">${artist['genre']['name']}</a>` : ''}</p> </div> </div> ${generateAssociatedMediaHTML(artist)} ${artist['description'] ? ` <h2>Overview</h2> <div data-toggle-content class="text-toggle-hidden">${md.render(artist['description'])}</div> <button data-toggle-button>Show more</button>` : '' } ${concertsList} ${albumsTable} </article> ` } export const generateBookHTML = (book, globals) => { const alt = `${book['title']}${book['author'] ? ` by ${book['author']}` : ''}` const percentage = book['progress'] ? `${book['progress']}%` : '' const status = book['status'] === 'finished' ? `Finished on <strong class="highlight-text">${formatDate(book['date_finished'])}</strong>` : percentage ? `<div class="progress-bar-wrapper" title="${percentage}"> <div style="width:${percentage}" class="progress-bar"></div> </div>` : '' return ` <a class="icon-link" href="/books">${ICON_MAP['arrowLeft']} Back to books</a> <article class="book-focus"> <div class="book-display"> <img srcset=" ${globals['cdn_url']}${book['image']}?class=verticalsm&type=webp 200w, ${globals['cdn_url']}${book['image']}?class=verticalmd&type=webp 400w, ${globals['cdn_url']}${book['image']}?class=verticalbase&type=webp 800w " sizes="(max-width: 450px) 203px, (max-width: 850px) 406px, 812px" src="${globals['cdn_url']}${book['image']}?class=verticalsm&type=webp" alt="${alt}" loading="lazy" decoding="async" width="200" height="307" /> <div class="book-meta"> <p class="title"><strong>${book['title']}</strong></p> ${book['rating'] ? `<p>${book['rating']}</p>` : ''} ${book['author'] ? `<p class="sub-meta">By ${book['author']}</p>` : ''} ${book['favorite'] ? `<p class="sub-meta favorite">${ICON_MAP['heart']} This is one of my favorite books!</p>` : ''} ${book['tattoo'] ? `<p class="sub-meta tattoo">${ICON_MAP['needle']} I have a tattoo inspired by this book!</p>` : ''} ${status ? `<p class="sub-meta">${status}</p>` : ''} </div> </div> ${book['review'] ? `${warningBanner}<h2>My thoughts</h2><p>${book['review']}</p>` : ''} ${generateAssociatedMediaHTML(book)} <h2>Overview</h2> <p>${md.render(book['description'])}</p> </article> ` } export const generateGenreHTML = (genre) => { const artistCount = genre['artists']?.length || 0 const connectingWords = artistCount > 1 ? 'artists are' : 'artist is' const mediaLinks = generateMediaLinks(genre['artists'], 'artist', 5) return ` <a class="icon-link" href="/music">${ICON_MAP['arrowLeft']} Back to music</a> <h2>${genre['name']}</h2> <article class="genre-focus"> ${mediaLinks ? ` <p>My top <strong class="highlight-text">${genre['name']}</strong> ${connectingWords} ${mediaLinks}. I've listened to <strong class="highlight-text">${genre['total_plays']}</strong> tracks from this genre.</p> <hr />` : ''} ${generateAssociatedMediaHTML(genre, true)} ${genre['description'] ? ` <h3>Overview</h3> <div data-toggle-content class="text-toggle-hidden"> ${md.render(genre['description'])} <p><a href="${genre['wiki_link']}">Continue reading at Wikipedia.</a></p> <p><em>Wikipedia content provided under the terms of the <a href="https://creativecommons.org/licenses/by-sa/3.0/">Creative Commons BY-SA license</a>.</em></p> </div> <button data-toggle-button>Show more</button>` : ''} </article> ` } export const generateMetadata = (data, type, globals) => { let title = globals['site_name'] let description = data['description'] || globals['site_description'] const canonicalUrl = data['url'] ? `${globals['url']}${data['url']}` : globals['url'] const ogImage = `${globals['cdn_url']}${data['image'] || globals['avatar']}?class=w800` description = convert(truncateHtml(md.render(description), 100, { byWords: true, ellipsis: '...' }), { wordwrap: false, selectors: [ { selector: 'a', options: { ignoreHref: true } }, { selector: 'h1', options: { uppercase: false } }, { selector: 'h2', options: { uppercase: false } }, { selector: 'h3', options: { uppercase: false } }, { selector: '*', format: 'block' } ] }).replace(/\s+/g, ' ').trim() switch (type) { case 'artist': title = `Artists / ${data['name']} / ${globals['site_name']}` break case 'genre': title = `Genre / ${data['name']} / ${globals['site_name']}` break case 'book': title = `Books / ${data['title']} by ${data.author} / ${globals['site_name']}` break case 'movie': title = `Movies / ${data['title']} (${data.year}) / ${globals['site_name']}` break case 'show': title = `Shows / ${data['title']} / ${globals['site_name']}` break default: title = `${data['title'] || globals['site_name']}` } return { title, description, 'og:title': title, 'og:description': description, 'og:image': ogImage, 'og:url': canonicalUrl, 'canonical': canonicalUrl } } export const generateWatchingHTML = (media, globals, type) => { const isShow = type === 'show' const label = isShow ? 'show' : 'movie' const lastWatched = media['last_watched'] || (isShow && media['episode']?.['last_watched_at']) return ` <a class="icon-link" href="/watching">${ICON_MAP.arrowLeft} Back to watching</a> <article class="watching focus"> <img srcset=" ${globals['cdn_url']}${media['backdrop']}?class=bannersm&type=webp 256w, ${globals['cdn_url']}${media['backdrop']}?class=bannermd&type=webp 512w, ${globals['cdn_url']}${media['backdrop']}?class=bannerbase&type=webp 1024w " sizes="(max-width: 450px) 256px, (max-width: 850px) 512px, 1024px" src="${globals['cdn_url']}${media['backdrop']}?class=bannersm&type=webp" alt="${media['title']} / ${media['year']}" class="image-banner" loading="eager" decoding="async" width="256" height="180" /> <div class="meta"> <p class="title"><strong>${media['title']}</strong> (${media['year']})</p> ${media['favorite'] ? `<p class="sub-meta favorite">${ICON_MAP['heart']} This is one of my favorite ${label}s!</p>` : ''} ${media['tattoo'] ? `<p class="sub-meta tattoo">${ICON_MAP['needle']} I have a tattoo inspired by this ${label}!</p>` : ''} ${media['collected'] ? `<p class="sub-meta collected">${ICON_MAP['circleCheck']} This ${label} is in my collection!</p>` : ''} ${lastWatched ? `<p class="sub-meta">Last watched on <strong class="highlight-text">${formatDate(lastWatched)}</strong></p>` : ''} </div> ${media['review'] ? `${warningBanner}<h2>My thoughts</h2><p>${md.render(media['review'])}</p>` : ''} ${generateAssociatedMediaHTML(media)} ${media['description'] ? `<h2>Overview</h2><p>${md.render(media['description'])}</p>` : ''} </article> ` } export const generateConcertModal = (concert) => { const venue = concert['venue_name'] ? concert['venue_latitude'] && concert['venue_longitude'] ? `<a href="https://www.openstreetmap.org/?mlat=${concert['venue_latitude']}&mlon=${concert['venue_longitude']}#map=18/${concert['venue_latitude']}/${concert['venue_longitude']}">${concert['venue_name_short']}</a>` : concert['venue_name_short'] : '' const notesModal = concert['notes'] ? `<input class="modal-input" id="${concert['id']}" type="checkbox" tabindex="0" /> <label class="modal-toggle" for="${concert['id']}">${ICON_MAP['infoCircle']}</label> <div class="modal-wrapper"> <div class="modal-body"> <label class="modal-close" for="${concert['id']}">${ICON_MAP['circleX']}</label> <div> <h3>Notes</h3> ${md.render(concert['notes'])} </div> </div> </div>` : '' return ` <li> <strong class="highlight-text">${formatDate(concert['date'])}</strong> at ${venue} ${notesModal} </li> ` }