Add filters

This commit is contained in:
Marcus Kida 2026-02-11 09:34:50 +01:00
parent a636ecb6ad
commit 409b8fbcee
3 changed files with 281 additions and 4 deletions

View file

@ -6,6 +6,7 @@ import (
"fmt"
"log"
"net/http"
"net/url"
"strconv"
"strings"
"time"
@ -160,8 +161,9 @@ func handleAdminRepeaters(db *sql.DB) http.HandlerFunc {
}
search := strings.TrimSpace(q.Get("q"))
filters, filterMode := parseAdminFilters(q)
repeaters, total, err := queryAdminRepeaters(db, search, page, perPage)
repeaters, total, err := queryAdminRepeaters(db, search, page, perPage, filters, filterMode)
if err != nil {
http.Error(w, `{"error":"database query failed"}`, http.StatusInternalServerError)
return
@ -176,3 +178,17 @@ func handleAdminRepeaters(db *sql.DB) http.HandlerFunc {
})
}
}
func parseAdminFilters(q url.Values) ([]Filter, string) {
mode := q.Get("filter_mode")
if mode != "or" {
mode = "and"
}
raw := q.Get("filters")
if raw == "" {
return nil, mode
}
var filters []Filter
json.Unmarshal([]byte(raw), &filters)
return filters, mode
}

51
db.go
View file

