1
Fork 0

Add a basic search bar (#2).

This commit is contained in:
Bauke 2022-01-03 18:21:21 +01:00
parent 00ca736fd5
commit 11e436eca6
Signed by: Bauke
GPG Key ID: C1C0F29952BCF558
7 changed files with 222 additions and 0 deletions

View File

@ -18,3 +18,7 @@ a {
text-decoration-color: var(--foreground-1); text-decoration-color: var(--foreground-1);
} }
} }
.hidden {
display: none;
}

View File

@ -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;
}
}
}

View File

@ -5,6 +5,7 @@
@use 'themes/love'; @use 'themes/love';
// Component styling // Component styling
@use 'components/search-bar';
@use 'components/shared-footer'; @use 'components/shared-footer';
// Page styling // Page styling

View File

@ -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<string, unknown>;
type State = {
searchQueryDebounced: SearchBar['searchQuery'];
searchResults: SearchResult[];
searchState: 'searching' | 'waiting';
searchValue: string;
};
export default class SearchBar extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
searchQueryDebounced: debounce(this.searchQuery, 500),
searchResults: [],
searchState: 'waiting',
searchValue: '',
};
}
searchQuery = async (): Promise<void> => {
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<void> => {
const input = event.target as HTMLInputElement;
this.setState({searchState: 'searching', searchValue: input.value});
void this.state.searchQueryDebounced();
};
render() {
const results: Array<ReturnType<typeof html>> = [];
for (const result of this.state.searchResults) {
let disambiguation;
if (result.disambiguation !== undefined) {
disambiguation = html`
${' '}
<span class="disambiguation">(${result.disambiguation})</span>
`;
}
results.push(
html`
<li>
<a class="search-result" href="/release/${result.id}">
<span class="display">${result.artist} - ${result.title}</span>
${disambiguation}
</a>
</li>
`,
);
}
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`<li class="search-state">No releases found</li>`);
} else if (isLoading) {
results.unshift(
html`<li class="search-state">Searching for releases…</li>`,
);
}
return html`
<div class="search-bar">
<input
onInput=${this.onInput}
placeholder="Search for a MusicBrainz release"
type="text"
value="${this.state.searchValue}"
/>
<ul class="${noSearchValue ? 'hidden' : ''}">
${results}
</ul>
</div>
`;
}
}

View File

@ -1,5 +1,6 @@
import {Component, html} from 'htm/preact'; import {Component, html} from 'htm/preact';
import SearchBar from '../components/search-bar.js';
import SharedFooter from '../components/shared-footer.js'; import SharedFooter from '../components/shared-footer.js';
export default class HomePage extends Component { export default class HomePage extends Component {
@ -12,6 +13,10 @@ export default class HomePage extends Component {
<h1>Blink</h1> <h1>Blink</h1>
</header> </header>
<main>
<${SearchBar} />
</main>
<${SharedFooter} /> <${SharedFooter} />
</div> </div>
`; `;

View File

@ -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);
};
}

View File

@ -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<SearchResult[]> {
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;
}