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

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