Add filters
This commit is contained in:
parent
a636ecb6ad
commit
409b8fbcee
3 changed files with 281 additions and 4 deletions
18
admin.go
18
admin.go
|
|
@ -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
51
db.go
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 = "×";
|
||||
|
||||
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";
|
||||
|
|
|
|||
Loading…
Reference in a new issue