update
This commit is contained in:
281
src/app.js
281
src/app.js
@@ -105,6 +105,15 @@ const TRANSLATIONS = {
|
||||
"events.bump_reserved_note": "Om bump används kan finalgeneratorn reservera platser i högre finaler redan från start.",
|
||||
"events.actions": "Åtgärder",
|
||||
"events.manage_title": "Hantera",
|
||||
"events.branding": "Branding för detta event",
|
||||
"events.branding_note": "Lämna fält tomma för att ärva global branding från Inställningar. Logo används i overlay och bäddas in i PDF-export när den kan konverteras.",
|
||||
"events.brand_name": "Brandnamn",
|
||||
"events.brand_tagline": "Brandtext",
|
||||
"events.brand_footer": "PDF-footer",
|
||||
"events.brand_theme": "PDF-tema",
|
||||
"events.brand_logo": "Eventlogo",
|
||||
"events.branding_use_global": "Använd globalt standardtema",
|
||||
"events.branding_save": "Spara branding",
|
||||
"events.session_name": "Sessionsnamn",
|
||||
"events.duration_placeholder": "Längd (min)",
|
||||
"events.max_cars_placeholder": "Max bilar (valfritt)",
|
||||
@@ -221,6 +230,8 @@ const TRANSLATIONS = {
|
||||
"timing.disconnected": "Frånkopplad",
|
||||
"timing.last_message": "Senaste meddelande",
|
||||
"timing.control": "Sessionkontroll",
|
||||
"timing.speaker_panel": "Speaker-panel",
|
||||
"timing.speaker_panel_hint": "Dessa växlar slår av/på cues direkt för pågående session och overlay utan att lämna Tidtagning.",
|
||||
"timing.select_session": "Välj session",
|
||||
"timing.set_active": "Sätt aktiv",
|
||||
"timing.start": "Starta",
|
||||
@@ -307,7 +318,7 @@ const TRANSLATIONS = {
|
||||
"settings.logo": "Logo / overlay",
|
||||
"settings.logo_upload": "Ladda logo",
|
||||
"settings.logo_clear": "Rensa logo",
|
||||
"settings.logo_note": "Logon visas i overlay. PDF använder fortfarande textheader i denna version.",
|
||||
"settings.logo_note": "Logon visas i overlay. PDF-export försöker bädda in loggan automatiskt via backend.",
|
||||
"settings.storage": "Lagring",
|
||||
"settings.backend_url": "Backend URL",
|
||||
"settings.backend_status": "Backend-status",
|
||||
@@ -541,6 +552,15 @@ const TRANSLATIONS = {
|
||||
"events.bump_reserved_note": "If bump-up is used, finals can reserve slots in upper mains from the start.",
|
||||
"events.actions": "Actions",
|
||||
"events.manage_title": "Manage",
|
||||
"events.branding": "Branding for this event",
|
||||
"events.branding_note": "Leave fields empty to inherit global branding from Settings. The logo is used in overlay and embedded in PDF exports when it can be converted.",
|
||||
"events.brand_name": "Brand name",
|
||||
"events.brand_tagline": "Brand tagline",
|
||||
"events.brand_footer": "PDF footer",
|
||||
"events.brand_theme": "PDF theme",
|
||||
"events.brand_logo": "Event logo",
|
||||
"events.branding_use_global": "Use global default theme",
|
||||
"events.branding_save": "Save branding",
|
||||
"events.session_name": "Session name",
|
||||
"events.duration_placeholder": "Duration (min)",
|
||||
"events.max_cars_placeholder": "Max cars (optional)",
|
||||
@@ -657,6 +677,8 @@ const TRANSLATIONS = {
|
||||
"timing.disconnected": "Disconnected",
|
||||
"timing.last_message": "Last message",
|
||||
"timing.control": "Session Control",
|
||||
"timing.speaker_panel": "Speaker panel",
|
||||
"timing.speaker_panel_hint": "These toggles enable or disable cues live for the current session and overlay without leaving Timing.",
|
||||
"timing.select_session": "Select session",
|
||||
"timing.set_active": "Set Active",
|
||||
"timing.start": "Start",
|
||||
@@ -743,7 +765,7 @@ const TRANSLATIONS = {
|
||||
"settings.logo": "Logo / overlay",
|
||||
"settings.logo_upload": "Upload logo",
|
||||
"settings.logo_clear": "Clear logo",
|
||||
"settings.logo_note": "The logo is shown in overlay. PDF still uses a text header in this version.",
|
||||
"settings.logo_note": "The logo is shown in overlay. PDF export attempts to embed the logo automatically via the backend.",
|
||||
"settings.storage": "Storage",
|
||||
"settings.backend_url": "Backend URL",
|
||||
"settings.backend_status": "Backend status",
|
||||
@@ -1375,6 +1397,7 @@ function normalizeSession(session) {
|
||||
function normalizeEvent(event) {
|
||||
return {
|
||||
...event,
|
||||
branding: normalizeBrandingConfig(event?.branding),
|
||||
raceConfig: {
|
||||
qualifyingScoring: event?.raceConfig?.qualifyingScoring === "best" ? "best" : "points",
|
||||
qualifyingRounds: Math.max(1, Number(event?.raceConfig?.qualifyingRounds || 3) || 3),
|
||||
@@ -1396,6 +1419,30 @@ function normalizeEvent(event) {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeBrandingConfig(branding) {
|
||||
const theme = ["classic", "minimal", "motorsport"].includes(String(branding?.pdfTheme || "").toLowerCase())
|
||||
? String(branding.pdfTheme).toLowerCase()
|
||||
: "";
|
||||
return {
|
||||
brandName: String(branding?.brandName || "").trim(),
|
||||
brandTagline: String(branding?.brandTagline || "").trim(),
|
||||
pdfFooter: String(branding?.pdfFooter || "").trim(),
|
||||
pdfTheme: theme,
|
||||
logoDataUrl: String(branding?.logoDataUrl || "").trim(),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveEventBranding(event) {
|
||||
const local = normalizeBrandingConfig(event?.branding);
|
||||
return {
|
||||
brandName: local.brandName || state.settings.clubName || "JMK RB",
|
||||
brandTagline: local.brandTagline || state.settings.clubTagline || "Live Event",
|
||||
pdfFooter: local.pdfFooter || state.settings.pdfFooter || "Generated by JMK RB Live Event",
|
||||
pdfTheme: local.pdfTheme || state.settings.pdfTheme || "classic",
|
||||
logoDataUrl: local.logoDataUrl || state.settings.logoDataUrl || "",
|
||||
};
|
||||
}
|
||||
|
||||
function scheduleBackendSync() {
|
||||
clearTimeout(backendSyncTimer);
|
||||
backendSyncTimer = setTimeout(() => {
|
||||
@@ -2225,6 +2272,7 @@ function renderEventManager(eventId) {
|
||||
const carOptions = state.cars
|
||||
.map((c) => `<option value="${c.id}">${escapeHtml(c.name)} (${escapeHtml(c.transponder)})</option>`)
|
||||
.join("");
|
||||
const branding = normalizeBrandingConfig(event.branding);
|
||||
const gridSessions = event.mode === "race" ? sessions.filter((session) => normalizeStartMode(session.startMode) === "position") : [];
|
||||
if (selectedGridSessionId && !gridSessions.some((session) => session.id === selectedGridSessionId)) {
|
||||
selectedGridSessionId = "";
|
||||
@@ -2301,6 +2349,30 @@ function renderEventManager(eventId) {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel mt-16">
|
||||
<div class="panel-header"><h3>${t("events.branding")}</h3></div>
|
||||
<form id="eventBrandingForm" class="panel-body form-grid cols-3">
|
||||
<input name="brandName" value="${escapeHtml(branding.brandName)}" placeholder="${t("events.brand_name")}" />
|
||||
<input name="brandTagline" value="${escapeHtml(branding.brandTagline)}" placeholder="${t("events.brand_tagline")}" />
|
||||
<input name="pdfFooter" value="${escapeHtml(branding.pdfFooter)}" placeholder="${t("events.brand_footer")}" />
|
||||
<select name="pdfTheme">
|
||||
<option value="" ${!branding.pdfTheme ? "selected" : ""}>${t("events.branding_use_global")}</option>
|
||||
<option value="classic" ${branding.pdfTheme === "classic" ? "selected" : ""}>${t("settings.pdf_theme_classic")}</option>
|
||||
<option value="minimal" ${branding.pdfTheme === "minimal" ? "selected" : ""}>${t("settings.pdf_theme_minimal")}</option>
|
||||
<option value="motorsport" ${branding.pdfTheme === "motorsport" ? "selected" : ""}>${t("settings.pdf_theme_motorsport")}</option>
|
||||
</select>
|
||||
<button class="btn btn-primary" type="submit">${t("events.branding_save")}</button>
|
||||
</form>
|
||||
<div class="panel-body">
|
||||
<p class="hint">${t("events.branding_note")}</p>
|
||||
<div class="actions">
|
||||
<input id="eventLogoUpload" type="file" accept="image/*" />
|
||||
<button id="eventLogoClear" class="btn" type="button">${t("settings.logo_clear")}</button>
|
||||
</div>
|
||||
${branding.logoDataUrl ? `<div class="logo-preview mt-16"><img src="${escapeHtml(branding.logoDataUrl)}" alt="event-logo" /></div>` : ""}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
${
|
||||
event.mode === "track"
|
||||
? `
|
||||
@@ -2524,6 +2596,47 @@ function renderEventManager(eventId) {
|
||||
}
|
||||
`;
|
||||
|
||||
document.getElementById("eventBrandingForm")?.addEventListener("submit", (e) => {
|
||||
e.preventDefault();
|
||||
const form = new FormData(e.currentTarget);
|
||||
event.branding = normalizeBrandingConfig({
|
||||
...event.branding,
|
||||
brandName: String(form.get("brandName") || "").trim(),
|
||||
brandTagline: String(form.get("brandTagline") || "").trim(),
|
||||
pdfFooter: String(form.get("pdfFooter") || "").trim(),
|
||||
pdfTheme: String(form.get("pdfTheme") || "").trim(),
|
||||
});
|
||||
saveState();
|
||||
renderEventManager(eventId);
|
||||
});
|
||||
|
||||
document.getElementById("eventLogoUpload")?.addEventListener("change", (eventInput) => {
|
||||
const input = eventInput.currentTarget;
|
||||
const file = input instanceof HTMLInputElement ? input.files?.[0] : null;
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
event.branding = normalizeBrandingConfig({
|
||||
...event.branding,
|
||||
logoDataUrl: typeof reader.result === "string" ? reader.result : "",
|
||||
});
|
||||
saveState();
|
||||
renderEventManager(eventId);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
|
||||
document.getElementById("eventLogoClear")?.addEventListener("click", () => {
|
||||
event.branding = normalizeBrandingConfig({
|
||||
...event.branding,
|
||||
logoDataUrl: "",
|
||||
});
|
||||
saveState();
|
||||
renderEventManager(eventId);
|
||||
});
|
||||
|
||||
document.getElementById("sessionForm")?.addEventListener("submit", (e) => {
|
||||
e.preventDefault();
|
||||
const form = new FormData(e.currentTarget);
|
||||
@@ -3007,6 +3120,21 @@ function renderTiming() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel mt-16">
|
||||
<div class="panel-header"><h3>${t("timing.speaker_panel")}</h3></div>
|
||||
<div class="panel-body">
|
||||
<p class="hint">${t("timing.speaker_panel_hint")}</p>
|
||||
<div class="check-grid">
|
||||
${renderSpeakerToggle("speakerPassingCueEnabled", "settings.speaker_passing_cue")}
|
||||
${renderSpeakerToggle("speakerLeaderCueEnabled", "settings.speaker_leader_cue")}
|
||||
${renderSpeakerToggle("speakerBestLapCueEnabled", "settings.speaker_bestlap_cue")}
|
||||
${renderSpeakerToggle("speakerTop3CueEnabled", "settings.speaker_top3_cue")}
|
||||
${renderSpeakerToggle("speakerSessionStartCueEnabled", "settings.speaker_start_cue")}
|
||||
${renderSpeakerToggle("speakerFinishCueEnabled", "settings.speaker_finish_cue")}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel mt-16">
|
||||
<div class="panel-header"><h3>${t("timing.leaderboard")}</h3></div>
|
||||
<div class="panel-body">
|
||||
@@ -3143,6 +3271,25 @@ function renderTiming() {
|
||||
document.getElementById("openOverlay")?.addEventListener("click", openOverlayWindow);
|
||||
document.getElementById("openSpeakerOverlay")?.addEventListener("click", () => openOverlayWindow("speaker"));
|
||||
document.getElementById("openResultsOverlay")?.addEventListener("click", () => openOverlayWindow("results"));
|
||||
document.querySelectorAll("[data-speaker-setting]").forEach((node) => {
|
||||
node.addEventListener("change", (event) => {
|
||||
const input = event.currentTarget;
|
||||
if (!(input instanceof HTMLInputElement)) {
|
||||
return;
|
||||
}
|
||||
state.settings[input.dataset.speakerSetting] = input.checked;
|
||||
saveState();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderSpeakerToggle(settingKey, labelKey) {
|
||||
return `
|
||||
<label class="check-card">
|
||||
<input type="checkbox" data-speaker-setting="${settingKey}" ${state.settings[settingKey] ? "checked" : ""} />
|
||||
<span>${t(labelKey)}</span>
|
||||
</label>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderGuide() {
|
||||
@@ -3269,6 +3416,7 @@ function renderOverlay() {
|
||||
const sessionTiming = active ? getSessionTiming(active) : null;
|
||||
const recent = active && result ? result.passings.slice(-8).reverse() : [];
|
||||
const event = active ? state.events.find((item) => item.id === active.eventId) : null;
|
||||
const branding = resolveEventBranding(event);
|
||||
const practiceRows = event ? buildPracticeStandings(event) : [];
|
||||
const qualifyingRows = event ? buildQualifyingStandings(event) : [];
|
||||
const finalRows = event ? buildFinalStandings(event) : [];
|
||||
@@ -3282,10 +3430,10 @@ function renderOverlay() {
|
||||
? `
|
||||
<header class="overlay-header">
|
||||
<div>
|
||||
${state.settings.logoDataUrl ? `<img class="overlay-logo" src="${escapeHtml(state.settings.logoDataUrl)}" alt="logo" />` : ""}
|
||||
${branding.logoDataUrl ? `<img class="overlay-logo" src="${escapeHtml(branding.logoDataUrl)}" alt="logo" />` : ""}
|
||||
<p class="overlay-kicker">${escapeHtml(getEventName(active.eventId))}</p>
|
||||
<h1>${escapeHtml(active.name)} • ${escapeHtml(getSessionTypeLabel(active.type))}</h1>
|
||||
<p>${escapeHtml(getStartModeLabel(active.startMode))} • ${escapeHtml(modeLabel)}</p>
|
||||
<p>${escapeHtml(branding.brandName)} • ${escapeHtml(getStartModeLabel(active.startMode))} • ${escapeHtml(modeLabel)}</p>
|
||||
</div>
|
||||
<div class="overlay-meta">
|
||||
<div class="overlay-clock">${formatCountdown(sessionTiming?.remainingMs ?? 0)}</div>
|
||||
@@ -3600,6 +3748,18 @@ function renderSettings() {
|
||||
<input type="checkbox" name="speakerFinishCueEnabled" ${state.settings.speakerFinishCueEnabled ? "checked" : ""} />
|
||||
<span>${t("settings.speaker_finish_cue")}</span>
|
||||
</label>
|
||||
<label class="toggle">
|
||||
<input type="checkbox" name="speakerBestLapCueEnabled" ${state.settings.speakerBestLapCueEnabled ? "checked" : ""} />
|
||||
<span>${t("settings.speaker_bestlap_cue")}</span>
|
||||
</label>
|
||||
<label class="toggle">
|
||||
<input type="checkbox" name="speakerTop3CueEnabled" ${state.settings.speakerTop3CueEnabled ? "checked" : ""} />
|
||||
<span>${t("settings.speaker_top3_cue")}</span>
|
||||
</label>
|
||||
<label class="toggle">
|
||||
<input type="checkbox" name="speakerSessionStartCueEnabled" ${state.settings.speakerSessionStartCueEnabled ? "checked" : ""} />
|
||||
<span>${t("settings.speaker_start_cue")}</span>
|
||||
</label>
|
||||
<button class="btn btn-primary" type="submit">${t("settings.save")}</button>
|
||||
<button id="settingsConnect" class="btn" type="button">${t("settings.connect_now")}</button>
|
||||
</form>
|
||||
@@ -3712,6 +3872,9 @@ function renderSettings() {
|
||||
state.settings.speakerPassingCueEnabled = form.get("speakerPassingCueEnabled") === "on";
|
||||
state.settings.speakerLeaderCueEnabled = form.get("speakerLeaderCueEnabled") === "on";
|
||||
state.settings.speakerFinishCueEnabled = form.get("speakerFinishCueEnabled") === "on";
|
||||
state.settings.speakerBestLapCueEnabled = form.get("speakerBestLapCueEnabled") === "on";
|
||||
state.settings.speakerTop3CueEnabled = form.get("speakerTop3CueEnabled") === "on";
|
||||
state.settings.speakerSessionStartCueEnabled = form.get("speakerSessionStartCueEnabled") === "on";
|
||||
state.settings.clubName = String(form.get("clubName") || "").trim() || "JMK RB";
|
||||
state.settings.clubTagline = String(form.get("clubTagline") || "").trim() || "Live Event";
|
||||
state.settings.pdfFooter = String(form.get("pdfFooter") || "").trim() || "Generated by JMK RB Live Event";
|
||||
@@ -5140,7 +5303,20 @@ function renderFinalMatrix(event) {
|
||||
`;
|
||||
}
|
||||
|
||||
function buildPrintBrandBlock(branding) {
|
||||
return `
|
||||
<div class="print-brand-block">
|
||||
${branding.logoDataUrl ? `<img class="print-logo" src="${escapeHtml(branding.logoDataUrl)}" alt="logo" />` : ""}
|
||||
<div>
|
||||
<strong>${escapeHtml(branding.brandName)}</strong>
|
||||
<p>${escapeHtml(branding.brandTagline)}</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function buildRaceStartListsHtml(event) {
|
||||
const branding = resolveEventBranding(event);
|
||||
const sessions = getSessionsForEvent(event.id)
|
||||
.filter((session) => session.mode === "race")
|
||||
.sort((left, right) => {
|
||||
@@ -5152,8 +5328,16 @@ function buildRaceStartListsHtml(event) {
|
||||
});
|
||||
|
||||
return `
|
||||
<h1>${escapeHtml(event.name)}</h1>
|
||||
<p>${escapeHtml(getClassName(event.classId))} • ${escapeHtml(event.date || "-")}</p>
|
||||
<header class="print-header">
|
||||
<div>
|
||||
<p class="print-kicker">${escapeHtml(getClassName(event.classId))}</p>
|
||||
<h1>${escapeHtml(event.name)}</h1>
|
||||
<p>${escapeHtml(event.date || "-")}</p>
|
||||
</div>
|
||||
<div class="print-meta">
|
||||
${buildPrintBrandBlock(branding)}
|
||||
</div>
|
||||
</header>
|
||||
<h2>${t("events.start_lists")}</h2>
|
||||
${sessions
|
||||
.map((session) => {
|
||||
@@ -5186,6 +5370,7 @@ function buildRaceStartListsHtml(event) {
|
||||
}
|
||||
|
||||
function buildRaceResultsHtml(event) {
|
||||
const branding = resolveEventBranding(event);
|
||||
return `
|
||||
<header class="print-header">
|
||||
<div>
|
||||
@@ -5194,7 +5379,7 @@ function buildRaceResultsHtml(event) {
|
||||
<p>${escapeHtml(event.date || "-")}</p>
|
||||
</div>
|
||||
<div class="print-meta">
|
||||
<strong>JMK RB Live Event</strong>
|
||||
${buildPrintBrandBlock(branding)}
|
||||
</div>
|
||||
</header>
|
||||
<section class="print-block">
|
||||
@@ -5235,6 +5420,9 @@ function openPrintWindow(title, bodyHtml) {
|
||||
.print-header { display: flex; justify-content: space-between; gap: 24px; align-items: flex-start; padding-bottom: 16px; border-bottom: 4px solid #10131a; }
|
||||
.print-kicker { text-transform: uppercase; letter-spacing: 0.08em; color: #5b677f; font-size: 12px; }
|
||||
.print-meta { text-align: right; font-size: 13px; }
|
||||
.print-brand-block { display: flex; align-items: center; justify-content: flex-end; gap: 12px; }
|
||||
.print-brand-block p { margin: 0; }
|
||||
.print-logo { max-width: 92px; max-height: 52px; object-fit: contain; }
|
||||
.print-block { margin-top: 24px; break-inside: avoid; }
|
||||
table { width: 100%; border-collapse: collapse; margin-top: 12px; }
|
||||
th, td { border: 1px solid #b9c2d4; padding: 8px; text-align: left; }
|
||||
@@ -5281,7 +5469,44 @@ async function requestPdfExport(payload) {
|
||||
}
|
||||
}
|
||||
|
||||
function loadImageElement(src) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const image = new Image();
|
||||
image.onload = () => resolve(image);
|
||||
image.onerror = () => reject(new Error("Image load failed"));
|
||||
image.src = src;
|
||||
});
|
||||
}
|
||||
|
||||
async function ensurePdfLogoDataUrl(dataUrl) {
|
||||
if (!dataUrl) {
|
||||
return "";
|
||||
}
|
||||
if (/^data:image\/jpeg;base64,/i.test(dataUrl)) {
|
||||
return dataUrl;
|
||||
}
|
||||
try {
|
||||
const image = await loadImageElement(dataUrl);
|
||||
const canvas = document.createElement("canvas");
|
||||
const width = Math.max(1, image.naturalWidth || image.width || 1);
|
||||
const height = Math.max(1, image.naturalHeight || image.height || 1);
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) {
|
||||
return "";
|
||||
}
|
||||
context.fillStyle = "#ffffff";
|
||||
context.fillRect(0, 0, width, height);
|
||||
context.drawImage(image, 0, 0, width, height);
|
||||
return canvas.toDataURL("image/jpeg", 0.92);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
async function exportRaceStartListsPdf(event) {
|
||||
const branding = resolveEventBranding(event);
|
||||
const sessions = getSessionsForEvent(event.id)
|
||||
.filter((session) => session.mode === "race")
|
||||
.sort((left, right) => {
|
||||
@@ -5304,23 +5529,26 @@ async function exportRaceStartListsPdf(event) {
|
||||
filename: `${event.name.replaceAll(/\s+/g, "_")}_startlists.pdf`,
|
||||
title: event.name,
|
||||
subtitle: `${getClassName(event.classId)} • ${event.date || "-"}`,
|
||||
brandName: state.settings.clubName || "JMK RB",
|
||||
brandTagline: state.settings.clubTagline || "Live Event",
|
||||
footer: state.settings.pdfFooter || "Generated by JMK RB Live Event",
|
||||
theme: state.settings.pdfTheme || "classic",
|
||||
brandName: branding.brandName,
|
||||
brandTagline: branding.brandTagline,
|
||||
footer: branding.pdfFooter,
|
||||
theme: branding.pdfTheme,
|
||||
logoDataUrl: await ensurePdfLogoDataUrl(branding.logoDataUrl),
|
||||
sections,
|
||||
});
|
||||
}
|
||||
|
||||
async function exportRaceResultsPdf(event) {
|
||||
const branding = resolveEventBranding(event);
|
||||
await requestPdfExport({
|
||||
filename: `${event.name.replaceAll(/\s+/g, "_")}_results.pdf`,
|
||||
title: event.name,
|
||||
subtitle: `${getClassName(event.classId)} • ${event.date || "-"}`,
|
||||
brandName: state.settings.clubName || "JMK RB",
|
||||
brandTagline: state.settings.clubTagline || "Live Event",
|
||||
footer: state.settings.pdfFooter || "Generated by JMK RB Live Event",
|
||||
theme: state.settings.pdfTheme || "classic",
|
||||
brandName: branding.brandName,
|
||||
brandTagline: branding.brandTagline,
|
||||
footer: branding.pdfFooter,
|
||||
theme: branding.pdfTheme,
|
||||
logoDataUrl: await ensurePdfLogoDataUrl(branding.logoDataUrl),
|
||||
sections: [
|
||||
buildPdfSection(
|
||||
t("events.practice_standings"),
|
||||
@@ -5343,14 +5571,16 @@ async function exportRaceResultsPdf(event) {
|
||||
|
||||
async function exportSessionHeatSheetPdf(session) {
|
||||
const event = state.events.find((item) => item.id === session.eventId);
|
||||
const branding = resolveEventBranding(event);
|
||||
await requestPdfExport({
|
||||
filename: `${(event?.name || "event").replaceAll(/\s+/g, "_")}_${session.name.replaceAll(/\s+/g, "_")}.pdf`,
|
||||
title: event?.name || t("common.unknown_event"),
|
||||
subtitle: `${getSessionTypeLabel(session.type)} • ${session.name} • ${getClassName(event?.classId || "")}`,
|
||||
brandName: state.settings.clubName || "JMK RB",
|
||||
brandTagline: state.settings.clubTagline || "Live Event",
|
||||
footer: state.settings.pdfFooter || "Generated by JMK RB Live Event",
|
||||
theme: state.settings.pdfTheme || "classic",
|
||||
brandName: branding.brandName,
|
||||
brandTagline: branding.brandTagline,
|
||||
footer: branding.pdfFooter,
|
||||
theme: branding.pdfTheme,
|
||||
logoDataUrl: await ensurePdfLogoDataUrl(branding.logoDataUrl),
|
||||
sections: [
|
||||
buildPdfSection(
|
||||
`${session.name} • ${getSessionTypeLabel(session.type)}`,
|
||||
@@ -5370,10 +5600,19 @@ function reorderList(items, fromIndex, toIndex) {
|
||||
|
||||
function buildSessionHeatSheetHtml(session) {
|
||||
const event = state.events.find((item) => item.id === session.eventId);
|
||||
const branding = resolveEventBranding(event);
|
||||
const entries = getSessionGridEntries(session);
|
||||
return `
|
||||
<h1>${escapeHtml(event?.name || t("common.unknown_event"))}</h1>
|
||||
<p>${escapeHtml(getClassName(event?.classId || ""))} • ${escapeHtml(session.name)} • ${escapeHtml(getSessionTypeLabel(session.type))}</p>
|
||||
<header class="print-header">
|
||||
<div>
|
||||
<p class="print-kicker">${escapeHtml(getClassName(event?.classId || ""))}</p>
|
||||
<h1>${escapeHtml(event?.name || t("common.unknown_event"))}</h1>
|
||||
<p>${escapeHtml(session.name)} • ${escapeHtml(getSessionTypeLabel(session.type))}</p>
|
||||
</div>
|
||||
<div class="print-meta">
|
||||
${buildPrintBrandBlock(branding)}
|
||||
</div>
|
||||
</header>
|
||||
<p>${t("table.start_mode")}: ${escapeHtml(getStartModeLabel(session.startMode))} • ${t("table.duration")}: ${session.durationMin} min</p>
|
||||
${
|
||||
entries.length
|
||||
|
||||
Reference in New Issue
Block a user