Add secure public driver training profiles

This commit is contained in:
larssand
2026-08-24 16:54:35 +02:00
parent c2ed00861a
commit 4a74785791
12 changed files with 1360 additions and 12 deletions

View File

@@ -42,6 +42,8 @@ export function renderTrainingView(deps) {
const recommendation = buildTrainingRecommendation(stats);
const nextMilestone = getNextTrainingMilestone(stats);
const goals = state.training.goals.filter((goal) => goal.driverId === selectedDriverId);
const profileShare = (state.training.shares || []).find((share) => share.driverId === selectedDriverId && share.enabled);
const publicProfileUrl = profileShare ? buildPublicProfileUrl(profileShare.token, state.settings?.language) : "";
const driverOptions = drivers
.map((driver) => `<option value="${escapeHtml(driver.id)}" ${driver.id === selectedDriverId ? "selected" : ""}>${escapeHtml(driver.name)}</option>`)
.join("");
@@ -93,6 +95,22 @@ export function renderTrainingView(deps) {
</tr>
`).join("")
: `<tr><td colspan="4">${escapeHtml(t("training.no_history"))}</td></tr>`;
const clubRows = drivers
.map((driver) => ({ driver, stats: driver.id === selectedDriverId ? stats : buildDriverTrainingStats(state, driver.id) }))
.sort((left, right) => right.stats.totalLaps - left.stats.totalLaps || String(left.driver.name).localeCompare(String(right.driver.name)))
.map((entry, index) => {
const milestone = getNextTrainingMilestone(entry.stats);
return `
<tr class="${entry.driver.id === selectedDriverId ? "is-selected" : ""}">
<td>${index + 1}</td>
<td><button class="training-club-driver" data-driver-id="${escapeHtml(entry.driver.id)}" type="button">${escapeHtml(entry.driver.name)}</button></td>
<td>${entry.stats.totalLaps}</td>
<td>${entry.stats.trainingDays}</td>
<td>${formatLap(entry.stats.bestLapMs)}</td>
<td>${milestone.targetLaps}</td>
</tr>
`;
}).join("");
dom.view.innerHTML = `
<section class="training-hero">
@@ -162,6 +180,35 @@ export function renderTrainingView(deps) {
</section>
</div>
<section class="panel mt-16 training-share-panel">
<div class="panel-header">
<h3>${escapeHtml(t("training.public_profile"))}</h3>
<span class="badge ${profileShare ? "badge-online" : "badge-offline"}">${escapeHtml(t(profileShare ? "training.public_profile_active" : "training.public_profile_off"))}</span>
</div>
<div class="panel-body training-share-body">
<div>
<p>${escapeHtml(t("training.public_profile_help"))}</p>
<p class="hint">${escapeHtml(t("training.public_profile_private"))}</p>
</div>
${profileShare ? `
<label class="training-share-url">
<span>${escapeHtml(t("training.public_profile_link"))}</span>
<input id="trainingPublicProfileUrl" value="${escapeHtml(publicProfileUrl)}" readonly />
</label>
<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>
<button class="btn" id="trainingPublicProfileRotate" type="button">${escapeHtml(t("training.replace_link"))}</button>
<button class="btn btn-danger" id="trainingPublicProfileDisable" type="button">${escapeHtml(t("training.disable_profile"))}</button>
</div>
` : `
<div class="actions">
<button class="btn btn-primary" id="trainingPublicProfileEnable" type="button">${escapeHtml(t("training.enable_profile"))}</button>
</div>
`}
</div>
</section>
<section class="panel mt-16">
<div class="panel-header">
<h3>${escapeHtml(t("training.history"))}</h3>
@@ -175,6 +222,16 @@ export function renderTrainingView(deps) {
</div>
</section>
<section class="panel mt-16">
<div class="panel-header"><h3>${escapeHtml(t("training.club_progress"))}</h3><span class="badge">${drivers.length}</span></div>
<div class="panel-body table-wrap training-history-wrap">
<table class="training-history-table training-club-table">
<thead><tr><th>#</th><th>${escapeHtml(t("training.driver"))}</th><th>${escapeHtml(t("training.laps"))}</th><th>${escapeHtml(t("training.training_days"))}</th><th>${escapeHtml(t("training.best_lap"))}</th><th>${escapeHtml(t("training.next_target"))}</th></tr></thead>
<tbody>${clubRows}</tbody>
</table>
</div>
</section>
<section class="panel mt-16">
<div class="panel-header"><h3>${escapeHtml(t("training.goals"))}</h3><span class="badge">${goals.length}</span></div>
<div class="panel-body training-goals">${goalMarkup}</div>
@@ -222,6 +279,35 @@ export function renderTrainingView(deps) {
renderView();
});
});
document.getElementById("trainingPublicProfileEnable")?.addEventListener("click", () => {
replacePublicProfileShare(state, selectedDriverId);
saveState();
renderView();
});
document.getElementById("trainingPublicProfileCopy")?.addEventListener("click", async () => {
await copyText(publicProfileUrl);
});
document.getElementById("trainingPublicProfileOpen")?.addEventListener("click", () => {
window.open(publicProfileUrl, "_blank", "noopener,noreferrer");
});
document.getElementById("trainingPublicProfileRotate")?.addEventListener("click", () => {
if (!confirm(t("training.replace_link_confirm"))) return;
replacePublicProfileShare(state, selectedDriverId);
saveState();
renderView();
});
document.getElementById("trainingPublicProfileDisable")?.addEventListener("click", () => {
state.training.shares = state.training.shares.map((share) => share.driverId === selectedDriverId ? { ...share, enabled: false } : share);
saveState();
renderView();
});
document.querySelectorAll(".training-club-driver").forEach((button) => {
button.addEventListener("click", () => {
setSelectedDriverId(button.dataset.driverId || "");
renderView();
});
});
}
function trainingStat(label, value, note) {
@@ -231,3 +317,44 @@ function trainingStat(label, value, note) {
function trainingInsight(label, value) {
return `<div><span>${label}</span><strong>${value}</strong></div>`;
}
function buildPublicProfileUrl(token, language) {
const url = new URL(`/public-training/${encodeURIComponent(token)}`, window.location.origin);
url.searchParams.set("lang", language === "en" ? "en" : "sv");
return url.toString();
}
function generateShareToken() {
const bytes = new Uint8Array(24);
crypto.getRandomValues(bytes);
return btoa(String.fromCharCode(...bytes)).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
}
function replacePublicProfileShare(state, driverId) {
const nextShare = {
driverId,
token: generateShareToken(),
enabled: true,
createdAt: new Date().toISOString(),
};
state.training.shares = [...state.training.shares.filter((share) => share.driverId !== driverId), nextShare];
}
async function copyText(value) {
if (navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(value);
return;
} catch {
// HTTP LAN pages may not receive Clipboard API permission.
}
}
const input = document.createElement("textarea");
input.value = value;
input.style.position = "fixed";
input.style.opacity = "0";
document.body.appendChild(input);
input.select();
document.execCommand("copy");
input.remove();
}