`handle_event("switch_tab", ...)` ran `String.to_existing_atom/1` on
the raw `tab` value from the browser, so a stale, forged, or
mistyped client event with any unknown string crashed the LiveView
with `ArgumentError: not an already existing atom`.
Compares against an explicit `@valid_tabs` allowlist (`:single`,
`:csv`, `:adif`) — known values switch the active tab, anything
else is ignored.
945 lines
31 KiB
Elixir
945 lines
31 KiB
Elixir
defmodule MicrowavepropWeb.SubmitLive do
|
|
@moduledoc "`/submit` QSO submission form; enqueues enrichment jobs on save."
|
|
use MicrowavepropWeb, :live_view
|
|
use LiveStash, stored_keys: [:active_tab]
|
|
|
|
alias Microwaveprop.Propagation.BandConfig
|
|
alias Microwaveprop.Radio
|
|
alias Microwaveprop.Radio.AdifImport
|
|
alias Microwaveprop.Radio.Contact
|
|
alias Microwaveprop.Radio.CsvImport
|
|
alias Microwaveprop.Workers.ContactWeatherEnqueueWorker
|
|
|
|
@band_options BandConfig.band_options()
|
|
|
|
@mode_options ~w(CW SSB FM FT8 FT4 Q65)
|
|
|
|
@impl true
|
|
def mount(_params, _session, socket) do
|
|
socket =
|
|
socket
|
|
|> allow_upload(:csv,
|
|
accept: ~w(.csv),
|
|
max_entries: 1,
|
|
max_file_size: 10_000_000,
|
|
auto_upload: false
|
|
)
|
|
|> allow_upload(:adif,
|
|
accept: :any,
|
|
max_entries: 1,
|
|
max_file_size: 10_000_000,
|
|
auto_upload: false
|
|
)
|
|
|
|
changeset = Radio.change_contact(%Contact{})
|
|
|
|
socket =
|
|
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_preview: nil,
|
|
csv_email: ""
|
|
)
|
|
|
|
case LiveStash.recover_state(socket) do
|
|
{:recovered, socket} ->
|
|
{:ok, socket}
|
|
|
|
_ ->
|
|
{:ok, socket}
|
|
end
|
|
end
|
|
|
|
@valid_tabs ~w(single csv adif)a
|
|
|
|
@impl true
|
|
def handle_event("switch_tab", %{"tab" => tab}, socket) do
|
|
case Enum.find(@valid_tabs, &(Atom.to_string(&1) == tab)) do
|
|
nil ->
|
|
# Stale or forged client event — ignore instead of crashing the
|
|
# LiveView via String.to_existing_atom/1.
|
|
{:noreply, socket}
|
|
|
|
atom ->
|
|
socket = assign(socket, active_tab: atom)
|
|
{:noreply, MicrowavepropWeb.LiveStashGuard.stash(socket)}
|
|
end
|
|
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
|
|
|
|
def handle_event("validate_adif", _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
|
|
{contact_params, user_id} = merge_user_params(contact_params, socket)
|
|
|
|
case Radio.create_contact(contact_params, user_id) 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, :duplicate, existing} ->
|
|
{:noreply,
|
|
socket
|
|
|> put_flash(
|
|
:error,
|
|
"A matching contact already exists: #{existing.station1} ↔ #{existing.station2} on #{existing.band} MHz"
|
|
)
|
|
|> push_navigate(to: ~p"/contacts/#{existing.id}")}
|
|
|
|
{:error, changeset} ->
|
|
{:noreply, assign(socket, form: to_form(changeset))}
|
|
end
|
|
end
|
|
end
|
|
|
|
def handle_event("upload_csv", params, socket) do
|
|
email =
|
|
case current_user(socket.assigns) do
|
|
%{email: user_email} -> user_email
|
|
_ -> params |> Map.get("submitter_email", "") |> String.trim()
|
|
end
|
|
|
|
private = Map.get(params, "private") == "true"
|
|
|
|
if 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_preview(content, email, private, socket)
|
|
|
|
[] ->
|
|
{:noreply, put_flash(socket, :error, "Please select a CSV file")}
|
|
end
|
|
end
|
|
end
|
|
|
|
def handle_event("cancel_csv_upload", %{"ref" => ref}, socket) do
|
|
{:noreply, cancel_upload(socket, :csv, ref)}
|
|
end
|
|
|
|
def handle_event("upload_adif", params, socket) do
|
|
email =
|
|
case current_user(socket.assigns) do
|
|
%{email: user_email} -> user_email
|
|
_ -> params |> Map.get("submitter_email", "") |> String.trim()
|
|
end
|
|
|
|
private = Map.get(params, "private") == "true"
|
|
|
|
if email == "" do
|
|
{:noreply, put_flash(socket, :error, "Email is required for ADIF upload")}
|
|
else
|
|
uploaded_contents =
|
|
consume_uploaded_entries(socket, :adif, fn %{path: path}, _entry ->
|
|
{:ok, File.read!(path)}
|
|
end)
|
|
|
|
case uploaded_contents do
|
|
[content] ->
|
|
handle_adif_preview(content, email, private, socket)
|
|
|
|
[] ->
|
|
{:noreply, put_flash(socket, :error, "Please select an ADIF file")}
|
|
end
|
|
end
|
|
end
|
|
|
|
def handle_event("cancel_adif_upload", %{"ref" => ref}, socket) do
|
|
{:noreply, cancel_upload(socket, :adif, ref)}
|
|
end
|
|
|
|
def handle_event("confirm_csv", _params, socket) do
|
|
case socket.assigns.csv_preview do
|
|
%{valid: valid_rows, refinements: refinements} = preview
|
|
when valid_rows != [] or refinements != [] ->
|
|
private = socket.assigns[:csv_private] || false
|
|
{:ok, run_id} = CsvImport.enqueue(preview, socket.assigns.csv_email, private: private)
|
|
|
|
{:noreply,
|
|
socket
|
|
|> assign(csv_preview: nil, csv_private: false)
|
|
|> push_navigate(to: ~p"/imports/#{run_id}")}
|
|
|
|
_ ->
|
|
{:noreply, put_flash(socket, :error, "Nothing to import.")}
|
|
end
|
|
end
|
|
|
|
def handle_event("cancel_csv", _params, socket) do
|
|
{:noreply, assign(socket, csv_preview: nil, csv_private: false)}
|
|
end
|
|
|
|
defp handle_adif_preview(content, email, private, socket) do
|
|
case AdifImport.preview(content, email) do
|
|
{:ok, preview} ->
|
|
{:noreply, assign(socket, csv_preview: preview, csv_email: email, csv_private: private)}
|
|
|
|
{:error, :no_records} ->
|
|
{:noreply, put_flash(socket, :error, "ADIF file contains no records")}
|
|
end
|
|
end
|
|
|
|
defp handle_csv_preview(content, email, private, socket) do
|
|
case CsvImport.preview(content, email) do
|
|
{:ok, preview} ->
|
|
{:noreply, assign(socket, csv_preview: preview, csv_email: email, csv_private: private)}
|
|
|
|
{: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
|
|
|
|
defp merge_user_params(params, socket) do
|
|
case current_user(socket.assigns) do
|
|
%{id: user_id, email: user_email} ->
|
|
{Map.put(params, "submitter_email", user_email), user_id}
|
|
|
|
_ ->
|
|
{params, nil}
|
|
end
|
|
end
|
|
|
|
defp error_to_string(:too_large), do: "File is too large"
|
|
defp error_to_string(:too_many_files), do: "Only one file allowed"
|
|
defp error_to_string(:not_accepted), do: "File type not accepted"
|
|
defp error_to_string(other), do: to_string(other)
|
|
|
|
@impl true
|
|
def render(assigns) do
|
|
~H"""
|
|
<Layouts.app flash={@flash} current_scope={@current_scope}>
|
|
<.header>
|
|
Submit Contact
|
|
<:subtitle>Help us build a better propagation model</:subtitle>
|
|
</.header>
|
|
|
|
<div class="alert alert-info mb-6 text-sm leading-relaxed">
|
|
<div>
|
|
<p class="font-semibold mb-1">Every contact matters</p>
|
|
<p>
|
|
Our propagation model improves with real-world data. Each verified contact
|
|
you submit is matched against atmospheric conditions at the time of your contact,
|
|
helping us calibrate predictions for all amateur bands from 50 MHz and up. The
|
|
more contacts we have, the better our forecasts get for everyone.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="alert 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 role="tablist" class="tabs tabs-box mb-6">
|
|
<button
|
|
role="tab"
|
|
class={["tab", @active_tab == :single && "tab-active"]}
|
|
phx-click="switch_tab"
|
|
phx-value-tab="single"
|
|
>
|
|
Single Contact
|
|
</button>
|
|
<button
|
|
role="tab"
|
|
class={["tab", @active_tab == :csv && "tab-active"]}
|
|
phx-click="switch_tab"
|
|
phx-value-tab="csv"
|
|
>
|
|
Upload CSV
|
|
</button>
|
|
<button
|
|
role="tab"
|
|
class={["tab", @active_tab == :adif && "tab-active"]}
|
|
phx-click="switch_tab"
|
|
phx-value-tab="adif"
|
|
>
|
|
Upload ADIF
|
|
</button>
|
|
</div>
|
|
|
|
<%= case @active_tab do %>
|
|
<% :single -> %>
|
|
<.single_contact_form
|
|
form={@form}
|
|
band_options={@band_options}
|
|
mode_options={@mode_options}
|
|
current_user={current_user(assigns)}
|
|
/>
|
|
<% :csv -> %>
|
|
<%= if @csv_preview do %>
|
|
<.csv_preview preview={@csv_preview} />
|
|
<% else %>
|
|
<.csv_upload_form uploads={@uploads} current_user={current_user(assigns)} />
|
|
<% end %>
|
|
<% :adif -> %>
|
|
<%= if @csv_preview do %>
|
|
<.csv_preview preview={@csv_preview} />
|
|
<% else %>
|
|
<.adif_upload_form uploads={@uploads} current_user={current_user(assigns)} />
|
|
<% end %>
|
|
<% end %>
|
|
</Layouts.app>
|
|
"""
|
|
end
|
|
|
|
# In-template variant — takes an assigns map directly (not a socket).
|
|
defp current_user(%{current_scope: %{user: %{} = user}}), do: user
|
|
defp current_user(_), do: nil
|
|
|
|
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-3 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[:height1_ft]}
|
|
type="number"
|
|
label="Height 1 (ft AGL)"
|
|
min="0"
|
|
max="1000"
|
|
placeholder="Optional"
|
|
/>
|
|
<.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
|
|
/>
|
|
<.input
|
|
field={@form[:height2_ft]}
|
|
type="number"
|
|
label="Height 2 (ft AGL)"
|
|
min="0"
|
|
max="1000"
|
|
placeholder="Optional"
|
|
/>
|
|
</div>
|
|
|
|
<p class="text-sm text-base-content/60 -mt-2">
|
|
Be as specific as possible with grid squares (8 characters preferred, e.g. EM12kp37).
|
|
Antenna heights are optional but improve terrain-clearance analysis.
|
|
</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="Optional"
|
|
options={@mode_options}
|
|
/>
|
|
<.input
|
|
field={@form[:qso_timestamp]}
|
|
type="text"
|
|
label="Timestamp (UTC, 24h)"
|
|
placeholder="YYYY-MM-DD HH:MM"
|
|
pattern="\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(:\d{2})?Z?"
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<%= if @current_user do %>
|
|
<input
|
|
type="hidden"
|
|
name="contact[submitter_email]"
|
|
value={@current_user.email}
|
|
/>
|
|
<% else %>
|
|
<.input
|
|
field={@form[:submitter_email]}
|
|
type="email"
|
|
label="Your Email"
|
|
placeholder="you@example.com"
|
|
required
|
|
/>
|
|
<% end %>
|
|
|
|
<.input
|
|
field={@form[:notes]}
|
|
type="textarea"
|
|
label="Notes (optional)"
|
|
placeholder="Operator notes — propagation mode, weather, equipment, QSO commentary…"
|
|
rows="3"
|
|
maxlength="2000"
|
|
/>
|
|
|
|
<div class="mt-4">
|
|
<.input
|
|
field={@form[:private]}
|
|
type="checkbox"
|
|
label="Private — only visible to me and administrators"
|
|
/>
|
|
</div>
|
|
|
|
<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="alert text-sm">
|
|
<div>
|
|
<p class="mb-2">Upload a CSV file with multiple contacts. Columns:</p>
|
|
<code class="text-xs">
|
|
station1, station2, grid1, grid2, band, mode, qso_timestamp, notes
|
|
</code>
|
|
<ul class="list-disc list-outside pl-5 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 (8 characters preferred, e.g. EM12kp37)
|
|
</li>
|
|
<li>Band in MHz (e.g. 10000, 24000)</li>
|
|
<li>
|
|
Mode is <strong>optional</strong> — omit the <code>mode</code> column entirely
|
|
(6 columns total) or leave its value blank.
|
|
</li>
|
|
<li>
|
|
Notes is <strong>optional</strong> — free-form operator commentary up to
|
|
2000 characters. Leave the cell blank to store NULL.
|
|
</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>
|
|
</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 :for={entry <- @uploads.csv.entries} class="space-y-1">
|
|
<div class="flex items-center justify-between text-xs tabular-nums">
|
|
<span class="truncate pr-2">{entry.client_name}</span>
|
|
<span class="flex items-center gap-2 shrink-0">
|
|
<span :if={uploading?(entry)}>{entry.progress}%</span>
|
|
<button
|
|
type="button"
|
|
class="btn btn-ghost btn-xs"
|
|
phx-click="cancel_csv_upload"
|
|
phx-value-ref={entry.ref}
|
|
aria-label="Cancel upload"
|
|
>
|
|
<.icon name="hero-x-mark" class="w-4 h-4" />
|
|
</button>
|
|
</span>
|
|
</div>
|
|
<progress
|
|
:if={uploading?(entry)}
|
|
class="progress progress-primary w-full"
|
|
value={entry.progress}
|
|
max="100"
|
|
>
|
|
</progress>
|
|
<div :for={err <- upload_errors(@uploads.csv, entry)} class="text-xs text-error">
|
|
{error_to_string(err)}
|
|
</div>
|
|
</div>
|
|
|
|
<div :for={err <- upload_errors(@uploads.csv)} class="text-xs text-error">
|
|
{error_to_string(err)}
|
|
</div>
|
|
|
|
<%= if @current_user do %>
|
|
<input type="hidden" name="submitter_email" value={@current_user.email} />
|
|
<% else %>
|
|
<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>
|
|
<% end %>
|
|
|
|
<label class="flex items-center gap-2 cursor-pointer mt-2">
|
|
<input type="checkbox" name="private" value="true" class="checkbox checkbox-sm" />
|
|
<span class="text-sm">Private — only visible to me and administrators</span>
|
|
</label>
|
|
|
|
<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 adif_upload_form(assigns) do
|
|
assigns = assign(assigns, :mode_mapping, AdifImport.mode_mapping_reference())
|
|
|
|
~H"""
|
|
<div class="space-y-6">
|
|
<div class="alert text-sm">
|
|
<div>
|
|
<p class="mb-2">
|
|
Upload an ADIF (.adi / .adif) file exported from your logging program.
|
|
</p>
|
|
<ul class="list-disc list-outside pl-5 mt-2 space-y-1 text-base-content/60">
|
|
<li>
|
|
Required fields: <code>CALL</code>, <code>STATION_CALLSIGN</code>
|
|
(or <code>OPERATOR</code>), <code>GRIDSQUARE</code>, <code>MY_GRIDSQUARE</code>, <code>QSO_DATE</code>, <code>TIME_ON</code>,
|
|
and <code>FREQ</code>
|
|
or <code>BAND</code>
|
|
</li>
|
|
<li>Contacts on amateur bands from 50 MHz and up will be imported</li>
|
|
<li>HF contacts (below 50 MHz) are silently skipped</li>
|
|
<li>Frequencies are fuzzy-matched to the nearest amateur band</li>
|
|
<li>
|
|
Operator commentary from <code>NOTES</code>
|
|
is carried over (falling back to <code>COMMENT</code>)
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="alert text-sm">
|
|
<div class="w-full">
|
|
<p class="font-semibold mb-2">Mode handling</p>
|
|
<p class="mb-2 text-base-content/70">
|
|
ADIF modes are normalized to the six modes we track. SUBMODE takes
|
|
precedence when present (e.g. <code>MODE=MFSK, SUBMODE=FT8</code> imports as FT8).
|
|
</p>
|
|
<table class="table table-xs mt-2">
|
|
<thead>
|
|
<tr>
|
|
<th>ADIF value</th>
|
|
<th>Stored as</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr :for={{adif, mapped} <- @mode_mapping}>
|
|
<td><code>{adif}</code></td>
|
|
<td><code>{mapped}</code></td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<form
|
|
id="adif-upload-form"
|
|
phx-submit="upload_adif"
|
|
phx-change="validate_adif"
|
|
class="space-y-4"
|
|
>
|
|
<div class="fieldset mb-2">
|
|
<label>
|
|
<span class="label mb-1">ADIF File</span>
|
|
<.live_file_input upload={@uploads.adif} class="file-input file-input-bordered w-full" />
|
|
</label>
|
|
</div>
|
|
|
|
<div :for={entry <- @uploads.adif.entries} class="space-y-1">
|
|
<div class="flex items-center justify-between text-xs tabular-nums">
|
|
<span class="truncate pr-2">{entry.client_name}</span>
|
|
<span class="flex items-center gap-2 shrink-0">
|
|
<span :if={uploading?(entry)}>{entry.progress}%</span>
|
|
<button
|
|
type="button"
|
|
class="btn btn-ghost btn-xs"
|
|
phx-click="cancel_adif_upload"
|
|
phx-value-ref={entry.ref}
|
|
aria-label="Cancel upload"
|
|
>
|
|
<.icon name="hero-x-mark" class="w-4 h-4" />
|
|
</button>
|
|
</span>
|
|
</div>
|
|
<progress
|
|
:if={uploading?(entry)}
|
|
class="progress progress-primary w-full"
|
|
value={entry.progress}
|
|
max="100"
|
|
>
|
|
</progress>
|
|
<div :for={err <- upload_errors(@uploads.adif, entry)} class="text-xs text-error">
|
|
{error_to_string(err)}
|
|
</div>
|
|
</div>
|
|
|
|
<div :for={err <- upload_errors(@uploads.adif)} class="text-xs text-error">
|
|
{error_to_string(err)}
|
|
</div>
|
|
|
|
<%= if @current_user do %>
|
|
<input type="hidden" name="submitter_email" value={@current_user.email} />
|
|
<% else %>
|
|
<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>
|
|
<% end %>
|
|
|
|
<label class="flex items-center gap-2 cursor-pointer mt-2">
|
|
<input type="checkbox" name="private" value="true" class="checkbox checkbox-sm" />
|
|
<span class="text-sm">Private — only visible to me and administrators</span>
|
|
</label>
|
|
|
|
<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_preview(assigns) do
|
|
assigns =
|
|
assign(assigns,
|
|
valid_count: length(assigns.preview.valid),
|
|
invalid_count: length(assigns.preview.invalid),
|
|
duplicate_count: length(assigns.preview.duplicates),
|
|
refinement_count: length(assigns.preview.refinements || []),
|
|
valid_sample: Enum.take(assigns.preview.valid, 20),
|
|
refinement_sample: Enum.take(assigns.preview.refinements || [], 50)
|
|
)
|
|
|
|
~H"""
|
|
<div class="space-y-6">
|
|
<div>
|
|
<h2 class="text-xl font-bold mb-2">Review before submitting</h2>
|
|
<p class="text-sm opacity-70">
|
|
Processed {@preview.total_rows} {if @preview.total_rows == 1, do: "row", else: "rows"}.
|
|
Nothing has been inserted yet — confirm below to import.
|
|
</p>
|
|
</div>
|
|
|
|
<div class="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
|
<.summary_card
|
|
tone="success"
|
|
label="Valid"
|
|
value={@valid_count}
|
|
hint="Will be inserted"
|
|
/>
|
|
<.summary_card
|
|
tone="info"
|
|
label="Refinements"
|
|
value={@refinement_count}
|
|
hint="Will update existing contacts"
|
|
/>
|
|
<.summary_card
|
|
tone="warning"
|
|
label="Duplicates"
|
|
value={@duplicate_count}
|
|
hint="Match existing or earlier rows"
|
|
/>
|
|
<.summary_card
|
|
tone="error"
|
|
label="Invalid"
|
|
value={@invalid_count}
|
|
hint="Skipped — see errors"
|
|
/>
|
|
</div>
|
|
|
|
<div :if={@invalid_count > 0}>
|
|
<h3 class="font-semibold mb-2">Invalid rows ({@invalid_count})</h3>
|
|
<div class="overflow-x-auto rounded-box border border-base-300">
|
|
<table class="table table-sm">
|
|
<thead>
|
|
<tr>
|
|
<th class="w-24">Row</th>
|
|
<th>Errors</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr :for={row <- Enum.take(@preview.invalid, 100)}>
|
|
<td class="font-mono">Row {row.row_num}</td>
|
|
<td>
|
|
<div :for={msg <- row.messages}>{msg}</div>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<p :if={@invalid_count > 100} class="text-xs opacity-60 mt-1">
|
|
Showing first 100 of {@invalid_count} invalid rows.
|
|
</p>
|
|
</div>
|
|
|
|
<div :if={@refinement_count > 0}>
|
|
<h3 class="font-semibold mb-2">Refinements ({@refinement_count})</h3>
|
|
<p class="text-xs opacity-60 mb-2">
|
|
These rows match an existing contact but add more precise data.
|
|
Confirming will update the existing contact in place.
|
|
</p>
|
|
<div class="overflow-x-auto rounded-box border border-base-300">
|
|
<table class="table table-sm">
|
|
<thead>
|
|
<tr>
|
|
<th class="w-20">Row</th>
|
|
<th>Station 1</th>
|
|
<th>Station 2</th>
|
|
<th>Band</th>
|
|
<th>Changes</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr :for={row <- @refinement_sample}>
|
|
<td class="font-mono">Row {row.row_num}</td>
|
|
<td>{row.attrs["station1"]} <span class="opacity-60">{row.attrs["grid1"]}</span></td>
|
|
<td>{row.attrs["station2"]} <span class="opacity-60">{row.attrs["grid2"]}</span></td>
|
|
<td class="tabular-nums">{row.attrs["band"]}</td>
|
|
<td class="text-xs">
|
|
<div :for={{field, value} <- Enum.sort(Map.to_list(row.changes))}>
|
|
<span class="opacity-60">{field}:</span>
|
|
<code class="text-xs">{value}</code>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<p :if={@refinement_count > length(@refinement_sample)} class="text-xs opacity-60 mt-1">
|
|
Showing first {length(@refinement_sample)} of {@refinement_count} refinements.
|
|
</p>
|
|
</div>
|
|
|
|
<div :if={@duplicate_count > 0}>
|
|
<h3 class="font-semibold mb-2">Duplicates ({@duplicate_count})</h3>
|
|
<p class="text-xs opacity-60 mb-2">
|
|
Same two callsigns at the same grids on the same band within an hour.
|
|
</p>
|
|
<div class="overflow-x-auto rounded-box border border-base-300">
|
|
<table class="table table-sm">
|
|
<thead>
|
|
<tr>
|
|
<th class="w-20">Row</th>
|
|
<th>Station 1</th>
|
|
<th>Station 2</th>
|
|
<th>Band</th>
|
|
<th>Timestamp</th>
|
|
<th>Source</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr :for={row <- Enum.take(@preview.duplicates, 100)}>
|
|
<td class="font-mono">Row {row.row_num}</td>
|
|
<td>{row.attrs["station1"]} <span class="opacity-60">{row.attrs["grid1"]}</span></td>
|
|
<td>{row.attrs["station2"]} <span class="opacity-60">{row.attrs["grid2"]}</span></td>
|
|
<td class="tabular-nums">{row.attrs["band"]}</td>
|
|
<td class="font-mono text-xs">
|
|
{Calendar.strftime(row.timestamp, "%Y-%m-%d %H:%M UTC")}
|
|
</td>
|
|
<td class="text-xs opacity-70">{duplicate_source(row.reason)}</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<p :if={@duplicate_count > 100} class="text-xs opacity-60 mt-1">
|
|
Showing first 100 of {@duplicate_count} duplicates.
|
|
</p>
|
|
</div>
|
|
|
|
<div :if={@valid_count > 0}>
|
|
<h3 class="font-semibold mb-2">Valid rows ready to import ({@valid_count})</h3>
|
|
<div class="overflow-x-auto rounded-box border border-base-300">
|
|
<table class="table table-sm">
|
|
<thead>
|
|
<tr>
|
|
<th class="w-20">Row</th>
|
|
<th>Station 1</th>
|
|
<th>Station 2</th>
|
|
<th>Band</th>
|
|
<th>Mode</th>
|
|
<th>Timestamp</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr :for={row <- @valid_sample}>
|
|
<td class="font-mono">Row {row.row_num}</td>
|
|
<td>{row.attrs["station1"]} <span class="opacity-60">{row.attrs["grid1"]}</span></td>
|
|
<td>{row.attrs["station2"]} <span class="opacity-60">{row.attrs["grid2"]}</span></td>
|
|
<td class="tabular-nums">{row.attrs["band"]}</td>
|
|
<td>{row.attrs["mode"]}</td>
|
|
<td class="font-mono text-xs">
|
|
{Calendar.strftime(row.timestamp, "%Y-%m-%d %H:%M UTC")}
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<p :if={@valid_count > length(@valid_sample)} class="text-xs opacity-60 mt-1">
|
|
Showing first {length(@valid_sample)} of {@valid_count} valid rows.
|
|
</p>
|
|
</div>
|
|
|
|
<div class="flex flex-wrap gap-2 pt-4 border-t border-base-300">
|
|
<button
|
|
:if={@valid_count > 0 or @refinement_count > 0}
|
|
type="button"
|
|
class="btn btn-primary btn-lg"
|
|
phx-click="confirm_csv"
|
|
phx-disable-with="Importing..."
|
|
data-confirm={confirm_prompt(@valid_count, @refinement_count)}
|
|
>
|
|
<.icon name="hero-check" class="w-5 h-5" />
|
|
{confirm_button_label(@valid_count, @refinement_count)}
|
|
</button>
|
|
<button type="button" class="btn btn-ghost" phx-click="cancel_csv">
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</div>
|
|
"""
|
|
end
|
|
|
|
attr :tone, :string, required: true
|
|
attr :label, :string, required: true
|
|
attr :value, :integer, required: true
|
|
attr :hint, :string, required: true
|
|
|
|
defp summary_card(assigns) do
|
|
~H"""
|
|
<div class={[
|
|
"rounded-box border p-4",
|
|
case @tone do
|
|
"success" -> "border-success/30 bg-success/10"
|
|
"warning" -> "border-warning/30 bg-warning/10"
|
|
"error" -> "border-error/30 bg-error/10"
|
|
"info" -> "border-info/30 bg-info/10"
|
|
_ -> "border-base-300 bg-base-200"
|
|
end
|
|
]}>
|
|
<div class="text-xs uppercase tracking-wider opacity-70">{@label}</div>
|
|
<div class="text-3xl font-bold tabular-nums">{@value}</div>
|
|
<div class="text-xs opacity-60">{@hint}</div>
|
|
</div>
|
|
"""
|
|
end
|
|
|
|
defp duplicate_source(:existing_contact), do: "Already in database"
|
|
defp duplicate_source(:earlier_in_upload), do: "Earlier row in this upload"
|
|
defp duplicate_source(_), do: "Duplicate"
|
|
|
|
defp pluralize(1, word), do: word
|
|
defp pluralize(_, word), do: word <> "s"
|
|
|
|
# An upload entry is actively streaming when its progress is partway
|
|
# between 0 and 100. With `auto_upload: false` entries sit at
|
|
# `progress: 0` after the user picks a file and only start streaming
|
|
# when the form is submitted, so gating the progress bar and percent
|
|
# text on the 0 < progress < 100 window keeps them hidden until the
|
|
# upload is actually in flight.
|
|
defp uploading?(%{progress: progress}), do: progress > 0 and progress < 100
|
|
defp uploading?(_), do: false
|
|
|
|
defp confirm_button_label(valid, 0) do
|
|
"Looks good — submit #{valid} #{pluralize(valid, "contact")}"
|
|
end
|
|
|
|
defp confirm_button_label(0, refined) do
|
|
"Looks good — refine #{refined} existing #{pluralize(refined, "contact")}"
|
|
end
|
|
|
|
defp confirm_button_label(valid, refined) do
|
|
"Looks good — submit #{valid} and refine #{refined}"
|
|
end
|
|
|
|
defp confirm_prompt(valid, 0), do: "Import #{valid} contacts?"
|
|
defp confirm_prompt(0, refined), do: "Refine #{refined} existing contacts?"
|
|
|
|
defp confirm_prompt(valid, refined) do
|
|
"Import #{valid} contacts and refine #{refined} existing?"
|
|
end
|
|
end
|