Add secure public driver training profiles
This commit is contained in:
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user