Add local and public QR sharing for training profiles

This commit is contained in:
larssand
2026-09-03 20:15:13 +02:00
parent 4f847610dc
commit 2d56b74731
15 changed files with 965 additions and 226 deletions

View File

@@ -114,7 +114,14 @@ export function renderTrainingView(deps) {
&& (!goal.classId || String(goal.classId) === selectedClassId)
));
const profileShare = (state.training.shares || []).find((share) => profileReferences.has(String(share.driverId || "")) && share.enabled);
const publicProfileUrl = profileShare ? buildPublicProfileUrl(profileShare.token, state.settings?.language) : "";
const publicBaseUrl = normalizePublicBaseUrl(state.settings?.publicBaseUrl);
const localBaseUrl = window.location.origin;
const publicProfileUrl = profileShare ? buildPublicProfileUrl(profileShare.token, state.settings?.language, publicBaseUrl || localBaseUrl) : "";
const qrLanguage = state.settings?.language === "en" ? "en" : "sv";
const localQrUrl = profileShare ? buildPublicProfileQrUrl(profileShare.token, qrLanguage, "local") : "";
const publicQrUrl = profileShare && publicBaseUrl && publicBaseUrl !== localBaseUrl
? buildPublicProfileQrUrl(profileShare.token, qrLanguage, publicBaseUrl)
: "";
const driverOptions = `<option value="">${escapeHtml(t("training.overview_title"))}</option>` + profiles
.map((profile) => {
const classNames = profile.classIds
@@ -439,11 +446,36 @@ export function renderTrainingView(deps) {
</div>
<div class="training-share-compact">
<p>${escapeHtml(t("training.public_profile_help"))}</p>
<label class="training-share-url">
<span>${escapeHtml(t("training.public_base_url"))}</span>
<input id="trainingPublicBaseUrl" type="url" value="${escapeHtml(publicBaseUrl)}" placeholder="https://training.example.se" />
<small>${escapeHtml(t("training.public_base_url_hint"))}</small>
</label>
<div class="actions">
<button class="btn" id="trainingPublicBaseSave" type="button">${escapeHtml(t("training.save_public_url"))}</button>
<button class="btn" id="trainingPublicAddressDetect" type="button">${escapeHtml(t("training.detect_public_ip"))}</button>
</div>
<p class="hint" id="trainingPublicAddressStatus"></p>
${profileShare ? `
<label class="training-share-url">
<span>${escapeHtml(t("training.public_profile_link"))}</span>
<input id="trainingPublicProfileUrl" value="${escapeHtml(publicProfileUrl)}" readonly />
</label>
<div class="training-profile-qr-list">
<div class="training-profile-qr">
<strong>${escapeHtml(t("training.local_profile_qr"))}</strong>
<img src="${escapeHtml(localQrUrl)}" alt="${escapeHtml(t("training.local_profile_qr"))}" />
<small>${escapeHtml(t("training.local_profile_qr_hint"))}</small>
</div>
${publicQrUrl ? `
<div class="training-profile-qr">
<strong>${escapeHtml(t("training.public_profile_qr"))}</strong>
<img src="${escapeHtml(publicQrUrl)}" alt="${escapeHtml(t("training.public_profile_qr"))}" />
<small>${escapeHtml(new URL(publicProfileUrl).host)}</small>
</div>
` : ""}
</div>
<small class="training-profile-qr-hint">${escapeHtml(t("training.public_profile_qr_hint"))}</small>
<div class="actions">
<button class="btn btn-primary" id="trainingPublicProfileCopy" type="button">${escapeHtml(t("training.copy_link"))}</button>
<button class="btn" id="trainingPublicProfileOpen" type="button">${escapeHtml(t("training.open_profile"))}</button>
@@ -668,6 +700,31 @@ export function renderTrainingView(deps) {
});
});
document.getElementById("trainingPublicBaseSave")?.addEventListener("click", () => {
const input = document.getElementById("trainingPublicBaseUrl");
const normalized = normalizePublicBaseUrl(input?.value);
if (input?.value && !normalized) {
document.getElementById("trainingPublicAddressStatus").textContent = t("training.public_url_invalid");
return;
}
state.settings.publicBaseUrl = normalized;
saveState();
renderView();
});
document.getElementById("trainingPublicAddressDetect")?.addEventListener("click", async () => {
const status = document.getElementById("trainingPublicAddressStatus");
if (status) status.textContent = t("training.detecting_public_ip");
try {
const response = await fetch("/api/network/public-address", { cache: "no-store" });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const payload = await response.json();
const input = document.getElementById("trainingPublicBaseUrl");
if (input) input.value = payload.suggestedBaseUrl || "";
if (status) status.textContent = t("training.detected_public_ip", { address: payload.address || "-" });
} catch {
if (status) status.textContent = t("training.detect_public_ip_failed");
}
});
document.getElementById("trainingPublicProfileEnable")?.addEventListener("click", () => {
replacePublicProfileShare(state, selectedProfileId, profileReferences);
saveState();
@@ -928,12 +985,29 @@ function formatTrainingHighlight(highlight, { t, formatLap }) {
return t("training.highlight_today_laps", { laps: highlight.value });
}
function buildPublicProfileUrl(token, language) {
const url = new URL(`/public-training/${encodeURIComponent(token)}`, window.location.origin);
function normalizePublicBaseUrl(value) {
try {
const url = new URL(String(value || "").trim());
return ["http:", "https:"].includes(url.protocol) ? url.origin : "";
} catch {
return "";
}
}
function buildPublicProfileUrl(token, language, publicBaseUrl = "") {
const url = new URL(`/public-training/${encodeURIComponent(token)}`, publicBaseUrl || window.location.origin);
url.searchParams.set("lang", language === "en" ? "en" : "sv");
return url.toString();
}
function buildPublicProfileQrUrl(token, language, baseUrl) {
const params = new URLSearchParams({
lang: language === "en" ? "en" : "sv",
baseUrl,
});
return `/public-training/qr/${encodeURIComponent(token)}?${params.toString()}`;
}
function generateShareToken() {
const bytes = new Uint8Array(24);
crypto.getRandomValues(bytes);