- Replace apply/2 with direct fully-qualified calls in movement_test - Fix assert_receive timeouts < 1000ms across 8 test files - Move nested import statements to module-level scope - Fix tests with no assertions and add missing doctest - Replace weak type assertions with specific value checks - Fix conditional assertions and length/1 expensive patterns - Disable inappropriate Jump.CredoChecks.AvoidSocketAssignsInTest - Fix tests not calling application code with credo:disable - Add various credo:disable comments for legitimate patterns
372 lines
10 KiB
Elixir
372 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="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" phx-change="filter_callsign_form" id="filter-callsign-form" 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="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="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 - default to sidebar open */
|
|
@media (min-width: 1024px) {
|
|
#aprs-map {
|
|
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;
|
|
}
|
|
|
|
/* Desktop: default to visible, only hide when explicitly closed */
|
|
@media (min-width: 1024px) {
|
|
.slideover-panel {
|
|
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
|