Profile page:
* New MicrowavepropWeb.UserProfileLive at /u/:callsign is a public
page showing a user's contacts and beacons. Resolves case-
insensitively so /u/w5isp and /u/W5ISP are the same thing; unknown
callsigns redirect to /. Uses daisyUI card / stats / table
components with an avatar-placeholder initial and hero icons.
* Accounts.get_user_by_callsign/1 (case-insensitive) plus
Radio.list_contacts_for_user/1 and Beacons.list_beacons_for_user/1
back the page. Beacons list includes both approved and pending so
owners see their drafts.
* The top nav bar and the three LiveView sidebars (MapLive,
WeatherMapLive, ContactMapLive) now render the logged-in callsign
as a navigate link to /u/:callsign instead of a static label.
* Nine new tests cover the lookup, the LiveView render, and the
ownership-scoped queries.
Flexible band input:
* New Microwaveprop.Radio.BandResolver module converts any of:
ADIF wavelength labels ("33cm", "1.25cm", "6mm", case/whitespace
insensitive), numeric frequency strings ("903.100", "10368.000"),
and canonical MHz integers into the one of the site's known bands.
Returns the nearest allowed band for numeric inputs >= 900 MHz,
nil otherwise.
* 902 MHz is added to Contact.@allowed_bands, ContactEdit.@allowed_bands,
AdifImport.@allowed_bands, and the BandResolver list so "33cm"
round-trips end-to-end.
* AdifImport and CsvImport now delegate band resolution to
BandResolver, and Radio.create_contact/2 normalizes the :band attr
on the way in so the manual form and any API callers benefit too.
CsvImport's "invalid band" tests previously used 99999 MHz which
the new resolver snaps to the nearest allowed band; swapped to
"notaband" which is truly unresolvable.
Contacts and beacons list UX:
* Remove the "Submitted" column from /contacts — it duplicated info
already visible on the detail page and was pushing the real
columns off narrow viewports. submitted_cell/1 and its three
column-specific tests go with it.
* Hide the Lat / Lon columns from /beacons — six decimal places of
coordinates weren't useful next to the grid square and took a
disproportionate amount of row width.
299 lines
10 KiB
Elixir
299 lines
10 KiB
Elixir
defmodule MicrowavepropWeb.ContactMapLive do
|
|
@moduledoc false
|
|
use MicrowavepropWeb, :live_view
|
|
|
|
alias Microwaveprop.Radio
|
|
|
|
@band_colors %{
|
|
1296 => "#475569",
|
|
2304 => "#7c3aed",
|
|
3456 => "#4f46e5",
|
|
5760 => "#2563eb",
|
|
10_000 => "#059669",
|
|
24_000 => "#d97706",
|
|
47_000 => "#ea580c",
|
|
68_000 => "#dc2626",
|
|
75_000 => "#c026d3",
|
|
122_000 => "#db2777",
|
|
134_000 => "#e11d48",
|
|
241_000 => "#b91c1c"
|
|
}
|
|
|
|
@impl true
|
|
def mount(_params, _session, socket) do
|
|
# Shell renders immediately with filter chrome (count + bands list). The
|
|
# JS hook fetches the ~5 MB contacts payload from `/api/contacts/map`
|
|
# after mount, which streams it as pre-gzipped bytes via the browser's
|
|
# native `Content-Encoding: gzip` handling. This gets the heavy payload
|
|
# off the initial HTML entirely.
|
|
%{count: count, bands: bands} = Radio.contact_map_payload()
|
|
|
|
{:ok,
|
|
assign(socket,
|
|
page_title: "Contact Map",
|
|
contact_count: count,
|
|
all_bands: bands,
|
|
enabled_bands: MapSet.new(bands),
|
|
callsign_filter: ""
|
|
)}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("toggle_band", %{"band" => band_str}, socket) do
|
|
band = String.to_integer(band_str)
|
|
|
|
enabled =
|
|
if MapSet.member?(socket.assigns.enabled_bands, band) do
|
|
MapSet.delete(socket.assigns.enabled_bands, band)
|
|
else
|
|
MapSet.put(socket.assigns.enabled_bands, band)
|
|
end
|
|
|
|
socket = assign(socket, enabled_bands: enabled)
|
|
{:noreply, push_filter(socket)}
|
|
end
|
|
|
|
def handle_event("bands_all", _params, socket) do
|
|
socket = assign(socket, enabled_bands: MapSet.new(socket.assigns.all_bands))
|
|
{:noreply, push_filter(socket)}
|
|
end
|
|
|
|
def handle_event("bands_none", _params, socket) do
|
|
socket = assign(socket, enabled_bands: MapSet.new())
|
|
{:noreply, push_filter(socket)}
|
|
end
|
|
|
|
def handle_event("filter_callsign", %{"callsign" => callsign}, socket) do
|
|
socket = assign(socket, callsign_filter: String.trim(callsign))
|
|
{:noreply, push_filter(socket)}
|
|
end
|
|
|
|
defp push_filter(socket) do
|
|
push_event(socket, "apply_filter", %{
|
|
enabled_bands: MapSet.to_list(socket.assigns.enabled_bands),
|
|
callsign: socket.assigns.callsign_filter
|
|
})
|
|
end
|
|
|
|
defp band_label(mhz) when mhz >= 1000 do
|
|
ghz = mhz / 1000
|
|
label = if ghz == trunc(ghz), do: "#{trunc(ghz)}", else: "#{Float.round(ghz / 1, 1)}"
|
|
"#{label} GHz"
|
|
end
|
|
|
|
defp band_label(mhz), do: "#{mhz} MHz"
|
|
|
|
defp band_color(mhz), do: Map.get(@band_colors, mhz, "#64748b")
|
|
|
|
@impl true
|
|
def render(assigns) do
|
|
~H"""
|
|
<div class="fixed inset-0 flex">
|
|
<div class="flex-1 relative">
|
|
<div
|
|
id="contacts-map"
|
|
phx-hook="ContactsMap"
|
|
phx-update="ignore"
|
|
data-fetch-url="/api/contacts/map"
|
|
class="absolute inset-0 z-0"
|
|
>
|
|
</div>
|
|
|
|
<%!-- Mobile floating controls --%>
|
|
<div
|
|
id="map-controls"
|
|
class="md:hidden absolute top-2 left-12 z-[1000] flex flex-col gap-2 max-w-[calc(100vw-4rem)]"
|
|
>
|
|
<div
|
|
data-theme="dark"
|
|
class="bg-neutral text-neutral-content shadow rounded-box border border-base-300 p-2 flex flex-col gap-2"
|
|
>
|
|
<div class="font-bold text-sm leading-tight px-1 flex items-center justify-between gap-2">
|
|
<div class="min-w-0">
|
|
<span>Contact Map</span>
|
|
<div id="contact-count-mobile" class="font-normal text-xs opacity-70">
|
|
{Integer.to_string(@contact_count)} contacts
|
|
</div>
|
|
</div>
|
|
<button
|
|
id="mobile-panel-toggle"
|
|
class="btn btn-xs btn-square btn-ghost"
|
|
onclick="document.getElementById('panel-extras').classList.toggle('hidden')"
|
|
>
|
|
<.icon name="hero-bars-3" class="size-4" />
|
|
</button>
|
|
</div>
|
|
|
|
<div id="panel-extras" class="hidden flex-col gap-2">
|
|
<input
|
|
type="text"
|
|
placeholder="Filter callsign..."
|
|
phx-keyup="filter_callsign"
|
|
phx-key="Enter"
|
|
phx-change="filter_callsign"
|
|
phx-debounce="300"
|
|
name="callsign"
|
|
value={@callsign_filter}
|
|
class="input input-xs w-full"
|
|
/>
|
|
|
|
<div class="flex items-center justify-between">
|
|
<span class="text-xs font-bold">Bands</span>
|
|
<span class="flex gap-2">
|
|
<button phx-click="bands_all" class="text-[10px] underline opacity-70">All</button>
|
|
<button phx-click="bands_none" class="text-[10px] underline opacity-70">
|
|
None
|
|
</button>
|
|
</span>
|
|
</div>
|
|
|
|
<%= for band <- @all_bands do %>
|
|
<label class="flex items-center gap-2 cursor-pointer text-xs">
|
|
<input
|
|
type="checkbox"
|
|
checked={MapSet.member?(@enabled_bands, band)}
|
|
phx-click="toggle_band"
|
|
phx-value-band={band}
|
|
style={"accent-color:#{band_color(band)}"}
|
|
/>
|
|
<span
|
|
class="inline-block w-3 h-0.5 rounded-sm shrink-0"
|
|
style={"background:#{band_color(band)}"}
|
|
>
|
|
</span>
|
|
<span>{band_label(band)}</span>
|
|
</label>
|
|
<% end %>
|
|
|
|
<div class="flex flex-col gap-1 border-t border-base-300 pt-2">
|
|
<.link navigate="/map" class="btn btn-xs btn-ghost justify-start">
|
|
Propagation Map
|
|
</.link>
|
|
<.link navigate="/path" class="btn btn-xs btn-ghost justify-start">
|
|
Path Calculator
|
|
</.link>
|
|
<.link navigate="/beacons" class="btn btn-xs btn-ghost justify-start">
|
|
Beacons
|
|
</.link>
|
|
<.link navigate="/contacts" class="btn btn-xs btn-ghost justify-start">
|
|
Contacts
|
|
</.link>
|
|
<.link navigate="/submit" class="btn btn-xs btn-ghost justify-start">
|
|
Submit a Contact
|
|
</.link>
|
|
<.link navigate="/about" class="btn btn-xs btn-ghost justify-start">
|
|
About
|
|
</.link>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<%!-- Desktop right sidebar --%>
|
|
<div
|
|
id="sidebar"
|
|
data-theme="dark"
|
|
class="hidden md:flex flex-col w-56 shrink-0 h-full bg-neutral text-neutral-content border-l border-base-300 overflow-hidden"
|
|
>
|
|
<div class="p-3 flex flex-col gap-2 shrink-0 overflow-y-auto flex-1">
|
|
<div class="font-bold text-sm leading-tight px-1">
|
|
<span>Contact Map</span>
|
|
<div id="contact-count" class="font-normal text-xs opacity-70">
|
|
{Integer.to_string(@contact_count)} contacts
|
|
</div>
|
|
</div>
|
|
|
|
<%!-- Callsign filter --%>
|
|
<input
|
|
type="text"
|
|
placeholder="Filter callsign..."
|
|
phx-keyup="filter_callsign"
|
|
phx-key="Enter"
|
|
phx-change="filter_callsign"
|
|
phx-debounce="300"
|
|
name="callsign"
|
|
value={@callsign_filter}
|
|
class="input input-sm w-full"
|
|
/>
|
|
|
|
<%!-- Band checkboxes --%>
|
|
<div class="flex items-center justify-between px-1">
|
|
<span class="text-xs font-bold">Bands</span>
|
|
<span class="flex gap-2">
|
|
<button phx-click="bands_all" class="text-[10px] underline opacity-70">All</button>
|
|
<button phx-click="bands_none" class="text-[10px] underline opacity-70">None</button>
|
|
</span>
|
|
</div>
|
|
|
|
<%= for band <- @all_bands do %>
|
|
<label class="flex items-center gap-2 cursor-pointer px-1 text-sm leading-relaxed">
|
|
<input
|
|
type="checkbox"
|
|
checked={MapSet.member?(@enabled_bands, band)}
|
|
phx-click="toggle_band"
|
|
phx-value-band={band}
|
|
style={"accent-color:#{band_color(band)}"}
|
|
/>
|
|
<span
|
|
class="inline-block w-3.5 h-0.5 rounded-sm shrink-0"
|
|
style={"background:#{band_color(band)}"}
|
|
>
|
|
</span>
|
|
<span>{band_label(band)}</span>
|
|
</label>
|
|
<% end %>
|
|
|
|
<%!-- Navigation --%>
|
|
<ul class="menu menu-xs border-t border-base-300 pt-2 px-0">
|
|
<li>
|
|
<.link navigate="/map">Propagation Map</.link>
|
|
</li>
|
|
<li>
|
|
<.link navigate="/path">Path Calculator</.link>
|
|
</li>
|
|
<li>
|
|
<.link navigate="/beacons">Beacons</.link>
|
|
</li>
|
|
<li>
|
|
<.link navigate="/contacts">Contacts</.link>
|
|
</li>
|
|
<li>
|
|
<.link navigate="/submit">Submit a Contact</.link>
|
|
</li>
|
|
<li>
|
|
<.link navigate="/about">About</.link>
|
|
</li>
|
|
</ul>
|
|
|
|
<%!-- Auth --%>
|
|
<ul class="menu menu-xs border-t border-base-300 pt-2 px-0">
|
|
<%= if @current_scope && @current_scope.user do %>
|
|
<li>
|
|
<.link navigate={~p"/u/#{@current_scope.user.callsign}"} class="font-semibold">
|
|
{@current_scope.user.callsign}
|
|
</.link>
|
|
</li>
|
|
<li>
|
|
<.link href={~p"/users/settings"}>Settings</.link>
|
|
</li>
|
|
<li>
|
|
<.link href={~p"/users/log-out"} method="delete">Log out</.link>
|
|
</li>
|
|
<% else %>
|
|
<li>
|
|
<.link href={~p"/users/register"}>Register</.link>
|
|
</li>
|
|
<li>
|
|
<.link href={~p"/users/log-in"}>Log in</.link>
|
|
</li>
|
|
<% end %>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<Layouts.flash_group flash={@flash} />
|
|
"""
|
|
end
|
|
end
|