diff --git a/examples/index.html b/examples/index.html index 7cfc931..d266743 100644 --- a/examples/index.html +++ b/examples/index.html @@ -98,6 +98,18 @@

LOSOS Examples

Browse any user's repos with live API data. File tree and README on demand.
GitHub API boot options
+ +
📺
+
TV Series Browser
+
300+ TV series from Wikidata SPARQL. Genre filters, search, detail modal with IMDb links.
+
Wikidata SPARQL Wikipedia API search filter
+
+ +
🎵
+
MusicBrainz Browser
+
Albums across 10 genres from MusicBrainz API. Cover art, genre and type filters, search, detail modal.
+
MusicBrainz API Cover Art Archive search filter
+
Universal Browser
diff --git a/examples/musicbrainz/index.html b/examples/musicbrainz/index.html new file mode 100644 index 0000000..eb3927c --- /dev/null +++ b/examples/musicbrainz/index.html @@ -0,0 +1,191 @@ + + + + + + MusicBrainz Browser + + + + + + + + +
+
+
+
Loading music from MusicBrainz...
+
+
+
+ + + + diff --git a/examples/musicbrainz/panes/music-pane.js b/examples/musicbrainz/panes/music-pane.js new file mode 100644 index 0000000..669c8ac --- /dev/null +++ b/examples/musicbrainz/panes/music-pane.js @@ -0,0 +1,487 @@ +import { createStore } from '../../../losos/store.js' +import { html, render, onUnmount, keyed } from '../../../losos/html.js' + +export default { + label: 'Browse', + icon: '\uD83C\uDFB5', + + canHandle(subject, store) { + var node = store.get(subject.value) + var type = store.type(node) + return type && type.includes('MusicCatalog') + }, + + render(subject, lionStore, container) { + var node = lionStore.get(subject.value) + if (!node) return + + var data = window.__musicData + if (!data) { + var dataEl = document.querySelector('script[type="application/ld+json"]') + try { data = JSON.parse(dataEl.textContent) } catch (e) { return } + } + + var store = createStore(data) + var root = store.get('#this') + + var searchQuery = '' + var activeGenre = null + var activeType = null + var sortBy = 'score' + var selected = null + var PAGE_SIZE = 60 + var visibleCount = PAGE_SIZE + + // ========== HELPERS ========== + + function allGenres() { + var all = store.propAll(root, 'releases') + var counts = {} + all.forEach(function(r) { + var genres = (r['genre'] || '').split(', ').filter(Boolean) + genres.forEach(function(g) { + counts[g] = (counts[g] || 0) + 1 + }) + }) + return Object.keys(counts) + .sort(function(a, b) { return counts[b] - counts[a] }) + .slice(0, 20) + } + + function allTypes() { + var all = store.propAll(root, 'releases') + var counts = {} + all.forEach(function(r) { + var t = r['releaseType'] || 'Album' + counts[t] = (counts[t] || 0) + 1 + }) + return Object.keys(counts) + .sort(function(a, b) { return counts[b] - counts[a] }) + } + + function matchesSearch(r, q) { + return (r['name'] || '').toLowerCase().indexOf(q) !== -1 || + (r['artist'] || '').toLowerCase().indexOf(q) !== -1 || + (r['genre'] || '').toLowerCase().indexOf(q) !== -1 + } + + function matchesGenre(r, genre) { + var genres = (r['genre'] || '').split(', ') + return genres.indexOf(genre) !== -1 + } + + function matchesType(r, type) { + return (r['releaseType'] || 'Album') === type + } + + function filterReleases() { + var all = store.propAll(root, 'releases') + var result = all + + if (activeGenre) { + result = result.filter(function(r) { return matchesGenre(r, activeGenre) }) + } + if (activeType) { + result = result.filter(function(r) { return matchesType(r, activeType) }) + } + if (searchQuery) { + var q = searchQuery.toLowerCase() + result = result.filter(function(r) { return matchesSearch(r, q) }) + } + + if (sortBy === 'name') { + result.sort(function(a, b) { return (a['name'] || '').localeCompare(b['name'] || '') }) + } else if (sortBy === 'year') { + result.sort(function(a, b) { return (b['releaseYear'] || 0) - (a['releaseYear'] || 0) }) + } else if (sortBy === 'artist') { + result.sort(function(a, b) { return (a['artist'] || '').localeCompare(b['artist'] || '') }) + } + // 'score' keeps original order (by MusicBrainz relevance) + + return result + } + + function mbUrl(mbid) { + return 'https://musicbrainz.org/release-group/' + mbid + } + + // ========== RENDER CARD ========== + + function renderCard(r) { + var img = r['image'] + var yr = r['releaseYear'] + var genres = (r['genre'] || '').split(', ').filter(Boolean).slice(0, 2) + var type = r['releaseType'] || 'Album' + + return html` +
+
+ ${img + ? html`${r['name']} + ` + : html`
\uD83C\uDFB5
` + } +
+
+
${r['name']}
+
${r['artist']}
+
+ ${yr ? html`${yr}` : null} + ${type !== 'Album' ? html`${type}` : null} +
+ ${genres.length > 0 ? html` +
+ ${genres.map(function(g) { return html`${g}` })} +
+ ` : null} +
+
+ ` + } + + // ========== RENDER DETAIL MODAL ========== + + function renderDetail(r) { + if (!r) return null + var yr = r['releaseYear'] + var genres = (r['genre'] || '').split(', ').filter(Boolean) + var type = r['releaseType'] || 'Album' + + return html` +
+
+
+ ${r['image'] + ? html`${r['name']} + ` + : html`
\uD83C\uDFB5
` + } + +
+ +
+

${r['name']}

+
${r['artist']}
+ +
+ ${yr ? html`${yr}` : null} + ${type} +
+ + ${genres.length > 0 ? html` +
+ ${genres.map(function(g) { return html`${g}` })} +
+ ` : null} + + ${r['releaseDate'] ? html` +
+
Release Date
+
${r['releaseDate']}
+
+ ` : null} + + +
+
+
+ ` + } + + // ========== MAIN RENDER ========== + + function renderApp() { + var filtered = filterReleases() + var total = store.propAll(root, 'releases').length + var genres = allGenres() + var types = allTypes() + var visible = filtered.slice(0, visibleCount) + var hasMore = filtered.length > visibleCount + + render(container, html` + + +
+
+
+
+ +
${filtered.length} of ${total} albums
+
+
+ + ${[ + { id: 'score', label: 'Relevance' }, + { id: 'year', label: 'Newest' }, + { id: 'artist', label: 'Artist' }, + { id: 'name', label: 'A\u2013Z' } + ].map(function(s) { + return html`` + })} +
+
+
+ + ${types.length > 1 ? html` +
+ + ${types.map(function(t) { + var active = activeType === t + return html`` + })} +
+ ` : null} + +
+ + ${genres.map(function(g) { + var active = activeGenre === g + return html`` + })} +
+ +
+
+ ${filtered.length} albums${activeGenre ? ' in ' + activeGenre : ''}${activeType ? ' (' + activeType + ')' : ''}${searchQuery ? ' matching \u201C' + searchQuery + '\u201D' : ''} +
+ + ${filtered.length > 0 ? html` +
+ ${keyed(visible, function(r) { return r['@id'] }, function(r) { return renderCard(r) })} +
+ ${hasMore ? html` +
+ +
+ ` : null} + ` : html` +
+
\uD83C\uDFB5
+
No albums found
+
+ `} +
+
+ + ${selected ? renderDetail(selected) : null} + `) + } + + // ========== INIT ========== + var unsub = store.onChange(renderApp) + setTimeout(renderApp, 0) + onUnmount(container, unsub) + } +} diff --git a/examples/musicbrainz/panes/source-pane.js b/examples/musicbrainz/panes/source-pane.js new file mode 100644 index 0000000..e7f04f3 --- /dev/null +++ b/examples/musicbrainz/panes/source-pane.js @@ -0,0 +1,24 @@ +import { html, render } from '../../../losos/html.js' + +export default { + label: 'Source', + icon: '\uD83D\uDCC4', + + canHandle(subject, store) { + return store.get(subject.value) != null + }, + + render(subject, store, container) { + var node = store.get(subject.value) + if (!node) return + var raw = JSON.stringify(node, null, 2) + + render(container, html` +
+

JSON-LD Source

+

Raw linked data from MusicBrainz API

+
${raw}
+
+ `) + } +} diff --git a/examples/wikidata/index.html b/examples/wikidata/index.html new file mode 100644 index 0000000..15bc5f0 --- /dev/null +++ b/examples/wikidata/index.html @@ -0,0 +1,302 @@ + + + + + + TV Series Browser + + + + + + + + +
+
+
+
Loading TV series from Wikidata...
+
+
+
+ + + + diff --git a/examples/wikidata/panes/series-pane.js b/examples/wikidata/panes/series-pane.js new file mode 100644 index 0000000..7853854 --- /dev/null +++ b/examples/wikidata/panes/series-pane.js @@ -0,0 +1,521 @@ +import { createStore } from '../../../losos/store.js' +import { html, render, onUnmount, keyed } from '../../../losos/html.js' + +export default { + label: 'Browse', + icon: '\uD83D\uDCFA', + + canHandle(subject, store) { + var node = store.get(subject.value) + var type = store.type(node) + return type && type.includes('TVCatalog') + }, + + render(subject, lionStore, container) { + var node = lionStore.get(subject.value) + if (!node) return + + var data = window.__seriesData + if (!data) { + var dataEl = document.querySelector('script[type="application/ld+json"]') + try { data = JSON.parse(dataEl.textContent) } catch (e) { return } + } + + var store = createStore(data) + var root = store.get('#this') + + var searchQuery = '' + var activeGenre = null + var sortBy = 'popular' + var selected = null + var PAGE_SIZE = 60 + var visibleCount = PAGE_SIZE + + // ========== HELPERS ========== + + function allGenres() { + var all = store.propAll(root, 'series') + var counts = {} + all.forEach(function(s) { + var genres = (s['genre'] || '').split(', ').filter(Boolean) + genres.forEach(function(g) { + counts[g] = (counts[g] || 0) + 1 + }) + }) + return Object.keys(counts) + .sort(function(a, b) { return counts[b] - counts[a] }) + .slice(0, 20) + } + + function matchesSearch(s, q) { + return (s['name'] || '').toLowerCase().indexOf(q) !== -1 || + (s['description'] || '').toLowerCase().indexOf(q) !== -1 || + (s['network'] || '').toLowerCase().indexOf(q) !== -1 || + (s['country'] || '').toLowerCase().indexOf(q) !== -1 + } + + function matchesGenre(s, genre) { + var genres = (s['genre'] || '').split(', ') + return genres.indexOf(genre) !== -1 + } + + function filterSeries() { + var all = store.propAll(root, 'series') + var result = all + + if (activeGenre) { + result = result.filter(function(s) { return matchesGenre(s, activeGenre) }) + } + if (searchQuery) { + var q = searchQuery.toLowerCase() + result = result.filter(function(s) { return matchesSearch(s, q) }) + } + + if (sortBy === 'name') { + result.sort(function(a, b) { return (a['name'] || '').localeCompare(b['name'] || '') }) + } else if (sortBy === 'year') { + result.sort(function(a, b) { return (b['startYear'] || 0) - (a['startYear'] || 0) }) + } else if (sortBy === 'episodes') { + result.sort(function(a, b) { return (b['episodes'] || 0) - (a['episodes'] || 0) }) + } + + return result + } + + function yearRange(s) { + if (!s['startYear']) return '' + if (s['endYear'] && s['endYear'] !== s['startYear']) { + return s['startYear'] + '\u2013' + s['endYear'] + } + return String(s['startYear']) + } + + function formatEpisodes(n) { + if (!n) return '' + return n + ' ep' + (n !== 1 ? 's' : '') + } + + function wikidataUrl(id) { + return 'https://www.wikidata.org/wiki/' + id + } + + function imdbUrl(id) { + if (!id) return '' + return 'https://www.imdb.com/title/' + id + '/' + } + + function thumbUrl(imageUrl) { + if (!imageUrl) return '' + if (imageUrl.indexOf('commons.wikimedia.org') !== -1 || imageUrl.indexOf('upload.wikimedia.org') !== -1) { + if (imageUrl.indexOf('Special:FilePath') !== -1) { + return imageUrl + '?width=400' + } + return imageUrl.replace('/commons/', '/commons/thumb/') + '/400px-' + imageUrl.split('/').pop() + } + return imageUrl + } + + // ========== RENDER CARD ========== + + function renderCard(s) { + var img = s['image'] + var isLogo = s['isLogo'] === true + var yr = yearRange(s) + var genres = (s['genre'] || '').split(', ').filter(Boolean).slice(0, 2) + var posterClass = isLogo ? 'tv-card-poster tv-card-poster-logo' : 'tv-card-poster' + + return html` +
+
+ ${img + ? html`${s['name']} + ` + : html`
\uD83D\uDCFA
` + } +
+
+
${s['name']}
+
+ ${yr ? html`${yr}` : null} + ${s['episodes'] ? html`${formatEpisodes(s['episodes'])}` : null} +
+ ${genres.length > 0 ? html` +
+ ${genres.map(function(g) { return html`${g}` })} +
+ ` : null} +
+
+ ` + } + + // ========== RENDER DETAIL MODAL ========== + + function renderDetail(s) { + if (!s) return null + var yr = yearRange(s) + var isLogo = s['isLogo'] === true + var genres = (s['genre'] || '').split(', ').filter(Boolean) + var networks = (s['network'] || '').split(', ').filter(Boolean) + var countries = (s['country'] || '').split(', ').filter(Boolean) + var heroClass = s['image'] ? (isLogo ? 'tv-modal-hero tv-modal-hero-logo' : 'tv-modal-hero') : 'tv-modal-hero tv-modal-hero-empty' + + return html` +
+
+ ${s['image'] ? html` +
+ ${s['name']} + ${isLogo ? null : html`
`} + +
+ ` : html` +
+
\uD83D\uDCFA
+ +
+ `} + +
+

${s['name']}

+ ${yr ? html`
${yr}
` : null} + + ${genres.length > 0 ? html` +
+ ${genres.map(function(g) { return html`${g}` })} +
+ ` : null} + + ${s['description'] ? html` +

${s['description']}

+ ` : null} + +
+ ${s['episodes'] ? html` +
+
${s['episodes']}
+
Episodes
+
+ ` : null} + ${s['seasons'] ? html` +
+
${s['seasons']}
+
Seasons
+
+ ` : null} + ${s['sitelinks'] ? html` +
+
${s['sitelinks']}
+
Wiki Links
+
+ ` : null} +
+ + ${networks.length > 0 ? html` +
+
Network
+
${networks.join(', ')}
+
+ ` : null} + + ${countries.length > 0 ? html` +
+
Country
+
${countries.join(', ')}
+
+ ` : null} + + +
+
+
+ ` + } + + // ========== MAIN RENDER ========== + + function renderApp() { + var filtered = filterSeries() + var total = store.propAll(root, 'series').length + var genres = allGenres() + var visible = filtered.slice(0, visibleCount) + var hasMore = filtered.length > visibleCount + + render(container, html` + + +
+
+
+
+ +
${filtered.length} of ${total} series
+
+
+ + ${[ + { id: 'popular', label: 'Popular' }, + { id: 'year', label: 'Newest' }, + { id: 'episodes', label: 'Episodes' }, + { id: 'name', label: 'A\u2013Z' } + ].map(function(s) { + return html`` + })} +
+
+
+ +
+ + ${genres.map(function(g) { + var active = activeGenre === g + return html`` + })} +
+ +
+
+ ${filtered.length} series${activeGenre ? ' in ' + activeGenre : ''}${searchQuery ? ' matching \u201C' + searchQuery + '\u201D' : ''} +
+ + ${filtered.length > 0 ? html` +
+ ${keyed(visible, function(s) { return s['@id'] }, function(s) { return renderCard(s) })} +
+ ${hasMore ? html` +
+ +
+ ` : null} + ` : html` +
+
\uD83D\uDCFA
+
No series found
+
+ `} +
+
+ + ${selected ? renderDetail(selected) : null} + `) + } + + // ========== INIT ========== + var unsub = store.onChange(renderApp) + setTimeout(renderApp, 0) + onUnmount(container, unsub) + } +} diff --git a/examples/wikidata/panes/source-pane.js b/examples/wikidata/panes/source-pane.js new file mode 100644 index 0000000..66ace5e --- /dev/null +++ b/examples/wikidata/panes/source-pane.js @@ -0,0 +1,24 @@ +import { html, render } from '../../../losos/html.js' + +export default { + label: 'Source', + icon: '\uD83D\uDCC4', + + canHandle(subject, store) { + return store.get(subject.value) != null + }, + + render(subject, store, container) { + var node = store.get(subject.value) + if (!node) return + var raw = JSON.stringify(node, null, 2) + + render(container, html` +
+

JSON-LD Source

+

Raw linked data from Wikidata SPARQL

+
${raw}
+
+ `) + } +}