This commit is contained in:
larssand
2026-03-14 11:47:09 +01:00
parent 3b0af41466
commit 7af303463a
3 changed files with 439 additions and 55 deletions

View File

@@ -31,12 +31,14 @@ RC timing app med sponsor-eventflöde (delade bilar/transpondrar mellan olika he
- overlay-vy för extern leaderboard-skärm
- flera overlay-lägen: leaderboard, speaker och results
- speaker-overlay med eventmarkörer och separata speaker-cues
- live speaker-panel i `Timing` för att slå av/på cues under pågående session
- speaker-cues och klubbinfo/PDF-header styrs från `Settings`
- logo-upload för overlay från `Settings`
- branding per event/race med egen logo, tagline, footer och PDF-tema i `Hantera`
- extra speaker-cues för `session start`, `new best lap` och `top 3 change`
- valbart PDF-tema: `classic`, `minimal`, `motorsport`
- utskrift av startlistor och resultat
- servergenererad PDF-export för startlistor, heatsheets och resultat
- utskrift av startlistor och resultat med vald branding
- servergenererad PDF-export för startlistor, heatsheets och resultat med inbäddad logo
- genererade kval/finaler ärver tid och starttyp från raceformatet
- finish-ljud som siren i stället för browser-röst
- Sessioner: `practice`, `qualification`, `heat`, `final`

207
server.js
View File

@@ -302,6 +302,7 @@ function normalizePdfExportPayload(input = {}) {
const brandName = String(input.brandName || "JMK RB").trim();
const brandTagline = String(input.brandTagline || "Live Event").trim();
const footer = String(input.footer || "").trim();
const logoDataUrl = String(input.logoDataUrl || "").trim();
const theme = ["classic", "minimal", "motorsport"].includes(String(input.theme || "").toLowerCase())
? String(input.theme).toLowerCase()
: "classic";
@@ -317,7 +318,7 @@ function normalizePdfExportPayload(input = {}) {
}))
: [];
return { title, subtitle, brandName, brandTagline, footer, theme, filename, sections };
return { title, subtitle, brandName, brandTagline, footer, theme, logoDataUrl, filename, sections };
}
function buildSimplePdf(payload) {
@@ -325,10 +326,13 @@ function buildSimplePdf(payload) {
const pageHeight = 842;
const marginLeft = 42;
const marginTop = 56;
const marginBottom = 56;
const lineHeight = 15;
const maxChars = 86;
const logo = parsePdfLogo(payload.logoDataUrl);
const headerOffset = logo ? logo.displayHeight + 18 : 0;
const lines = buildPdfLines(payload, maxChars);
const linesPerPage = Math.max(10, Math.floor((pageHeight - marginTop * 2) / lineHeight));
const linesPerPage = Math.max(10, Math.floor((pageHeight - marginTop - marginBottom - headerOffset) / lineHeight));
const pages = [];
for (let index = 0; index < lines.length; index += linesPerPage) {
pages.push(lines.slice(index, index + linesPerPage));
@@ -338,37 +342,168 @@ function buildSimplePdf(payload) {
}
const objects = [];
objects.push("1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n");
const kids = [];
const fontObjectId = 3 + pages.length * 2;
const pageObjectIds = [];
const contentObjectIds = [];
let nextObjectId = 3;
for (let index = 0; index < pages.length; index += 1) {
const pageObjectId = 3 + index * 2;
const contentObjectId = 4 + index * 2;
kids.push(`${pageObjectId} 0 R`);
const contentStream = buildPdfContentStream(pages[index], marginLeft, pageHeight - marginTop, lineHeight);
objects.push(
`${pageObjectId} 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${pageWidth} ${pageHeight}] /Resources << /Font << /F1 ${fontObjectId} 0 R >> >> /Contents ${contentObjectId} 0 R >>\nendobj\n`
);
objects.push(`${contentObjectId} 0 obj\n<< /Length ${Buffer.byteLength(contentStream, "utf8")} >>\nstream\n${contentStream}\nendstream\nendobj\n`);
pageObjectIds.push(nextObjectId++);
contentObjectIds.push(nextObjectId++);
}
objects.splice(1, 0, `2 0 obj\n<< /Type /Pages /Count ${pages.length} /Kids [${kids.join(" ")}] >>\nendobj\n`);
objects.push(`${fontObjectId} 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Courier >>\nendobj\n`);
const fontObjectId = nextObjectId++;
const imageObjectId = logo ? nextObjectId++ : null;
let pdf = "%PDF-1.4\n";
objects.push(createPdfTextObject(1, "<< /Type /Catalog /Pages 2 0 R >>"));
objects.push(createPdfTextObject(2, `<< /Type /Pages /Count ${pages.length} /Kids [${pageObjectIds.map((id) => `${id} 0 R`).join(" ")}] >>`));
objects.push(createPdfTextObject(fontObjectId, "<< /Type /Font /Subtype /Type1 /BaseFont /Courier >>"));
if (logo && imageObjectId) {
objects.push(
createPdfStreamObject(
imageObjectId,
`<< /Type /XObject /Subtype /Image /Width ${logo.width} /Height ${logo.height} /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode`,
logo.data
)
);
}
for (let index = 0; index < pages.length; index += 1) {
const resources = imageObjectId
? `<< /Font << /F1 ${fontObjectId} 0 R >> /XObject << /Im1 ${imageObjectId} 0 R >> >>`
: `<< /Font << /F1 ${fontObjectId} 0 R >> >>`;
const contentStream = Buffer.from(
buildPdfContentStream(
pages[index],
marginLeft,
pageHeight - marginTop - headerOffset,
lineHeight,
logo
? {
x: marginLeft,
y: pageHeight - marginTop - logo.displayHeight,
width: logo.displayWidth,
height: logo.displayHeight,
}
: null
),
"utf8"
);
objects.push(
createPdfTextObject(
pageObjectIds[index],
`<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${pageWidth} ${pageHeight}] /Resources ${resources} /Contents ${contentObjectIds[index]} 0 R >>`
)
);
objects.push(createPdfStreamObject(contentObjectIds[index], "<<", contentStream));
}
return assemblePdf(objects);
}
function createPdfTextObject(id, body) {
return {
id,
buffer: Buffer.from(`${id} 0 obj\n${body}\nendobj\n`, "binary"),
};
}
function createPdfStreamObject(id, dictionaryPrefix, streamBuffer) {
return {
id,
buffer: Buffer.concat([
Buffer.from(`${id} 0 obj\n${dictionaryPrefix} /Length ${streamBuffer.length} >>\nstream\n`, "binary"),
streamBuffer,
Buffer.from("\nendstream\nendobj\n", "binary"),
]),
};
}
function assemblePdf(objects) {
const ordered = [...objects].sort((left, right) => left.id - right.id);
const chunks = [Buffer.from("%PDF-1.4\n", "binary")];
const offsets = [0];
objects.forEach((object) => {
offsets.push(Buffer.byteLength(pdf, "utf8"));
pdf += object;
let position = chunks[0].length;
ordered.forEach((object) => {
offsets[object.id] = position;
chunks.push(object.buffer);
position += object.buffer.length;
});
const xrefOffset = Buffer.byteLength(pdf, "utf8");
pdf += `xref\n0 ${objects.length + 1}\n`;
pdf += "0000000000 65535 f \n";
offsets.slice(1).forEach((offset) => {
pdf += `${String(offset).padStart(10, "0")} 00000 n \n`;
});
pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF`;
return Buffer.from(pdf, "utf8");
const xrefOffset = position;
const size = ordered.length + 1;
let xref = `xref\n0 ${size}\n0000000000 65535 f \n`;
for (let id = 1; id < size; id += 1) {
xref += `${String(offsets[id] || 0).padStart(10, "0")} 00000 n \n`;
}
chunks.push(Buffer.from(`${xref}trailer\n<< /Size ${size} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF`, "binary"));
return Buffer.concat(chunks);
}
function parsePdfLogo(dataUrl) {
const match = /^data:image\/jpeg;base64,([A-Za-z0-9+/=\s]+)$/i.exec(String(dataUrl || "").trim());
if (!match) {
return null;
}
const data = Buffer.from(match[1].replace(/\s+/g, ""), "base64");
const dimensions = getJpegDimensions(data);
if (!dimensions) {
return null;
}
const scale = Math.min(150 / dimensions.width, 52 / dimensions.height, 1);
return {
data,
width: dimensions.width,
height: dimensions.height,
displayWidth: Math.max(1, Math.round(dimensions.width * scale)),
displayHeight: Math.max(1, Math.round(dimensions.height * scale)),
};
}
function getJpegDimensions(buffer) {
if (!Buffer.isBuffer(buffer) || buffer.length < 4 || buffer[0] !== 0xff || buffer[1] !== 0xd8) {
return null;
}
let offset = 2;
while (offset + 1 < buffer.length) {
while (offset < buffer.length && buffer[offset] !== 0xff) {
offset += 1;
}
while (offset < buffer.length && buffer[offset] === 0xff) {
offset += 1;
}
if (offset >= buffer.length) {
break;
}
const marker = buffer[offset];
offset += 1;
if (marker === 0xd9 || marker === 0xda) {
break;
}
if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) {
continue;
}
if (offset + 1 >= buffer.length) {
break;
}
const length = buffer.readUInt16BE(offset);
if (length < 2 || offset + length > buffer.length) {
break;
}
if (
(marker >= 0xc0 && marker <= 0xc3) ||
(marker >= 0xc5 && marker <= 0xc7) ||
(marker >= 0xc9 && marker <= 0xcb) ||
(marker >= 0xcd && marker <= 0xcf)
) {
if (length >= 7) {
return {
height: buffer.readUInt16BE(offset + 3),
width: buffer.readUInt16BE(offset + 5),
};
}
break;
}
offset += length;
}
return null;
}
function buildPdfLines(payload, maxChars) {
@@ -473,16 +608,24 @@ function wrapPdfText(text, maxChars) {
return lines;
}
function buildPdfContentStream(lines, x, y, lineHeight) {
function buildPdfContentStream(lines, x, y, lineHeight, logoPlacement = null) {
const escaped = lines.map((line) => escapePdfText(line));
return [
const commands = [];
if (logoPlacement) {
commands.push("q");
commands.push(`${logoPlacement.width} 0 0 ${logoPlacement.height} ${logoPlacement.x} ${logoPlacement.y} cm`);
commands.push("/Im1 Do");
commands.push("Q");
}
commands.push(
"BT",
"/F1 11 Tf",
`${lineHeight} TL`,
`${x} ${y} Td`,
...escaped.map((line) => `(${line}) Tj\nT*`),
"ET",
].join("\n");
"ET"
);
return commands.join("\n");
}
function escapePdfText(text) {

View File

@@ -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