weather page
This commit is contained in:
parent
46d34a7c5e
commit
51efdf34a2
18 changed files with 173 additions and 133 deletions
|
|
@ -393,26 +393,29 @@ defmodule AprsWeb.CoreComponents do
|
|||
end
|
||||
|
||||
@doc """
|
||||
Renders a header with title.
|
||||
Renders a modern site header with navigation links.
|
||||
"""
|
||||
attr :class, :string, default: nil
|
||||
|
||||
slot :inner_block, required: true
|
||||
slot :subtitle
|
||||
slot :actions
|
||||
attr :class, :string, default: ""
|
||||
|
||||
def header(assigns) do
|
||||
~H"""
|
||||
<header class={[@actions != [] && "flex items-center justify-between gap-6", @class]}>
|
||||
<div>
|
||||
<h1 class="text-lg font-semibold leading-8 text-zinc-800">
|
||||
{render_slot(@inner_block)}
|
||||
</h1>
|
||||
<p :if={@subtitle != []} class="mt-2 text-sm leading-6 text-zinc-600">
|
||||
{render_slot(@subtitle)}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex-none">{render_slot(@actions)}</div>
|
||||
<header class={["bg-white shadow sticky top-0 z-40", @class]}>
|
||||
<nav
|
||||
class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 flex h-16 items-center justify-between"
|
||||
aria-label="Main"
|
||||
>
|
||||
<div class="flex items-center space-x-4">
|
||||
<a href="/" class="text-xl font-bold text-blue-700 hover:text-blue-900 transition-colors">
|
||||
aprs.me
|
||||
</a>
|
||||
</div>
|
||||
<div class="flex items-center space-x-6">
|
||||
<a href="/" class="text-gray-700 hover:text-blue-700 font-medium transition-colors">Home</a>
|
||||
<a href="/about" class="text-gray-700 hover:text-blue-700 font-medium transition-colors">
|
||||
About
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
"""
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
<.header />
|
||||
<main class="min-h-screen bg-white">
|
||||
<div>
|
||||
<.flash kind={:info} title="Success!" flash={@flash} />
|
||||
|
|
|
|||
9
lib/aprs_web/live/about_live.ex
Normal file
9
lib/aprs_web/live/about_live.ex
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
defmodule AprsWeb.AboutLive do
|
||||
@moduledoc false
|
||||
use AprsWeb, :live_view
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
{:ok, assign(socket, page_title: "About aprs.me")}
|
||||
end
|
||||
end
|
||||
0
lib/aprs_web/live/about_live.html.heex
Normal file
0
lib/aprs_web/live/about_live.html.heex
Normal file
|
|
@ -1,74 +1,6 @@
|
|||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 my-8">
|
||||
<div class="mb-8">
|
||||
<.header>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold text-gray-900">
|
||||
Bad APRS Packets
|
||||
<span class="ml-2 text-base font-normal text-gray-500">
|
||||
(Showing {length(@bad_packets)} most recent)
|
||||
</span>
|
||||
</h1>
|
||||
<p class="mt-1 text-sm text-gray-500">
|
||||
Packets that failed to parse or process (limited to 100 most recent)
|
||||
<%= if @last_updated do %>
|
||||
<span class="text-xs text-gray-400 ml-2">
|
||||
Last updated: {Calendar.strftime(@last_updated, "%H:%M:%S UTC")}
|
||||
</span>
|
||||
<% end %>
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center space-x-4">
|
||||
<button
|
||||
phx-click="refresh"
|
||||
class="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled={@loading}
|
||||
>
|
||||
<%= if @loading do %>
|
||||
<svg
|
||||
class="animate-spin -ml-1 mr-2 h-4 w-4 text-white"
|
||||
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>
|
||||
Refreshing...
|
||||
<% else %>
|
||||
<svg class="w-4 h-4 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
Refresh
|
||||
<% end %>
|
||||
</button>
|
||||
<.link navigate={~p"/packets"} class="text-sm text-blue-600 hover:text-blue-800">
|
||||
View Good Packets →
|
||||
</.link>
|
||||
<.link navigate={~p"/"} class="text-sm text-blue-600 hover:text-blue-800">
|
||||
← Back to Map
|
||||
</.link>
|
||||
</div>
|
||||
</div>
|
||||
</.header>
|
||||
<.header />
|
||||
</div>
|
||||
|
||||
<div class="mt-6 bg-white shadow-sm rounded-lg overflow-hidden">
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ defmodule AprsWeb.MapLive.Index do
|
|||
overlay_callsign: "",
|
||||
trail_duration: "1",
|
||||
historical_hours: "1",
|
||||
packet_age_threshold: 3600,
|
||||
packet_age_threshold: one_hour_ago,
|
||||
slideover_open: true,
|
||||
replay_active: false,
|
||||
replay_start_time: nil,
|
||||
|
|
@ -1016,7 +1016,26 @@ defmodule AprsWeb.MapLive.Index do
|
|||
defp packet_within_time_threshold?(packet, threshold) do
|
||||
case packet do
|
||||
%{received_at: received_at} when not is_nil(received_at) ->
|
||||
DateTime.compare(received_at, threshold) in [:gt, :eq]
|
||||
threshold_dt =
|
||||
cond do
|
||||
is_integer(threshold) ->
|
||||
# Assume seconds since epoch
|
||||
DateTime.from_unix!(threshold)
|
||||
|
||||
is_binary(threshold) ->
|
||||
case DateTime.from_iso8601(threshold) do
|
||||
{:ok, dt, _} -> dt
|
||||
_ -> DateTime.utc_now()
|
||||
end
|
||||
|
||||
match?(%DateTime{}, threshold) ->
|
||||
threshold
|
||||
|
||||
true ->
|
||||
DateTime.utc_now()
|
||||
end
|
||||
|
||||
DateTime.compare(received_at, threshold_dt) in [:gt, :eq]
|
||||
|
||||
_ ->
|
||||
# If no timestamp, treat as current
|
||||
|
|
@ -1381,7 +1400,7 @@ defmodule AprsWeb.MapLive.Index do
|
|||
<div class="aprs-callsign"><strong><a href="/map/callsign/#{packet_info.callsign}">#{packet_info.callsign}</a></strong></div>
|
||||
#{comment_html}
|
||||
<div class="aprs-coords">#{Float.round(PacketUtils.to_float(lat), 4)}, #{Float.round(PacketUtils.to_float(lon), 4)}</div>
|
||||
<div class="aprs-time">#{packet_info.timestamp}</div>
|
||||
<div class="aprs-time">#{format_popup_timestamp(packet_info.timestamp)}</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
|
@ -1408,11 +1427,11 @@ defmodule AprsWeb.MapLive.Index do
|
|||
|
||||
defp build_weather_popup_html(packet, callsign) do
|
||||
received_at = get_received_at(packet)
|
||||
timestamp_str = format_timestamp(received_at)
|
||||
timestamp_str = format_popup_timestamp(received_at)
|
||||
|
||||
"""
|
||||
<strong>#{callsign} - Weather Report</strong><br>
|
||||
<small>#{timestamp_str} UTC</small>
|
||||
<small>#{timestamp_str}</small>
|
||||
<hr>
|
||||
Temperature: #{PacketUtils.get_weather_field(packet, :temperature)}°F<br>
|
||||
Humidity: #{PacketUtils.get_weather_field(packet, :humidity)}%<br>
|
||||
|
|
@ -1432,10 +1451,30 @@ defmodule AprsWeb.MapLive.Index do
|
|||
end
|
||||
end
|
||||
|
||||
defp format_timestamp(received_at) do
|
||||
if received_at,
|
||||
do: Calendar.strftime(received_at, "%Y-%m-%d %H:%M:%S"),
|
||||
else: "N/A"
|
||||
defp format_popup_timestamp(ts) do
|
||||
cond do
|
||||
is_binary(ts) ->
|
||||
case DateTime.from_iso8601(ts) do
|
||||
{:ok, dt, _} -> Calendar.strftime(dt, "%Y-%m-%d %H:%M:%S UTC")
|
||||
_ -> ts
|
||||
end
|
||||
|
||||
is_integer(ts) ->
|
||||
ts
|
||||
|> DateTime.from_unix!(:millisecond)
|
||||
|> Calendar.strftime("%Y-%m-%d %H:%M:%S UTC")
|
||||
|
||||
match?(%DateTime{}, ts) ->
|
||||
Calendar.strftime(ts, "%Y-%m-%d %H:%M:%S UTC")
|
||||
|
||||
match?(%NaiveDateTime{}, ts) ->
|
||||
ts
|
||||
|> DateTime.from_naive!("Etc/UTC")
|
||||
|> Calendar.strftime("%Y-%m-%d %H:%M:%S UTC")
|
||||
|
||||
true ->
|
||||
to_string(ts)
|
||||
end
|
||||
end
|
||||
|
||||
@spec generate_callsign(map() | struct()) :: String.t()
|
||||
|
|
|
|||
|
|
@ -1,23 +1,6 @@
|
|||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 my-8">
|
||||
<div class="mb-8">
|
||||
<.header>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold text-gray-900">Packets for {@callsign}</h1>
|
||||
<p class="mt-1 text-sm text-gray-500">
|
||||
Showing up to 100 packets (stored and live) for callsign {@callsign}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center space-x-4">
|
||||
<.link navigate={~p"/"} class="text-sm text-blue-600 hover:text-blue-800">
|
||||
← Back to Map
|
||||
</.link>
|
||||
<.link navigate={~p"/packets"} class="text-sm text-blue-600 hover:text-blue-800">
|
||||
All Packets
|
||||
</.link>
|
||||
</div>
|
||||
</div>
|
||||
</.header>
|
||||
<.header />
|
||||
</div>
|
||||
|
||||
<%= if @error do %>
|
||||
|
|
|
|||
|
|
@ -1,21 +1,6 @@
|
|||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 my-8">
|
||||
<div class="mb-8">
|
||||
<.header>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold text-gray-900">APRS Packets</h1>
|
||||
<p class="mt-1 text-sm text-gray-500">Live and recent APRS packets from the network</p>
|
||||
</div>
|
||||
<div class="flex items-center space-x-4">
|
||||
<.link navigate={~p"/badpackets"} class="text-sm text-blue-600 hover:text-blue-800">
|
||||
View Bad Packets →
|
||||
</.link>
|
||||
<.link navigate={~p"/"} class="text-sm text-blue-600 hover:text-blue-800">
|
||||
← Back to Map
|
||||
</.link>
|
||||
</div>
|
||||
</div>
|
||||
</.header>
|
||||
<.header />
|
||||
</div>
|
||||
|
||||
<div class="mt-6 bg-white shadow-sm rounded-lg overflow-hidden">
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ defmodule AprsWeb.UserConfirmationInstructionsLive do
|
|||
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<.header>Resend confirmation instructions</.header>
|
||||
<.header />
|
||||
|
||||
<.simple_form
|
||||
:let={f}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ defmodule AprsWeb.UserConfirmationLive do
|
|||
def render(%{live_action: :edit} = assigns) do
|
||||
~H"""
|
||||
<div class="mx-auto max-w-sm">
|
||||
<.header class="text-center">Confirm Account</.header>
|
||||
<.header class="text-center" />
|
||||
|
||||
<.simple_form :let={f} for={%{}} as={:user} id="confirmation_form" phx-submit="confirm_account">
|
||||
<.input field={{f, :token}} type="hidden" value={@token} />
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ defmodule AprsWeb.UserForgotPasswordLive do
|
|||
def render(assigns) do
|
||||
~H"""
|
||||
<div class="mx-auto max-w-sm">
|
||||
<.header class="text-center" />
|
||||
|
||||
<.header class="text-center">
|
||||
Forgot your password?
|
||||
<:subtitle>We'll send a password reset link to your inbox</:subtitle>
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ defmodule AprsWeb.UserLoginLive do
|
|||
def render(assigns) do
|
||||
~H"""
|
||||
<div class="mx-auto max-w-sm">
|
||||
<.header class="text-center" />
|
||||
|
||||
<.header class="text-center">
|
||||
Sign in to account
|
||||
<:subtitle>
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ defmodule AprsWeb.UserRegistrationLive do
|
|||
def render(assigns) do
|
||||
~H"""
|
||||
<div class="mx-auto max-w-sm">
|
||||
<.header class="text-center" />
|
||||
|
||||
<.header class="text-center">
|
||||
Register for an account
|
||||
<:subtitle>
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ defmodule AprsWeb.UserResetPasswordLive do
|
|||
def render(assigns) do
|
||||
~H"""
|
||||
<div class="mx-auto max-w-sm">
|
||||
<.header class="text-center">Reset Password</.header>
|
||||
<.header class="text-center" />
|
||||
|
||||
<.simple_form
|
||||
:let={f}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ defmodule AprsWeb.UserSettingsLive do
|
|||
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<.header>Change Email</.header>
|
||||
<.header />
|
||||
|
||||
<.simple_form
|
||||
:let={f}
|
||||
|
|
@ -35,7 +35,7 @@ defmodule AprsWeb.UserSettingsLive do
|
|||
</:actions>
|
||||
</.simple_form>
|
||||
|
||||
<.header>Change Password</.header>
|
||||
<.header />
|
||||
|
||||
<.simple_form
|
||||
:let={f}
|
||||
|
|
|
|||
27
lib/aprs_web/live/weather_live/callsign_view.ex
Normal file
27
lib/aprs_web/live/weather_live/callsign_view.ex
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
defmodule AprsWeb.WeatherLive.CallsignView do
|
||||
use AprsWeb, :live_view
|
||||
|
||||
alias Aprs.Packets
|
||||
alias AprsWeb.MapLive.PacketUtils
|
||||
|
||||
@impl true
|
||||
def mount(%{"callsign" => callsign}, _session, socket) do
|
||||
normalized_callsign = String.upcase(String.trim(callsign))
|
||||
weather_packet = get_latest_weather_packet(normalized_callsign)
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:callsign, normalized_callsign)
|
||||
|> assign(:weather_packet, weather_packet)
|
||||
|> assign(:page_title, "Weather for #{normalized_callsign}")
|
||||
|
||||
{:ok, socket}
|
||||
end
|
||||
|
||||
defp get_latest_weather_packet(callsign) do
|
||||
# Get the most recent packet for this callsign that is a weather report
|
||||
%{callsign: callsign, limit: 10}
|
||||
|> Packets.get_recent_packets()
|
||||
|> Enum.find(&PacketUtils.is_weather_packet?/1)
|
||||
end
|
||||
end
|
||||
53
lib/aprs_web/live/weather_live/callsign_view.html.heex
Normal file
53
lib/aprs_web/live/weather_live/callsign_view.html.heex
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
<%= if @weather_packet do %>
|
||||
<div class="max-w-lg mx-auto mt-8 p-6 bg-white rounded shadow">
|
||||
<h1 class="text-2xl font-bold mb-2">Weather for {@callsign}</h1>
|
||||
<div class="text-gray-600 mb-4">
|
||||
{PacketUtils.get_timestamp(@weather_packet)}
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<strong>Temperature:</strong>
|
||||
{PacketUtils.get_weather_field(@weather_packet, :temperature)}°F
|
||||
</div>
|
||||
<div>
|
||||
<strong>Humidity:</strong>
|
||||
{PacketUtils.get_weather_field(@weather_packet, :humidity)}%
|
||||
</div>
|
||||
<div>
|
||||
<strong>Wind:</strong>
|
||||
{PacketUtils.get_weather_field(@weather_packet, :wind_direction)}° @ {PacketUtils.get_weather_field(
|
||||
@weather_packet,
|
||||
:wind_speed
|
||||
)} mph
|
||||
</div>
|
||||
<div>
|
||||
<strong>Gusts:</strong>
|
||||
{PacketUtils.get_weather_field(@weather_packet, :wind_gust)} mph
|
||||
</div>
|
||||
<div>
|
||||
<strong>Pressure:</strong>
|
||||
{PacketUtils.get_weather_field(@weather_packet, :pressure)} hPa
|
||||
</div>
|
||||
<div>
|
||||
<strong>Rain (1h):</strong>
|
||||
{PacketUtils.get_weather_field(@weather_packet, :rain_1h)} in
|
||||
</div>
|
||||
<div>
|
||||
<strong>Rain (24h):</strong>
|
||||
{PacketUtils.get_weather_field(@weather_packet, :rain_24h)} in
|
||||
</div>
|
||||
<div>
|
||||
<strong>Rain (since midnight):</strong>
|
||||
{PacketUtils.get_weather_field(@weather_packet, :rain_since_midnight)} in
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 text-sm text-gray-500">
|
||||
<strong>Raw comment:</strong> {@weather_packet.comment || @weather_packet["comment"]}
|
||||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="max-w-lg mx-auto mt-8 p-6 bg-white rounded shadow text-center">
|
||||
<h1 class="text-2xl font-bold mb-2">Weather for {@callsign}</h1>
|
||||
<div class="text-gray-600">No recent weather data available for this callsign.</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
|
@ -40,6 +40,8 @@ defmodule AprsWeb.Router do
|
|||
live "/packets", PacketsLive.Index, :index
|
||||
live "/packets/:callsign", PacketsLive.CallsignView, :index
|
||||
live "/badpackets", BadPacketsLive.Index, :index
|
||||
live "/weather/:callsign", WeatherLive.CallsignView, :index
|
||||
live "/about", AboutLive, :index
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue