From dae281efee15981be3c6d93a2ea7804e58fa8fb0 Mon Sep 17 00:00:00 2001 From: Dave Gallant Date: Mon, 16 Feb 2026 12:31:56 -0500 Subject: [PATCH] Add support for multiple filters --- backend/app.go | 39 +++++++++++++++++- src/App.vue | 105 ++++++++++++++++++++++++++++++++++++++----------- src/theme.css | 72 +++++++++++++++++++++++++++++++++ 3 files changed, 192 insertions(+), 24 deletions(-) diff --git a/backend/app.go b/backend/app.go index 09eefa1..79391e8 100644 --- a/backend/app.go +++ b/backend/app.go @@ -82,10 +82,47 @@ func respondWithJSON(w http.ResponseWriter, code int, payload interface{}) { // @Summary Lists all topics stored in the database // @Description All topics will be listed. There is currently no pagination implemented. // @ID list-topics +// @Param filters query string false "JSON array of filter strings" // @Router /topics [get] // @Success 200 {array} Topic func (a *App) listTopics(w http.ResponseWriter, r *http.Request) { - respondWithJSON(w, http.StatusOK, a.CurrentTopics) + filtersParam := r.URL.Query().Get("filters") + + if filtersParam == "" { + respondWithJSON(w, http.StatusOK, a.CurrentTopics) + return + } + + var filters []string + err := json.Unmarshal([]byte(filtersParam), &filters) + if err != nil { + log.Warn().Msgf("could not parse filters parameter: %s", err) + respondWithJSON(w, http.StatusOK, a.CurrentTopics) + return + } + + if len(filters) == 0 { + respondWithJSON(w, http.StatusOK, a.CurrentTopics) + return + } + + // Filter topics + var filteredTopics []Topic + for _, topic := range a.CurrentTopics { + searchText := strings.ToLower(topic.Title + " [" + topic.Offer.DealerName + "]") + matchesAll := true + for _, filter := range filters { + if !strings.Contains(searchText, strings.ToLower(filter)) { + matchesAll = false + break + } + } + if matchesAll { + filteredTopics = append(filteredTopics, topic) + } + } + + respondWithJSON(w, http.StatusOK, filteredTopics) } // getTopicDetails godoc diff --git a/src/App.vue b/src/App.vue index 9a0274c..9cf6d8e 100644 --- a/src/App.vue +++ b/src/App.vue @@ -54,7 +54,8 @@ function hashString(str) { export default { data() { return { - filter: decodeURIComponent(window.location.href.split("filter=")[1] || ""), + filterInput: "", + activeFilters: this.parseFiltersFromUrl(), sortMethod: "score", topics: [], isMobile: false, @@ -85,11 +86,12 @@ export default { computed: { filteredTopics() { - const filterTerm = this.filter.toLowerCase(); + const filterTerms = this.activeFilters.map(f => f.toLowerCase()); const filtered = this.topics.filter((row) => { + if (filterTerms.length === 0) return true; const searchText = `${row.title} [${row.Offer.dealer_name}]`.toLowerCase(); - return searchText.includes(filterTerm); + return filterTerms.every(term => searchText.includes(term)); }); const sortFns = { @@ -136,15 +138,19 @@ export default { }, highlightText(text) { - if (!this.filter) return text; + if (!this.activeFilters || this.activeFilters.length === 0) return text; - const lowerText = text.toLowerCase(); - const lowerFilter = this.filter.toLowerCase(); + let result = text; + for (const filter of this.activeFilters) { + const lowerText = result.toLowerCase(); + const lowerFilter = filter.toLowerCase(); - if (!lowerText.includes(lowerFilter)) return text; - - const regex = new RegExp(this.filter, "ig"); - return text.replace(regex, (match) => `${match}`); + if (lowerText.includes(lowerFilter)) { + const regex = new RegExp(filter.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), "ig"); + result = result.replace(regex, (match) => `${match}`); + } + } + return result; }, initializeTheme() { @@ -211,13 +217,58 @@ export default { if (event.key === "/" && !isInput) { event.preventDefault(); - this.$refs.filter.focus(); + this.$refs.filterInput.focus(); } }, - createFilterRoute(params) { - this.$refs.filter.blur(); - history.pushState({}, null, `${window.location.origin}#/filter=${encodeURIComponent(params)}`); + parseFiltersFromUrl() { + const hash = window.location.hash || ""; + const match = hash.match(/filters=([^&]*)/); + if (match && match[1]) { + try { + const decoded = decodeURIComponent(match[1]); + return JSON.parse(decoded); + } catch (e) { + return []; + } + } + // Legacy single filter support + const legacyMatch = hash.match(/filter=([^&]*)/); + if (legacyMatch && legacyMatch[1]) { + const decoded = decodeURIComponent(legacyMatch[1]); + return decoded ? [decoded] : []; + } + return []; + }, + + updateUrlWithFilters() { + if (this.activeFilters.length > 0) { + const encoded = encodeURIComponent(JSON.stringify(this.activeFilters)); + history.pushState({}, null, `${window.location.origin}#/filters=${encoded}`); + } else { + history.pushState({}, null, window.location.origin); + } + }, + + applyFilter() { + const trimmed = this.filterInput.trim(); + if (trimmed && !this.activeFilters.includes(trimmed)) { + this.activeFilters.push(trimmed); + this.filterInput = ""; + this.$refs.filterInput.blur(); + this.updateUrlWithFilters(); + } + }, + + clearFilter(index) { + this.activeFilters.splice(index, 1); + this.updateUrlWithFilters(); + }, + + clearAllFilters() { + this.activeFilters = []; + this.filterInput = ""; + this.updateUrlWithFilters(); }, fetchDeals() { @@ -280,15 +331,23 @@ export default {
- +
+ + {{ filter }} + + + +
diff --git a/src/theme.css b/src/theme.css index fbf7998..48c04bc 100644 --- a/src/theme.css +++ b/src/theme.css @@ -178,6 +178,78 @@ a:visited { color: var(--text-secondary); } +/* ============================================ + Filter Container & Tags + ============================================ */ + +.filter-container { + flex: 1; + max-width: 500px; + display: flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + border: 1px solid var(--border-color-light); + border-radius: 4px; + background-color: var(--bg-input); + transition: all 0.2s ease; +} + +.filter-container:focus-within { + border-color: var(--border-color-hover); + background-color: var(--bg-input-focus); + box-shadow: 0 0 0 2px var(--shadow-light); +} + +.filter-container .search-input { + flex: 1; + border: none; + padding: 4px 0; + background: transparent; + box-shadow: none; +} + +.filter-container .search-input:focus { + outline: none; + box-shadow: none; +} + +.filter-tag { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 6px 2px 8px; + background-color: var(--border-color-light); + border-radius: 4px; + font-size: 12px; + font-weight: 500; + color: var(--text-primary); + flex-shrink: 0; +} + +.filter-tag-clear { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + padding: 0; + border: none; + background-color: var(--text-secondary); + border-radius: 50%; + cursor: pointer; + transition: all 0.2s ease; + color: var(--bg-input); +} + +.filter-tag-clear:hover { + background-color: var(--text-primary); +} + +.filter-tag-clear .material-symbols-outlined { + font-size: 12px; +} + /* ============================================ Icon Buttons (Theme & Sort Toggle) ============================================ */