update
This commit is contained in:
@@ -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
|
- overlay-vy för extern leaderboard-skärm
|
||||||
- flera overlay-lägen: leaderboard, speaker och results
|
- flera overlay-lägen: leaderboard, speaker och results
|
||||||
- speaker-overlay med eventmarkörer och separata speaker-cues
|
- 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`
|
- speaker-cues och klubbinfo/PDF-header styrs från `Settings`
|
||||||
- logo-upload för overlay 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`
|
- extra speaker-cues för `session start`, `new best lap` och `top 3 change`
|
||||||
- valbart PDF-tema: `classic`, `minimal`, `motorsport`
|
- valbart PDF-tema: `classic`, `minimal`, `motorsport`
|
||||||
- utskrift av startlistor och resultat
|
- utskrift av startlistor och resultat med vald branding
|
||||||
- servergenererad PDF-export för startlistor, heatsheets och resultat
|
- servergenererad PDF-export för startlistor, heatsheets och resultat med inbäddad logo
|
||||||
- genererade kval/finaler ärver tid och starttyp från raceformatet
|
- genererade kval/finaler ärver tid och starttyp från raceformatet
|
||||||
- finish-ljud som siren i stället för browser-röst
|
- finish-ljud som siren i stället för browser-röst
|
||||||
- Sessioner: `practice`, `qualification`, `heat`, `final`
|
- Sessioner: `practice`, `qualification`, `heat`, `final`
|
||||||
|
|||||||
207
server.js
207
server.js
@@ -302,6 +302,7 @@ function normalizePdfExportPayload(input = {}) {
|
|||||||
const brandName = String(input.brandName || "JMK RB").trim();
|
const brandName = String(input.brandName || "JMK RB").trim();
|
||||||
const brandTagline = String(input.brandTagline || "Live Event").trim();
|
const brandTagline = String(input.brandTagline || "Live Event").trim();
|
||||||
const footer = String(input.footer || "").trim();
|
const footer = String(input.footer || "").trim();
|
||||||
|
const logoDataUrl = String(input.logoDataUrl || "").trim();
|
||||||
const theme = ["classic", "minimal", "motorsport"].includes(String(input.theme || "").toLowerCase())
|
const theme = ["classic", "minimal", "motorsport"].includes(String(input.theme || "").toLowerCase())
|
||||||
? String(input.theme).toLowerCase()
|
? String(input.theme).toLowerCase()
|
||||||
: "classic";
|
: "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) {
|
function buildSimplePdf(payload) {
|
||||||
@@ -325,10 +326,13 @@ function buildSimplePdf(payload) {
|
|||||||
const pageHeight = 842;
|
const pageHeight = 842;
|
||||||
const marginLeft = 42;
|
const marginLeft = 42;
|
||||||
const marginTop = 56;
|
const marginTop = 56;
|
||||||
|
const marginBottom = 56;
|
||||||
const lineHeight = 15;
|
const lineHeight = 15;
|
||||||
const maxChars = 86;
|
const maxChars = 86;
|
||||||
|
const logo = parsePdfLogo(payload.logoDataUrl);
|
||||||
|
const headerOffset = logo ? logo.displayHeight + 18 : 0;
|
||||||
const lines = buildPdfLines(payload, maxChars);
|
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 = [];
|
const pages = [];
|
||||||
for (let index = 0; index < lines.length; index += linesPerPage) {
|
for (let index = 0; index < lines.length; index += linesPerPage) {
|
||||||
pages.push(lines.slice(index, index + linesPerPage));
|
pages.push(lines.slice(index, index + linesPerPage));
|
||||||
@@ -338,37 +342,168 @@ function buildSimplePdf(payload) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const objects = [];
|
const objects = [];
|
||||||
objects.push("1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n");
|
const pageObjectIds = [];
|
||||||
|
const contentObjectIds = [];
|
||||||
const kids = [];
|
let nextObjectId = 3;
|
||||||
const fontObjectId = 3 + pages.length * 2;
|
|
||||||
for (let index = 0; index < pages.length; index += 1) {
|
for (let index = 0; index < pages.length; index += 1) {
|
||||||
const pageObjectId = 3 + index * 2;
|
pageObjectIds.push(nextObjectId++);
|
||||||
const contentObjectId = 4 + index * 2;
|
contentObjectIds.push(nextObjectId++);
|
||||||
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`);
|
|
||||||
}
|
}
|
||||||
objects.splice(1, 0, `2 0 obj\n<< /Type /Pages /Count ${pages.length} /Kids [${kids.join(" ")}] >>\nendobj\n`);
|
const fontObjectId = nextObjectId++;
|
||||||
objects.push(`${fontObjectId} 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Courier >>\nendobj\n`);
|
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];
|
const offsets = [0];
|
||||||
objects.forEach((object) => {
|
let position = chunks[0].length;
|
||||||
offsets.push(Buffer.byteLength(pdf, "utf8"));
|
ordered.forEach((object) => {
|
||||||
pdf += object;
|
offsets[object.id] = position;
|
||||||
|
chunks.push(object.buffer);
|
||||||
|
position += object.buffer.length;
|
||||||
});
|
});
|
||||||
const xrefOffset = Buffer.byteLength(pdf, "utf8");
|
const xrefOffset = position;
|
||||||
pdf += `xref\n0 ${objects.length + 1}\n`;
|
const size = ordered.length + 1;
|
||||||
pdf += "0000000000 65535 f \n";
|
let xref = `xref\n0 ${size}\n0000000000 65535 f \n`;
|
||||||
offsets.slice(1).forEach((offset) => {
|
for (let id = 1; id < size; id += 1) {
|
||||||
pdf += `${String(offset).padStart(10, "0")} 00000 n \n`;
|
xref += `${String(offsets[id] || 0).padStart(10, "0")} 00000 n \n`;
|
||||||
});
|
}
|
||||||
pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF`;
|
chunks.push(Buffer.from(`${xref}trailer\n<< /Size ${size} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF`, "binary"));
|
||||||
return Buffer.from(pdf, "utf8");
|
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) {
|
function buildPdfLines(payload, maxChars) {
|
||||||
@@ -473,16 +608,24 @@ function wrapPdfText(text, maxChars) {
|
|||||||
return lines;
|
return lines;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildPdfContentStream(lines, x, y, lineHeight) {
|
function buildPdfContentStream(lines, x, y, lineHeight, logoPlacement = null) {
|
||||||
const escaped = lines.map((line) => escapePdfText(line));
|
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",
|
"BT",
|
||||||
"/F1 11 Tf",
|
"/F1 11 Tf",
|
||||||
`${lineHeight} TL`,
|
`${lineHeight} TL`,
|
||||||
`${x} ${y} Td`,
|
`${x} ${y} Td`,
|
||||||
...escaped.map((line) => `(${line}) Tj\nT*`),
|
...escaped.map((line) => `(${line}) Tj\nT*`),
|
||||||
"ET",
|
"ET"
|
||||||
].join("\n");
|
);
|
||||||
|
return commands.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapePdfText(text) {
|
function escapePdfText(text) {
|
||||||
|
|||||||
277
src/app.js
277
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.bump_reserved_note": "Om bump används kan finalgeneratorn reservera platser i högre finaler redan från start.",
|
||||||
"events.actions": "Åtgärder",
|
"events.actions": "Åtgärder",
|
||||||
"events.manage_title": "Hantera",
|
"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.session_name": "Sessionsnamn",
|
||||||
"events.duration_placeholder": "Längd (min)",
|
"events.duration_placeholder": "Längd (min)",
|
||||||
"events.max_cars_placeholder": "Max bilar (valfritt)",
|
"events.max_cars_placeholder": "Max bilar (valfritt)",
|
||||||
@@ -221,6 +230,8 @@ const TRANSLATIONS = {
|
|||||||
"timing.disconnected": "Frånkopplad",
|
"timing.disconnected": "Frånkopplad",
|
||||||
"timing.last_message": "Senaste meddelande",
|
"timing.last_message": "Senaste meddelande",
|
||||||
"timing.control": "Sessionkontroll",
|
"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.select_session": "Välj session",
|
||||||
"timing.set_active": "Sätt aktiv",
|
"timing.set_active": "Sätt aktiv",
|
||||||
"timing.start": "Starta",
|
"timing.start": "Starta",
|
||||||
@@ -307,7 +318,7 @@ const TRANSLATIONS = {
|
|||||||
"settings.logo": "Logo / overlay",
|
"settings.logo": "Logo / overlay",
|
||||||
"settings.logo_upload": "Ladda logo",
|
"settings.logo_upload": "Ladda logo",
|
||||||
"settings.logo_clear": "Rensa 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.storage": "Lagring",
|
||||||
"settings.backend_url": "Backend URL",
|
"settings.backend_url": "Backend URL",
|
||||||
"settings.backend_status": "Backend-status",
|
"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.bump_reserved_note": "If bump-up is used, finals can reserve slots in upper mains from the start.",
|
||||||
"events.actions": "Actions",
|
"events.actions": "Actions",
|
||||||
"events.manage_title": "Manage",
|
"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.session_name": "Session name",
|
||||||
"events.duration_placeholder": "Duration (min)",
|
"events.duration_placeholder": "Duration (min)",
|
||||||
"events.max_cars_placeholder": "Max cars (optional)",
|
"events.max_cars_placeholder": "Max cars (optional)",
|
||||||
@@ -657,6 +677,8 @@ const TRANSLATIONS = {
|
|||||||
"timing.disconnected": "Disconnected",
|
"timing.disconnected": "Disconnected",
|
||||||
"timing.last_message": "Last message",
|
"timing.last_message": "Last message",
|
||||||
"timing.control": "Session Control",
|
"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.select_session": "Select session",
|
||||||
"timing.set_active": "Set Active",
|
"timing.set_active": "Set Active",
|
||||||
"timing.start": "Start",
|
"timing.start": "Start",
|
||||||
@@ -743,7 +765,7 @@ const TRANSLATIONS = {
|
|||||||
"settings.logo": "Logo / overlay",
|
"settings.logo": "Logo / overlay",
|
||||||
"settings.logo_upload": "Upload logo",
|
"settings.logo_upload": "Upload logo",
|
||||||
"settings.logo_clear": "Clear 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.storage": "Storage",
|
||||||
"settings.backend_url": "Backend URL",
|
"settings.backend_url": "Backend URL",
|
||||||
"settings.backend_status": "Backend status",
|
"settings.backend_status": "Backend status",
|
||||||
@@ -1375,6 +1397,7 @@ function normalizeSession(session) {
|
|||||||
function normalizeEvent(event) {
|
function normalizeEvent(event) {
|
||||||
return {
|
return {
|
||||||
...event,
|
...event,
|
||||||
|
branding: normalizeBrandingConfig(event?.branding),
|
||||||
raceConfig: {
|
raceConfig: {
|
||||||
qualifyingScoring: event?.raceConfig?.qualifyingScoring === "best" ? "best" : "points",
|
qualifyingScoring: event?.raceConfig?.qualifyingScoring === "best" ? "best" : "points",
|
||||||
qualifyingRounds: Math.max(1, Number(event?.raceConfig?.qualifyingRounds || 3) || 3),
|
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() {
|
function scheduleBackendSync() {
|
||||||
clearTimeout(backendSyncTimer);
|
clearTimeout(backendSyncTimer);
|
||||||
backendSyncTimer = setTimeout(() => {
|
backendSyncTimer = setTimeout(() => {
|
||||||
@@ -2225,6 +2272,7 @@ function renderEventManager(eventId) {
|
|||||||
const carOptions = state.cars
|
const carOptions = state.cars
|
||||||
.map((c) => `<option value="${c.id}">${escapeHtml(c.name)} (${escapeHtml(c.transponder)})</option>`)
|
.map((c) => `<option value="${c.id}">${escapeHtml(c.name)} (${escapeHtml(c.transponder)})</option>`)
|
||||||
.join("");
|
.join("");
|
||||||
|
const branding = normalizeBrandingConfig(event.branding);
|
||||||
const gridSessions = event.mode === "race" ? sessions.filter((session) => normalizeStartMode(session.startMode) === "position") : [];
|
const gridSessions = event.mode === "race" ? sessions.filter((session) => normalizeStartMode(session.startMode) === "position") : [];
|
||||||
if (selectedGridSessionId && !gridSessions.some((session) => session.id === selectedGridSessionId)) {
|
if (selectedGridSessionId && !gridSessions.some((session) => session.id === selectedGridSessionId)) {
|
||||||
selectedGridSessionId = "";
|
selectedGridSessionId = "";
|
||||||
@@ -2301,6 +2349,30 @@ function renderEventManager(eventId) {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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"
|
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) => {
|
document.getElementById("sessionForm")?.addEventListener("submit", (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const form = new FormData(e.currentTarget);
|
const form = new FormData(e.currentTarget);
|
||||||
@@ -3007,6 +3120,21 @@ function renderTiming() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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">
|
<section class="panel mt-16">
|
||||||
<div class="panel-header"><h3>${t("timing.leaderboard")}</h3></div>
|
<div class="panel-header"><h3>${t("timing.leaderboard")}</h3></div>
|
||||||
<div class="panel-body">
|
<div class="panel-body">
|
||||||
@@ -3143,6 +3271,25 @@ function renderTiming() {
|
|||||||
document.getElementById("openOverlay")?.addEventListener("click", openOverlayWindow);
|
document.getElementById("openOverlay")?.addEventListener("click", openOverlayWindow);
|
||||||
document.getElementById("openSpeakerOverlay")?.addEventListener("click", () => openOverlayWindow("speaker"));
|
document.getElementById("openSpeakerOverlay")?.addEventListener("click", () => openOverlayWindow("speaker"));
|
||||||
document.getElementById("openResultsOverlay")?.addEventListener("click", () => openOverlayWindow("results"));
|
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() {
|
function renderGuide() {
|
||||||
@@ -3269,6 +3416,7 @@ function renderOverlay() {
|
|||||||
const sessionTiming = active ? getSessionTiming(active) : null;
|
const sessionTiming = active ? getSessionTiming(active) : null;
|
||||||
const recent = active && result ? result.passings.slice(-8).reverse() : [];
|
const recent = active && result ? result.passings.slice(-8).reverse() : [];
|
||||||
const event = active ? state.events.find((item) => item.id === active.eventId) : null;
|
const event = active ? state.events.find((item) => item.id === active.eventId) : null;
|
||||||
|
const branding = resolveEventBranding(event);
|
||||||
const practiceRows = event ? buildPracticeStandings(event) : [];
|
const practiceRows = event ? buildPracticeStandings(event) : [];
|
||||||
const qualifyingRows = event ? buildQualifyingStandings(event) : [];
|
const qualifyingRows = event ? buildQualifyingStandings(event) : [];
|
||||||
const finalRows = event ? buildFinalStandings(event) : [];
|
const finalRows = event ? buildFinalStandings(event) : [];
|
||||||
@@ -3282,10 +3430,10 @@ function renderOverlay() {
|
|||||||
? `
|
? `
|
||||||
<header class="overlay-header">
|
<header class="overlay-header">
|
||||||
<div>
|
<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>
|
<p class="overlay-kicker">${escapeHtml(getEventName(active.eventId))}</p>
|
||||||
<h1>${escapeHtml(active.name)} • ${escapeHtml(getSessionTypeLabel(active.type))}</h1>
|
<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>
|
||||||
<div class="overlay-meta">
|
<div class="overlay-meta">
|
||||||
<div class="overlay-clock">${formatCountdown(sessionTiming?.remainingMs ?? 0)}</div>
|
<div class="overlay-clock">${formatCountdown(sessionTiming?.remainingMs ?? 0)}</div>
|
||||||
@@ -3600,6 +3748,18 @@ function renderSettings() {
|
|||||||
<input type="checkbox" name="speakerFinishCueEnabled" ${state.settings.speakerFinishCueEnabled ? "checked" : ""} />
|
<input type="checkbox" name="speakerFinishCueEnabled" ${state.settings.speakerFinishCueEnabled ? "checked" : ""} />
|
||||||
<span>${t("settings.speaker_finish_cue")}</span>
|
<span>${t("settings.speaker_finish_cue")}</span>
|
||||||
</label>
|
</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 class="btn btn-primary" type="submit">${t("settings.save")}</button>
|
||||||
<button id="settingsConnect" class="btn" type="button">${t("settings.connect_now")}</button>
|
<button id="settingsConnect" class="btn" type="button">${t("settings.connect_now")}</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -3712,6 +3872,9 @@ function renderSettings() {
|
|||||||
state.settings.speakerPassingCueEnabled = form.get("speakerPassingCueEnabled") === "on";
|
state.settings.speakerPassingCueEnabled = form.get("speakerPassingCueEnabled") === "on";
|
||||||
state.settings.speakerLeaderCueEnabled = form.get("speakerLeaderCueEnabled") === "on";
|
state.settings.speakerLeaderCueEnabled = form.get("speakerLeaderCueEnabled") === "on";
|
||||||
state.settings.speakerFinishCueEnabled = form.get("speakerFinishCueEnabled") === "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.clubName = String(form.get("clubName") || "").trim() || "JMK RB";
|
||||||
state.settings.clubTagline = String(form.get("clubTagline") || "").trim() || "Live Event";
|
state.settings.clubTagline = String(form.get("clubTagline") || "").trim() || "Live Event";
|
||||||
state.settings.pdfFooter = String(form.get("pdfFooter") || "").trim() || "Generated by JMK RB 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) {
|
function buildRaceStartListsHtml(event) {
|
||||||
|
const branding = resolveEventBranding(event);
|
||||||
const sessions = getSessionsForEvent(event.id)
|
const sessions = getSessionsForEvent(event.id)
|
||||||
.filter((session) => session.mode === "race")
|
.filter((session) => session.mode === "race")
|
||||||
.sort((left, right) => {
|
.sort((left, right) => {
|
||||||
@@ -5152,8 +5328,16 @@ function buildRaceStartListsHtml(event) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return `
|
return `
|
||||||
|
<header class="print-header">
|
||||||
|
<div>
|
||||||
|
<p class="print-kicker">${escapeHtml(getClassName(event.classId))}</p>
|
||||||
<h1>${escapeHtml(event.name)}</h1>
|
<h1>${escapeHtml(event.name)}</h1>
|
||||||
<p>${escapeHtml(getClassName(event.classId))} • ${escapeHtml(event.date || "-")}</p>
|
<p>${escapeHtml(event.date || "-")}</p>
|
||||||
|
</div>
|
||||||
|
<div class="print-meta">
|
||||||
|
${buildPrintBrandBlock(branding)}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
<h2>${t("events.start_lists")}</h2>
|
<h2>${t("events.start_lists")}</h2>
|
||||||
${sessions
|
${sessions
|
||||||
.map((session) => {
|
.map((session) => {
|
||||||
@@ -5186,6 +5370,7 @@ function buildRaceStartListsHtml(event) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildRaceResultsHtml(event) {
|
function buildRaceResultsHtml(event) {
|
||||||
|
const branding = resolveEventBranding(event);
|
||||||
return `
|
return `
|
||||||
<header class="print-header">
|
<header class="print-header">
|
||||||
<div>
|
<div>
|
||||||
@@ -5194,7 +5379,7 @@ function buildRaceResultsHtml(event) {
|
|||||||
<p>${escapeHtml(event.date || "-")}</p>
|
<p>${escapeHtml(event.date || "-")}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="print-meta">
|
<div class="print-meta">
|
||||||
<strong>JMK RB Live Event</strong>
|
${buildPrintBrandBlock(branding)}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<section class="print-block">
|
<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-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-kicker { text-transform: uppercase; letter-spacing: 0.08em; color: #5b677f; font-size: 12px; }
|
||||||
.print-meta { text-align: right; font-size: 13px; }
|
.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; }
|
.print-block { margin-top: 24px; break-inside: avoid; }
|
||||||
table { width: 100%; border-collapse: collapse; margin-top: 12px; }
|
table { width: 100%; border-collapse: collapse; margin-top: 12px; }
|
||||||
th, td { border: 1px solid #b9c2d4; padding: 8px; text-align: left; }
|
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) {
|
async function exportRaceStartListsPdf(event) {
|
||||||
|
const branding = resolveEventBranding(event);
|
||||||
const sessions = getSessionsForEvent(event.id)
|
const sessions = getSessionsForEvent(event.id)
|
||||||
.filter((session) => session.mode === "race")
|
.filter((session) => session.mode === "race")
|
||||||
.sort((left, right) => {
|
.sort((left, right) => {
|
||||||
@@ -5304,23 +5529,26 @@ async function exportRaceStartListsPdf(event) {
|
|||||||
filename: `${event.name.replaceAll(/\s+/g, "_")}_startlists.pdf`,
|
filename: `${event.name.replaceAll(/\s+/g, "_")}_startlists.pdf`,
|
||||||
title: event.name,
|
title: event.name,
|
||||||
subtitle: `${getClassName(event.classId)} • ${event.date || "-"}`,
|
subtitle: `${getClassName(event.classId)} • ${event.date || "-"}`,
|
||||||
brandName: state.settings.clubName || "JMK RB",
|
brandName: branding.brandName,
|
||||||
brandTagline: state.settings.clubTagline || "Live Event",
|
brandTagline: branding.brandTagline,
|
||||||
footer: state.settings.pdfFooter || "Generated by JMK RB Live Event",
|
footer: branding.pdfFooter,
|
||||||
theme: state.settings.pdfTheme || "classic",
|
theme: branding.pdfTheme,
|
||||||
|
logoDataUrl: await ensurePdfLogoDataUrl(branding.logoDataUrl),
|
||||||
sections,
|
sections,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function exportRaceResultsPdf(event) {
|
async function exportRaceResultsPdf(event) {
|
||||||
|
const branding = resolveEventBranding(event);
|
||||||
await requestPdfExport({
|
await requestPdfExport({
|
||||||
filename: `${event.name.replaceAll(/\s+/g, "_")}_results.pdf`,
|
filename: `${event.name.replaceAll(/\s+/g, "_")}_results.pdf`,
|
||||||
title: event.name,
|
title: event.name,
|
||||||
subtitle: `${getClassName(event.classId)} • ${event.date || "-"}`,
|
subtitle: `${getClassName(event.classId)} • ${event.date || "-"}`,
|
||||||
brandName: state.settings.clubName || "JMK RB",
|
brandName: branding.brandName,
|
||||||
brandTagline: state.settings.clubTagline || "Live Event",
|
brandTagline: branding.brandTagline,
|
||||||
footer: state.settings.pdfFooter || "Generated by JMK RB Live Event",
|
footer: branding.pdfFooter,
|
||||||
theme: state.settings.pdfTheme || "classic",
|
theme: branding.pdfTheme,
|
||||||
|
logoDataUrl: await ensurePdfLogoDataUrl(branding.logoDataUrl),
|
||||||
sections: [
|
sections: [
|
||||||
buildPdfSection(
|
buildPdfSection(
|
||||||
t("events.practice_standings"),
|
t("events.practice_standings"),
|
||||||
@@ -5343,14 +5571,16 @@ async function exportRaceResultsPdf(event) {
|
|||||||
|
|
||||||
async function exportSessionHeatSheetPdf(session) {
|
async function exportSessionHeatSheetPdf(session) {
|
||||||
const event = state.events.find((item) => item.id === session.eventId);
|
const event = state.events.find((item) => item.id === session.eventId);
|
||||||
|
const branding = resolveEventBranding(event);
|
||||||
await requestPdfExport({
|
await requestPdfExport({
|
||||||
filename: `${(event?.name || "event").replaceAll(/\s+/g, "_")}_${session.name.replaceAll(/\s+/g, "_")}.pdf`,
|
filename: `${(event?.name || "event").replaceAll(/\s+/g, "_")}_${session.name.replaceAll(/\s+/g, "_")}.pdf`,
|
||||||
title: event?.name || t("common.unknown_event"),
|
title: event?.name || t("common.unknown_event"),
|
||||||
subtitle: `${getSessionTypeLabel(session.type)} • ${session.name} • ${getClassName(event?.classId || "")}`,
|
subtitle: `${getSessionTypeLabel(session.type)} • ${session.name} • ${getClassName(event?.classId || "")}`,
|
||||||
brandName: state.settings.clubName || "JMK RB",
|
brandName: branding.brandName,
|
||||||
brandTagline: state.settings.clubTagline || "Live Event",
|
brandTagline: branding.brandTagline,
|
||||||
footer: state.settings.pdfFooter || "Generated by JMK RB Live Event",
|
footer: branding.pdfFooter,
|
||||||
theme: state.settings.pdfTheme || "classic",
|
theme: branding.pdfTheme,
|
||||||
|
logoDataUrl: await ensurePdfLogoDataUrl(branding.logoDataUrl),
|
||||||
sections: [
|
sections: [
|
||||||
buildPdfSection(
|
buildPdfSection(
|
||||||
`${session.name} • ${getSessionTypeLabel(session.type)}`,
|
`${session.name} • ${getSessionTypeLabel(session.type)}`,
|
||||||
@@ -5370,10 +5600,19 @@ function reorderList(items, fromIndex, toIndex) {
|
|||||||
|
|
||||||
function buildSessionHeatSheetHtml(session) {
|
function buildSessionHeatSheetHtml(session) {
|
||||||
const event = state.events.find((item) => item.id === session.eventId);
|
const event = state.events.find((item) => item.id === session.eventId);
|
||||||
|
const branding = resolveEventBranding(event);
|
||||||
const entries = getSessionGridEntries(session);
|
const entries = getSessionGridEntries(session);
|
||||||
return `
|
return `
|
||||||
|
<header class="print-header">
|
||||||
|
<div>
|
||||||
|
<p class="print-kicker">${escapeHtml(getClassName(event?.classId || ""))}</p>
|
||||||
<h1>${escapeHtml(event?.name || t("common.unknown_event"))}</h1>
|
<h1>${escapeHtml(event?.name || t("common.unknown_event"))}</h1>
|
||||||
<p>${escapeHtml(getClassName(event?.classId || ""))} • ${escapeHtml(session.name)} • ${escapeHtml(getSessionTypeLabel(session.type))}</p>
|
<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>
|
<p>${t("table.start_mode")}: ${escapeHtml(getStartModeLabel(session.startMode))} • ${t("table.duration")}: ${session.durationMin} min</p>
|
||||||
${
|
${
|
||||||
entries.length
|
entries.length
|
||||||
|
|||||||
Reference in New Issue
Block a user