aprs.me/lib/aprsme_web/live/map_live/components.ex
Graham McIntire 6744455ac4
Fix get_received_at to properly handle Packet structs
Structs don't implement the Access behaviour, so packet[:received_at]
doesn't work. Use pattern matching to handle Aprsme.Packet structs
separately with dot notation, and use Map.get for regular maps.

This fixes the UndefinedFunctionError when rendering tracked callsign
information with Packet structs from the database.
2025-10-25 18:43:14 -05:00

369 lines
10 KiB
Elixir

defmodule AprsmeWeb.MapLive.Components do
@moduledoc """
Reusable function components for the Map LiveView.
Extracted to improve maintainability and reduce the main LiveView module size.
"""
use AprsmeWeb, :html
import AprsmeWeb.TimeHelpers, only: [time_ago_in_words: 1]
attr :flash, :map, default: %{}
attr :slideover_open, :boolean, default: false
attr :map_center, :map, required: true
attr :map_zoom, :integer, required: true
attr :rest, :global
def map_container(assigns) do
~H"""
<div
id="aprs-map"
phx-update="ignore"
phx-hook="APRSMap"
data-center={Jason.encode!(@map_center)}
data-zoom={@map_zoom}
class={[
@slideover_open && "slideover-open",
!@slideover_open && "slideover-closed"
]}
>
</div>
"""
end
attr :slideover_open, :boolean, required: true
attr :loading, :boolean, default: false
attr :connection_status, :string, default: "pending"
attr :packets, :list, default: []
attr :show_all_packets, :boolean, default: true
attr :tracked_callsign, :string, default: ""
attr :tracked_callsign_latest_packet, :map, default: nil
attr :rest, :global
def slideover_panel(assigns) do
~H"""
<div class={[
"slideover-panel",
@slideover_open && "slideover-open",
!@slideover_open && "slideover-closed"
]}>
<.slideover_header {assigns} />
<.slideover_content {assigns} />
</div>
"""
end
defp slideover_header(assigns) do
~H"""
<div class="flex items-center justify-between p-4 border-b bg-gray-50">
<h2 class="text-lg font-semibold text-gray-900">APRS Packets</h2>
<button
phx-click="toggle_slideover"
class="p-2 text-gray-400 hover:text-gray-600 transition-colors"
>
<.icon name="hero-x-mark" class="w-5 h-5" />
</button>
</div>
"""
end
defp slideover_content(assigns) do
~H"""
<div class="flex-1 overflow-hidden flex flex-col">
<%= if @loading do %>
<.loading_indicator />
<% else %>
<.packet_controls {assigns} />
<.packet_list {assigns} />
<% end %>
</div>
"""
end
defp loading_indicator(assigns) do
~H"""
<div class="flex items-center justify-center h-full">
<div class="text-center">
<div class="inline-flex items-center">
<svg class="animate-spin h-8 w-8 text-blue-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
>
</path>
</svg>
<span class="ml-3 text-lg text-gray-700">Loading packets...</span>
</div>
</div>
</div>
"""
end
defp packet_controls(assigns) do
~H"""
<div class="p-4 border-b space-y-3">
<.connection_indicator connection_status={@connection_status} />
<.view_toggle show_all_packets={@show_all_packets} />
<.callsign_filter
tracked_callsign={@tracked_callsign}
tracked_callsign_latest_packet={@tracked_callsign_latest_packet}
/>
</div>
"""
end
defp connection_indicator(assigns) do
~H"""
<div class="flex items-center space-x-2">
<div class={[
"h-3 w-3 rounded-full",
@connection_status == "connected" && "bg-green-500",
@connection_status == "connecting" && "bg-yellow-500",
@connection_status == "disconnected" && "bg-red-500",
@connection_status == "pending" && "bg-gray-400"
]}>
</div>
<span class="text-sm text-gray-700">
<%= case @connection_status do %>
<% "connected" -> %>
Connected to APRS-IS
<% "connecting" -> %>
Connecting to APRS-IS...
<% "disconnected" -> %>
Disconnected from APRS-IS
<% _ -> %>
Initializing...
<% end %>
</span>
</div>
"""
end
defp view_toggle(assigns) do
~H"""
<div class="flex items-center space-x-4">
<label class="inline-flex items-center">
<input
type="radio"
name="view_mode"
value="all"
checked={@show_all_packets}
phx-click="toggle_view_mode"
phx-value-mode="all"
class="text-blue-600"
/>
<span class="ml-2 text-sm">All Packets</span>
</label>
<label class="inline-flex items-center">
<input
type="radio"
name="view_mode"
value="with_position"
checked={!@show_all_packets}
phx-click="toggle_view_mode"
phx-value-mode="with_position"
class="text-blue-600"
/>
<span class="ml-2 text-sm">With Position</span>
</label>
</div>
"""
end
defp callsign_filter(assigns) do
~H"""
<div>
<form phx-submit="filter_callsign" class="relative">
<input
type="text"
name="callsign"
value={@tracked_callsign}
placeholder="Filter by callsign..."
class="w-full px-3 py-2 border border-gray-300 rounded-md pr-8 text-sm focus:ring-blue-500 focus:border-blue-500"
phx-debounce="300"
phx-change="filter_callsign"
/>
<%= if @tracked_callsign != "" do %>
<button
type="button"
phx-click="clear_callsign_filter"
class="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
<.icon name="hero-x-mark" class="w-4 h-4" />
</button>
<% end %>
</form>
<%= if @tracked_callsign_latest_packet do %>
<div class="mt-2 text-xs text-gray-600">
Last seen: {time_ago_in_words(get_received_at(@tracked_callsign_latest_packet))}
</div>
<% end %>
</div>
"""
end
defp packet_list(assigns) do
~H"""
<div class="flex-1 overflow-y-auto p-4">
<div id="packets" class="space-y-3" phx-update="stream">
<div :for={{id, packet} <- @packets} id={id}>
<.packet_card packet={packet} />
</div>
</div>
</div>
"""
end
defp packet_card(assigns) do
~H"""
<div class="bg-white border border-gray-200 rounded-lg p-4 hover:border-blue-300 transition-colors">
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="font-semibold text-gray-900">
{@packet.sender}
<%= if @packet.ssid && @packet.ssid != "0" do %>
<span class="text-gray-500">-{@packet.ssid}</span>
<% end %>
</div>
<%= if @packet.has_position do %>
<div class="text-sm text-gray-600 mt-1">
📍 {format_coordinates(@packet.lat, @packet.lon)}
</div>
<% end %>
<%= if @packet.comment do %>
<div class="text-sm text-gray-700 mt-2">
{@packet.comment}
</div>
<% end %>
<div class="text-xs text-gray-500 mt-2">
via {@packet.path || "direct"} • {time_ago_in_words(@packet.received_at)}
</div>
</div>
<%= if @packet.has_position do %>
<button
phx-click="center_on_packet"
phx-value-lat={@packet.lat}
phx-value-lon={@packet.lon}
phx-value-sender={@packet.sender}
class="ml-2 p-2 text-blue-600 hover:bg-blue-50 rounded transition-colors"
title="Center map on this location"
>
<.icon name="hero-map-pin" class="w-5 h-5" />
</button>
<% end %>
</div>
</div>
"""
end
# Helper functions
defp format_coordinates(lat, lon) when is_number(lat) and is_number(lon) do
"#{Float.round(lat, 4)}, #{Float.round(lon, 4)}"
end
defp format_coordinates(_, _), do: "Unknown location"
# Style component for better organization
def map_styles(assigns) do
~H"""
<style>
#aprs-map {
position: absolute;
top: 0;
left: 0;
bottom: 0;
height: 100vh;
z-index: 1;
transition: right 0.3s ease-in-out;
}
/* Desktop styles */
@media (min-width: 1024px) {
#aprs-map.slideover-open {
right: 352px;
}
#aprs-map.slideover-closed {
right: 0;
}
}
/* Slideover panel base styles */
.slideover-panel {
position: fixed;
top: 0;
right: 0;
bottom: 0;
width: 352px;
background: white;
box-shadow: -2px 0 8px rgba(0, 0, 0, 0.1);
z-index: 50;
display: flex;
flex-direction: column;
transition: transform 0.3s ease-in-out;
overflow: hidden;
}
.slideover-panel.slideover-open {
transform: translateX(0);
}
.slideover-panel.slideover-closed {
transform: translateX(100%);
}
/* Mobile responsiveness */
@media (max-width: 1023px) {
#aprs-map {
right: 0 !important;
}
.slideover-panel {
width: 100%;
max-width: 400px;
}
}
/* Loading spinner animation */
@keyframes spin {
to { transform: rotate(360deg); }
}
.animate-spin {
animation: spin 1s linear infinite;
}
/* Custom scrollbar for packet list */
#packets::-webkit-scrollbar {
width: 6px;
}
#packets::-webkit-scrollbar-track {
background: #f1f1f1;
}
#packets::-webkit-scrollbar-thumb {
background: #888;
border-radius: 3px;
}
#packets::-webkit-scrollbar-thumb:hover {
background: #555;
}
</style>
"""
end
# Helper function to get received_at from packet (handles both structs and maps)
defp get_received_at(%Aprsme.Packet{} = packet) do
packet.received_at
end
defp get_received_at(packet) when is_map(packet) do
# For regular maps, try both atom and string keys
Map.get(packet, :received_at) || Map.get(packet, "received_at")
end
defp get_received_at(_), do: nil
end