Beacon list map, profile page polish, nav ordering fix
/beacons gains a Leaflet map above the table showing every approved beacon as a circle marker with a popup that links to the detail page. New BeaconsListMap JS hook (assets/js/beacons_list_map_hook.ts) walks a data-beacons JSON payload, fitBounds over the marker set, and mirrors the format_freq/1 integer-comma formatting used on the detail view so the popups match the rest of the UI. Approved/on-air beacons render green, off-air beacons render gray. BeaconLive.Index.mount/3 now materializes the list into an assign so it can encode it into the data attribute; the live PubSub path keeps the map in sync on create/update/delete. Profile (/u/:callsign) polish: drop the hero-radio, hero-signal, and hero-information-circle icons per request, and replace the daisyUI `avatar placeholder` wrapper with a plain flex-centered circle so the initial letter actually lives inside the circle instead of anchored to the top-left corner (daisyUI 5 removed the old placeholder-centering behavior). Navigation ordering: in the top navbar (layouts.ex) Contacts now sits immediately before Contact Map instead of after it. All three vertical sidebars already had them adjacent in the correct order, so only the horizontal nav changed.
This commit is contained in:
parent
f844c9746c
commit
810f5a22ce
5 changed files with 150 additions and 22 deletions
|
|
@ -19,6 +19,7 @@ import {WeatherMap} from "./weather_map_hook"
|
|||
import {LocateMe, CopyLink} from "./locate_me_hook"
|
||||
import {RoverMap} from "./rover_map_hook"
|
||||
import {BeaconMap} from "./beacon_map_hook"
|
||||
import {BeaconsListMap} from "./beacons_list_map_hook"
|
||||
|
||||
interface UtcClockHook extends ViewHook {
|
||||
timer: ReturnType<typeof setInterval>
|
||||
|
|
@ -73,7 +74,7 @@ const csrfToken = document.querySelector("meta[name='csrf-token']")!.getAttribut
|
|||
const liveSocket = new LiveSocket("/live", Socket, {
|
||||
longPollFallbackMs: 2500,
|
||||
params: initLiveStash({_csrf_token: csrfToken}),
|
||||
hooks: {...colocatedHooks, PropagationMap, UtcClock, ContactMap, ContactsMap, ElevationProfile, WeatherMap, LocateMe, CopyLink, RoverMap, BeaconMap, CommaNumber},
|
||||
hooks: {...colocatedHooks, PropagationMap, UtcClock, ContactMap, ContactsMap, ElevationProfile, WeatherMap, LocateMe, CopyLink, RoverMap, BeaconMap, BeaconsListMap, CommaNumber},
|
||||
})
|
||||
|
||||
// Show progress bar on live navigation and form submits
|
||||
|
|
|
|||
102
assets/js/beacons_list_map_hook.ts
Normal file
102
assets/js/beacons_list_map_hook.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
// Map used by /beacons — renders every approved beacon as a circle
|
||||
// marker with a popup linking to the detail page.
|
||||
|
||||
interface BeaconPoint {
|
||||
id: string
|
||||
callsign: string
|
||||
lat: number
|
||||
lon: number
|
||||
frequency_mhz: number
|
||||
grid: string
|
||||
keying: string
|
||||
on_the_air: boolean
|
||||
}
|
||||
|
||||
interface BeaconsListMapHook extends ViewHook {
|
||||
mounted(this: BeaconsListMapHook): void
|
||||
}
|
||||
|
||||
function formatFreq(freq: number): string {
|
||||
// Mirror Microwaveprop.Beacons.Beacon.format_freq/1 — integers get
|
||||
// comma separators, floats keep up to three decimals trimmed.
|
||||
if (Number.isInteger(freq)) return freq.toLocaleString("en-US")
|
||||
const trimmed = freq.toFixed(3).replace(/\.?0+$/, "")
|
||||
const [whole, frac] = trimmed.split(".")
|
||||
const wholeFormatted = parseInt(whole, 10).toLocaleString("en-US")
|
||||
return frac ? `${wholeFormatted}.${frac}` : wholeFormatted
|
||||
}
|
||||
|
||||
function escapeHtml(str: string): string {
|
||||
return str.replace(/[&<>"']/g, ch => {
|
||||
switch (ch) {
|
||||
case "&": return "&"
|
||||
case "<": return "<"
|
||||
case ">": return ">"
|
||||
case "\"": return """
|
||||
case "'": return "'"
|
||||
default: return ch
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const BeaconsListMap = {
|
||||
mounted(this: BeaconsListMapHook) {
|
||||
const beacons: BeaconPoint[] = JSON.parse(this.el.dataset.beacons || "[]")
|
||||
|
||||
// DFW fallback so the map still renders when no beacons exist yet.
|
||||
const defaultCenter: [number, number] = [32.897, -97.038]
|
||||
|
||||
const map = L.map(this.el, {
|
||||
zoomControl: true,
|
||||
scrollWheelZoom: false,
|
||||
attributionControl: true
|
||||
}).setView(defaultCenter, 6)
|
||||
|
||||
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
||||
attribution: "© OpenStreetMap contributors",
|
||||
maxZoom: 19
|
||||
}).addTo(map)
|
||||
|
||||
const bounds: [number, number][] = []
|
||||
|
||||
for (const b of beacons) {
|
||||
if (!Number.isFinite(b.lat) || !Number.isFinite(b.lon)) continue
|
||||
|
||||
const color = b.on_the_air ? "#16a34a" : "#94a3b8"
|
||||
const fill = b.on_the_air ? "#22c55e" : "#cbd5e1"
|
||||
|
||||
const popupHtml =
|
||||
`<div style="font-size:12px;line-height:1.5;min-width:160px;">` +
|
||||
`<strong>${escapeHtml(b.callsign)}</strong>` +
|
||||
(b.on_the_air
|
||||
? ""
|
||||
: ` <span style="color:#ca8a04;">· off-air</span>`) +
|
||||
`<br/>` +
|
||||
`${formatFreq(b.frequency_mhz)} MHz<br/>` +
|
||||
`Grid ${escapeHtml(b.grid)}<br/>` +
|
||||
`Keying: ${escapeHtml(b.keying)}<br/>` +
|
||||
`<a href="/beacons/${b.id}" style="color:#3b82f6;">View details →</a>` +
|
||||
`</div>`
|
||||
|
||||
L.circleMarker([b.lat, b.lon], {
|
||||
radius: 7,
|
||||
color,
|
||||
fillColor: fill,
|
||||
fillOpacity: 0.9,
|
||||
weight: 2
|
||||
})
|
||||
.bindPopup(popupHtml, { closeButton: true })
|
||||
.bindTooltip(b.callsign, { direction: "top", offset: [0, -8] })
|
||||
.addTo(map)
|
||||
|
||||
bounds.push([b.lat, b.lon])
|
||||
}
|
||||
|
||||
if (bounds.length > 0) {
|
||||
map.fitBounds(L.latLngBounds(bounds), { padding: [30, 30], maxZoom: 10 })
|
||||
}
|
||||
|
||||
// Invalidate once after mount in case the parent was hidden.
|
||||
setTimeout(() => map.invalidateSize(), 50)
|
||||
}
|
||||
}
|
||||
|
|
@ -43,8 +43,8 @@ defmodule MicrowavepropWeb.Layouts do
|
|||
<.link navigate="/map" class="btn btn-ghost btn-sm">Map</.link>
|
||||
<.link navigate="/path" class="btn btn-ghost btn-sm">Path</.link>
|
||||
<.link navigate="/beacons" class="btn btn-ghost btn-sm">Beacons</.link>
|
||||
<.link navigate="/contacts/map" class="btn btn-ghost btn-sm">Contact Map</.link>
|
||||
<.link navigate="/contacts" class="btn btn-ghost btn-sm">Contacts</.link>
|
||||
<.link navigate="/contacts/map" class="btn btn-ghost btn-sm">Contact Map</.link>
|
||||
<.link navigate="/submit" class="btn btn-ghost btn-sm">Submit</.link>
|
||||
<.link navigate="/about" class="btn btn-ghost btn-sm">About</.link>
|
||||
<.link
|
||||
|
|
|
|||
|
|
@ -19,6 +19,15 @@ defmodule MicrowavepropWeb.BeaconLive.Index do
|
|||
</:actions>
|
||||
</.header>
|
||||
|
||||
<div
|
||||
id="beacons-map"
|
||||
phx-hook="BeaconsListMap"
|
||||
phx-update="ignore"
|
||||
data-beacons={@beacons_json}
|
||||
class="h-80 w-full rounded-lg border border-base-300 mb-6 z-0"
|
||||
>
|
||||
</div>
|
||||
|
||||
<.table
|
||||
id="beacons"
|
||||
rows={@streams.beacons}
|
||||
|
|
@ -101,6 +110,8 @@ defmodule MicrowavepropWeb.BeaconLive.Index do
|
|||
def mount(_params, _session, socket) do
|
||||
if connected?(socket), do: Beacons.subscribe_beacons()
|
||||
|
||||
beacons = Beacons.list_beacons()
|
||||
|
||||
pending =
|
||||
if admin?(socket.assigns.current_scope) do
|
||||
Beacons.list_pending_beacons()
|
||||
|
|
@ -112,7 +123,8 @@ defmodule MicrowavepropWeb.BeaconLive.Index do
|
|||
socket
|
||||
|> assign(:page_title, "Beacons")
|
||||
|> assign(:pending, pending)
|
||||
|> stream(:beacons, Beacons.list_beacons())
|
||||
|> assign(:beacons_json, encode_beacons(beacons))
|
||||
|> stream(:beacons, beacons)
|
||||
|> stream(:pending, pending)}
|
||||
end
|
||||
|
||||
|
|
@ -148,6 +160,8 @@ defmodule MicrowavepropWeb.BeaconLive.Index do
|
|||
|
||||
@impl true
|
||||
def handle_info({type, %Beacon{}}, socket) when type in [:created, :updated, :deleted] do
|
||||
beacons = Beacons.list_beacons()
|
||||
|
||||
pending =
|
||||
if admin?(socket.assigns.current_scope) do
|
||||
Beacons.list_pending_beacons()
|
||||
|
|
@ -158,10 +172,35 @@ defmodule MicrowavepropWeb.BeaconLive.Index do
|
|||
{:noreply,
|
||||
socket
|
||||
|> assign(:pending, pending)
|
||||
|> stream(:beacons, Beacons.list_beacons(), reset: true)
|
||||
|> assign(:beacons_json, encode_beacons(beacons))
|
||||
|> stream(:beacons, beacons, reset: true)
|
||||
|> stream(:pending, pending, reset: true)}
|
||||
end
|
||||
|
||||
defp admin?(%{user: %{is_admin: true}}), do: true
|
||||
defp admin?(_), do: false
|
||||
|
||||
# Serialize just the fields the map hook needs. `phx-update="ignore"`
|
||||
# means this data attribute is only read on initial hook mount — the
|
||||
# map won't re-render when beacons change without a full LV reload.
|
||||
# For now that's fine because list churn is rare; upgrade to
|
||||
# push_event("update_beacons", ...) if we start getting live PubSub
|
||||
# updates that need to reflect on the map without a refresh.
|
||||
defp encode_beacons(beacons) do
|
||||
beacons
|
||||
|> Enum.filter(&(is_number(&1.lat) and is_number(&1.lon)))
|
||||
|> Enum.map(fn b ->
|
||||
%{
|
||||
id: b.id,
|
||||
callsign: b.callsign,
|
||||
lat: b.lat,
|
||||
lon: b.lon,
|
||||
frequency_mhz: b.frequency_mhz,
|
||||
grid: b.grid,
|
||||
keying: Beacon.keying_label(b.keying),
|
||||
on_the_air: b.on_the_air
|
||||
}
|
||||
end)
|
||||
|> Jason.encode!()
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -44,10 +44,8 @@ defmodule MicrowavepropWeb.UserProfileLive do
|
|||
<Layouts.app flash={@flash} current_scope={@current_scope} max_width="max-w-5xl">
|
||||
<div class="card bg-base-200 shadow-sm mb-6">
|
||||
<div class="card-body flex-row items-center gap-4 py-5">
|
||||
<div class="avatar placeholder">
|
||||
<div class="bg-primary text-primary-content rounded-full w-16">
|
||||
<span class="text-2xl font-mono">{initial(@profile)}</span>
|
||||
</div>
|
||||
<div class="flex h-16 w-16 shrink-0 items-center justify-center rounded-full bg-primary text-primary-content">
|
||||
<span class="font-mono text-2xl font-semibold leading-none">{initial(@profile)}</span>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h1 class="card-title font-mono text-2xl">{@profile.callsign}</h1>
|
||||
|
|
@ -61,16 +59,10 @@ defmodule MicrowavepropWeb.UserProfileLive do
|
|||
|
||||
<div class="stats bg-base-200 shadow-sm mb-6 w-full">
|
||||
<div class="stat">
|
||||
<div class="stat-figure text-primary">
|
||||
<.icon name="hero-radio" class="w-8 h-8" />
|
||||
</div>
|
||||
<div class="stat-title">Contacts submitted</div>
|
||||
<div class="stat-value text-primary">{length(@contacts)}</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-figure text-secondary">
|
||||
<.icon name="hero-signal" class="w-8 h-8" />
|
||||
</div>
|
||||
<div class="stat-title">Beacons submitted</div>
|
||||
<div class="stat-value text-secondary">{length(@beacons)}</div>
|
||||
</div>
|
||||
|
|
@ -80,10 +72,7 @@ defmodule MicrowavepropWeb.UserProfileLive do
|
|||
<div class="card-body">
|
||||
<h2 class="card-title">Contacts</h2>
|
||||
<%= if @contacts == [] do %>
|
||||
<div class="alert">
|
||||
<.icon name="hero-information-circle" class="w-5 h-5" />
|
||||
<span>No contacts submitted yet.</span>
|
||||
</div>
|
||||
<p class="text-sm opacity-70">No contacts submitted yet.</p>
|
||||
<% else %>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-zebra table-sm">
|
||||
|
|
@ -121,10 +110,7 @@ defmodule MicrowavepropWeb.UserProfileLive do
|
|||
<div class="card-body">
|
||||
<h2 class="card-title">Beacons</h2>
|
||||
<%= if @beacons == [] do %>
|
||||
<div class="alert">
|
||||
<.icon name="hero-information-circle" class="w-5 h-5" />
|
||||
<span>No beacons submitted yet.</span>
|
||||
</div>
|
||||
<p class="text-sm opacity-70">No beacons submitted yet.</p>
|
||||
<% else %>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-zebra table-sm">
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue