Skip to content

Commit 728b368

Browse files
committed
Search for albums and artists
1 parent dde557e commit 728b368

File tree

4 files changed

+226
-5
lines changed

4 files changed

+226
-5
lines changed

packages/components/command-bar/package.json

+3-1
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,12 @@
88
},
99
"dependencies": {
1010
"@echo/components-shared-controllers": "^1.0.0",
11+
"@echo/components-router": "^1.0.0",
1112
"@echo/core-types": "^1.0.0",
1213
"@echo/services-bootstrap-runtime": "^1.0.0",
1314
"effect": "^3.8.3",
1415
"lit": "^3.2.0",
15-
"@lit/task": "^1.0.1"
16+
"@lit/task": "^1.0.1",
17+
"@shoelace-style/shoelace": "^2.18.0"
1618
}
1719
}
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,210 @@
11
import { LitElement, css, html } from "lit";
2-
import { customElement } from "lit/decorators.js";
2+
import { customElement, property, state } from "lit/decorators.js";
3+
import "@shoelace-style/shoelace/dist/components/popup/popup";
4+
import { Library, type Album, type Artist } from "@echo/core-types";
5+
import { EffectFn } from "@echo/components-shared-controllers/src/effect-fn.controller";
6+
import { Option } from "effect";
37

48
/**
59
* Component that displays a search bar that can search in the user's library
610
* and execute commands.
711
*/
812
@customElement("command-bar")
913
export class CommandBar extends LitElement {
14+
@state()
15+
private resultsVisible = false;
16+
17+
@state()
18+
private searchResults: [Album[], Artist[]] = [[], []];
19+
20+
private previousSearchTimeout: NodeJS.Timeout | undefined;
21+
22+
private search = new EffectFn(this, Library.search, {
23+
complete: (results) => {
24+
this.searchResults = results;
25+
this.resultsVisible = true;
26+
},
27+
});
28+
1029
static styles = css`
1130
input {
1231
padding: 0.5rem;
1332
border: 1px solid var(--border-color);
1433
background-color: var(--background-color-muted);
34+
color: var(--text-color);
35+
font-family: "DepartureMono", monospace;
1536
font-size: 1rem;
37+
outline: none;
1638
width: 95%;
1739
}
1840
19-
input::placeholder {
20-
font-family: "DepartureMono", monospace;
41+
input:focus {
42+
border-color: var(--accent-color);
43+
}
44+
45+
div.search-results {
46+
background-color: var(--background-color-muted);
47+
}
48+
`;
49+
50+
connectedCallback(): void {
51+
super.connectedCallback();
52+
53+
// Listen for the Escape key to close the search bar.
54+
window.addEventListener("keydown", (event) => this._onKeyDown(event));
55+
window.addEventListener("mousedown", (event) => this._onMouseDown(event));
56+
}
57+
58+
render() {
59+
return html`
60+
<sl-popup ?active=${this.resultsVisible} placement="bottom" sync="width">
61+
<input
62+
slot="anchor"
63+
placeholder="Search or command"
64+
@input="${this._onQueryChanged}"
65+
@focus="${() => (this.resultsVisible = true)}"
66+
/>
67+
68+
<div class="search-results">
69+
${this.searchResults[0].map(
70+
(album) => html`
71+
<command-bar-result
72+
title="${album.name}"
73+
subtitle="${album.artist.name}"
74+
.imageSource="${album.embeddedCover}"
75+
link="/albums/${album.id}"
76+
@click="${this._onOptionSelected}"
77+
></command-bar-result>
78+
`,
79+
)}
80+
${this.searchResults[1].map(
81+
(artist) => html`
82+
<command-bar-result
83+
title="${artist.name}"
84+
subtitle=""
85+
.imageSource="${artist.image}"
86+
rounded
87+
link="/artists/${artist.id}"
88+
@click="${this._onOptionSelected}"
89+
></command-bar-result>
90+
`,
91+
)}
92+
</div>
93+
</sl-popup>
94+
`;
95+
}
96+
97+
private _onQueryChanged(event: Event) {
98+
const query = (event.target as HTMLInputElement).value;
99+
100+
if (this.previousSearchTimeout) {
101+
clearTimeout(this.previousSearchTimeout);
102+
}
103+
104+
this.previousSearchTimeout = setTimeout(() => {
105+
this.search.run(query);
106+
}, 200);
107+
}
108+
109+
private _onKeyDown(event: KeyboardEvent) {
110+
if (event.key === "Escape") {
111+
this.resultsVisible = false;
112+
}
113+
}
114+
115+
private _onMouseDown(event: MouseEvent) {
116+
const elementPath = event.composedPath();
117+
const popup = this.shadowRoot?.querySelector("sl-popup") as HTMLElement;
118+
119+
// Close the search results if the user clicks outside of the popup.
120+
// `composedPath` returns a list of all elements that the event will pass
121+
// through, so if the popup is not in the path, the user clicked outside.
122+
if (!elementPath.includes(popup)) {
123+
this.resultsVisible = false;
124+
}
125+
}
126+
127+
private _onOptionSelected() {
128+
this.resultsVisible = false;
129+
}
130+
}
131+
132+
@customElement("command-bar-result")
133+
class CommandBarResult extends LitElement {
134+
@property({ type: String })
135+
title = "";
136+
137+
@property({ type: String })
138+
subtitle = "";
139+
140+
@property({ type: Object })
141+
imageSource: Option.Option<Blob> = Option.none();
142+
143+
@property({ type: String })
144+
link = "";
145+
146+
@property({ type: Boolean })
147+
rounded = false;
148+
149+
static styles = css`
150+
a {
151+
display: flex;
152+
align-items: center;
153+
cursor: pointer;
154+
gap: 1rem;
155+
text-decoration: none;
156+
color: inherit;
157+
padding: 0.5rem;
158+
width: 100%;
159+
}
160+
161+
a:hover {
162+
background-color: var(--background-color);
163+
}
164+
165+
img {
166+
width: 3rem;
167+
height: 3rem;
168+
border-radius: 0.5rem;
169+
}
170+
171+
img.rounded {
172+
border-radius: 50%;
173+
}
174+
175+
.info {
176+
display: flex;
177+
flex-direction: column;
178+
}
179+
180+
.info > * {
181+
margin: 0;
21182
}
22183
`;
23184

24185
render() {
25-
return html`<input placeholder="Search or command (f)" />`;
186+
return html`
187+
<a href=${this.link}>
188+
${Option.isSome(this.imageSource) &&
189+
html`
190+
<img
191+
class="${this.rounded ? "rounded" : ""}"
192+
src="${URL.createObjectURL(this.imageSource.value)}"
193+
alt="${this.title}"
194+
/>
195+
`}
196+
<div class="info">
197+
<h4>${this.title}</h4>
198+
<p>${this.subtitle}</p>
199+
</div>
200+
</a>
201+
`;
26202
}
27203
}
28204

29205
declare global {
30206
interface HTMLElementTagNameMap {
31207
"command-bar": CommandBar;
208+
"command-bar-result": CommandBarResult;
32209
}
33210
}

packages/core/types/src/services/library.ts

+7
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,13 @@ export type ILibrary = {
5454
readonly albumDetail: (
5555
albumId: AlbumId,
5656
) => Effect.Effect<Option.Option<Album>, NonExistingArtistReferenced>;
57+
58+
/**
59+
* Searches for albums and artists that match the given term.
60+
*/
61+
readonly search: (
62+
term: string,
63+
) => Effect.Effect<[Album[], Artist[]], NonExistingArtistReferenced>;
5764
};
5865

5966
/**

packages/services/library/index.ts

+35
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,41 @@ export const LibraryLive = Layer.effect(
119119
Stream.catchAll(() => Stream.empty),
120120
);
121121
}),
122+
search: (term) =>
123+
Effect.gen(function* () {
124+
if (term.length === 0) {
125+
return [[], []];
126+
}
127+
128+
const albumsTable = yield* database.table("albums");
129+
const artistsTable = yield* database.table("artists");
130+
131+
const matchingAlbums = yield* albumsTable.filtered({
132+
filter: {
133+
name: term,
134+
},
135+
limit: 5,
136+
});
137+
138+
const matchingArtists = yield* artistsTable.filtered({
139+
filter: {
140+
name: term,
141+
},
142+
limit: 5,
143+
});
144+
145+
const resolvedAlbums = yield* resolveAllAlbums(
146+
matchingAlbums,
147+
artistsTable,
148+
);
149+
150+
const resolvedArtists = matchingArtists.map(toArtistSchema);
151+
152+
return [
153+
sortAlbumsByArtistName(resolvedAlbums),
154+
sortArtistsByName(resolvedArtists),
155+
];
156+
}),
122157
});
123158
}),
124159
);

0 commit comments

Comments
 (0)