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"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
@ -160,8 +161,9 @@ func handleAdminRepeaters(db *sql.DB) http.HandlerFunc {
|
||||||
}
|
}
|
||||||
|
|
||||||
search := strings.TrimSpace(q.Get("q"))
|
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 {
|
if err != nil {
|
||||||
http.Error(w, `{"error":"database query failed"}`, http.StatusInternalServerError)
|
http.Error(w, `{"error":"database query failed"}`, http.StatusInternalServerError)
|
||||||
return
|
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"`
|
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) {
|
func openDB(dsn string) (*sql.DB, error) {
|
||||||
var db *sql.DB
|
var db *sql.DB
|
||||||
var err error
|
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.
|
// 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
|
paramIdx := 0
|
||||||
nextParam := func() string {
|
nextParam := func() string {
|
||||||
paramIdx++
|
paramIdx++
|
||||||
|
|
@ -347,16 +353,55 @@ func queryAdminRepeaters(db *sql.DB, search string, page, perPage int) ([]Repeat
|
||||||
var args []interface{}
|
var args []interface{}
|
||||||
if search != "" {
|
if search != "" {
|
||||||
p := nextParam()
|
p := nextParam()
|
||||||
where = " WHERE callsign ILIKE " + p +
|
where = " WHERE (callsign ILIKE " + p +
|
||||||
" OR city ILIKE " + p +
|
" OR city ILIKE " + p +
|
||||||
" OR state ILIKE " + p +
|
" OR state ILIKE " + p +
|
||||||
" OR country ILIKE " + p +
|
" OR country ILIKE " + p +
|
||||||
" OR array_to_string(networks, ',') ILIKE " + p +
|
" OR array_to_string(networks, ',') ILIKE " + p +
|
||||||
" OR id::text = " + nextParam()
|
" OR id::text = " + nextParam() + ")"
|
||||||
pattern := "%" + search + "%"
|
pattern := "%" + search + "%"
|
||||||
args = append(args, 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
|
var total int
|
||||||
err := db.QueryRow("SELECT COUNT(*) FROM repeaters"+where, args...).Scan(&total)
|
err := db.QueryRow("SELECT COUNT(*) FROM repeaters"+where, args...).Scan(&total)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -451,6 +451,90 @@
|
||||||
font-size: 13px; cursor: pointer; color: var(--text);
|
font-size: 13px; cursor: pointer; color: var(--text);
|
||||||
}
|
}
|
||||||
.multicheck-label input[type="checkbox"] { width: 16px; height: 16px; cursor: pointer; }
|
.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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
@ -468,9 +552,18 @@
|
||||||
<div class="topbar">
|
<div class="topbar">
|
||||||
<h1>DMRmap Admin</h1>
|
<h1>DMRmap Admin</h1>
|
||||||
<input type="text" class="search-input" id="search-input" placeholder="Search callsign, city, country, network..." />
|
<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>
|
<span class="topbar-info" id="total-info"></span>
|
||||||
<button class="logout-btn" id="logout-btn">Logout</button>
|
<button class="logout-btn" id="logout-btn">Logout</button>
|
||||||
</div>
|
</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">
|
<div class="table-wrap">
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
|
|
@ -548,6 +641,16 @@
|
||||||
var nextBtn = document.getElementById("next-btn");
|
var nextBtn = document.getElementById("next-btn");
|
||||||
var pageInfo = document.getElementById("page-info");
|
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 detailOverlay = document.getElementById("detail-overlay");
|
||||||
var detailPanel = document.getElementById("detail-panel");
|
var detailPanel = document.getElementById("detail-panel");
|
||||||
var detailTitle = document.getElementById("detail-title");
|
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() {
|
function fetchRepeaters() {
|
||||||
loadingEl.style.display = "";
|
loadingEl.style.display = "";
|
||||||
emptyState.style.display = "none";
|
emptyState.style.display = "none";
|
||||||
|
|
@ -609,6 +819,12 @@
|
||||||
});
|
});
|
||||||
if (q) params.set("q", q);
|
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)
|
apiFetch("/admin/api/repeaters?" + params)
|
||||||
.then(function (data) {
|
.then(function (data) {
|
||||||
loadingEl.style.display = "none";
|
loadingEl.style.display = "none";
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue