Implement search
This commit is contained in:
parent
d887261305
commit
4f435b7bdc
7 changed files with 363 additions and 5 deletions
67
db.go
67
db.go
|
|
@ -368,6 +368,73 @@ func distToSegmentWithParam(pLat, pLng, aLat, aLng, bLat, bLng float64) (dist, 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 {
|
||||
_, err := db.Exec(`UPDATE repeaters SET
|
||||
callsign=$2, freq_tx=$3, freq_rx=$4, freq_offset=$5, band=$6,
|
||||
|
|
|
|||
70
handlers.go
70
handlers.go
|
|
@ -13,6 +13,12 @@ type apiResponse struct {
|
|||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
type searchResponse struct {
|
||||
Repeaters []Repeater `json:"repeaters"`
|
||||
Count int `json:"count"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
type routeRequest struct {
|
||||
Points [][2]float64 `json:"points"`
|
||||
Band string `json:"band"`
|
||||
|
|
@ -22,6 +28,70 @@ type routeRequest struct {
|
|||
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 {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
|
|
|
|||
2
main.go
2
main.go
|
|
@ -36,6 +36,8 @@ func main() {
|
|||
mux.HandleFunc("/api/repeaters", handleRepeaters(db))
|
||||
mux.HandleFunc("/api/repeaters/radius", handleRadiusRepeaters(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 != "" {
|
||||
mux.HandleFunc("/admin/", handleAdminPage())
|
||||
|
|
|
|||
|
|
@ -550,6 +550,7 @@
|
|||
loginWrap.style.display = "none";
|
||||
adminWrap.style.display = "block";
|
||||
fetchRepeaters();
|
||||
checkHash();
|
||||
}
|
||||
|
||||
function showLogin() {
|
||||
|
|
@ -751,12 +752,36 @@
|
|||
detailBody.innerHTML = html;
|
||||
detailOverlay.style.display = "block";
|
||||
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() {
|
||||
detailOverlay.style.display = "none";
|
||||
detailPanel.classList.remove("open");
|
||||
editingRepeater = null;
|
||||
history.replaceState(null, "", window.location.pathname);
|
||||
}
|
||||
|
||||
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
|
||||
if (token) {
|
||||
showAdmin();
|
||||
|
|
|
|||
141
static/app.js
141
static/app.js
|
|
@ -17,6 +17,7 @@
|
|||
var routePoints = null; // stored [lat, lng] pairs for re-fetching on band change
|
||||
var isRouteMode = false;
|
||||
var isPinMode = false;
|
||||
var isSearchMode = false;
|
||||
var pinMarker = null;
|
||||
var pinCircle = null;
|
||||
var pinLatLng = null;
|
||||
|
|
@ -53,6 +54,9 @@
|
|||
var pinClearBtn = document.getElementById("pin-clear");
|
||||
var pinListEl = document.getElementById("pin-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";
|
||||
|
||||
|
|
@ -317,7 +321,7 @@
|
|||
|
||||
// === Viewport mode ===
|
||||
function fetchRepeaters() {
|
||||
if (isRouteMode || isPinMode) return;
|
||||
if (isRouteMode || isPinMode || isSearchMode) return;
|
||||
|
||||
if (getSelectedNetworks() === "none") {
|
||||
markerLayer.clearLayers();
|
||||
|
|
@ -516,6 +520,7 @@
|
|||
if (!fromAddr || !toAddr) return;
|
||||
|
||||
if (isPinMode) clearPin();
|
||||
if (isSearchMode) clearSearch();
|
||||
routeBtn.disabled = true;
|
||||
routeBtn.innerHTML = '<span class="spinner"></span>Routing...';
|
||||
showStatus("Geocoding...");
|
||||
|
|
@ -623,10 +628,18 @@
|
|||
var item = document.createElement("div");
|
||||
item.className = "pin-list-item";
|
||||
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 =
|
||||
'<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="dist">' + r.distance + " km</span>";
|
||||
detail;
|
||||
item.addEventListener("click", function (e) {
|
||||
if (e.target.closest("a")) return;
|
||||
map.setView([r.lat, r.lng], 14);
|
||||
|
|
@ -801,12 +814,13 @@
|
|||
});
|
||||
|
||||
map.on("click", function (e) {
|
||||
if (isRouteMode) return;
|
||||
if (isRouteMode || isSearchMode) return;
|
||||
placePin(e.latlng);
|
||||
});
|
||||
|
||||
function refetchActive() {
|
||||
if (isPinMode) fetchPinRepeaters();
|
||||
if (isSearchMode) doSearch();
|
||||
else if (isPinMode) fetchPinRepeaters();
|
||||
else if (isRouteMode) fetchRouteRepeaters();
|
||||
else fetchRepeaters();
|
||||
}
|
||||
|
|
@ -859,6 +873,123 @@
|
|||
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();
|
||||
}
|
||||
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();
|
||||
}
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -15,6 +15,11 @@
|
|||
<label class="band-label band-70cm"><input type="checkbox" id="band-70cm" checked> 70cm</label>
|
||||
<span id="count" class="count"></span>
|
||||
</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">×</button>
|
||||
</div>
|
||||
<div id="search-list" class="pin-list" style="display:none"></div>
|
||||
<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-dmrplus"><input type="checkbox" id="net-dmrplus"> DMR+</label>
|
||||
|
|
|
|||
|
|
@ -229,6 +229,54 @@ html, body {
|
|||
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 {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue