prop/lib/microwaveprop_web/live/submit_live.ex
Graham McIntire c1da0f20b1
Add LiveStash for state recovery across reconnects
- Installed live_stash ~> 0.1 with browser memory adapter
- Map page: recovers selected_band and selected_time on reconnect
- Submit page: recovers active_tab (single/csv) on reconnect
- JS params initialized with initLiveStash wrapper
2026-04-02 13:12:17 -05:00

374 lines
12 KiB
Elixir

defmodule MicrowavepropWeb.SubmitLive do
@moduledoc false
use MicrowavepropWeb, :live_view
use LiveStash
alias Microwaveprop.Radio
alias Microwaveprop.Radio.Contact
alias Microwaveprop.Radio.CsvImport
alias Microwaveprop.Workers.ContactWeatherEnqueueWorker
@band_options [
{"1296 MHz", "1296"},
{"2304 MHz", "2304"},
{"3456 MHz", "3456"},
{"5760 MHz", "5760"},
{"10 GHz", "10000"},
{"24 GHz", "24000"},
{"47 GHz", "47000"},
{"68 GHz", "68000"},
{"76 GHz", "75000"},
{"122 GHz", "122000"},
{"134 GHz", "134000"},
{"241 GHz", "241000"}
]
@mode_options ~w(CW SSB FM FT8 FT4)
@impl true
def mount(_params, _session, socket) do
socket = allow_upload(socket, :csv, accept: ~w(.csv), max_entries: 1, max_file_size: 10_000_000)
case LiveStash.recover_state(socket) do
{:recovered, socket} ->
{:ok, socket}
_ ->
changeset = Radio.change_contact(%Contact{})
{:ok,
assign(socket,
page_title: "Submit Contact",
form: to_form(changeset),
band_options: @band_options,
mode_options: @mode_options,
submitted_at: nil,
active_tab: :single,
csv_result: nil,
csv_email: ""
)}
end
end
@impl true
def handle_event("switch_tab", %{"tab" => tab}, socket) do
socket = assign(socket, active_tab: String.to_existing_atom(tab))
{:noreply, LiveStash.stash_assigns(socket, [:active_tab])}
end
def handle_event("validate", %{"contact" => contact_params}, socket) do
changeset =
%Contact{}
|> Radio.change_contact(contact_params)
|> Map.put(:action, :validate)
{:noreply, assign(socket, form: to_form(changeset))}
end
def handle_event("validate_csv", _params, socket) do
{:noreply, socket}
end
# Minimum milliseconds between submissions per LiveView session.
# For broader IP-based rate limiting, use a reverse proxy (nginx limit_req, Cloudflare).
@submission_cooldown_ms 30_000
def handle_event("save", %{"contact" => contact_params}, socket) do
if recently_submitted?(socket) do
{:noreply, put_flash(socket, :error, "Please wait before submitting another contact.")}
else
case Radio.create_contact(contact_params) do
{:ok, contact} ->
ContactWeatherEnqueueWorker.enqueue_for_contact(contact)
{:noreply,
socket
|> assign(submitted_at: System.monotonic_time(:millisecond))
|> put_flash(:info, "Contact submitted successfully!")
|> push_navigate(to: ~p"/contacts/#{contact.id}")}
{:error, changeset} ->
{:noreply, assign(socket, form: to_form(changeset))}
end
end
end
def handle_event("upload_csv", %{"submitter_email" => email}, socket) do
if String.trim(email) == "" do
{:noreply, put_flash(socket, :error, "Email is required for CSV upload")}
else
uploaded_contents =
consume_uploaded_entries(socket, :csv, fn %{path: path}, _entry ->
{:ok, File.read!(path)}
end)
case uploaded_contents do
[content] ->
handle_csv_import(content, email, socket)
[] ->
{:noreply, put_flash(socket, :error, "Please select a CSV file")}
end
end
end
def handle_event("reset_csv", _params, socket) do
{:noreply, assign(socket, csv_result: nil)}
end
defp handle_csv_import(content, email, socket) do
case CsvImport.import(content, email) do
{:ok, result} ->
{:noreply, assign(socket, csv_result: result, csv_email: email)}
{:error, :empty_csv} ->
{:noreply, put_flash(socket, :error, "CSV file is empty")}
{:error, :no_data_rows} ->
{:noreply, put_flash(socket, :error, "CSV file has no data rows")}
end
end
defp recently_submitted?(socket) do
case socket.assigns.submitted_at do
nil -> false
ts -> System.monotonic_time(:millisecond) - ts < @submission_cooldown_ms
end
end
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash}>
<.header>
Submit Contact
<:subtitle>Help us build a better propagation model</:subtitle>
</.header>
<div class="bg-info/10 border border-info/30 rounded-box p-4 mb-6 text-sm leading-relaxed">
<p class="font-semibold text-info mb-1">Every contact matters</p>
<p>
Our propagation model improves with real-world data. Each verified microwave contact
you submit is matched against atmospheric conditions at the time of your contact,
helping us calibrate predictions for all bands from 10 GHz to 241 GHz. The more contacts
we have, the better our forecasts get for everyone.
</p>
</div>
<div class="bg-base-200 rounded-box p-4 mb-6 text-sm leading-relaxed">
<p>
Your contact will be available on the
<.link navigate="/contacts" class="link link-primary">contacts page</.link>
shortly after submission, with terrain analysis, atmospheric data, and propagation scoring
added automatically.
</p>
</div>
<div class="flex gap-1 bg-base-200 rounded-lg p-1 mb-6 w-fit">
<button
class={[
"px-4 py-2 text-sm font-medium rounded-md transition-colors",
if(@active_tab == :single, do: "bg-primary text-primary-content", else: "hover:bg-base-300")
]}
phx-click="switch_tab"
phx-value-tab="single"
>
Single Contact
</button>
<button
class={[
"px-4 py-2 text-sm font-medium rounded-md transition-colors",
if(@active_tab == :csv, do: "bg-primary text-primary-content", else: "hover:bg-base-300")
]}
phx-click="switch_tab"
phx-value-tab="csv"
>
Upload CSV
</button>
</div>
<%= if @active_tab == :single do %>
<.single_contact_form form={@form} band_options={@band_options} mode_options={@mode_options} />
<% else %>
<%= if @csv_result do %>
<.csv_results result={@csv_result} />
<% else %>
<.csv_upload_form uploads={@uploads} />
<% end %>
<% end %>
</Layouts.app>
"""
end
defp single_contact_form(assigns) do
~H"""
<.form for={@form} id="contact-form" phx-change="validate" phx-submit="save" class="space-y-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<.input field={@form[:station1]} type="text" label="Station 1" placeholder="W5XD" required />
<.input
field={@form[:grid1]}
type="text"
label="Grid 1"
placeholder="EM12kp"
phx-debounce="blur"
required
/>
<.input field={@form[:station2]} type="text" label="Station 2" placeholder="K5TR" required />
<.input
field={@form[:grid2]}
type="text"
label="Grid 2"
placeholder="EM00cd"
phx-debounce="blur"
required
/>
</div>
<p class="text-sm text-base-content/60 -mt-2">
Be as specific as possible with grid squares (6 characters preferred, e.g. EM12kp).
</p>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<.input
field={@form[:band]}
type="select"
label="Band"
prompt="Select band"
options={@band_options}
required
/>
<.input
field={@form[:mode]}
type="select"
label="Mode"
prompt="Select mode"
options={@mode_options}
required
/>
<.input field={@form[:qso_timestamp]} type="datetime-local" label="Timestamp (UTC)" required />
</div>
<.input
field={@form[:submitter_email]}
type="email"
label="Your Email"
placeholder="you@example.com"
required
/>
<div class="mt-6">
<.button phx-disable-with="Submitting..." class="btn btn-primary btn-lg w-full sm:w-auto">
<.icon name="hero-arrow-up-tray" class="w-5 h-5" /> Submit Contact
</.button>
</div>
</.form>
"""
end
defp csv_upload_form(assigns) do
~H"""
<div class="space-y-6">
<div class="bg-base-200 rounded-box p-4 text-sm">
<p class="mb-2">
Upload a CSV file with multiple contacts. Required columns:
</p>
<code class="text-xs">station1, station2, grid1, grid2, band, mode, qso_timestamp</code>
<ul class="list-disc list-inside mt-2 space-y-1 text-base-content/60">
<li>
Timestamps in most formats accepted (e.g. <code>2024-06-15T14:30:00Z</code>,
<code>6/15/2024 2:30 PM</code>, <code>2024-06-15 14:30</code>). All times assumed UTC.
</li>
<li>Grid squares should be as detailed as possible (6 characters preferred, e.g. EM12kp)</li>
<li>Band in MHz (e.g. 10000, 24000)</li>
</ul>
<p class="mt-2">
<a href="/downloads/sample_contacts.csv" download class="link link-primary">
<.icon name="hero-arrow-down-tray" class="w-4 h-4" /> Download sample CSV
</a>
</p>
</div>
<form id="csv-upload-form" phx-submit="upload_csv" phx-change="validate_csv" class="space-y-4">
<div class="fieldset mb-2">
<label>
<span class="label mb-1">CSV File</span>
<.live_file_input upload={@uploads.csv} class="file-input file-input-bordered w-full" />
</label>
</div>
<div class="fieldset mb-2">
<label>
<span class="label mb-1">Your Email</span>
<input
type="email"
name="submitter_email"
class="w-full input"
placeholder="you@example.com"
value=""
/>
</label>
</div>
<div class="mt-6">
<button type="submit" class="btn btn-primary btn-lg w-full sm:w-auto">
<.icon name="hero-arrow-up-tray" class="w-5 h-5" /> Upload Contacts
</button>
</div>
</form>
</div>
"""
end
defp csv_results(assigns) do
~H"""
<div class="space-y-4">
<%= if length(@result.imported) > 0 do %>
<div class="alert alert-success">
<.icon name="hero-check-circle" class="w-5 h-5" />
<span>
{length(@result.imported)} {if length(@result.imported) == 1,
do: "contact",
else: "contacts"} imported successfully
</span>
</div>
<% end %>
<%= if length(@result.errors) > 0 do %>
<div class="alert alert-error">
<.icon name="hero-exclamation-triangle" class="w-5 h-5" />
<span>
{length(@result.errors)} {if length(@result.errors) == 1, do: "row", else: "rows"} had errors
</span>
</div>
<div class="overflow-x-auto">
<table class="table table-sm">
<thead>
<tr>
<th>Row</th>
<th>Errors</th>
</tr>
</thead>
<tbody>
<%= for {row_num, messages} <- @result.errors do %>
<tr>
<td class="font-mono">Row {row_num}</td>
<td>
<%= for msg <- messages do %>
<div>{msg}</div>
<% end %>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
<% end %>
<button type="button" class="btn btn-outline" phx-click="reset_csv">
Upload another
</button>
</div>
"""
end
end