@ -124,6 +124,12 @@ type Repeater struct {
LastPolled *time.Time `json:"last_polled"`
}
type Filter struct {
Field string `json:"field"`
Op string `json:"op"`
Value string `json:"value"`
}
func openDB(dsn string) (*sql.DB, error) {
var db *sql.DB
var err error
@ -336,7 +342,7 @@ func queryRepeatersInRadius(db *sql.DB, lat, lng, radiusKm float64, band string,
}
// Admin query: paginated list of all repeaters with optional search.
func queryAdminRepeaters(db *sql.DB, search string, page, perPage int) ([]Repeater, int, error) {
func queryAdminRepeaters(db *sql.DB, search string, page, perPage int, filters []Filter, filterMode string) ([]Repeater, int, error) {
paramIdx := 0
nextParam := func() string {
paramIdx++
@ -347,16 +353,55 @@ func queryAdminRepeaters(db *sql.DB, search string, page, perPage int) ([]Repeat
var args []interface{}
if search != "" {
p := nextParam()
where = " WHERE callsign ILIKE " + p +
where = " WHERE (callsign ILIKE " + p +
" OR city ILIKE " + p +
" OR state ILIKE " + p +
" OR country ILIKE " + p +
" OR array_to_string(networks, ',') ILIKE " + p +
" OR id::text = " + nextParam()
" OR id::text = " + nextParam() + ")"
pattern := "%" + search + "%"
args = append(args, pattern, search)
}
if len(filters) > 0 {
var clauses []string
for _, f := range filters {
switch f.Field {
case "country":
switch f.Op {
case "eq":
clauses = append(clauses, "country = "+nextParam())
args = append(args, f.Value)
case "neq":
clauses = append(clauses, "country != "+nextParam())
args = append(args, f.Value)
case "contains":
clauses = append(clauses, "country ILIKE "+nextParam())
args = append(args, "%"+f.Value+"%")
}
case "bm_status":
switch f.Op {
case "empty":
clauses = append(clauses, "bm_status IS NULL")
case "not_empty":
clauses = append(clauses, "bm_status IS NOT NULL")
}
}
}
if len(clauses) > 0 {
joiner := " AND "
if filterMode == "or" {
joiner = " OR "
}
filterExpr := "(" + strings.Join(clauses, joiner) + ")"
if where == "" {
where = " WHERE " + filterExpr
} else {
where += " AND " + filterExpr
}
}
}
var total int
err := db.QueryRow("SELECT COUNT(*) FROM repeaters"+where, args...).Scan(&total)
if err != nil {

View file

@ -451,6 +451,90 @@
font-size: 13px; cursor: pointer; color: var(--text);
}
.multicheck-label input[type="checkbox"] { width: 16px; height: 16px; cursor: pointer; }
.topbar-btn {
padding: 6px 12px;
border: 1px solid var(--border);
border-radius: 6px;
font-size: 12px;
font-weight: 600;
color: var(--text-muted);
background: var(--bg);
cursor: pointer;
white-space: nowrap;
}
.topbar-btn:hover { background: var(--hover-bg); }
.topbar-btn.active { background: var(--tag-blue-bg); color: var(--tag-blue-text); border-color: var(--tag-blue-text); }
.filter-bar {
background: var(--bg);
border-bottom: 1px solid var(--border-light);
padding: 10px 24px;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
}
.filter-mode-toggle { display: flex; gap: 0; }
.filter-mode-btn {
padding: 4px 10px;
border: 1px solid var(--border);
font-size: 11px;
font-weight: 700;
color: var(--text-muted);
background: var(--bg);
cursor: pointer;
}
.filter-mode-btn:first-child { border-radius: 4px 0 0 4px; }
.filter-mode-btn:last-child { border-radius: 0 4px 4px 0; border-left: none; }
.filter-mode-btn.active { background: var(--tag-blue-bg); color: var(--tag-blue-text); border-color: var(--tag-blue-text); }
.filter-list { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
.filter-row {
display: inline-flex;
align-items: center;
gap: 4px;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 6px;
padding: 4px 6px;
}
.filter-select, .filter-input {
padding: 4px 8px;
border: 1px solid var(--border);
border-radius: 4px;
font-size: 12px;
background: var(--input-bg);
color: var(--text);
outline: none;
}
.filter-select:focus, .filter-input:focus { border-color: var(--accent); }
.filter-input { width: 120px; }
.filter-remove-btn {
background: none;
border: none;
font-size: 16px;
color: var(--text-faint);
cursor: pointer;
padding: 0 4px;
line-height: 1;
}
.filter-remove-btn:hover { color: var(--danger); }
.filter-add-btn {
padding: 4px 10px;
border: 1px dashed var(--border);
border-radius: 6px;
font-size: 12px;
color: var(--text-faint);
background: none;
cursor: pointer;
}
.filter-add-btn:hover { border-color: var(--text-muted); color: var(--text-muted); }
</style>
</head>
<body>
@ -468,9 +552,18 @@
<div class="topbar">
<h1>DMRmap Admin</h1>
<input type="text" class="search-input" id="search-input" placeholder="Search callsign, city, country, network..." />
<button class="topbar-btn" id="toggle-filters-btn">Filters</button>
<span class="topbar-info" id="total-info"></span>
<button class="logout-btn" id="logout-btn">Logout</button>
</div>
<div class="filter-bar" id="filter-bar" style="display:none">
<div class="filter-mode-toggle">
<button class="filter-mode-btn active" data-mode="and">AND</button>
<button class="filter-mode-btn" data-mode="or">OR</button>
</div>
<div id="filter-list" class="filter-list"></div>
<button class="filter-add-btn" id="add-filter-btn">+ Add filter</button>
</div>
<div class="table-wrap">
<table>
<thead>
@ -548,6 +641,16 @@
var nextBtn = document.getElementById("next-btn");
var pageInfo = document.getElementById("page-info");
var toggleFiltersBtn = document.getElementById("toggle-filters-btn");
var filterBar = document.getElementById("filter-bar");
var filterList = document.getElementById("filter-list");
var addFilterBtn = document.getElementById("add-filter-btn");
var filterDefs = {
country: { label: "Country", ops: [{v:"eq",l:"is"},{v:"neq",l:"is not"},{v:"contains",l:"contains"}], hasValue: true },
bm_status: { label: "BM Status", ops: [{v:"empty",l:"is empty"},{v:"not_empty",l:"is not empty"}], hasValue: false }
};
var detailOverlay = document.getElementById("detail-overlay");
var detailPanel = document.getElementById("detail-panel");
var detailTitle = document.getElementById("detail-title");
@ -597,6 +700,113 @@
});
}
function addFilterRow() {
var row = document.createElement("div");
row.className = "filter-row";
var fieldKeys = Object.keys(filterDefs);
var fieldSel = document.createElement("select");
fieldSel.className = "filter-select";
for (var i = 0; i < fieldKeys.length; i++) {
var opt = document.createElement("option");
opt.value = fieldKeys[i];
opt.textContent = filterDefs[fieldKeys[i]].label;
fieldSel.appendChild(opt);
}
var opSel = document.createElement("select");
opSel.className = "filter-select";
var valInput = document.createElement("input");
valInput.type = "text";
valInput.className = "filter-input";
valInput.placeholder = "Value...";
var removeBtn = document.createElement("button");
removeBtn.className = "filter-remove-btn";
removeBtn.innerHTML = "&times;";
function updateOps() {
var def = filterDefs[fieldSel.value];
opSel.innerHTML = "";
for (var i = 0; i < def.ops.length; i++) {
var opt = document.createElement("option");
opt.value = def.ops[i].v;
opt.textContent = def.ops[i].l;
opSel.appendChild(opt);
}
valInput.style.display = def.hasValue ? "" : "none";
valInput.value = "";
}
updateOps();
fieldSel.addEventListener("change", function () {
updateOps();
onFilterChange();
});
opSel.addEventListener("change", onFilterChange);
valInput.addEventListener("input", function () {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(onFilterChange, 300);
});
removeBtn.addEventListener("click", function () {
row.remove();
onFilterChange();
});
row.appendChild(fieldSel);
row.appendChild(opSel);
row.appendChild(valInput);
row.appendChild(removeBtn);
filterList.appendChild(row);
}
function getActiveFilters() {
var rows = filterList.querySelectorAll(".filter-row");
if (!rows.length) return null;
var filters = [];
for (var i = 0; i < rows.length; i++) {
var fieldSel = rows[i].querySelector("select:first-child");
var opSel = rows[i].querySelectorAll("select")[1];
var valInput = rows[i].querySelector(".filter-input");
var def = filterDefs[fieldSel.value];
if (def.hasValue && !valInput.value.trim()) continue;
filters.push({
field: fieldSel.value,
op: opSel.value,
value: def.hasValue ? valInput.value.trim() : ""
});
}
if (!filters.length) return null;
var modeBtn = document.querySelector(".filter-mode-btn.active");
var mode = modeBtn ? modeBtn.getAttribute("data-mode") : "and";
return { filters: filters, mode: mode };
}
function onFilterChange() {
currentPage = 1;
fetchRepeaters();
}
toggleFiltersBtn.addEventListener("click", function () {
var visible = filterBar.style.display !== "none";
filterBar.style.display = visible ? "none" : "flex";
toggleFiltersBtn.classList.toggle("active", !visible);
});
addFilterBtn.addEventListener("click", function () {
addFilterRow();
});
document.querySelectorAll(".filter-mode-btn").forEach(function (btn) {
btn.addEventListener("click", function () {
document.querySelectorAll(".filter-mode-btn").forEach(function (b) { b.classList.remove("active"); });
btn.classList.add("active");
onFilterChange();
});
});
function fetchRepeaters() {
loadingEl.style.display = "";
emptyState.style.display = "none";
@ -609,6 +819,12 @@
});
if (q) params.set("q", q);
var filterData = getActiveFilters();
if (filterData) {
params.set("filters", JSON.stringify(filterData.filters));
params.set("filter_mode", filterData.mode);
}
apiFetch("/admin/api/repeaters?" + params)
.then(function (data) {
loadingEl.style.display = "none";