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
|
||||
- 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
207
server.js
@@ -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) {
|
||||
|
||||
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