378 lines
12 KiB
Elixir
378 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
|