/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.
156 lines
5.5 KiB
Elixir
156 lines
5.5 KiB
Elixir
defmodule MicrowavepropWeb.UserProfileLive do
|
|
@moduledoc false
|
|
use MicrowavepropWeb, :live_view
|
|
|
|
alias Microwaveprop.Accounts
|
|
alias Microwaveprop.Beacons
|
|
alias Microwaveprop.Beacons.Beacon
|
|
alias Microwaveprop.Radio
|
|
|
|
@impl true
|
|
def mount(%{"callsign" => raw_callsign}, _session, socket) do
|
|
case Accounts.get_user_by_callsign(raw_callsign) do
|
|
nil ->
|
|
{:ok,
|
|
socket
|
|
|> put_flash(:error, "No profile for #{raw_callsign}.")
|
|
|> push_navigate(to: ~p"/")}
|
|
|
|
user ->
|
|
contacts = Radio.list_contacts_for_user(user)
|
|
beacons = Beacons.list_beacons_for_user(user)
|
|
|
|
{:ok,
|
|
assign(socket,
|
|
page_title: user.callsign,
|
|
profile: user,
|
|
contacts: contacts,
|
|
beacons: beacons
|
|
)}
|
|
end
|
|
end
|
|
|
|
defp initial(%{name: name}) when is_binary(name) and name != "" do
|
|
name |> String.trim() |> String.first() |> String.upcase()
|
|
end
|
|
|
|
defp initial(%{callsign: callsign}) when is_binary(callsign), do: callsign |> String.first() |> String.upcase()
|
|
|
|
defp initial(_), do: "?"
|
|
|
|
@impl true
|
|
def render(assigns) do
|
|
~H"""
|
|
<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="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>
|
|
<p class="text-sm opacity-70">
|
|
<span :if={@profile.name}>{@profile.name} ·</span>
|
|
Member since {Calendar.strftime(@profile.inserted_at, "%B %Y")}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="stats bg-base-200 shadow-sm mb-6 w-full">
|
|
<div class="stat">
|
|
<div class="stat-title">Contacts submitted</div>
|
|
<div class="stat-value text-primary">{length(@contacts)}</div>
|
|
</div>
|
|
<div class="stat">
|
|
<div class="stat-title">Beacons submitted</div>
|
|
<div class="stat-value text-secondary">{length(@beacons)}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card bg-base-200 shadow-sm mb-6">
|
|
<div class="card-body">
|
|
<h2 class="card-title">Contacts</h2>
|
|
<%= if @contacts == [] do %>
|
|
<p class="text-sm opacity-70">No contacts submitted yet.</p>
|
|
<% else %>
|
|
<div class="overflow-x-auto">
|
|
<table class="table table-zebra table-sm">
|
|
<thead>
|
|
<tr>
|
|
<th>When</th>
|
|
<th>Stations</th>
|
|
<th>Band</th>
|
|
<th>Mode</th>
|
|
<th>Distance</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr :for={contact <- @contacts} class="hover">
|
|
<td class="whitespace-nowrap">
|
|
{Calendar.strftime(contact.qso_timestamp, "%Y-%m-%d %H:%M UTC")}
|
|
</td>
|
|
<td>
|
|
<.link navigate={~p"/contacts/#{contact.id}"} class="link link-hover font-mono">
|
|
{contact.station1} ↔ {contact.station2}
|
|
</.link>
|
|
</td>
|
|
<td>{contact.band && "#{contact.band} MHz"}</td>
|
|
<td>{contact.mode}</td>
|
|
<td>{contact.distance_km && "#{contact.distance_km} km"}</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<% end %>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card bg-base-200 shadow-sm">
|
|
<div class="card-body">
|
|
<h2 class="card-title">Beacons</h2>
|
|
<%= if @beacons == [] do %>
|
|
<p class="text-sm opacity-70">No beacons submitted yet.</p>
|
|
<% else %>
|
|
<div class="overflow-x-auto">
|
|
<table class="table table-zebra table-sm">
|
|
<thead>
|
|
<tr>
|
|
<th>Callsign</th>
|
|
<th>Frequency</th>
|
|
<th>Grid</th>
|
|
<th>Keying</th>
|
|
<th>Status</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr :for={beacon <- @beacons} class="hover">
|
|
<td>
|
|
<.link navigate={~p"/beacons/#{beacon.id}"} class="link link-hover font-mono">
|
|
{beacon.callsign}
|
|
</.link>
|
|
</td>
|
|
<td class="whitespace-nowrap">
|
|
{Beacon.format_freq(beacon.frequency_mhz)} MHz
|
|
</td>
|
|
<td>{beacon.grid}</td>
|
|
<td>{Beacon.keying_label(beacon.keying)}</td>
|
|
<td>
|
|
<div class={[
|
|
"badge badge-sm",
|
|
if(beacon.approved, do: "badge-success", else: "badge-warning")
|
|
]}>
|
|
{if beacon.approved, do: "Approved", else: "Pending"}
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<% end %>
|
|
</div>
|
|
</div>
|
|
</Layouts.app>
|
|
"""
|
|
end
|
|
end
|