diff --git a/admin.go b/admin.go index 979dad4..e63c0ec 100644 --- a/admin.go +++ b/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 +} diff --git a/db.go b/db.go index 2ea25ad..9b36d95 100644 --- a/db.go +++ b/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 { diff --git a/static/admin.html b/static/admin.html index a050997..20f35e6 100644 --- a/static/admin.html +++ b/static/admin.html @@ -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); }
@@ -468,9 +552,18 @@ +