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
|
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,
|
||||||
|
|
|
||||||
70
handlers.go
70
handlers.go
|
|
@ -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 {
|
||||||
|
|
|
||||||
2
main.go
2
main.go
|
|
@ -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())
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
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 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();
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -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">×</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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue