Add a basic search bar (#2).
This commit is contained in:
parent
00ca736fd5
commit
11e436eca6
|
@ -18,3 +18,7 @@ a {
|
||||||
text-decoration-color: var(--foreground-1);
|
text-decoration-color: var(--foreground-1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
|
|
@ -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>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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>
|
||||||
`;
|
`;
|
||||||
|
|
|
@ -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);
|
||||||
|
};
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
Loading…
Reference in New Issue