Implement search

This commit is contained in:
Marcus Kida 2026-02-10 10:57:41 +01:00
parent d887261305
commit 4f435b7bdc
7 changed files with 363 additions and 5 deletions

67
db.go
View file

@ -368,6 +368,73 @@ func distToSegmentWithParam(pLat, pLng, aLat, aLng, bLat, bLng float64) (dist, t
return math.Sqrt(dx*dx + dy*dy), t return math.Sqrt(dx*dx + dy*dy), t
} }
func queryRepeaterSearch(db *sql.DB, search string, limit int) ([]Repeater, int, error) {
if limit <= 0 || limit > 200 {
limit = 25
}
pattern := "%" + search + "%"
where := `WHERE callsign ILIKE $1 OR city ILIKE $1 OR state ILIKE $1 OR country ILIKE $1 OR network ILIKE $1 OR id::text = $2`
var total int
err := db.QueryRow("SELECT COUNT(*) FROM repeaters "+where, pattern, search).Scan(&total)
if err != nil {
return nil, 0, err
}
rows, err := db.Query(`SELECT id, callsign, freq_tx, freq_rx, freq_offset, band, lat, lng, city, state, country,
color_code, ts_linked, trustee, ipsc_network, network, hotspot, status,
last_seen, bm_status, bm_status_text, hardware, firmware, pep, agl, website, description,
import_freq_inconsistent, last_polled
FROM repeaters `+where+` ORDER BY callsign LIMIT $3`, pattern, search, limit)
if err != nil {
return nil, 0, err
}
defer rows.Close()
threshold := time.Now().Add(-7 * 24 * time.Hour)
var results []Repeater
for rows.Next() {
var r Repeater
if err := rows.Scan(&r.ID, &r.Callsign, &r.FreqTx, &r.FreqRx, &r.FreqOffset, &r.Band,
&r.Lat, &r.Lng, &r.City, &r.State, &r.Country,
&r.ColorCode, &r.TsLinked, &r.Trustee,
&r.IpscNetwork, &r.Network, &r.Hotspot, &r.Status,
&r.LastSeen, &r.BmStatus, &r.BmStatusText, &r.Hardware,
&r.Firmware, &r.Pep, &r.Agl, &r.Website, &r.Description,
&r.ImportFreqInconsistent, &r.LastPolled); err != nil {
return nil, 0, err
}
r.Inactive = r.LastSeen != nil && r.LastSeen.Before(threshold)
results = append(results, r)
}
if results == nil {
results = []Repeater{}
}
return results, total, rows.Err()
}
func queryRepeaterByID(db *sql.DB, id int) (*Repeater, error) {
var r Repeater
err := db.QueryRow(`SELECT id, callsign, freq_tx, freq_rx, freq_offset, band, lat, lng, city, state, country,
color_code, ts_linked, trustee, ipsc_network, network, hotspot, status,
last_seen, bm_status, bm_status_text, hardware, firmware, pep, agl, website, description,
import_freq_inconsistent, last_polled
FROM repeaters WHERE id = $1`, id).Scan(
&r.ID, &r.Callsign, &r.FreqTx, &r.FreqRx, &r.FreqOffset, &r.Band,
&r.Lat, &r.Lng, &r.City, &r.State, &r.Country,
&r.ColorCode, &r.TsLinked, &r.Trustee,
&r.IpscNetwork, &r.Network, &r.Hotspot, &r.Status,
&r.LastSeen, &r.BmStatus, &r.BmStatusText, &r.Hardware,
&r.Firmware, &r.Pep, &r.Agl, &r.Website, &r.Description,
&r.ImportFreqInconsistent, &r.LastPolled)
if err != nil {
return nil, err
}
threshold := time.Now().Add(-7 * 24 * time.Hour)
r.Inactive = r.LastSeen != nil && r.LastSeen.Before(threshold)
return &r, nil
}
func updateRepeater(db *sql.DB, r Repeater) error { func updateRepeater(db *sql.DB, r Repeater) error {
_, err := db.Exec(`UPDATE repeaters SET _, err := db.Exec(`UPDATE repeaters SET
callsign=$2, freq_tx=$3, freq_rx=$4, freq_offset=$5, band=$6, callsign=$2, freq_tx=$3, freq_rx=$4, freq_offset=$5, band=$6,

View file

@ -13,6 +13,12 @@ type apiResponse struct {
Count int `json:"count"` Count int `json:"count"`
} }
type searchResponse struct {
Repeaters []Repeater `json:"repeaters"`
Count int `json:"count"`
Total int `json:"total"`
}
type routeRequest struct { type routeRequest struct {
Points [][2]float64 `json:"points"` Points [][2]float64 `json:"points"`
Band string `json:"band"` Band string `json:"band"`
@ -22,6 +28,70 @@ type routeRequest struct {
Inactive bool `json:"inactive"` Inactive bool `json:"inactive"`
} }
func handleSearchRepeaters(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
return
}
q := strings.TrimSpace(r.URL.Query().Get("q"))
if q == "" {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(searchResponse{Repeaters: []Repeater{}, Count: 0, Total: 0})
return
}
limit := 25
if v := r.URL.Query().Get("limit"); v != "" {
if p, err := strconv.Atoi(v); err == nil && p > 0 {
limit = p
}
}
if limit > 200 {
limit = 200
}
repeaters, total, err := queryRepeaterSearch(db, q, limit)
if err != nil {
http.Error(w, `{"error":"database query failed"}`, http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(searchResponse{
Repeaters: repeaters,
Count: len(repeaters),
Total: total,
})
}
}
func handleRepeaterByID(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
return
}
idStr := r.URL.Query().Get("id")
id, err := strconv.Atoi(idStr)
if err != nil || id <= 0 {
http.Error(w, `{"error":"missing or invalid id"}`, http.StatusBadRequest)
return
}
rpt, err := queryRepeaterByID(db, id)
if err != nil {
http.Error(w, `{"error":"repeater not found"}`, http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(rpt)
}
}
func handleRepeaters(db *sql.DB) http.HandlerFunc { func handleRepeaters(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet { if r.Method != http.MethodGet {

View file

@ -36,6 +36,8 @@ func main() {
mux.HandleFunc("/api/repeaters", handleRepeaters(db)) mux.HandleFunc("/api/repeaters", handleRepeaters(db))
mux.HandleFunc("/api/repeaters/radius", handleRadiusRepeaters(db)) mux.HandleFunc("/api/repeaters/radius", handleRadiusRepeaters(db))
mux.HandleFunc("/api/repeaters/route", handleRouteRepeaters(db)) mux.HandleFunc("/api/repeaters/route", handleRouteRepeaters(db))
mux.HandleFunc("/api/repeater", handleRepeaterByID(db))
mux.HandleFunc("/api/repeaters/search", handleSearchRepeaters(db))
if adminToken := os.Getenv("ADMIN_TOKEN"); adminToken != "" { if adminToken := os.Getenv("ADMIN_TOKEN"); adminToken != "" {
mux.HandleFunc("/admin/", handleAdminPage()) mux.HandleFunc("/admin/", handleAdminPage())

View file

@ -550,6 +550,7 @@
loginWrap.style.display = "none"; loginWrap.style.display = "none";
adminWrap.style.display = "block"; adminWrap.style.display = "block";
fetchRepeaters(); fetchRepeaters();
checkHash();
} }
function showLogin() { function showLogin() {
@ -751,12 +752,36 @@
detailBody.innerHTML = html; detailBody.innerHTML = html;
detailOverlay.style.display = "block"; detailOverlay.style.display = "block";
detailPanel.classList.add("open"); detailPanel.classList.add("open");
history.replaceState(null, "", "#" + r.id);
}
function openDetailByID(id) {
// Check if repeater is already loaded in current page
for (var i = 0; i < currentRepeaters.length; i++) {
if (currentRepeaters[i].id === id) {
openDetail(i);
return;
}
}
// Otherwise search for it via API
apiFetch("/admin/api/repeaters?q=" + id + "&per_page=50")
.then(function (data) {
for (var i = 0; i < data.repeaters.length; i++) {
if (data.repeaters[i].id === id) {
currentRepeaters = data.repeaters;
openDetail(i);
return;
}
}
})
.catch(function () {});
} }
function closeDetail() { function closeDetail() {
detailOverlay.style.display = "none"; detailOverlay.style.display = "none";
detailPanel.classList.remove("open"); detailPanel.classList.remove("open");
editingRepeater = null; editingRepeater = null;
history.replaceState(null, "", window.location.pathname);
} }
function collectFormData() { function collectFormData() {
@ -882,6 +907,16 @@
} }
}); });
// Hash navigation
function checkHash() {
var hash = window.location.hash.replace("#", "");
var id = parseInt(hash);
if (!id || id <= 0) return;
openDetailByID(id);
}
window.addEventListener("hashchange", checkHash);
// Init // Init
if (token) { if (token) {
showAdmin(); showAdmin();

View file

@ -17,6 +17,7 @@
var routePoints = null; // stored [lat, lng] pairs for re-fetching on band change var routePoints = null; // stored [lat, lng] pairs for re-fetching on band change
var isRouteMode = false; var isRouteMode = false;
var isPinMode = false; var isPinMode = false;
var isSearchMode = false;
var pinMarker = null; var pinMarker = null;
var pinCircle = null; var pinCircle = null;
var pinLatLng = null; var pinLatLng = null;
@ -53,6 +54,9 @@
var pinClearBtn = document.getElementById("pin-clear"); var pinClearBtn = document.getElementById("pin-clear");
var pinListEl = document.getElementById("pin-list"); var pinListEl = document.getElementById("pin-list");
var routeListEl = document.getElementById("route-list"); var routeListEl = document.getElementById("route-list");
var searchInput = document.getElementById("search-input");
var searchListEl = document.getElementById("search-list");
var searchClearBtn = document.getElementById("search-clear");
clearBtn.style.display = "none"; clearBtn.style.display = "none";
@ -317,7 +321,7 @@
// === Viewport mode === // === Viewport mode ===
function fetchRepeaters() { function fetchRepeaters() {
if (isRouteMode || isPinMode) return; if (isRouteMode || isPinMode || isSearchMode) return;
if (getSelectedNetworks() === "none") { if (getSelectedNetworks() === "none") {
markerLayer.clearLayers(); markerLayer.clearLayers();
@ -516,6 +520,7 @@
if (!fromAddr || !toAddr) return; if (!fromAddr || !toAddr) return;
if (isPinMode) clearPin(); if (isPinMode) clearPin();
if (isSearchMode) clearSearch();
routeBtn.disabled = true; routeBtn.disabled = true;
routeBtn.innerHTML = '<span class="spinner"></span>Routing...'; routeBtn.innerHTML = '<span class="spinner"></span>Routing...';
showStatus("Geocoding..."); showStatus("Geocoding...");
@ -623,10 +628,18 @@
var item = document.createElement("div"); var item = document.createElement("div");
item.className = "pin-list-item"; item.className = "pin-list-item";
var bandColor = r.band === "2m" ? "#1976D2" : "#D32F2F"; var bandColor = r.band === "2m" ? "#1976D2" : "#D32F2F";
var detail;
if (r.distance !== undefined && r.distance !== null) {
detail = '<span class="dist">' + r.distance + " km</span>";
} else {
var loc = r.city || "";
if (r.country) loc += (loc ? ", " : "") + r.country;
detail = '<span class="dist">' + escapeHtml(loc) + "</span>";
}
item.innerHTML = item.innerHTML =
'<a class="callsign" href="https://brandmeister.network/?page=repeater&id=' + r.id + '" target="_blank" rel="noopener">' + escapeHtml(r.callsign) + "</a>" + '<a class="callsign" href="https://brandmeister.network/?page=repeater&id=' + r.id + '" target="_blank" rel="noopener">' + escapeHtml(r.callsign) + "</a>" +
'<span class="freq" style="color:' + bandColor + '">' + r.freq_tx.toFixed(4) + "</span>" + '<span class="freq" style="color:' + bandColor + '">' + r.freq_tx.toFixed(4) + "</span>" +
'<span class="dist">' + r.distance + " km</span>"; detail;
item.addEventListener("click", function (e) { item.addEventListener("click", function (e) {
if (e.target.closest("a")) return; if (e.target.closest("a")) return;
map.setView([r.lat, r.lng], 14); map.setView([r.lat, r.lng], 14);
@ -801,12 +814,13 @@
}); });
map.on("click", function (e) { map.on("click", function (e) {
if (isRouteMode) return; if (isRouteMode || isSearchMode) return;
placePin(e.latlng); placePin(e.latlng);
}); });
function refetchActive() { function refetchActive() {
if (isPinMode) fetchPinRepeaters(); if (isSearchMode) doSearch();
else if (isPinMode) fetchPinRepeaters();
else if (isRouteMode) fetchRouteRepeaters(); else if (isRouteMode) fetchRouteRepeaters();
else fetchRepeaters(); else fetchRepeaters();
} }
@ -859,6 +873,123 @@
if (e.key === "Enter") toInput.focus(); if (e.key === "Enter") toInput.focus();
}); });
// === Init === // === Search ===
var searchTimer = null;
var searchController = null;
function clearSearch() {
isSearchMode = false;
searchListEl.style.display = "none";
searchListEl.innerHTML = "";
searchInput.value = "";
searchClearBtn.style.display = "none";
if (window.location.hash) history.replaceState(null, "", window.location.pathname);
}
function doSearch() {
var q = searchInput.value.trim();
if (q.length < 2) {
if (isSearchMode) {
clearSearch();
fetchRepeaters(); fetchRepeaters();
}
return;
}
// Enter search mode: clear pin/route
if (isPinMode) clearPin();
if (isRouteMode) clearRoute();
isSearchMode = true;
searchClearBtn.style.display = "";
history.replaceState(null, "", "#" + encodeURIComponent(q));
if (searchController) searchController.abort();
searchController = new AbortController();
showStatus("Searching...");
fetch("/api/repeaters/search?q=" + encodeURIComponent(q), { signal: searchController.signal })
.then(function (resp) {
if (!resp.ok) throw new Error("HTTP " + resp.status);
return resp.json();
})
.then(function (data) {
displayRepeaters(data.repeaters);
showCount(data.count);
renderRepeaterList(searchListEl, data.repeaters);
if (data.total > data.count) {
var msg = document.createElement("div");
msg.className = "search-more-msg";
msg.textContent = "Showing " + data.count + " of " + data.total + " results";
searchListEl.appendChild(msg);
}
searchListEl.style.display = "";
})
.catch(function (err) {
if (err.name === "AbortError") return;
console.error("Search error:", err);
showStatus("Error");
});
}
searchInput.addEventListener("input", function () {
clearTimeout(searchTimer);
searchTimer = setTimeout(doSearch, 300);
});
searchInput.addEventListener("keydown", function (e) {
if (e.key === "Escape") {
if (isSearchMode) {
clearSearch();
fetchRepeaters();
}
searchInput.blur();
}
});
searchClearBtn.addEventListener("click", function () {
clearSearch();
fetchRepeaters();
});
// === Hash navigation ===
function checkHash() {
var hash = decodeURIComponent(window.location.hash.replace("#", ""));
if (!hash) return;
var id = parseInt(hash);
if (id > 0) {
fetch("/api/repeater?id=" + id)
.then(function (resp) {
if (!resp.ok) throw new Error("HTTP " + resp.status);
return resp.json();
})
.then(function (r) {
map.setView([r.lat, r.lng], 14);
setTimeout(function () {
markerLayer.eachLayer(function (layer) {
if (layer.getLatLng &&
layer.getLatLng().lat === r.lat &&
layer.getLatLng().lng === r.lng) {
layer.openPopup();
}
});
}, 600);
})
.catch(function (err) {
console.error("Hash nav error:", err);
});
} else {
searchInput.value = hash;
doSearch();
}
}
window.addEventListener("hashchange", checkHash);
// === Init ===
if (window.location.hash) {
checkHash();
} else {
fetchRepeaters();
}
})(); })();

View file

@ -15,6 +15,11 @@
<label class="band-label band-70cm"><input type="checkbox" id="band-70cm" checked> 70cm</label> <label class="band-label band-70cm"><input type="checkbox" id="band-70cm" checked> 70cm</label>
<span id="count" class="count"></span> <span id="count" class="count"></span>
</div> </div>
<div class="controls-row search-row">
<input type="text" id="search-input" placeholder="Search callsign, city, ID..." autocomplete="off" />
<button type="button" id="search-clear" class="search-clear-btn" style="display:none">&times;</button>
</div>
<div id="search-list" class="pin-list" style="display:none"></div>
<div class="controls-row network-row"> <div class="controls-row network-row">
<label class="net-label net-bm"><input type="checkbox" id="net-bm" checked> BM</label> <label class="net-label net-bm"><input type="checkbox" id="net-bm" checked> BM</label>
<label class="net-label net-dmrplus"><input type="checkbox" id="net-dmrplus"> DMR+</label> <label class="net-label net-dmrplus"><input type="checkbox" id="net-dmrplus"> DMR+</label>

View file

@ -229,6 +229,54 @@ html, body {
white-space: nowrap; white-space: nowrap;
} }
.search-row {
margin-top: 6px;
position: relative;
}
.search-row input[type="text"] {
width: 100%;
padding: 7px 28px 7px 10px;
border: 1px solid var(--border);
border-radius: 5px;
font-size: 13px;
outline: none;
background: var(--input-bg);
color: var(--text);
}
.search-row input[type="text"]:focus {
border-color: #4CAF50;
}
.search-clear-btn {
position: absolute;
right: 4px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
font-size: 16px;
color: var(--text-fainter);
cursor: pointer;
padding: 2px 6px;
line-height: 1;
border-radius: 3px;
}
.search-clear-btn:hover {
color: var(--text-muted);
background: var(--hover-bg);
}
.search-more-msg {
padding: 6px 2px;
font-size: 11px;
color: var(--text-fainter);
text-align: center;
border-top: 1px solid var(--border-lighter);
}
.route-row { .route-row {
margin-top: 6px; margin-top: 6px;
} }