Polish timing layout and add TV overlay

This commit is contained in:
larssand
2026-03-15 13:20:57 +01:00
parent 1720f05269
commit 7e13ecd4ac
2 changed files with 190 additions and 43 deletions

View File

@@ -265,6 +265,7 @@ const TRANSLATIONS = {
"timing.open_overlay": "Öppna overlay", "timing.open_overlay": "Öppna overlay",
"timing.open_speaker_overlay": "Speaker overlay", "timing.open_speaker_overlay": "Speaker overlay",
"timing.open_results_overlay": "Result overlay", "timing.open_results_overlay": "Result overlay",
"timing.open_tv_overlay": "TV overlay",
"timing.close_details": "Stang", "timing.close_details": "Stang",
"timing.detail_title": "Leaderboard-detaljer", "timing.detail_title": "Leaderboard-detaljer",
"timing.lap_history": "Varvhistorik", "timing.lap_history": "Varvhistorik",
@@ -459,6 +460,7 @@ const TRANSLATIONS = {
"overlay.mode_leaderboard": "Leaderboard", "overlay.mode_leaderboard": "Leaderboard",
"overlay.mode_speaker": "Speaker", "overlay.mode_speaker": "Speaker",
"overlay.mode_results": "Resultat", "overlay.mode_results": "Resultat",
"overlay.mode_tv": "TV",
"overlay.fastest_lap": "Snabbaste varv", "overlay.fastest_lap": "Snabbaste varv",
"overlay.fullscreen": "Fullscreen", "overlay.fullscreen": "Fullscreen",
"overlay.leaderboard_live": "Live leaderboard", "overlay.leaderboard_live": "Live leaderboard",
@@ -747,6 +749,7 @@ const TRANSLATIONS = {
"timing.open_overlay": "Open overlay", "timing.open_overlay": "Open overlay",
"timing.open_speaker_overlay": "Speaker overlay", "timing.open_speaker_overlay": "Speaker overlay",
"timing.open_results_overlay": "Results overlay", "timing.open_results_overlay": "Results overlay",
"timing.open_tv_overlay": "TV overlay",
"timing.close_details": "Close", "timing.close_details": "Close",
"timing.detail_title": "Leaderboard details", "timing.detail_title": "Leaderboard details",
"timing.lap_history": "Lap history", "timing.lap_history": "Lap history",
@@ -941,6 +944,7 @@ const TRANSLATIONS = {
"overlay.mode_leaderboard": "Leaderboard", "overlay.mode_leaderboard": "Leaderboard",
"overlay.mode_speaker": "Speaker", "overlay.mode_speaker": "Speaker",
"overlay.mode_results": "Results", "overlay.mode_results": "Results",
"overlay.mode_tv": "TV",
"overlay.fastest_lap": "Fastest Lap", "overlay.fastest_lap": "Fastest Lap",
"overlay.fullscreen": "Fullscreen", "overlay.fullscreen": "Fullscreen",
"overlay.leaderboard_live": "Live leaderboard", "overlay.leaderboard_live": "Live leaderboard",
@@ -984,7 +988,7 @@ const TRANSLATIONS = {
const urlParams = new URLSearchParams(window.location.search); const urlParams = new URLSearchParams(window.location.search);
const overlayMode = urlParams.get("view") === "overlay"; const overlayMode = urlParams.get("view") === "overlay";
const overlayViewMode = ["leaderboard", "speaker", "results"].includes(String(urlParams.get("overlayMode") || "").toLowerCase()) const overlayViewMode = ["leaderboard", "speaker", "results", "tv"].includes(String(urlParams.get("overlayMode") || "").toLowerCase())
? String(urlParams.get("overlayMode")).toLowerCase() ? String(urlParams.get("overlayMode")).toLowerCase()
: "leaderboard"; : "leaderboard";
const state = loadState(); const state = loadState();
@@ -3556,52 +3560,73 @@ function renderTiming() {
dom.view.innerHTML = ` dom.view.innerHTML = `
<section class="panel"> <section class="panel">
<div class="panel-header"><h3>${t("timing.decoder_connection")}</h3></div> <div class="panel-header"><h3>${t("timing.decoder_connection")}</h3></div>
<div class="panel-body form-grid cols-4"> <div class="panel-body timing-top-grid">
<input id="timingWsUrl" value="${escapeHtml(state.settings.wsUrl)}" placeholder="ws://127.0.0.1:9000" /> <div class="timing-compact-card">
<button id="timingConnect" class="btn btn-primary">${t("timing.connect")}</button> <label class="timing-compact-label" for="timingWsUrl">${t("settings.decoder")}</label>
<button id="timingDisconnect" class="btn">${t("timing.disconnect")}</button> <input id="timingWsUrl" value="${escapeHtml(state.settings.wsUrl)}" placeholder="ws://127.0.0.1:9000" />
<button id="timingSimPass" class="btn">${t("timing.simulate")}</button> <div class="actions">
</div> <button id="timingConnect" class="btn btn-primary">${t("timing.connect")}</button>
<div class="panel-body"> <button id="timingDisconnect" class="btn">${t("timing.disconnect")}</button>
<p>${t("timing.status")}: <strong>${state.decoder.connected ? t("timing.connected") : t("timing.disconnected")}</strong></p> <button id="timingSimPass" class="btn">${t("timing.simulate")}</button>
<p>${t("timing.last_message")}: ${state.decoder.lastMessageAt ? new Date(state.decoder.lastMessageAt).toLocaleString() : "-"}</p> </div>
<p class="error">${escapeHtml(state.decoder.lastError || "")}</p> </div>
<div class="timing-compact-card">
<span class="timing-compact-label">${t("timing.status")}</span>
<strong>${state.decoder.connected ? t("timing.connected") : t("timing.disconnected")}</strong>
<small>${t("timing.last_message")}: ${state.decoder.lastMessageAt ? new Date(state.decoder.lastMessageAt).toLocaleString() : "-"}</small>
<p class="error">${escapeHtml(state.decoder.lastError || "")}</p>
</div>
</div> </div>
</section> </section>
<section class="panel mt-16"> <section class="panel mt-16">
<div class="panel-header"><h3>${t("timing.control")}</h3></div> <div class="panel-header"><h3>${t("timing.control")}</h3></div>
<div class="panel-body form-grid cols-5"> <div class="panel-body timing-top-grid">
<select id="activeSessionSelect"> <div class="timing-compact-card timing-compact-card-wide">
<option value="">${t("timing.select_session")}</option> <label class="timing-compact-label" for="activeSessionSelect">${t("timing.select_session")}</label>
${state.sessions <select id="activeSessionSelect">
.map( <option value="">${t("timing.select_session")}</option>
(s) => `<option value="${s.id}" ${state.activeSessionId === s.id ? "selected" : ""}>${escapeHtml( ${state.sessions
getEventName(s.eventId) .map(
)}${escapeHtml(s.name)} ${escapeHtml(getSessionTypeLabel(s.type))}</option>` (s) => `<option value="${s.id}" ${state.activeSessionId === s.id ? "selected" : ""}>${escapeHtml(
) getEventName(s.eventId)
.join("")} )}${escapeHtml(s.name)}${escapeHtml(getSessionTypeLabel(s.type))}</option>`
</select> )
<button id="setActiveSession" class="btn">${t("timing.set_active")}</button> .join("")}
<button id="startSession" class="btn btn-primary">${t("timing.start")}</button> </select>
<button id="stopSession" class="btn">${t("timing.stop")}</button> <div class="actions">
<button id="resetSession" class="btn btn-danger">${t("timing.reset")}</button> <button id="setActiveSession" class="btn">${t("timing.set_active")}</button>
<button id="openOverlay" class="btn" type="button">${t("timing.open_overlay")}</button> <button id="startSession" class="btn btn-primary">${t("timing.start")}</button>
<button id="openSpeakerOverlay" class="btn" type="button">${t("timing.open_speaker_overlay")}</button> <button id="stopSession" class="btn">${t("timing.stop")}</button>
<button id="openResultsOverlay" class="btn" type="button">${t("timing.open_results_overlay")}</button> <button id="resetSession" class="btn btn-danger">${t("timing.reset")}</button>
</div>
</div>
<div class="timing-compact-card">
<span class="timing-compact-label">${t("overlay.title")}</span>
<div class="actions">
<button id="openOverlay" class="btn" type="button">${t("timing.open_overlay")}</button>
<button id="openSpeakerOverlay" class="btn" type="button">${t("timing.open_speaker_overlay")}</button>
<button id="openResultsOverlay" class="btn" type="button">${t("timing.open_results_overlay")}</button>
<button id="openTvOverlay" class="btn" type="button">${t("timing.open_tv_overlay")}</button>
</div>
</div>
</div> </div>
<div class="panel-body"> <div class="panel-body timing-session-summary">
${ ${
active active
? `<p><strong>${escapeHtml(active.name)}</strong> (${escapeHtml(getSessionTypeLabel(active.type))}) • ${escapeHtml( ? `<div class="timing-session-head">
getEventName(active.eventId) <strong>${escapeHtml(active.name)}</strong>
)}</p> <span class="pill">${escapeHtml(getSessionTypeLabel(active.type))}</span>
<p>${t("timing.status")}: ${escapeHtml(getStatusLabel(active.status))}${t("timing.started")}: ${active.startedAt ? new Date(active.startedAt).toLocaleTimeString() : "-"}</p> <span class="pill">${escapeHtml(getEventName(active.eventId))}</span>
<p>${t("table.start_mode")}: ${escapeHtml(getStartModeLabel(active.startMode))}${t("timing.seeding_mode")}: ${ <span class="pill ${active.status === "running" ? "pill-green" : ""}">${escapeHtml(getStatusLabel(active.status))}</span>
active.seedBestLapCount > 0 ? `${active.seedBestLapCount}` : "-" </div>
}</p> <div class="timing-session-stats">
<p>${clockLabel}: ${clockValue}</p> <article class="timing-session-stat"><span>${clockLabel}</span><strong>${clockValue}</strong></article>
<p>${t("timing.total_passings")}: ${result.passings.length}</p> <article class="timing-session-stat"><span>${t("timing.started")}</span><strong>${active.startedAt ? new Date(active.startedAt).toLocaleTimeString() : "-"}</strong></article>
<article class="timing-session-stat"><span>${t("table.start_mode")}</span><strong>${escapeHtml(getStartModeLabel(active.startMode))}</strong></article>
<article class="timing-session-stat"><span>${t("timing.seeding_mode")}</span><strong>${active.seedBestLapCount > 0 ? `${active.seedBestLapCount}` : "-"}</strong></article>
<article class="timing-session-stat"><span>${t("timing.total_passings")}</span><strong>${result.passings.length}</strong></article>
</div>
${ ${
active.type === "free_practice" active.type === "free_practice"
? `<p class="hint">${t("events.free_practice_note")}</p>` ? `<p class="hint">${t("events.free_practice_note")}</p>`
@@ -3822,6 +3847,7 @@ 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.getElementById("openTvOverlay")?.addEventListener("click", () => openOverlayWindow("tv"));
document.querySelectorAll("[data-speaker-setting]").forEach((node) => { document.querySelectorAll("[data-speaker-setting]").forEach((node) => {
node.addEventListener("change", (event) => { node.addEventListener("change", (event) => {
const input = event.currentTarget; const input = event.currentTarget;
@@ -4123,6 +4149,38 @@ function renderOverlay() {
</div> </div>
</section> </section>
` `
: overlayViewMode === "tv"
? `
<section class="overlay-board overlay-board-tv">
<div class="overlay-table-wrap overlay-display-wrap">
<section class="overlay-fastest-banner">
<div>
<span>${t("overlay.fastest_lap")}</span>
<strong>${formatLap(fastestRow?.bestLapMs)}</strong>
</div>
<div class="overlay-fastest-driver">${escapeHtml(fastestRow?.driverName || "-")}</div>
</section>
<section class="overlay-stats-row">
<article class="overlay-stat-card">
<span>${t("table.laps")}</span>
<strong>${topRow?.laps || 0}</strong>
<small>${escapeHtml(topRow?.driverName || "-")}</small>
</article>
<article class="overlay-stat-card">
<span>${t("timing.total_passings")}</span>
<strong>${result?.passings.length || 0}</strong>
<small>${sessionTiming?.untimed ? t("timing.elapsed") : t("timing.remaining")}</small>
</article>
</section>
<div class="overlay-leaderboard-card overlay-leaderboard-card-tv">
<div class="overlay-section-head">
<h3>${t("overlay.leaderboard_live")}</h3>
</div>
${renderOverlayLeaderboard(leaderboard)}
</div>
</div>
</section>
`
: ` : `
<section class="overlay-board"> <section class="overlay-board">
<div class="overlay-table-wrap overlay-display-wrap"> <div class="overlay-table-wrap overlay-display-wrap">
@@ -4471,7 +4529,7 @@ function renderSettings() {
dom.view.innerHTML = ` dom.view.innerHTML = `
<section class="panel"> <section class="panel">
<div class="panel-header"><h3>${t("settings.decoder")}</h3></div> <div class="panel-header"><h3>${t("settings.decoder")}</h3></div>
<form id="settingsForm" class="panel-body form-grid cols-3"> <form id="settingsForm" class="panel-body form-grid cols-3 settings-grid">
<input name="wsUrl" value="${escapeHtml(state.settings.wsUrl)}" placeholder="ws://127.0.0.1:9000" /> <input name="wsUrl" value="${escapeHtml(state.settings.wsUrl)}" placeholder="ws://127.0.0.1:9000" />
<input name="backendUrl" value="${escapeHtml( <input name="backendUrl" value="${escapeHtml(
state.settings.backendUrl || getDefaultBackendUrl() state.settings.backendUrl || getDefaultBackendUrl()
@@ -4491,7 +4549,7 @@ function renderSettings() {
<section class="panel mt-16"> <section class="panel mt-16">
<div class="panel-header"><h3>${t("settings.audio")}</h3></div> <div class="panel-header"><h3>${t("settings.audio")}</h3></div>
<div class="panel-body form-grid cols-4"> <div class="panel-body form-grid cols-4 settings-grid settings-grid-toggles">
<label class="toggle"> <label class="toggle">
<input type="checkbox" name="audioEnabled" form="settingsForm" ${state.settings.audioEnabled ? "checked" : ""} /> <input type="checkbox" name="audioEnabled" form="settingsForm" ${state.settings.audioEnabled ? "checked" : ""} />
<span>${t("settings.audio_enabled")}</span> <span>${t("settings.audio_enabled")}</span>
@@ -4538,7 +4596,7 @@ function renderSettings() {
<section class="panel mt-16"> <section class="panel mt-16">
<div class="panel-header"><h3>${t("settings.branding")}</h3></div> <div class="panel-header"><h3>${t("settings.branding")}</h3></div>
<div class="panel-body form-grid cols-3"> <div class="panel-body form-grid cols-3 settings-grid">
<input name="clubName" form="settingsForm" value="${escapeHtml(state.settings.clubName || "")}" placeholder="${t("settings.club_name")}" /> <input name="clubName" form="settingsForm" value="${escapeHtml(state.settings.clubName || "")}" placeholder="${t("settings.club_name")}" />
<input name="clubTagline" form="settingsForm" value="${escapeHtml(state.settings.clubTagline || "")}" placeholder="${t("settings.club_tagline")}" /> <input name="clubTagline" form="settingsForm" value="${escapeHtml(state.settings.clubTagline || "")}" placeholder="${t("settings.club_tagline")}" />
<input name="pdfFooter" form="settingsForm" value="${escapeHtml(state.settings.pdfFooter || "")}" placeholder="${t("settings.pdf_footer")}" /> <input name="pdfFooter" form="settingsForm" value="${escapeHtml(state.settings.pdfFooter || "")}" placeholder="${t("settings.pdf_footer")}" />
@@ -4565,7 +4623,7 @@ function renderSettings() {
<section class="panel mt-16"> <section class="panel mt-16">
<div class="panel-header"><h3>${t("settings.managed_ammc")}</h3></div> <div class="panel-header"><h3>${t("settings.managed_ammc")}</h3></div>
<form id="ammcForm" class="panel-body form-grid cols-2"> <form id="ammcForm" class="panel-body form-grid cols-2 settings-grid">
<label class="toggle"> <label class="toggle">
<input type="checkbox" name="managedEnabled" ${ammc.config.managedEnabled ? "checked" : ""} /> <input type="checkbox" name="managedEnabled" ${ammc.config.managedEnabled ? "checked" : ""} />
<span>${t("settings.enable_managed")}</span> <span>${t("settings.enable_managed")}</span>

View File

@@ -262,6 +262,14 @@ body {
padding: 12px 14px; padding: 12px 14px;
} }
.settings-grid {
gap: 10px;
}
.settings-grid-toggles .toggle {
min-height: 40px;
}
.actions { .actions {
display: flex; display: flex;
gap: 10px; gap: 10px;
@@ -481,6 +489,75 @@ select:focus {
font-size: 1.1rem; font-size: 1.1rem;
} }
.timing-top-grid {
display: grid;
grid-template-columns: minmax(0, 1.4fr) minmax(260px, 0.8fr);
gap: 12px;
}
.timing-compact-card {
border: 1px solid var(--line);
border-radius: 14px;
padding: 12px;
background: rgba(255, 255, 255, 0.03);
display: grid;
gap: 10px;
}
.timing-compact-card-wide {
min-width: 0;
}
.timing-compact-label {
color: var(--muted);
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.timing-session-summary {
display: grid;
gap: 14px;
}
.timing-session-head {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.timing-session-head strong {
font-size: 1.05rem;
}
.timing-session-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 10px;
}
.timing-session-stat {
border: 1px solid var(--line);
border-radius: 12px;
padding: 10px 12px;
background: rgba(255, 255, 255, 0.03);
}
.timing-session-stat span {
display: block;
color: var(--muted);
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.timing-session-stat strong {
display: block;
margin-top: 6px;
font-family: Orbitron, sans-serif;
}
.quick-add-spacer { .quick-add-spacer {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -733,6 +810,10 @@ select:focus {
gap: 18px; gap: 18px;
} }
.overlay-board-tv {
grid-template-columns: 1fr;
}
.overlay-speaker { .overlay-speaker {
display: grid; display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(320px, 0.8fr); grid-template-columns: minmax(0, 1.2fr) minmax(320px, 0.8fr);
@@ -821,6 +902,10 @@ select:focus {
padding: 14px; padding: 14px;
} }
.overlay-leaderboard-card-tv {
min-height: calc(100vh - 260px);
}
.overlay-section-head { .overlay-section-head {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -1133,6 +1218,10 @@ select:focus {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.timing-top-grid {
grid-template-columns: 1fr;
}
.overlay-stats-row { .overlay-stats-row {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }