diff --git a/db.go b/db.go index 8fda873..b6e364a 100644 --- a/db.go +++ b/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, diff --git a/handlers.go b/handlers.go index e68bdd1..553ea77 100644 --- a/handlers.go +++ b/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 { diff --git a/main.go b/main.go index 75d85a2..4c12b54 100644 --- a/main.go +++ b/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()) diff --git a/static/admin.html b/static/admin.html index 08f79c1..77a9fb0 100644 --- a/static/admin.html +++ b/static/admin.html @@ -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(); diff --git a/static/app.js b/static/app.js index c5fa16f..7d578d2 100644 --- a/static/app.js +++ b/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 = '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 = '' + r.distance + " km"; + } else { + var loc = r.city || ""; + if (r.country) loc += (loc ? ", " : "") + r.country; + detail = '' + escapeHtml(loc) + ""; + } item.innerHTML = '' + escapeHtml(r.callsign) + "" + '' + r.freq_tx.toFixed(4) + "" + - '' + r.distance + " km"; + 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(); }); + // === 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 === - fetchRepeaters(); + if (window.location.hash) { + checkHash(); + } else { + fetchRepeaters(); + } })(); diff --git a/static/index.html b/static/index.html index a0adb49..99bc7d0 100644 --- a/static/index.html +++ b/static/index.html @@ -15,6 +15,11 @@ +
+ + +
+
diff --git a/static/style.css b/static/style.css index 5956c12..2cdcec8 100644 --- a/static/style.css +++ b/static/style.css @@ -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; }