diff --git a/packages/components/command-bar/package.json b/packages/components/command-bar/package.json index bf0a95e..7846caa 100644 --- a/packages/components/command-bar/package.json +++ b/packages/components/command-bar/package.json @@ -8,10 +8,12 @@ }, "dependencies": { "@echo/components-shared-controllers": "^1.0.0", + "@echo/components-router": "^1.0.0", "@echo/core-types": "^1.0.0", "@echo/services-bootstrap-runtime": "^1.0.0", "effect": "^3.8.3", "lit": "^3.2.0", - "@lit/task": "^1.0.1" + "@lit/task": "^1.0.1", + "@shoelace-style/shoelace": "^2.18.0" } } \ No newline at end of file diff --git a/packages/components/command-bar/src/command-bar.ts b/packages/components/command-bar/src/command-bar.ts index c6c8948..c9e5d65 100644 --- a/packages/components/command-bar/src/command-bar.ts +++ b/packages/components/command-bar/src/command-bar.ts @@ -1,5 +1,9 @@ import { LitElement, css, html } from "lit"; -import { customElement } from "lit/decorators.js"; +import { customElement, property, state } from "lit/decorators.js"; +import "@shoelace-style/shoelace/dist/components/popup/popup"; +import { Library, type Album, type Artist } from "@echo/core-types"; +import { EffectFn } from "@echo/components-shared-controllers/src/effect-fn.controller"; +import { Option } from "effect"; /** * Component that displays a search bar that can search in the user's library @@ -7,27 +11,200 @@ import { customElement } from "lit/decorators.js"; */ @customElement("command-bar") export class CommandBar extends LitElement { + @state() + private resultsVisible = false; + + @state() + private searchResults: [Album[], Artist[]] = [[], []]; + + private previousSearchTimeout: NodeJS.Timeout | undefined; + + private search = new EffectFn(this, Library.search, { + complete: (results) => { + this.searchResults = results; + this.resultsVisible = true; + }, + }); + static styles = css` input { padding: 0.5rem; border: 1px solid var(--border-color); background-color: var(--background-color-muted); + color: var(--text-color); + font-family: "DepartureMono", monospace; font-size: 1rem; + outline: none; width: 95%; } - input::placeholder { - font-family: "DepartureMono", monospace; + input:focus { + border-color: var(--accent-color); + } + + div.search-results { + background-color: var(--background-color-muted); + } + `; + + connectedCallback(): void { + super.connectedCallback(); + + // Listen for the Escape key to close the search bar. + window.addEventListener("keydown", (event) => this._onKeyDown(event)); + window.addEventListener("mousedown", (event) => this._onMouseDown(event)); + } + + render() { + return html` + + + +
+ ${this.searchResults[0].map( + (album) => html` + + `, + )} + ${this.searchResults[1].map( + (artist) => html` + + `, + )} +
+
+ `; + } + + private _onQueryChanged(event: Event) { + const query = (event.target as HTMLInputElement).value; + + if (this.previousSearchTimeout) { + clearTimeout(this.previousSearchTimeout); + } + + this.previousSearchTimeout = setTimeout(() => { + this.search.run(query); + }, 200); + } + + private _onKeyDown(event: KeyboardEvent) { + if (event.key === "Escape") { + this.resultsVisible = false; + } + } + + private _onMouseDown(event: MouseEvent) { + const elementPath = event.composedPath(); + const popup = this.shadowRoot?.querySelector("sl-popup") as HTMLElement; + + // Close the search results if the user clicks outside of the popup. + // `composedPath` returns a list of all elements that the event will pass + // through, so if the popup is not in the path, the user clicked outside. + if (!elementPath.includes(popup)) { + this.resultsVisible = false; + } + } + + private _onOptionSelected() { + this.resultsVisible = false; + } +} + +@customElement("command-bar-result") +class CommandBarResult extends LitElement { + @property({ type: String }) + title = ""; + + @property({ type: String }) + subtitle = ""; + + @property({ type: Object }) + imageSource: Option.Option = Option.none(); + + @property({ type: String }) + link = ""; + + @property({ type: Boolean }) + rounded = false; + + static styles = css` + a { + display: flex; + align-items: center; + cursor: pointer; + gap: 1rem; + text-decoration: none; + color: inherit; + padding: 0.5rem; + width: 100%; + } + + a:hover { + background-color: var(--background-color); + } + + img { + width: 3rem; + height: 3rem; + border-radius: 0.5rem; + } + + img.rounded { + border-radius: 50%; + } + + .info { + display: flex; + flex-direction: column; + } + + .info > * { + margin: 0; } `; render() { - return html``; + return html` + + ${Option.isSome(this.imageSource) && + html` + ${this.title} + `} +
+

${this.title}

+

${this.subtitle}

+
+
+ `; } } declare global { interface HTMLElementTagNameMap { "command-bar": CommandBar; + "command-bar-result": CommandBarResult; } } diff --git a/packages/core/types/src/services/library.ts b/packages/core/types/src/services/library.ts index 8bd2128..3667a13 100644 --- a/packages/core/types/src/services/library.ts +++ b/packages/core/types/src/services/library.ts @@ -54,6 +54,13 @@ export type ILibrary = { readonly albumDetail: ( albumId: AlbumId, ) => Effect.Effect, NonExistingArtistReferenced>; + + /** + * Searches for albums and artists that match the given term. + */ + readonly search: ( + term: string, + ) => Effect.Effect<[Album[], Artist[]], NonExistingArtistReferenced>; }; /** diff --git a/packages/services/library/index.ts b/packages/services/library/index.ts index 645e50c..a8c9776 100644 --- a/packages/services/library/index.ts +++ b/packages/services/library/index.ts @@ -119,6 +119,41 @@ export const LibraryLive = Layer.effect( Stream.catchAll(() => Stream.empty), ); }), + search: (term) => + Effect.gen(function* () { + if (term.length === 0) { + return [[], []]; + } + + const albumsTable = yield* database.table("albums"); + const artistsTable = yield* database.table("artists"); + + const matchingAlbums = yield* albumsTable.filtered({ + filter: { + name: term, + }, + limit: 5, + }); + + const matchingArtists = yield* artistsTable.filtered({ + filter: { + name: term, + }, + limit: 5, + }); + + const resolvedAlbums = yield* resolveAllAlbums( + matchingAlbums, + artistsTable, + ); + + const resolvedArtists = matchingArtists.map(toArtistSchema); + + return [ + sortAlbumsByArtistName(resolvedAlbums), + sortArtistsByName(resolvedArtists), + ]; + }), }); }), );