diff --git a/static/css/chat.css b/static/css/chat.css index 1a8e934..c6c7b74 100644 --- a/static/css/chat.css +++ b/static/css/chat.css @@ -30,7 +30,7 @@ html, body { - font-family: "Lato", sans-serif; + font-family: "Work Sans", sans-serif; font-size: 15px; background: #181926; color: #cad3f5; @@ -284,6 +284,7 @@ body { padding-bottom: 30px; } +.dropdown, textarea, button, input, @@ -307,6 +308,8 @@ button { input, select { background: #363a4f; + padding: 2px 5px; + font-size: 14px; } #chat button { diff --git a/static/css/dropdown.css b/static/css/dropdown.css new file mode 100644 index 0000000..8db5fd4 --- /dev/null +++ b/static/css/dropdown.css @@ -0,0 +1,74 @@ +.dropdown { + position: relative; + user-select: none; + background: #363a4f; + padding: 2px 5px; + font-size: 14px; + white-space: nowrap; +} + +.dropdown .selected { + display: flex; + gap: 5px; + align-items: center; + cursor: pointer; +} + +.dropdown .selected::after { + content: ""; + display: block; + width: 16px; + height: 16px; + background-image: url(icons/chevron.svg); + background-position: center; + background-size: contain; + background-repeat: no-repeat; + transform: rotate(180deg); +} + +.dropdown .cont { + display: flex; + position: absolute; + bottom: calc(100% + 4px); + left: 0; + flex-direction: column; + background: #363a4f; + max-height: 250px; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05), 0 1px 3px rgba(0, 0, 0, 0.1); + border: 1px solid #494d64; +} + +.dropdown:not(.open) .cont { + display: none; +} + +.dropdown .opts { + display: flex; + flex-direction: column; + overflow-y: auto; +} + +.dropdown .opt { + padding: 4px 6px; + cursor: pointer; + background: #363a4f; +} + +.dropdown .opt:nth-child(2n+1) { + background: #32354a; +} + +.dropdown .opt:hover { + background: #2c2f44; +} + +.dropdown .opt.filtered { + display: none; +} + +.dropdown .search { + background: #2a2e41; + border-top: 2px solid #494d64; + font-size: 15px; + padding: 4px 6px; +} \ No newline at end of file diff --git a/static/css/markdown.css b/static/css/markdown.css index edd249f..6d34145 100644 --- a/static/css/markdown.css +++ b/static/css/markdown.css @@ -2,7 +2,6 @@ font-size: 15px; line-height: 23px; color: #CAD3F5; - font-family: system-ui, sans-serif; } .markdown h1, diff --git a/static/index.html b/static/index.html index c4f828b..567920e 100644 --- a/static/index.html +++ b/static/index.html @@ -6,9 +6,10 @@ - + + @@ -36,7 +37,7 @@
- +
diff --git a/static/js/chat.js b/static/js/chat.js index 6fe2243..5961872 100644 --- a/static/js/chat.js +++ b/static/js/chat.js @@ -585,5 +585,8 @@ scroll(!interacted); }); + dropdown($role); + dropdown($prompt); + loadModels().then(restore); })(); diff --git a/static/js/dropdown.js b/static/js/dropdown.js index a7666ae..059a882 100644 --- a/static/js/dropdown.js +++ b/static/js/dropdown.js @@ -3,17 +3,22 @@ #_select; #_dropdown; #_selected; + #_search; + #search = false; #selected = false; #options = []; constructor(el) { this.#_select = el; + this.#search = "searchable" in el.dataset; + this.#_select.querySelectorAll("option").forEach((option) => { this.#options.push({ value: option.value, - label: option.textContent.trim(), + label: option.textContent, + search: searchable(option.textContent), }); }); @@ -47,33 +52,72 @@ // dropdown this.#_dropdown = make("div", "dropdown"); - this.#_dropdown.addEventListener("click", () => { - this.#_dropdown.classList.add("open"); - }); - // selected item this.#_selected = make("div", "selected"); + this.#_selected.addEventListener("click", () => { + this.#_dropdown.classList.toggle("open"); + }); + this.#_dropdown.appendChild(this.#_selected); - // option wrapper - const _options = make("div", "options"); + // content + const _content = make("div", "cont"); - this.#_dropdown.appendChild(_options); + this.#_dropdown.appendChild(_content); + + // option wrapper + const _options = make("div", "opts"); + + _content.appendChild(_options); // options for (const option of this.#options) { - const _opt = make("div", "option"); + const _opt = make("div", "opt"); _opt.textContent = option.label; _opt.addEventListener("click", () => { this.#set(option.value); + + this.#_dropdown.classList.remove("open"); }); + _options.appendChild(_opt); + option.el = _opt; } + // live search (if enabled) + if (this.#search) { + this.#_search = make("input", "search"); + + this.#_search.type = "text"; + this.#_search.placeholder = "Search..."; + + this.#_search.addEventListener("input", () => { + this.#filter(); + }); + + this.#_search.addEventListener("keydown", (event) => { + if (event.key !== "Escape") { + return; + } + + if (this.#_search.value) { + this.#_search.value = ""; + + this.#_search.dispatchEvent(new Event("input")); + + return; + } + + this.#_dropdown.classList.remove("open"); + }); + + _content.appendChild(this.#_search); + } + // add to dom this.#_select.after(this.#_dropdown); @@ -92,9 +136,23 @@ this.#_selected.textContent = selection.label; } - #set(value) { - console.log("value", value); + #filter() { + if (!this.#_search) { + return; + } + const query = searchable(this.#_search.value); + + for (const option of this.#options) { + if (query && !option.search.includes(query)) { + option.el.classList.add("filtered"); + } else { + option.el.classList.remove("filtered"); + } + } + } + + #set(value) { const index = this.#options.findIndex((option) => option.value === value); if (this.#selected === index) { @@ -107,6 +165,16 @@ } } + function searchable(text) { + // lowercase + text = text.toLowerCase(); + + // only alpha-num + text = text.replace(/[^\w]/g, ""); + + return text.trim(); + } + document.body.addEventListener("click", (event) => { const clicked = event.target.closest(".dropdown"); diff --git a/static/js/lib.js b/static/js/lib.js index 3678546..8ed5626 100644 --- a/static/js/lib.js +++ b/static/js/lib.js @@ -1,105 +1,3 @@ -(() => { - const timeouts = new WeakMap(), - images = {}; - - marked.use({ - async: false, - breaks: false, - gfm: true, - pedantic: false, - - walkTokens: (token) => { - const { type, lang, text } = token; - - if (type !== "code") { - return; - } - - let code; - - if (lang && hljs.getLanguage(lang)) { - code = hljs.highlight(text, { - language: lang, - }); - } else { - code = hljs.highlightAuto(text); - } - - token.escaped = true; - token.text = code.value; - }, - - renderer: { - code(code) { - const header = `
${escapeHtml(code.lang)}
`; - - return `
${header}${code.text}
`; - }, - - image(image) { - const { href } = image; - - const id = `i_${btoa(href).replace(/=/g, "")}`, - style = prepareImage(id, href) || ""; - - return `
`; - }, - }, - }); - - function prepareImage(id, href) { - if (href in images) { - return images[href]; - } - - images[href] = false; - - const image = new Image(); - - image.addEventListener("load", () => { - const style = `aspect-ratio:${image.naturalWidth}/${image.naturalHeight};width:${image.naturalWidth}px;background-image:url(${href})`; - - images[href] = style; - - document.querySelectorAll(`.image.${id}`).forEach((img) => { - img.setAttribute("style", style); - }); - }); - - image.addEventListener("error", () => { - console.error(`Failed to load image: ${href}`); - }); - - image.src = href; - - return false; - } - - document.body.addEventListener("click", (event) => { - const button = event.target, - header = button.closest(".pre-header"), - pre = header?.closest("pre"), - code = pre?.querySelector("code"); - - if (!code) { - return; - } - - clearTimeout(timeouts.get(pre)); - - navigator.clipboard.writeText(code.textContent.trim()); - - button.classList.add("copied"); - - timeouts.set( - pre, - setTimeout(() => { - button.classList.remove("copied"); - }, 1000), - ); - }); -})(); - function storeValue(key, value) { if (!value) { localStorage.removeItem(key);