diff --git a/source/scss/_common.scss b/source/scss/_common.scss index 25f943e..78eef70 100644 --- a/source/scss/_common.scss +++ b/source/scss/_common.scss @@ -18,3 +18,7 @@ a { text-decoration-color: var(--foreground-1); } } + +.hidden { + display: none; +} diff --git a/source/scss/components/_search-bar.scss b/source/scss/components/_search-bar.scss new file mode 100644 index 0000000..815b357 --- /dev/null +++ b/source/scss/components/_search-bar.scss @@ -0,0 +1,48 @@ +.search-bar { + input[type='text'] { + background-color: var(--background-2); + border: 1px solid var(--foreground-1); + color: inherit; + padding: 0.5rem; + width: 100%; + } + + ul { + border: 1px solid var(--foreground-1); + border-top: none; + list-style: none; + margin: 0; + max-height: 50vh; + overflow-y: scroll; + padding: 0; + } + + li { + &:nth-child(even) { + background-color: var(--background-2); + } + + &:last-child { + margin-bottom: 0; + } + } + + .search-result, + .search-state { + padding: 1rem; + } + + .search-result { + display: block; + text-decoration: none; + + .display { + font-weight: bold; + } + + .disambiguation { + font-size: 60%; + vertical-align: middle; + } + } +} diff --git a/source/scss/style.scss b/source/scss/style.scss index d9f56c1..68ab941 100644 --- a/source/scss/style.scss +++ b/source/scss/style.scss @@ -5,6 +5,7 @@ @use 'themes/love'; // Component styling +@use 'components/search-bar'; @use 'components/shared-footer'; // Page styling diff --git a/source/ts/components/search-bar.ts b/source/ts/components/search-bar.ts new file mode 100644 index 0000000..d6595c1 --- /dev/null +++ b/source/ts/components/search-bar.ts @@ -0,0 +1,97 @@ +import {Component, html} from 'htm/preact'; + +import debounce from '../utilities/debounce.js'; +import searchReleases, {SearchResult} from '../utilities/search.js'; + +type Props = Record; + +type State = { + searchQueryDebounced: SearchBar['searchQuery']; + searchResults: SearchResult[]; + searchState: 'searching' | 'waiting'; + searchValue: string; +}; + +export default class SearchBar extends Component { + constructor(props: Props) { + super(props); + + this.state = { + searchQueryDebounced: debounce(this.searchQuery, 500), + searchResults: [], + searchState: 'waiting', + searchValue: '', + }; + } + + searchQuery = async (): Promise => { + if (this.state.searchValue.length === 0) { + this.setState({searchResults: [], searchState: 'waiting'}); + return; + } + + this.setState({ + searchResults: await searchReleases(this.state.searchValue), + searchState: 'waiting', + }); + }; + + onInput = async (event: InputEvent): Promise => { + const input = event.target as HTMLInputElement; + this.setState({searchState: 'searching', searchValue: input.value}); + void this.state.searchQueryDebounced(); + }; + + render() { + const results: Array> = []; + + for (const result of this.state.searchResults) { + let disambiguation; + if (result.disambiguation !== undefined) { + disambiguation = html` + ${' '} + (${result.disambiguation}) + `; + } + + results.push( + html` +
  • + + ${result.artist} - ${result.title} + ${disambiguation} + +
  • + `, + ); + } + + const isLoading = this.state.searchState === 'searching'; + // Hide results when the search input is empty. + const noSearchValue = this.state.searchValue.length === 0; + if (!noSearchValue && !isLoading && results.length === 0) { + // If it isn't empty but there are no results and we're also not loading + // new results, then show none were found. + results.push(html`
  • No releases found
  • `); + } else if (isLoading) { + results.unshift( + html`
  • Searching for releases…
  • `, + ); + } + + return html` + + `; + } +} diff --git a/source/ts/pages/home.ts b/source/ts/pages/home.ts index 1f57b11..3d258ca 100644 --- a/source/ts/pages/home.ts +++ b/source/ts/pages/home.ts @@ -1,5 +1,6 @@ import {Component, html} from 'htm/preact'; +import SearchBar from '../components/search-bar.js'; import SharedFooter from '../components/shared-footer.js'; export default class HomePage extends Component { @@ -12,6 +13,10 @@ export default class HomePage extends Component {

    Blink

    +
    + <${SearchBar} /> +
    + <${SharedFooter} /> `; diff --git a/source/ts/utilities/debounce.ts b/source/ts/utilities/debounce.ts new file mode 100644 index 0000000..6343a31 --- /dev/null +++ b/source/ts/utilities/debounce.ts @@ -0,0 +1,15 @@ +export default function debounce( + this: any, + fn: (...args: any[]) => any, + timeout = 250, +): typeof fn { + let timeoutId: number; + + return (...args) => { + clearTimeout(timeoutId); + + timeoutId = window.setTimeout(() => { + fn.apply(this, args); + }, timeout); + }; +} diff --git a/source/ts/utilities/search.ts b/source/ts/utilities/search.ts new file mode 100644 index 0000000..d3494b7 --- /dev/null +++ b/source/ts/utilities/search.ts @@ -0,0 +1,52 @@ +type ApiSearchData = { + releases: Array<{ + 'artist-credit': Array<{ + joinphrase?: string; + name: string; + }>; + disambiguation?: string; + id: string; + title: string; + }>; +}; + +export type SearchResult = { + artist: string; + disambiguation?: string; + id: string; + title: string; +}; + +export default async function searchReleases( + query: string, +): Promise { + query = encodeURIComponent(query); + const url = `https://musicbrainz.org/ws/2/release?query=${query}`; + const response = await window.fetch(url, { + headers: { + accept: 'application/json', + }, + }); + + if (!response.ok) { + throw new Error(`Search API returned ${response.status}`); + } + + const data = (await response.json()) as ApiSearchData; + const results: SearchResult[] = []; + + for (const release of data.releases) { + const artist = release['artist-credit'] + .map(({name, joinphrase}) => `${name}${joinphrase ?? ''}`) + .join(''); + + results.push({ + artist, + disambiguation: release.disambiguation, + id: release.id, + title: release.title, + }); + } + + return results; +}