Files
spothole/static/js/add-spot.js
T

295 lines
12 KiB
JavaScript

// Credentials schema per provider name. Defines the fields to collect and how to label them.
const PROVIDER_CREDENTIAL_SCHEMAS = {
// todo Figure out SOTA authentication
// see e.g. https://github.com/ham2k/app-polo/blob/main/src/extensions/activities/sota/SOTAAccount.jsx
// https://github.com/ham2k/app-polo/blob/main/src/store/apis/apiSOTA/apiSOTA.js
// Refresh token? Way to show user that they need to log in again because cached credentials aren't valid?
// todo type: text/password distinction on text boxes so API keys can be obscured
"SOTA": [
{key: "access_token", label: "SOTA Access Token", help: ""},
{key: "id_token", label: "SOTA ID Token", help: "TODO SOTA authentication to provide this..."}
],
"ParksNPeaks": [
{key: "user_id", label: "Parks N Peaks User ID", help: ""},
{key: "api_key", label: "Parks N Peaks API Key", help: "Get your API key from your Parks N Peaks account."}
],
"ZLOTA": [
{key: "user_id", label: "ZLOTA User ID", help: ""},
{key: "api_key", label: "ZLOTA User PIN", help: "Get your PIN from your ZLOTA account."}
],
"Tiles": [
{
key: "offline_spot_gateway_pin",
label: "Offline Spot Gateway PIN",
help: "Get your PIN from your Tiles on the Air account profile."
}
]
};
// Load and inject the reCAPTCHA script
function loadRecaptcha() {
if (!document.getElementById('recaptcha-script')) {
const script = document.createElement('script');
script.id = 'recaptcha-script';
script.src = 'https://www.google.com/recaptcha/api.js?render=explicit&onload=renderRecaptcha';
script.async = true;
script.defer = true;
document.head.appendChild(script);
}
$("#recaptcha-area").show();
}
// Called by reCAPTCHA after its script loads
function renderRecaptcha() {
window._recaptchaWidgetId = grecaptcha.render('recaptcha-widget', {
sitekey: RECAPTCHA_SITE_KEY,
size: 'normal'
});
}
// Update the "Send spot to..." area based on the currently selected activity
function updateUpstreamArea() {
if (!ALLOW_UPSTREAM_SPOTTING) {
$("#upstream-area").hide();
return;
}
const providers = SPOT_SUBMIT_PROVIDERS[$("#activity").val()] || [];
if (providers.length === 0) {
$("#upstream-area").hide();
return;
}
$("#upstream-area").show();
// Rebuild the provider checkboxes, keeping any that were already checked for the previous activity
const previouslyChecked = getSelectedUpstreamProviders();
const container = $("#upstream-providers").empty();
$.each(providers, function (i, name) {
const id = "submit-upstream-" + i;
const row = $("<div class='row g-2 align-items-center mb-1'></div>");
const check = $("<div class='form-check'></div>");
check.append($("<input class='form-check-input upstream-provider-check' type='checkbox'>")
.attr("id", id).val(name).prop("checked", previouslyChecked.includes(name)));
check.append($("<label class='form-check-label'></label>").attr("for", id).text(name));
row.append($("<div class='col-auto'></div>").append(check));
// Add a credentials button if this provider has an authentication mechanism and we need input from the user
if (PROVIDER_CREDENTIAL_SCHEMAS[name]) {
const button = $("<button type='button' class='btn btn-sm btn-outline-secondary'></button>")
.attr("aria-label", "Credentials for " + name)
.html("<i class='fa-solid fa-key'></i> Credentials")
.click(() => showCredentialsModal(name));
row.append($("<div class='col-auto'></div>").append(button));
}
container.append(row);
});
updateSpotholeOnlyCheck();
}
// The "Spothole only" checkbox is checked when no upstream providers are selected
function updateSpotholeOnlyCheck() {
$("#spothole-only").prop("checked", getSelectedUpstreamProviders().length === 0);
}
// Get the names of the upstream providers the user has chosen to send the spot to
function getSelectedUpstreamProviders() {
return $(".upstream-provider-check:checked").map(function () {
return $(this).val();
}).get();
}
// Show the credentials modal for the given upstream provider
function showCredentialsModal(providerName) {
if (!PROVIDER_CREDENTIAL_SCHEMAS[providerName]) return;
const schema = PROVIDER_CREDENTIAL_SCHEMAS[providerName];
const stored = loadCredentials(providerName);
$("#credentials-provider-name").text(providerName);
$("#credentials-fields").empty();
$.each(schema, function (i, field) {
const val = stored[field.key] || "";
let html = '<div class="mb-3">';
html += '<label for="cred-' + field.key + '" class="form-label">' + field.label + '</label>';
html += '<input type="text" class="form-control" id="cred-' + field.key + '" value="' + $('<div>').text(val).html() + '">';
if (field.help) {
html += '<div class="form-text">' + field.help + '</div>';
}
html += '</div>';
$("#credentials-fields").append(html);
});
// Store provider name for saveCredentials()
$("#credentials-modal").data("provider", providerName);
new bootstrap.Modal(document.getElementById('credentials-modal')).show();
}
// Save credentials from the modal to local storage
function saveCredentials() {
const providerName = $("#credentials-modal").data("provider");
if (!providerName || !PROVIDER_CREDENTIAL_SCHEMAS[providerName]) return;
const schema = PROVIDER_CREDENTIAL_SCHEMAS[providerName];
const creds = {};
$.each(schema, function (i, field) {
creds[field.key] = $("#cred-" + field.key).val();
});
localStorage.setItem("upstream-credentials-" + providerName, JSON.stringify(creds));
bootstrap.Modal.getInstance(document.getElementById('credentials-modal')).hide();
}
// Load credentials for a provider from local storage
function loadCredentials(providerName) {
const stored = localStorage.getItem("upstream-credentials-" + providerName);
return stored ? JSON.parse(stored) : {};
}
// Method called to add a spot to the server
function addSpot() {
try {
$("#result-bad").empty();
setSubmitting(true);
// Save settings (this will save "your call" for future use)
saveSettings();
// Unpack the user's entered values
const dx = $("#dx-call").val().toUpperCase();
const freqStr = $("#freq").val();
const mode = $("#mode")[0].value;
const activity = $("#activity")[0].value;
const activityRef = $("#activity-ref").val();
const dxGrid = $("#dx-grid").val();
const comment = $("#comment").val();
const de = $("#de-call").val().toUpperCase();
// Prepare the spot object for the server
const spot = {};
spot["dx_call"] = dx;
spot["freq"] = parseFloat(freqStr) * 1000;
if (mode !== "") spot["mode"] = mode;
if (activity !== "") spot["activities"] = [activity];
if (activityRef !== "") spot["activity_refs"] = [{activity: activity, id: activityRef}];
if (dxGrid !== "") spot["dx_grid"] = dxGrid;
if (comment !== "") spot["comment"] = comment;
spot["de_call"] = de;
spot["time"] = moment.utc().valueOf() / 1000.0;
// Prepare "handling" structure to tell the server what to do with this spot
const handling = {};
// Add CAPTCHA token if reCAPTCHA is loaded
if (window._recaptchaWidgetId !== undefined) {
handling["captcha_token"] = grecaptcha.getResponse(window._recaptchaWidgetId);
}
// Upstream submission
const upstreamProviders = getSelectedUpstreamProviders();
if (upstreamProviders.length > 0) {
handling["upstream_providers"] = upstreamProviders;
handling["upstream_credentials"] = {};
$.each(upstreamProviders, function (i, name) {
handling["upstream_credentials"][name] = loadCredentials(name);
});
}
$.ajax("/api/v3/spot", {
data: JSON.stringify({spot, handling}),
contentType: 'application/json',
type: 'POST',
timeout: 60000,
success: async function (result) {
// Reset CAPTCHA for next use
if (window._recaptchaWidgetId !== undefined) {
grecaptcha.reset(window._recaptchaWidgetId);
}
if (result && result.startsWith && result.startsWith("Warning")) {
$("#result-good").html("<div class='alert alert-warning fade show mb-0 mt-4' role='alert'><i class='fa-solid fa-triangle-exclamation'></i> " + result + " Returning you to the spots list...</div>");
} else {
$("#result-good").html("<div class='alert alert-success fade show mb-0 mt-4' role='alert'><i class='fa-solid fa-check'></i> Spot submitted. Returning you to the spots list...</div>");
}
$("#result-bad").html("");
setTimeout(() => {
$("#result-good").hide();
window.location.replace("/");
}, 2000);
},
error: function (result) {
setSubmitting(false);
if (window._recaptchaWidgetId !== undefined) {
grecaptcha.reset(window._recaptchaWidgetId);
}
if (result.responseText) {
showAddSpotError(result.responseText.slice(1, -1));
} else {
showAddSpotError("The server did not return a response.");
}
}
});
} catch (error) {
setSubmitting(false);
showAddSpotError(error);
}
return false;
}
// Show or hide the "in progress" state on the Spot button.
function setSubmitting(submitting) {
$("#add-spot-submit").prop("disabled", submitting);
$("#add-spot-spinner").toggle(submitting);
$("#add-spot-submit-text").text(submitting ? "Spotting..." : "Spot");
}
// Show an "add spot" error.
function showAddSpotError(text) {
const div = $("<div class='alert alert-danger alert-dismissible fade show mb-0 mt-4' role='alert'></div>");
div.append("<i class='fa-solid fa-triangle-exclamation'></i> ");
div.append(document.createTextNode(text));
div.append("<button type='button' class='btn-close' data-bs-dismiss='alert' aria-label='Close'></button>");
$("#result-bad").empty().append(div);
}
// Force callsign and mode capitalisation
$("#dx-call").change(function () {
$(this).val($(this).val().trim().toUpperCase());
});
$("#de-call").change(function () {
$(this).val($(this).val().trim().toUpperCase());
});
$("#mode").change(function () {
$(this).val($(this).val().trim().toUpperCase());
});
// Update upstream area when activity changes
$("#activity").change(function () {
updateUpstreamArea();
});
// Keep the "Spothole only" checkbox in sync with the upstream provider checkboxes
$("#upstream-providers").on("change", ".upstream-provider-check", function () {
updateSpotholeOnlyCheck();
});
// Clicking "Spothole only" deselects all upstream providers. It can't be unchecked directly, as that would leave
// nowhere to send the spot; the user unchecks it implicitly by choosing an upstream provider instead.
$("#spothole-only").change(function () {
$(".upstream-provider-check").prop("checked", false);
updateSpotholeOnlyCheck();
});
// Startup
$(document).ready(function () {
// Load reCAPTCHA if a site key is configured
if (RECAPTCHA_SITE_KEY) {
loadRecaptcha();
}
loadSettings();
// Update the upstream area for any pre-selected activity
updateUpstreamArea();
});