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""" <.header> Submit Contact <:subtitle>Help us build a better propagation model

Every contact matters

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.

Your contact will be available on the <.link navigate="/contacts" class="link link-primary">contacts page shortly after submission, with terrain analysis, atmospheric data, and propagation scoring added automatically.

<%= 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 %>
""" end defp single_contact_form(assigns) do ~H""" <.form for={@form} id="contact-form" phx-change="validate" phx-submit="save" class="space-y-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 />

Be as specific as possible with grid squares (6 characters preferred, e.g. EM12kp).

<.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 />
<.input field={@form[:submitter_email]} type="email" label="Your Email" placeholder="you@example.com" required />
<.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
""" end defp csv_upload_form(assigns) do ~H"""

Upload a CSV file with multiple contacts. Required columns:

station1, station2, grid1, grid2, band, mode, qso_timestamp

<.icon name="hero-arrow-down-tray" class="w-4 h-4" /> Download sample CSV

""" end defp csv_results(assigns) do ~H"""
<%= if length(@result.imported) > 0 do %>
<.icon name="hero-check-circle" class="w-5 h-5" /> {length(@result.imported)} {if length(@result.imported) == 1, do: "contact", else: "contacts"} imported successfully
<% end %> <%= if length(@result.errors) > 0 do %>
<.icon name="hero-exclamation-triangle" class="w-5 h-5" /> {length(@result.errors)} {if length(@result.errors) == 1, do: "row", else: "rows"} had errors
<%= for {row_num, messages} <- @result.errors do %> <% end %>
Row Errors
Row {row_num} <%= for msg <- messages do %>
{msg}
<% end %>
<% end %>
""" end end