Polish timing layout and add TV overlay
This commit is contained in:
144
src/app.js
144
src/app.js
@@ -265,6 +265,7 @@ const TRANSLATIONS = {
|
||||
"timing.open_overlay": "Öppna overlay",
|
||||
"timing.open_speaker_overlay": "Speaker overlay",
|
||||
"timing.open_results_overlay": "Result overlay",
|
||||
"timing.open_tv_overlay": "TV overlay",
|
||||
"timing.close_details": "Stang",
|
||||
"timing.detail_title": "Leaderboard-detaljer",
|
||||
"timing.lap_history": "Varvhistorik",
|
||||
@@ -459,6 +460,7 @@ const TRANSLATIONS = {
|
||||
"overlay.mode_leaderboard": "Leaderboard",
|
||||
"overlay.mode_speaker": "Speaker",
|
||||
"overlay.mode_results": "Resultat",
|
||||
"overlay.mode_tv": "TV",
|
||||
"overlay.fastest_lap": "Snabbaste varv",
|
||||
"overlay.fullscreen": "Fullscreen",
|
||||
"overlay.leaderboard_live": "Live leaderboard",
|
||||
@@ -747,6 +749,7 @@ const TRANSLATIONS = {
|
||||
"timing.open_overlay": "Open overlay",
|
||||
"timing.open_speaker_overlay": "Speaker overlay",
|
||||
"timing.open_results_overlay": "Results overlay",
|
||||
"timing.open_tv_overlay": "TV overlay",
|
||||
"timing.close_details": "Close",
|
||||
"timing.detail_title": "Leaderboard details",
|
||||
"timing.lap_history": "Lap history",
|
||||
@@ -941,6 +944,7 @@ const TRANSLATIONS = {
|
||||
"overlay.mode_leaderboard": "Leaderboard",
|
||||
"overlay.mode_speaker": "Speaker",
|
||||
"overlay.mode_results": "Results",
|
||||
"overlay.mode_tv": "TV",
|
||||
"overlay.fastest_lap": "Fastest Lap",
|
||||
"overlay.fullscreen": "Fullscreen",
|
||||
"overlay.leaderboard_live": "Live leaderboard",
|
||||
@@ -984,7 +988,7 @@ const TRANSLATIONS = {
|
||||
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
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()
|
||||
: "leaderboard";
|
||||
const state = loadState();
|
||||
@@ -3556,52 +3560,73 @@ function renderTiming() {
|
||||
dom.view.innerHTML = `
|
||||
<section class="panel">
|
||||
<div class="panel-header"><h3>${t("timing.decoder_connection")}</h3></div>
|
||||
<div class="panel-body form-grid cols-4">
|
||||
<input id="timingWsUrl" value="${escapeHtml(state.settings.wsUrl)}" placeholder="ws://127.0.0.1:9000" />
|
||||
<button id="timingConnect" class="btn btn-primary">${t("timing.connect")}</button>
|
||||
<button id="timingDisconnect" class="btn">${t("timing.disconnect")}</button>
|
||||
<button id="timingSimPass" class="btn">${t("timing.simulate")}</button>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<p>${t("timing.status")}: <strong>${state.decoder.connected ? t("timing.connected") : t("timing.disconnected")}</strong></p>
|
||||
<p>${t("timing.last_message")}: ${state.decoder.lastMessageAt ? new Date(state.decoder.lastMessageAt).toLocaleString() : "-"}</p>
|
||||
<p class="error">${escapeHtml(state.decoder.lastError || "")}</p>
|
||||
<div class="panel-body timing-top-grid">
|
||||
<div class="timing-compact-card">
|
||||
<label class="timing-compact-label" for="timingWsUrl">${t("settings.decoder")}</label>
|
||||
<input id="timingWsUrl" value="${escapeHtml(state.settings.wsUrl)}" placeholder="ws://127.0.0.1:9000" />
|
||||
<div class="actions">
|
||||
<button id="timingConnect" class="btn btn-primary">${t("timing.connect")}</button>
|
||||
<button id="timingDisconnect" class="btn">${t("timing.disconnect")}</button>
|
||||
<button id="timingSimPass" class="btn">${t("timing.simulate")}</button>
|
||||
</div>
|
||||
</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>
|
||||
</section>
|
||||
|
||||
<section class="panel mt-16">
|
||||
<div class="panel-header"><h3>${t("timing.control")}</h3></div>
|
||||
<div class="panel-body form-grid cols-5">
|
||||
<select id="activeSessionSelect">
|
||||
<option value="">${t("timing.select_session")}</option>
|
||||
${state.sessions
|
||||
.map(
|
||||
(s) => `<option value="${s.id}" ${state.activeSessionId === s.id ? "selected" : ""}>${escapeHtml(
|
||||
getEventName(s.eventId)
|
||||
)} • ${escapeHtml(s.name)} • ${escapeHtml(getSessionTypeLabel(s.type))}</option>`
|
||||
)
|
||||
.join("")}
|
||||
</select>
|
||||
<button id="setActiveSession" class="btn">${t("timing.set_active")}</button>
|
||||
<button id="startSession" class="btn btn-primary">${t("timing.start")}</button>
|
||||
<button id="stopSession" class="btn">${t("timing.stop")}</button>
|
||||
<button id="resetSession" class="btn btn-danger">${t("timing.reset")}</button>
|
||||
<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>
|
||||
<div class="panel-body timing-top-grid">
|
||||
<div class="timing-compact-card timing-compact-card-wide">
|
||||
<label class="timing-compact-label" for="activeSessionSelect">${t("timing.select_session")}</label>
|
||||
<select id="activeSessionSelect">
|
||||
<option value="">${t("timing.select_session")}</option>
|
||||
${state.sessions
|
||||
.map(
|
||||
(s) => `<option value="${s.id}" ${state.activeSessionId === s.id ? "selected" : ""}>${escapeHtml(
|
||||
getEventName(s.eventId)
|
||||
)} • ${escapeHtml(s.name)} • ${escapeHtml(getSessionTypeLabel(s.type))}</option>`
|
||||
)
|
||||
.join("")}
|
||||
</select>
|
||||
<div class="actions">
|
||||
<button id="setActiveSession" class="btn">${t("timing.set_active")}</button>
|
||||
<button id="startSession" class="btn btn-primary">${t("timing.start")}</button>
|
||||
<button id="stopSession" class="btn">${t("timing.stop")}</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 class="panel-body">
|
||||
<div class="panel-body timing-session-summary">
|
||||
${
|
||||
active
|
||||
? `<p><strong>${escapeHtml(active.name)}</strong> (${escapeHtml(getSessionTypeLabel(active.type))}) • ${escapeHtml(
|
||||
getEventName(active.eventId)
|
||||
)}</p>
|
||||
<p>${t("timing.status")}: ${escapeHtml(getStatusLabel(active.status))} • ${t("timing.started")}: ${active.startedAt ? new Date(active.startedAt).toLocaleTimeString() : "-"}</p>
|
||||
<p>${t("table.start_mode")}: ${escapeHtml(getStartModeLabel(active.startMode))} • ${t("timing.seeding_mode")}: ${
|
||||
active.seedBestLapCount > 0 ? `${active.seedBestLapCount}` : "-"
|
||||
}</p>
|
||||
<p>${clockLabel}: ${clockValue}</p>
|
||||
<p>${t("timing.total_passings")}: ${result.passings.length}</p>
|
||||
? `<div class="timing-session-head">
|
||||
<strong>${escapeHtml(active.name)}</strong>
|
||||
<span class="pill">${escapeHtml(getSessionTypeLabel(active.type))}</span>
|
||||
<span class="pill">${escapeHtml(getEventName(active.eventId))}</span>
|
||||
<span class="pill ${active.status === "running" ? "pill-green" : ""}">${escapeHtml(getStatusLabel(active.status))}</span>
|
||||
</div>
|
||||
<div class="timing-session-stats">
|
||||
<article class="timing-session-stat"><span>${clockLabel}</span><strong>${clockValue}</strong></article>
|
||||
<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"
|
||||
? `<p class="hint">${t("events.free_practice_note")}</p>`
|
||||
@@ -3822,6 +3847,7 @@ function renderTiming() {
|
||||
document.getElementById("openOverlay")?.addEventListener("click", openOverlayWindow);
|
||||
document.getElementById("openSpeakerOverlay")?.addEventListener("click", () => openOverlayWindow("speaker"));
|
||||
document.getElementById("openResultsOverlay")?.addEventListener("click", () => openOverlayWindow("results"));
|
||||
document.getElementById("openTvOverlay")?.addEventListener("click", () => openOverlayWindow("tv"));
|
||||
document.querySelectorAll("[data-speaker-setting]").forEach((node) => {
|
||||
node.addEventListener("change", (event) => {
|
||||
const input = event.currentTarget;
|
||||
@@ -4123,6 +4149,38 @@ function renderOverlay() {
|
||||
</div>
|
||||
</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">
|
||||
<div class="overlay-table-wrap overlay-display-wrap">
|
||||
@@ -4471,7 +4529,7 @@ function renderSettings() {
|
||||
dom.view.innerHTML = `
|
||||
<section class="panel">
|
||||
<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="backendUrl" value="${escapeHtml(
|
||||
state.settings.backendUrl || getDefaultBackendUrl()
|
||||
@@ -4491,7 +4549,7 @@ function renderSettings() {
|
||||
|
||||
<section class="panel mt-16">
|
||||
<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">
|
||||
<input type="checkbox" name="audioEnabled" form="settingsForm" ${state.settings.audioEnabled ? "checked" : ""} />
|
||||
<span>${t("settings.audio_enabled")}</span>
|
||||
@@ -4538,7 +4596,7 @@ function renderSettings() {
|
||||
|
||||
<section class="panel mt-16">
|
||||
<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="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")}" />
|
||||
@@ -4565,7 +4623,7 @@ function renderSettings() {
|
||||
|
||||
<section class="panel mt-16">
|
||||
<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">
|
||||
<input type="checkbox" name="managedEnabled" ${ammc.config.managedEnabled ? "checked" : ""} />
|
||||
<span>${t("settings.enable_managed")}</span>
|
||||
|
||||
@@ -262,6 +262,14 @@ body {
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.settings-grid {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.settings-grid-toggles .toggle {
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
@@ -481,6 +489,75 @@ select:focus {
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -733,6 +810,10 @@ select:focus {
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.overlay-board-tv {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.overlay-speaker {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.2fr) minmax(320px, 0.8fr);
|
||||
@@ -821,6 +902,10 @@ select:focus {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.overlay-leaderboard-card-tv {
|
||||
min-height: calc(100vh - 260px);
|
||||
}
|
||||
|
||||
.overlay-section-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1133,6 +1218,10 @@ select:focus {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.timing-top-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.overlay-stats-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user