diff --git a/frontend/src/components/input.ts b/frontend/src/components/input.ts index 44e078e..8323094 100644 --- a/frontend/src/components/input.ts +++ b/frontend/src/components/input.ts @@ -26,6 +26,10 @@ export class TesoftInput extends TesoftComponent { this.input.focus(); } + get value(): string { + return this.input.value; + } + set value(value: string) { this.input.value = value; } diff --git a/frontend/src/components/nav.njk b/frontend/src/components/nav.njk index 266e3aa..4504575 100644 --- a/frontend/src/components/nav.njk +++ b/frontend/src/components/nav.njk @@ -78,6 +78,7 @@ } dialog.search > div { + color: var(--light-gray); display: grid; gap: var(--medium-padding); grid-template-columns: 1fr; diff --git a/frontend/src/components/nav.ts b/frontend/src/components/nav.ts index 23fe09d..b599a72 100644 --- a/frontend/src/components/nav.ts +++ b/frontend/src/components/nav.ts @@ -1,4 +1,4 @@ -import {TesoftComponent} from "../scripts/main.ts"; +import {Debouncer, sendApiPost, TesoftComponent} from "../scripts/main.ts"; import {TesoftButton} from "./button.ts"; import {TesoftInput} from "./input.ts"; @@ -10,6 +10,9 @@ export class TesoftNav extends TesoftComponent { private readonly searchButton: TesoftButton; private readonly searchDialog: HTMLDialogElement; private readonly searchInput: TesoftInput; + private readonly searchResultsDiv: HTMLDivElement; + private readonly debouncedSearch: Debouncer<[string, boolean]>; + private readonly commands: Map Promise>; constructor() { super(); @@ -27,6 +30,12 @@ export class TesoftNav extends TesoftComponent { this.searchButton = shadowRoot.querySelector("tesoft-button.search")!; this.searchDialog = shadowRoot.querySelector("dialog.search")!; this.searchInput = shadowRoot.querySelector("dialog.search tesoft-input")!; + this.searchResultsDiv = shadowRoot.querySelector("dialog.search .results")!; + this.debouncedSearch = new Debouncer(this.applySearch.bind(this)); + this.commands = new Map Promise>([ + ["login", this.login.bind(this)], + ["logout", this.logout.bind(this)], + ]); this.smallMenuButton.addEventListener("click", () => { this.updateSmallMenu(); @@ -36,14 +45,21 @@ export class TesoftNav extends TesoftComponent { this.smallMenuDialog.style.width = `${this.smallMenuButton.offsetWidth}px`; }); - this.searchButton.addEventListener("click", () => { + this.searchButton.addEventListener("click", async () => { + await this.applySearch("", false); this.searchDialog.showModal(); this.searchInput.value = ""; this.searchInput.focus(); }); this.searchInput.addEventListener("input", () => { - // TODO + this.debouncedSearch.run(this.searchInput.value, false); + }); + + this.searchInput.addEventListener("keyup", async (event) => { + if (event.key === "Enter") { + await this.applySearch(this.searchInput.value, true); + } }); shadowRoot.addEventListener("slotchange", () => { @@ -68,6 +84,71 @@ export class TesoftNav extends TesoftComponent { } } } + + private async applySearch(text: string, confirmed: boolean) { + if (confirmed) { + this.debouncedSearch.stop(); + } + + this.setResultText(""); + + if (text.startsWith(">")) { + if (confirmed) { + await this.applyCommand(text.substring(1).trimStart()); + } else { + this.setResultText("Press enter to send command."); + } + } else { + console.log(text); + // TODO + } + } + + private async applyCommand(text: string) { + const spaceIndex = text.indexOf(" "); + const prefix = spaceIndex === -1 ? text : text.substring(0, spaceIndex); + + const command = this.commands.get(prefix); + if (command === undefined) { + this.setResultText("Unknown command."); + return; + } + await command(text.substring(prefix.length).trimStart()); + } + + private async login(text: string) { + const response = await sendApiPost("login", { + password: text, + }); + + if (response.ok) { + this.setResultText("Login successful."); + } else { + const body = await response.json(); + this.setResultText(`Login failed: ${body.message ?? body}`); + } + } + + private async logout(_text: string) { + const response = await sendApiPost("logout", {}); + + if (response.ok) { + this.setResultText("Logout successful."); + } else { + const body = await response.json(); + this.setResultText(`Logout failed: ${body.message ?? body}`); + } + } + + private setResultText(text: string) { + this.searchResultsDiv.innerHTML = ""; + + if (text) { + const div = document.createElement("div"); + div.textContent = text; + this.searchResultsDiv.appendChild(div); + } + } } customElements.define("tesoft-nav", TesoftNav); diff --git a/frontend/src/scripts/main.ts b/frontend/src/scripts/main.ts index 45f6c22..81d858a 100644 --- a/frontend/src/scripts/main.ts +++ b/frontend/src/scripts/main.ts @@ -83,3 +83,45 @@ export function onReady(callback: () => void, ...components: TesoftComponent[]) domLoaded(); } } + +export class Debouncer { + private timer: number | null; + private readonly apply: (...args: T) => void; + + constructor(apply: (...args: T) => void, timeoutMs: number = 300) { + this.timer = null; + this.apply = (...args: T) => { + if (this.timer !== null) { + clearTimeout(this.timer); + this.timer = null; + } + + this.timer = setTimeout(() => { + apply(...args); + }, timeoutMs); + }; + } + + run(...args: T) { + this.apply(...args); + } + + stop() { + if (this.timer !== null) { + clearTimeout(this.timer); + this.timer = null; + } + } +} + +export function sendApiPost(path: string, data: Record): Promise { + const url = new URL(`/api/${path}`, location.origin); + + return fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(data), + }); +}