From a929d7674a7f5411e1b7659828769d02e8be27f1 Mon Sep 17 00:00:00 2001 From: Netanel Draiman Date: Fri, 17 Oct 2025 18:35:19 +0300 Subject: [PATCH] feat(web): add provider filtering functionality Implement interactive provider filter with search capability allowing users to filter models by provider. The filter includes a dropdown popover with searchable provider list, checkbox selection, and URL parameter persistence. Filter state is preserved in URL query params and synchronized with the search functionality. fix: adjust button placement and formatting in the provider filter section fix: adjust provider popover positioning to align with button fix: change provider popover position from absolute to fixed feat(web): add provider reset button and enhance provider filtering functionality fix: clear provider search input when closing the popover refactor(web): optimize provider filter performance and reduce code duplication - Unified filter logic by integrating provider filtering into filterTable function - Eliminated duplicate search filter code in filterByProviders (reduced from ~30 to 9 lines) - Cached DOM queries (providerCountSpan) and provider values (allProviderValues) for better performance - Optimized filter flow with early returns to avoid unnecessary operations - Reduced table iterations from 2 to 1 when both filters are active - Improved algorithmic efficiency by checking provider filter first (fast Set lookup) style: clean up provider filter styles and improve hover effects fix: improve provider popover closing logic to handle nested clicks style: optimize provider list and checkbox flex properties for better layout fix: adjust provider popover positioning to use fixed layout relative to viewport refactor: remove redundant comments Reverted css formatting fix: prevent direct checkbox interaction to ensure consistent provider selection behavior Clicking directly on the checkbox vs the label was causing inconsistent selection logic due to event handling order. By disabling pointer events on checkboxes, all clicks now flow through the label handler consistently. --- packages/web/src/index.css | 148 ++++++++++++++++++++++++++++- packages/web/src/index.ts | 180 +++++++++++++++++++++++++++++++++++- packages/web/src/render.tsx | 41 ++++++++ 3 files changed, 365 insertions(+), 4 deletions(-) diff --git a/packages/web/src/index.css b/packages/web/src/index.css index 58a4a5c4..db484f8b 100644 --- a/packages/web/src/index.css +++ b/packages/web/src/index.css @@ -546,4 +546,150 @@ dialog { } } -} \ No newline at end of file +} + +/* Provider Filter */ +.provider-filter-button { + background-color: var(--color-background); + color: var(--color-text); + border: 1px solid var(--color-border); + display: flex; + align-items: center; + gap: 0.375rem; + + &:hover { + background-color: var(--color-surface); + } + + #provider-count { + color: var(--color-text-tertiary); + } +} + +.provider-popover { + position: fixed; + z-index: 100; + background-color: var(--color-background); + border: 1px solid var(--color-border); + border-radius: 0.375rem; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05), 0 4px 8px rgba(0, 0, 0, 0.05), + 0 8px 16px rgba(0, 0, 0, 0.07), 0 16px 32px rgba(0, 0, 0, 0.07); + width: 20rem; + max-height: 28rem; + overflow: hidden; +} + +.provider-popover-content { + display: flex; + flex-direction: column; + max-height: 28rem; +} + +.provider-search-container { + padding: 0.75rem; + border-bottom: 1px solid var(--color-border); + flex: 0 0 auto; + display: flex; + gap: 0.5rem; + align-items: center; + + input { + flex: 1 1 auto; + font-size: 0.8125rem; + line-height: 1.1; + padding: 0.5rem 0.625rem; + border-radius: 0.25rem; + border: 1px solid var(--color-border); + height: 2rem; + background: none; + color: var(--color-text); + + &:focus { + border-color: var(--color-brand); + outline: none; + } + } +} + +.provider-reset-button { + flex: 0 0 auto; + cursor: pointer; + border: 1px solid var(--color-border); + background-color: var(--color-background); + color: var(--color-text); + font-size: 0.8125rem; + line-height: 1.1; + height: 2rem; + padding: 0.5rem 0.75rem; + border-radius: 0.25rem; + white-space: nowrap; + + &:hover:not(:disabled) { + background-color: var(--color-surface); + } + + &:disabled { + cursor: not-allowed; + opacity: 0.5; + color: var(--color-text-tertiary); + } +} + +.provider-list { + flex: 1; + overflow-y: auto; + padding: 0.375rem; + overscroll-behavior: contain; + + &::-webkit-scrollbar { + width: 8px; + } + + &::-webkit-scrollbar-thumb { + background-color: var(--color-border); + border-radius: 4px; + } +} + +.provider-item { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.625rem; + cursor: pointer; + border-radius: 0.25rem; + user-select: none; + + &:hover { + background-color: var(--color-surface); + } +} + +.provider-checkbox { + flex-shrink: 0; + width: 1rem; + height: 1rem; + cursor: pointer; + accent-color: var(--color-brand); + pointer-events: none; +} + +.provider-icon { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + width: 1rem; + height: 1rem; + + svg { + width: 100%; + height: 100%; + color: var(--color-text-secondary); + } +} + +.provider-name { + flex: 1; + font-size: 0.875rem; +} diff --git a/packages/web/src/index.ts b/packages/web/src/index.ts index 393ee535..338fced1 100644 --- a/packages/web/src/index.ts +++ b/packages/web/src/index.ts @@ -143,21 +143,60 @@ document.querySelectorAll("th.sortable").forEach((header) => { }); }); +/////////////////////////////// +// Handle Provider Filter +/////////////////////////////// +const providerFilterButton = document.getElementById("provider-filter")!; +const providerPopover = document.getElementById("provider-popover")!; +const providerSearch = document.getElementById( + "provider-search" +)! as HTMLInputElement; +const providerResetButton = document.getElementById( + "provider-reset" +)! as HTMLButtonElement; +const providerCheckboxes = document.querySelectorAll( + ".provider-checkbox" +) as NodeListOf; +const providerItems = document.querySelectorAll( + ".provider-item" +) as NodeListOf; +const providerCountSpan = document.getElementById("provider-count")!; + +const allProviderValues = Array.from(providerCheckboxes).map((cb) => cb.value); +let selectedProviders = new Set(allProviderValues); + /////////////////// // Handle Search /////////////////// function filterTable(value: string) { - const lowerCaseValues = value.toLowerCase().split(",").filter(str => str.trim() !== ""); + const lowerCaseValues = value + .toLowerCase() + .split(",") + .filter((str) => str.trim() !== ""); const rows = document.querySelectorAll( "table tbody tr" ) as NodeListOf; rows.forEach((row) => { + const providerId = row.cells[2].textContent?.trim() || ""; + const isProviderSelected = selectedProviders.has(providerId); + + if (!isProviderSelected) { + row.style.display = "none"; + return; + } + + if (lowerCaseValues.length === 0) { + row.style.display = ""; + return; + } + const cellTexts = Array.from(row.cells).map((cell) => cell.textContent!.toLowerCase() ); - const isVisible = lowerCaseValues.length === 0 || - lowerCaseValues.some((lowerCaseValue) => cellTexts.some((text) => text.includes(lowerCaseValue))); + const isVisible = lowerCaseValues.some((lowerCaseValue) => + cellTexts.some((text) => text.includes(lowerCaseValue)) + ); row.style.display = isVisible ? "" : "none"; }); @@ -182,6 +221,124 @@ search.addEventListener("keydown", (e) => { } }); +function updateProviderCount() { + const totalProviders = providerCheckboxes.length; + const selectedCount = selectedProviders.size; + providerCountSpan.textContent = `${selectedCount}/${totalProviders}`; + providerResetButton.disabled = selectedCount === totalProviders; +} + +function filterByProviders() { + filterTable(search.value); + updateProviderCount(); + updateQueryParams({ + providers: + selectedProviders.size === providerCheckboxes.length + ? null + : Array.from(selectedProviders).sort().join(","), + }); +} + +function togglePopover() { + const isVisible = providerPopover.style.display !== "none"; + providerPopover.style.display = isVisible ? "none" : "block"; + + if (!isVisible) { + const buttonRect = providerFilterButton.getBoundingClientRect(); + providerPopover.style.top = `${buttonRect.bottom + 4}px`; + providerPopover.style.left = `${ + buttonRect.right - providerPopover.offsetWidth + }px`; + } +} + +function closePopover() { + providerPopover.style.display = "none"; + + providerSearch.value = ""; + filterProviderList(""); +} + +function filterProviderList(searchValue: string) { + const searchLower = searchValue.toLowerCase(); + providerItems.forEach((item) => { + const providerName = item.getAttribute("data-provider-name") || ""; + if (providerName.includes(searchLower)) { + item.style.display = ""; + } else { + item.style.display = "none"; + } + }); +} + +providerFilterButton.addEventListener("click", (e) => { + e.stopPropagation(); + togglePopover(); +}); + +document.addEventListener("click", (e) => { + if ( + !providerPopover.contains(e.target as Node) && + !providerFilterButton.contains(e.target as Node) + ) { + closePopover(); + } +}); + +providerSearch.addEventListener("input", () => { + filterProviderList(providerSearch.value); +}); + +providerResetButton.addEventListener("click", (e) => { + e.stopPropagation(); + + selectedProviders = new Set(allProviderValues); + providerCheckboxes.forEach((cb) => { + cb.checked = true; + }); + + providerSearch.value = ""; + filterProviderList(""); + + filterByProviders(); +}); + +providerItems.forEach((item) => { + item.addEventListener("click", (e) => { + e.preventDefault(); + + const checkbox = item.querySelector( + ".provider-checkbox" + ) as HTMLInputElement; + const providerId = checkbox.value; + const wasChecked = checkbox.checked; + const allSelected = selectedProviders.size === providerCheckboxes.length; + + if (allSelected) { + selectedProviders.clear(); + selectedProviders.add(providerId); + providerCheckboxes.forEach((cb) => { + cb.checked = cb.value === providerId; + }); + } else if (wasChecked) { + if (selectedProviders.size === 1) { + selectedProviders = new Set(allProviderValues); + providerCheckboxes.forEach((cb) => { + cb.checked = true; + }); + } else { + selectedProviders.delete(providerId); + checkbox.checked = false; + } + } else { + selectedProviders.add(providerId); + checkbox.checked = true; + } + + filterByProviders(); + }); +}); + /////////////////////////////////// // Handle Copy model ID function /////////////////////////////////// @@ -217,6 +374,19 @@ search.addEventListener("keydown", (e) => { function initializeFromURL() { const params = getQueryParams(); + (() => { + const providersParam = params.get("providers"); + if (providersParam) { + const providerIds = providersParam.split(","); + selectedProviders = new Set(providerIds); + + providerCheckboxes.forEach((cb) => { + cb.checked = selectedProviders.has(cb.value); + }); + } + updateProviderCount(); + })(); + (() => { const searchQuery = params.get("search"); if (!searchQuery) return; @@ -234,6 +404,10 @@ function initializeFromURL() { const direction = (params.get("order") as "asc" | "desc") || "asc"; sortTable(columnIndex, direction); })(); + + if (selectedProviders.size < providerCheckboxes.length) { + filterByProviders(); + } } document.addEventListener("DOMContentLoaded", initializeFromURL); diff --git a/packages/web/src/render.tsx b/packages/web/src/render.tsx index 41e1d461..defa629a 100644 --- a/packages/web/src/render.tsx +++ b/packages/web/src/render.tsx @@ -210,9 +210,50 @@ export const Rendered = renderToString( ⌘K + +