Files
Live_RC/src/settings.js
larssand 7f267e17fe fix(settings): open the right Settings tab from the update link and Race Day
The sidebar update notice now lands on System & backup at Updates, and
Race Day's decoder/backend/audio fix buttons open the matching tab,
instead of whichever tab was used last.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 20:44:28 +02:00

1060 lines
60 KiB
JavaScript

import { decoderDiagnosticsMarkup, mountDecoderDiagnostics } from "./decoder_diagnostics_view.js";
import { mountAuthSettings } from "./auth_view.js";
import { mountTtsSettings } from "./tts_settings_view.js";
let activeSettingsTab = "connection";
let pendingSettingsFocusId = "";
// Lets other views open Settings on the right tab (and scroll to a section) - e.g. the
// sidebar "update available" link lands on System & backup -> Updates.
export function openSettingsTab(tabId, focusId = "") {
activeSettingsTab = tabId;
pendingSettingsFocusId = focusId;
}
function formatBackupSize(bytes) {
const value = Math.max(0, Number(bytes || 0));
if (value < 1024) return `${value} B`;
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`;
return `${(value / (1024 * 1024)).toFixed(1)} MB`;
}
export function renderSettings(deps) {
const {
dom,
ammc,
state,
t,
backend,
settingsStorageNotice,
settingsStorageNoticeIsError,
getManagedWsUrl,
getBackendUrl,
getDefaultBackendUrl,
escapeHtml,
saveState,
renderView,
connectDecoder,
playPassingBeep,
playFinishSiren,
playStartCue,
speakText,
getSpeechVoices,
openOverlayWindow,
buildOverlayUrl,
normalizeObsOverlaySettings,
DEFAULT_OBS_OVERLAY_SETTINGS,
OBS_LAYOUTS,
OBS_THEMES,
pingBackend,
syncStateToBackend,
buildDataExportPayload,
downloadJsonFile,
importDataPayload,
buildDirectoryExportPayload,
downloadCsvFile,
buildDriversCsvRows,
buildCarsCsvRows,
importDirectoryCsv,
clearDataScope,
buildExportMeta,
normalizeStoredRacePreset,
saveAmmcConfigToBackend,
refreshAmmcStatus,
startManagedAmmc,
stopManagedAmmc,
normalizeRaceAudioSettings,
raceAudioEventDefinitions,
triggerRaceAudio,
serverBackups,
serverBackupsLoaded,
createManualServerBackup,
createSafetyServerBackup,
refreshServerBackups,
trackLayouts,
nextTrackLayoutLabel,
addTrackLayout,
deleteTrackLayout,
updateTrackLayoutLapWindow = () => ({ ok: false }),
updateStatus,
updateStatusLoaded,
updateCheckPending,
updateNotice,
updateNoticeIsError,
refreshUpdateStatus,
runUpdateNow,
setUpdateNotice,
} = deps;
const runWithSafetyBackup = async (action) => {
try {
await createSafetyServerBackup();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (!window.confirm(t("settings.safety_backup_failed_confirm", { msg: message }))) return false;
}
await action();
return true;
};
const pageOrigin = /^https?:$/.test(window.location.protocol) ? window.location.origin : "";
const configuredBackendUrl = String(state.settings.backendUrl || getDefaultBackendUrl()).replace(/\/+$/, "");
const backendUrlMismatch = Boolean(pageOrigin && configuredBackendUrl && pageOrigin !== configuredBackendUrl);
const ammcStatus = ammc.status || {};
const ammcOutput = Array.isArray(ammcStatus.lastOutput) ? ammcStatus.lastOutput : [];
const suggestedWsUrl = getManagedWsUrl("main");
const suggestedSplitWsUrl = getManagedWsUrl("split");
const suggestedPitEntryWsUrl = getManagedWsUrl("pit_entry");
const suggestedPitExitWsUrl = getManagedWsUrl("pit_exit");
const running = Boolean(ammcStatus.running);
const speechVoices = getSpeechVoices();
const raceAudio = normalizeRaceAudioSettings(state.settings.raceAudio);
const raceAudioVoiceOptions = speechVoices
.map((voice) => `<option value="${escapeHtml(voice.voiceURI)}" ${raceAudio.voiceUri === voice.voiceURI ? "selected" : ""}>${escapeHtml(`${voice.name} (${voice.lang})`)}</option>`)
.join("");
const raceAudioEventRows = raceAudioEventDefinitions.map((definition) => `
<tr>
<td><strong>${escapeHtml(definition.key.replaceAll("_", " "))}</strong><small>${escapeHtml(definition.group)} · ${escapeHtml(definition.priority)}</small></td>
<td><input type="checkbox" name="raceAudioEffect_${definition.key}" form="settingsForm" ${raceAudio.events[definition.key]?.effect ? "checked" : ""} ${definition.effect ? "" : "disabled"} /></td>
<td><input type="checkbox" name="raceAudioVoice_${definition.key}" form="settingsForm" ${raceAudio.events[definition.key]?.voice ? "checked" : ""} ${definition.voice ? "" : "disabled"} /></td>
<td><button type="button" class="btn" data-test-race-audio="${definition.key}">${t("audio.test")}</button></td>
</tr>
`).join("");
dom.view.innerHTML = `
<div class="settings-tabs" role="tablist">
<button type="button" class="settings-tab" data-settings-tab="connection">${t("settings.tab_connection")}</button>
<button type="button" class="settings-tab" data-settings-tab="audio">${t("settings.tab_audio")}</button>
<button type="button" class="settings-tab" data-settings-tab="appearance">${t("settings.tab_appearance")}</button>
<button type="button" class="settings-tab" data-settings-tab="system">${t("settings.tab_system")}</button>
</div>
<div data-settings-panel="connection">
<section class="panel">
<div class="panel-header"><h3>${t("settings.decoder")}</h3></div>
<form id="settingsForm" class="panel-body decoder-settings-form">
<div class="decoder-layout-picker">
<div>
<strong>${t("settings.decoder_setup_title")}</strong>
<p class="hint">${t("settings.decoder_setup_hint")}</p>
</div>
<div class="decoder-layout-options" role="group" aria-label="${t("settings.decoder_setup_title")}">
<button class="btn decoder-layout-button" type="button" data-decoder-layout="main">${t("settings.decoder_layout_main")}</button>
<button class="btn decoder-layout-button" type="button" data-decoder-layout="split">${t("settings.decoder_layout_split")}</button>
<button class="btn decoder-layout-button" type="button" data-decoder-layout="pit">${t("settings.decoder_layout_pit")}</button>
<button class="btn decoder-layout-button" type="button" data-decoder-layout="full">${t("settings.decoder_layout_full")}</button>
</div>
</div>
<p class="hint">${t("settings.decoder_roles_hint")}</p>
<div class="decoder-role-grid">
<section class="decoder-role-card decoder-role-card-main">
<div class="decoder-role-header">
<div>
<strong>${t("timing.main_decoder")}</strong>
<span class="decoder-role-badge is-required">${t("settings.role_required")}</span>
</div>
<p>${t("settings.main_role_hint")}</p>
</div>
<label class="decoder-role-body">
<span>${t("settings.main_ws_url")}</span>
<input name="wsUrl" value="${escapeHtml(state.settings.wsUrl)}" placeholder="ws://127.0.0.1:9000" />
</label>
</section>
<section id="settingsSplitCard" class="decoder-role-card">
<div class="decoder-role-header">
<div>
<strong>${t("timing.split_decoder")}</strong>
<span class="decoder-role-badge" data-decoder-state="split"></span>
</div>
<label class="toggle decoder-role-toggle">
<input id="settingsSplitEnabled" type="checkbox" name="splitDecoderEnabled" ${state.settings.splitDecoderEnabled ? "checked" : ""} />
<span>${t("settings.enable_split_decoder")}</span>
</label>
<p>${t("settings.split_role_hint")}</p>
</div>
<label class="decoder-role-body">
<span>${t("settings.split_ws_url")}</span>
<input name="splitWsUrl" value="${escapeHtml(state.settings.splitWsUrl || "")}" placeholder="ws://127.0.0.1:9001" />
</label>
</section>
<section id="settingsPitCard" class="decoder-role-card">
<div class="decoder-role-header">
<div>
<strong>${t("timing.pit_decoders")}</strong>
<span class="decoder-role-badge" data-decoder-state="pit"></span>
</div>
<label class="toggle decoder-role-toggle">
<input id="settingsPitEnabled" type="checkbox" name="pitDecoderEnabled" ${state.settings.pitDecoderEnabled ? "checked" : ""} />
<span>${t("settings.enable_pit_decoders")}</span>
</label>
<p>${t("settings.pit_role_hint")}</p>
</div>
<div class="decoder-role-body decoder-pit-fields">
<label>
<span>${t("settings.pit_entry_ws_url")}</span>
<input name="pitEntryWsUrl" value="${escapeHtml(state.settings.pitEntryWsUrl || "")}" placeholder="ws://127.0.0.1:9002" />
</label>
<label>
<span>${t("settings.pit_exit_ws_url")}</span>
<input name="pitExitWsUrl" value="${escapeHtml(state.settings.pitExitWsUrl || "")}" placeholder="ws://127.0.0.1:9003" />
</label>
</div>
</section>
</div>
<section class="decoder-connection-card">
<div>
<strong>${t("settings.connection_title")}</strong>
<p class="hint">${t("settings.backend_url_hint")}</p>
</div>
<label>
<span>${t("settings.backend_url")}</span>
<input name="backendUrl" value="${escapeHtml(
state.settings.backendUrl || getDefaultBackendUrl()
)}" placeholder="http://127.0.0.1:8081" />
</label>
${backendUrlMismatch ? `
<p class="settings-backend-mismatch">
${escapeHtml(t("settings.backend_url_mismatch", { origin: pageOrigin }))}
<button type="button" id="settingsUseCurrentOrigin" class="btn btn-small">${escapeHtml(t("settings.backend_url_use_current", { origin: pageOrigin }))}</button>
</p>
` : ""}
<label class="toggle decoder-reconnect-toggle">
<input type="checkbox" name="autoReconnect" ${state.settings.autoReconnect ? "checked" : ""} />
<span>${t("settings.auto_reconnect")}</span>
</label>
<div class="decoder-settings-actions">
<button class="btn btn-primary" type="submit">${t("settings.save")}</button>
<button id="settingsConnect" class="btn" type="button">${t("settings.connect_now")}</button>
</div>
</section>
</form>
<div class="panel-body">
<p>${t("settings.expected_json")}: <code>{"msg":"PASSING", "transponder":232323, "rtc_time":"..."}</code></p>
<p class="hint">${t("dashboard.live_note")}</p>
</div>
</section>
${decoderDiagnosticsMarkup(state.settings.language)}
</div>
<div data-settings-panel="audio">
<section class="panel mt-16">
<div class="panel-header"><h3>${t("tts.title")}</h3></div>
<div class="panel-body" id="ttsSettings"></div>
</section>
<section class="panel mt-16">
<div class="panel-header"><h3>${t("audio.race_sounds")}</h3></div>
<div class="panel-body form-grid cols-4 settings-grid settings-grid-toggles">
<label class="toggle"><input type="checkbox" name="raceAudioEnabled" form="settingsForm" ${raceAudio.enabled ? "checked" : ""} /><span>${t("audio.enabled")}</span></label>
<label class="toggle"><input type="checkbox" name="raceAudioMuted" form="settingsForm" ${raceAudio.muted ? "checked" : ""} /><span>${t("audio.mute")}</span></label>
<label><span>${t("audio.race_callouts")}</span><select name="raceAudioRaceCallouts" form="settingsForm"><option value="passing" ${raceAudio.raceCallouts === "passing" ? "selected" : ""}>${t("audio.race_callouts_passing")}</option><option value="full" ${raceAudio.raceCallouts === "full" ? "selected" : ""}>${t("audio.race_callouts_full")}</option></select></label>
<label><span>${t("audio.language")}</span><select name="raceAudioLanguage" form="settingsForm"><option value="sv" ${raceAudio.language === "sv" ? "selected" : ""}>Svenska</option><option value="en" ${raceAudio.language === "en" ? "selected" : ""}>English</option></select></label>
<label><span>${t("audio.voice_select")}</span><select name="raceAudioVoiceUri" form="settingsForm"><option value="">${t("audio.voice_auto")}</option>${raceAudioVoiceOptions}</select></label>
<label><span>${t("audio.voice_rate")}</span><input type="range" min="0.65" max="1.35" step="0.05" name="raceAudioVoiceRate" form="settingsForm" value="${raceAudio.voiceRate}" /></label>
<label><span>${t("audio.voice_pitch")}</span><input type="range" min="0.7" max="1.3" step="0.05" name="raceAudioVoicePitch" form="settingsForm" value="${raceAudio.voicePitch}" /></label>
<label><span>${t("audio.start_countdown")}</span><select name="raceAudioCountdown" form="settingsForm">${[0,10,30,60].map((value) => `<option value="${value}" ${raceAudio.startCountdownSeconds === value ? "selected" : ""}>${value ? `${value} s` : t("common.off")}</option>`).join("")}</select></label>
<label><span>${t("audio.master_volume")}</span><input type="range" min="0" max="1" step="0.05" name="raceAudioMaster" form="settingsForm" value="${raceAudio.masterVolume}" /></label>
<label><span>${t("audio.effects_volume")}</span><input type="range" min="0" max="1" step="0.05" name="raceAudioEffects" form="settingsForm" value="${raceAudio.effectsVolume}" /></label>
<label><span>${t("audio.voice_volume")}</span><input type="range" min="0" max="1" step="0.05" name="raceAudioVoice" form="settingsForm" value="${raceAudio.voiceVolume}" /></label>
<label><span>${t("audio.passing_volume")}</span><input type="range" min="0" max="1" step="0.05" name="raceAudioPassing" form="settingsForm" value="${raceAudio.passingVolume}" /></label>
<label class="toggle"><input type="checkbox" name="raceAudioStartLights" form="settingsForm" ${raceAudio.startLights ? "checked" : ""} /><span>${t("audio.start_lights")}</span></label>
<label class="toggle"><input type="checkbox" name="raceAudioRandomDelay" form="settingsForm" ${raceAudio.randomStartDelay ? "checked" : ""} /><span>${t("audio.random_delay")}</span></label>
<label><span>${t("audio.min_delay")}</span><input type="number" min="0" max="10" step="0.1" name="raceAudioMinDelay" form="settingsForm" value="${raceAudio.minStartDelay}" /></label>
<label><span>${t("audio.max_delay")}</span><input type="number" min="0" max="10" step="0.1" name="raceAudioMaxDelay" form="settingsForm" value="${raceAudio.maxStartDelay}" /></label>
<label class="toggle"><input type="checkbox" name="raceAudioPerCompetitor" form="settingsForm" ${raceAudio.perCompetitorPassingTone ? "checked" : ""} /><span>${t("audio.per_competitor")}</span></label>
<div class="actions-inline"><button id="settingsSaveRaceAudio" class="btn btn-primary" type="button">${t("common.save")}</button><button id="settingsRaceAudioTest" class="btn" type="button">${t("audio.test")}</button></div>
</div>
<div class="panel-body"><p class="hint">${t("audio.note")}</p><div class="table-wrap"><table class="audio-event-table"><thead><tr><th>Event</th><th>${t("audio.effect")}</th><th>${t("audio.voice")}</th><th></th></tr></thead><tbody>${raceAudioEventRows}</tbody></table></div></div>
</section>
</div>
<div data-settings-panel="appearance">
<section class="panel mt-16">
<div class="panel-header"><h3>${t("settings.branding")}</h3></div>
<div class="panel-body">
<p class="hint">${t("settings.branding_note")}</p>
</div>
<div class="panel-body form-grid cols-3 settings-grid">
<label><span>${t("settings.club_name")}</span><input name="clubName" form="settingsForm" value="${escapeHtml(state.settings.clubName || "")}" placeholder="JMK RB" /></label>
<label><span>${t("settings.club_tagline")}</span><input name="clubTagline" form="settingsForm" value="${escapeHtml(state.settings.clubTagline || "")}" placeholder="Live Timing & Race Control" /></label>
<label><span>${t("settings.pdf_footer")}</span><input name="pdfFooter" form="settingsForm" value="${escapeHtml(state.settings.pdfFooter || "")}" placeholder="Generated by RC RaceCore" /></label>
<label><span>${t("settings.pdf_theme")}</span><select name="pdfTheme" form="settingsForm">
<option value="classic" ${state.settings.pdfTheme === "classic" ? "selected" : ""}>${t("settings.pdf_theme_classic")}</option>
<option value="minimal" ${state.settings.pdfTheme === "minimal" ? "selected" : ""}>${t("settings.pdf_theme_minimal")}</option>
<option value="motorsport" ${state.settings.pdfTheme === "motorsport" ? "selected" : ""}>${t("settings.pdf_theme_motorsport")}</option>
</select></label>
</div>
<div class="panel-body">
<h4>${t("settings.logo")}</h4>
<p class="hint">${t("settings.logo_note")}</p>
<div class="actions">
<input id="logoUpload" type="file" accept="image/*" />
<button id="clearLogo" class="btn" type="button">${t("settings.logo_clear")}</button>
<button class="btn btn-primary" type="submit" form="settingsForm">${t("settings.save_branding")}</button>
</div>
${
state.settings.logoDataUrl
? `<div class="logo-preview mt-16"><img src="${escapeHtml(state.settings.logoDataUrl)}" alt="logo" /></div>`
: ""
}
</div>
</section>
<section class="panel mt-16">
<div class="panel-header"><h3>${t("settings.track_layouts")}</h3></div>
<div class="panel-body">
<p class="hint">${t("settings.track_layouts_hint")}</p>
<form id="trackLayoutForm" class="form-grid cols-3 settings-grid">
<label><span>${t("settings.track_layout_label")}</span><input name="label" placeholder="${escapeHtml(nextTrackLayoutLabel())}" /></label>
<label><span>${t("settings.track_layout_started_at")}</span><input name="startedAt" type="datetime-local" value="${escapeHtml((() => {
const now = new Date();
now.setSeconds(0, 0);
now.setMinutes(now.getMinutes() - now.getTimezoneOffset());
return now.toISOString().slice(0, 16);
})())}" /></label>
<label><span>${t("layouts.min_lap")}</span><input name="minLapSec" type="number" min="0" step="0.1" placeholder="${escapeHtml(t("layouts.lap_from_event"))}" /></label>
<label><span>${t("layouts.max_lap")}</span><input name="maxLapSec" type="number" min="0" step="0.1" placeholder="${escapeHtml(t("layouts.lap_from_event"))}" /></label>
<div class="actions"><button class="btn btn-primary" type="submit">${t("settings.track_layout_add")}</button></div>
</form>
<p class="hint">${t("layouts.lap_window_hint")}</p>
<div class="backup-list mt-16">
${(trackLayouts || []).length
? trackLayouts.slice().reverse().map((layout) => `
<article class="backup-row track-layout-row">
<div><strong>${escapeHtml(layout.label || t("settings.track_layout_unnamed"))}</strong><small>${escapeHtml(new Date(layout.startedAt).toLocaleString())}</small></div>
<form class="layout-lap-window" data-layout-lap-window="${escapeHtml(layout.id)}">
<label><span>${t("layouts.min_lap")}</span><input name="minLapSec" type="number" min="0" step="0.1" value="${layout.minLapMs ? (layout.minLapMs / 1000).toFixed(1) : ""}" placeholder="${escapeHtml(t("layouts.lap_from_event"))}" /></label>
<label><span>${t("layouts.max_lap")}</span><input name="maxLapSec" type="number" min="0" step="0.1" value="${layout.maxLapMs ? (layout.maxLapMs / 1000).toFixed(1) : ""}" placeholder="${escapeHtml(t("layouts.lap_from_event"))}" /></label>
<button class="btn btn-mini" type="submit">${t("common.save")}</button>
</form>
<button class="btn btn-mini" type="button" data-delete-track-layout="${escapeHtml(layout.id)}">${t("common.delete")}</button>
</article>`).join("")
: `<p class="hint">${t("settings.track_layout_none")}</p>`}
</div>
</div>
</section>
<section class="panel mt-16">
<div class="panel-header"><h3>${t("overlay.obs_config")}</h3></div>
<div class="panel-body">
<p class="hint">${t("overlay.obs_public_hint")}</p>
<div class="actions">
<button id="settingsLaunchObs" class="btn" type="button">${t("timing.open_obs_overlay")}</button>
<button id="settingsCopyObsUrl" class="btn" type="button">${t("overlay.obs_copy_url")}</button>
</div>
</div>
<div class="panel-body overlay-obs-config">
<div class="form-grid cols-4">
<label>
${t("overlay.obs_rows")}
<input id="obsRows" type="number" min="3" max="12" value="${normalizeObsOverlaySettings(state.settings.obsOverlay).rows}" />
</label>
<label>
${t("overlay.obs_layout")}
<select id="obsLayout">
${OBS_LAYOUTS.map((layout) => `<option value="${layout}" ${normalizeObsOverlaySettings(state.settings.obsOverlay).layout === layout ? "selected" : ""}>${t(`overlay.obs_layout_${layout}`)}</option>`).join("")}
</select>
</label>
<label>
${t("overlay.obs_theme")}
<select id="obsTheme">
${OBS_THEMES.map((theme) => `<option value="${theme}" ${normalizeObsOverlaySettings(state.settings.obsOverlay).theme === theme ? "selected" : ""}>${t(`overlay.obs_theme_${theme}`)}</option>`).join("")}
</select>
</label>
<label>
${t("overlay.obs_public_token")}
<input id="obsPublicToken" value="${escapeHtml(normalizeObsOverlaySettings(state.settings.obsOverlay).publicToken)}" placeholder="optional" />
<small>${t("overlay.obs_public_token_hint")}</small>
</label>
<label class="toggle"><input id="obsShowClock" type="checkbox" ${normalizeObsOverlaySettings(state.settings.obsOverlay).showClock ? "checked" : ""} /><span>${t("overlay.obs_show_clock")}</span></label>
<label class="toggle"><input id="obsShowFastest" type="checkbox" ${normalizeObsOverlaySettings(state.settings.obsOverlay).showFastest ? "checked" : ""} /><span>${t("overlay.obs_show_fastest")}</span></label>
<label class="toggle"><input id="obsShowGrid" type="checkbox" ${normalizeObsOverlaySettings(state.settings.obsOverlay).showGrid ? "checked" : ""} /><span>${t("overlay.obs_show_grid")}</span></label>
<label class="toggle"><input id="obsShowLaps" type="checkbox" ${normalizeObsOverlaySettings(state.settings.obsOverlay).showLaps ? "checked" : ""} /><span>${t("overlay.obs_show_laps")}</span></label>
<label class="toggle"><input id="obsShowResult" type="checkbox" ${normalizeObsOverlaySettings(state.settings.obsOverlay).showResult ? "checked" : ""} /><span>${t("overlay.obs_show_result")}</span></label>
<label class="toggle"><input id="obsShowBest" type="checkbox" ${normalizeObsOverlaySettings(state.settings.obsOverlay).showBest ? "checked" : ""} /><span>${t("overlay.obs_show_best")}</span></label>
<label class="toggle"><input id="obsShowGap" type="checkbox" ${normalizeObsOverlaySettings(state.settings.obsOverlay).showGap ? "checked" : ""} /><span>${t("overlay.obs_show_gap")}</span></label>
</div>
</div>
</section>
</div>
<div data-settings-panel="connection">
<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 settings-grid">
<p class="hint form-grid-full">${t("settings.managed_channels_hint")}</p>
<label class="toggle">
<input type="checkbox" name="managedEnabled" ${ammc.config.managedEnabled ? "checked" : ""} />
<span>${t("settings.enable_managed")}</span>
</label>
<label class="toggle">
<input type="checkbox" name="autoStart" ${ammc.config.autoStart ? "checked" : ""} />
<span>${t("settings.auto_start_ammc")}</span>
</label>
<label><span>${t("timing.main_decoder")} - ${t("settings.decoder_host")}</span><input name="decoderHost" value="${escapeHtml(ammc.config.decoderHost || "")}" placeholder="192.168.1.10" /></label>
<label><span>${t("timing.main_decoder")} - ${t("settings.ws_port")}</span><input name="wsPort" value="${escapeHtml(String(ammc.config.wsPort || 9000))}" placeholder="9000" /></label>
<label class="toggle"><input type="checkbox" name="splitEnabled" ${ammc.config.splitEnabled ? "checked" : ""} /><span>${t("settings.enable_split_decoder")}</span></label>
<span></span>
<label><span>${t("settings.split_decoder_host")}</span><input name="splitDecoderHost" value="${escapeHtml(ammc.config.splitDecoderHost || "")}" placeholder="192.168.1.11" /></label>
<label><span>${t("timing.split_decoder")} - ${t("settings.ws_port")}</span><input name="splitWsPort" value="${escapeHtml(String(ammc.config.splitWsPort || 9001))}" placeholder="9001" /></label>
<label class="toggle"><input type="checkbox" name="pitEnabled" ${ammc.config.pitEnabled ? "checked" : ""} /><span>${t("settings.enable_pit_decoders")}</span></label>
<span></span>
<label><span>${t("settings.pit_entry_decoder_host")}</span><input name="pitEntryDecoderHost" value="${escapeHtml(ammc.config.pitEntryDecoderHost || "")}" placeholder="192.168.1.12" /></label>
<label><span>${t("timing.pit_entry_decoder")} - ${t("settings.ws_port")}</span><input name="pitEntryWsPort" value="${escapeHtml(String(ammc.config.pitEntryWsPort || 9002))}" placeholder="9002" /></label>
<label><span>${t("settings.pit_exit_decoder_host")}</span><input name="pitExitDecoderHost" value="${escapeHtml(ammc.config.pitExitDecoderHost || "")}" placeholder="192.168.1.13" /></label>
<label><span>${t("timing.pit_exit_decoder")} - ${t("settings.ws_port")}</span><input name="pitExitWsPort" value="${escapeHtml(String(ammc.config.pitExitWsPort || 9003))}" placeholder="9003" /></label>
<label><span>${t("settings.executable_path")}</span><input name="executablePath" value="${escapeHtml(ammc.config.executablePath || "")}" placeholder="${t("settings.executable_path")}" /></label>
<label><span>${t("settings.working_dir")}</span><input name="workingDirectory" value="${escapeHtml(ammc.config.workingDirectory || "")}" placeholder="${t("settings.working_dir")}" /></label>
<label><span>${t("settings.extra_args")}</span><input name="extraArgs" value="${escapeHtml(ammc.config.extraArgs || "")}" placeholder="${t("settings.extra_args")}" /></label>
<div class="actions">
<button class="btn btn-primary" type="submit">${t("settings.save_ammc")}</button>
<button id="ammcRefresh" class="btn" type="button">${t("settings.refresh_ammc")}</button>
<button id="ammcStart" class="btn" type="button">${t("settings.start_ammc")}</button>
<button id="ammcStop" class="btn" type="button">${t("settings.stop_ammc")}</button>
<button id="ammcUseServerWs" class="btn" type="button">${t("settings.use_server_ws")}</button>
</div>
</form>
<div class="panel-body">
<p>${t("settings.managed_ammc_sub")}</p>
<p>${t("settings.bundled_hint")}</p>
<p>${t("settings.ammc_status")}: <strong>${running ? t("settings.running") : t("settings.stopped")}</strong></p>
<p>${t("settings.server_platform")}: <strong>${escapeHtml(String(ammcStatus.serverPlatform || "-"))}</strong></p>
<p>${t("settings.pid")}: <strong>${escapeHtml(String(ammcStatus.pid || "-"))}</strong></p>
<p>${t("settings.started_at")}: ${ammcStatus.startedAt ? new Date(ammcStatus.startedAt).toLocaleString() : "-"}</p>
<p>${t("settings.stopped_at")}: ${ammcStatus.stoppedAt ? new Date(ammcStatus.stoppedAt).toLocaleString() : "-"}</p>
<p>${t("settings.executable_path")}: <strong>${escapeHtml(String(ammcStatus.resolvedExecutablePath || ammc.config.executablePath || "-"))}</strong></p>
<p>${ammcStatus.executableExists ? t("settings.executable_found") : t("settings.executable_missing")}</p>
<p>${t("settings.decoder_host")}: <strong>${escapeHtml(String(ammc.config.decoderHost || "-"))}</strong></p>
<p>${t("settings.ws_port")}: <strong>${escapeHtml(String(ammc.config.wsPort || 9000))}</strong></p>
${ammc.config.splitEnabled ? `<p>${t("timing.split_decoder")}: <strong>${ammcStatus.split?.running ? t("settings.running") : t("settings.stopped")}</strong> • ${escapeHtml(String(ammc.config.splitDecoderHost || "-"))}:${escapeHtml(String(ammc.config.splitWsPort || 9001))}${ammcStatus.split?.lastError ? ` • <span class="error">${escapeHtml(ammcStatus.split.lastError)}</span>` : ""}</p>` : ""}
${ammc.config.pitEnabled ? `<p>${t("timing.pit_entry_decoder")}: <strong>${ammcStatus.pitEntry?.running ? t("settings.running") : t("settings.stopped")}</strong> • ${escapeHtml(String(ammc.config.pitEntryDecoderHost || "-"))}:${escapeHtml(String(ammc.config.pitEntryWsPort || 9002))}${ammcStatus.pitEntry?.lastError ? ` • <span class="error">${escapeHtml(ammcStatus.pitEntry.lastError)}</span>` : ""}</p><p>${t("timing.pit_exit_decoder")}: <strong>${ammcStatus.pitExit?.running ? t("settings.running") : t("settings.stopped")}</strong> • ${escapeHtml(String(ammc.config.pitExitDecoderHost || "-"))}:${escapeHtml(String(ammc.config.pitExitWsPort || 9003))}${ammcStatus.pitExit?.lastError ? ` • <span class="error">${escapeHtml(ammcStatus.pitExit.lastError)}</span>` : ""}</p>` : ""}
<p>${t("settings.last_error")}: ${escapeHtml(ammc.lastError || ammcStatus.lastError || "-")}</p>
<p>${t("settings.backend_url")}: <strong>${escapeHtml(getBackendUrl())}</strong></p>
<p>WebSocket URL: <strong>${escapeHtml(suggestedWsUrl)}</strong></p>
<pre class="log-box">${escapeHtml(
ammcOutput.length
? ammcOutput.map((entry) => `[${new Date(entry.ts).toLocaleTimeString()}] ${entry.role || "main"}/${entry.stream}: ${entry.line}`).join("\n")
: "-"
)}</pre>
</div>
</section>
</div>
<div data-settings-panel="system">
<section class="panel mt-16">
<div class="panel-header"><h3>${t("auth.title")}</h3></div>
<div class="panel-body" id="authSettings"></div>
</section>
<section class="panel mt-16" id="settingsUpdateSection">
<div class="panel-header"><h3>${t("settings.update_title")}</h3></div>
<div class="panel-body">
<p>${t("settings.update_current_version")}: <strong>v${escapeHtml(String(updateStatus?.currentVersion || ""))}</strong></p>
${!updateStatusLoaded
? `<p class="hint">${t("settings.update_checking")}</p>`
: updateStatus?.error
? `<p class="error">${escapeHtml(t("settings.update_error", { msg: updateStatus.error }))}</p>`
: updateStatus?.available
? `<p class="update-available"><strong>${escapeHtml(t("settings.update_available", { version: updateStatus.remoteVersion || "?" }))}</strong></p>`
: `<p class="hint">${t("settings.update_up_to_date")}</p>`}
<div class="actions">
<button id="settingsCheckUpdate" class="btn" type="button" ${updateCheckPending ? "disabled" : ""}>${t("settings.update_check_now")}</button>
${updateStatus?.available ? `<button id="settingsRunUpdate" class="btn btn-primary" type="button">${t("settings.update_run_now")}</button>` : ""}
</div>
${updateNotice ? `<p class="${updateNoticeIsError ? "error" : "hint"}">${escapeHtml(updateNotice)}</p>` : ""}
</div>
</section>
<section class="panel mt-16">
<div class="panel-header"><h3>${t("settings.storage")}</h3></div>
<div class="panel-body">
<p>${t("settings.backend_url")}: <strong>${escapeHtml(getBackendUrl())}</strong></p>
<p>${t("settings.backend_status")}: <strong>${backend.available ? t("settings.online") : t("settings.offline")}</strong></p>
<p>${t("settings.last_sync")}: ${backend.lastSyncAt ? new Date(backend.lastSyncAt).toLocaleString() : "-"}</p>
<p class="error">${escapeHtml(backend.lastError || "")}</p>
<div class="actions">
<button id="settingsTestBackend" class="btn" type="button">${t("settings.test_backend")}</button>
<button id="settingsSyncNow" class="btn btn-primary" type="button">${t("settings.sync_now")}</button>
</div>
<div class="backup-manager mt-16">
<div class="backup-manager-header">
<div>
<strong>${t("settings.server_backups")}</strong>
<p class="hint">${t("settings.server_backups_hint")}</p>
</div>
<div class="actions">
<button id="createServerBackup" class="btn btn-primary" type="button">${t("settings.create_backup")}</button>
<button id="refreshServerBackups" class="btn" type="button">${t("settings.refresh_backups")}</button>
</div>
</div>
<div class="backup-list">
${!serverBackupsLoaded
? `<p class="hint">${t("settings.loading_backups")}</p>`
: serverBackups.length
? serverBackups.slice(0, 10).map((backup) => `
<article class="backup-row">
<div><strong>${escapeHtml(new Date(backup.createdAt).toLocaleString())}</strong><small>${escapeHtml(backup.fileName)}</small></div>
<span>${escapeHtml(t(`settings.backup_reason_${backup.reason}`))}</span>
<span>${formatBackupSize(backup.sizeBytes)}</span>
</article>`).join("")
: `<p class="hint">${t("settings.no_backups")}</p>`}
</div>
<p class="hint">${t("settings.restore_backup_hint")}</p>
</div>
<div class="mt-16">
<p class="hint">${t("settings.storage_note_full")}</p>
<div class="actions">
<button id="exportData" class="btn" type="button">${t("settings.export_all_data")}</button>
<input id="importData" type="file" accept="application/json" />
</div>
</div>
<div class="mt-16">
<p class="hint">${t("settings.storage_note_directory")}</p>
<div class="actions">
<button id="exportDirectoryData" class="btn" type="button">${t("settings.export_directory")}</button>
<input id="importDirectoryData" type="file" accept="application/json" />
</div>
</div>
<div class="mt-16">
<p class="hint">${t("settings.storage_note_csv")}</p>
<div class="actions">
<button id="exportDriversCsv" class="btn" type="button">${t("settings.export_drivers_csv")}</button>
<button id="selectDriversCsv" class="btn" type="button">${t("settings.import_drivers_csv")}</button>
<button id="downloadDriversCsvTemplate" class="btn" type="button">${t("settings.download_drivers_csv_template")}</button>
<input id="importDriversCsv" type="file" accept=".csv,text/csv,text/plain" hidden />
<button id="exportCarsCsv" class="btn" type="button">${t("settings.export_cars_csv")}</button>
<button id="selectCarsCsv" class="btn" type="button">${t("settings.import_cars_csv")}</button>
<input id="importCarsCsv" type="file" accept=".csv,text/csv,text/plain" hidden />
</div>
<details class="csv-import-guide">
<summary>${t("settings.driver_csv_guide_title")}</summary>
<div class="csv-import-guide-body">
<p><strong>${t("settings.driver_csv_required")}</strong></p>
<p>${t("settings.driver_csv_columns")}</p>
<p>${t("settings.driver_csv_behavior")}</p>
<span class="csv-import-example-label">${t("settings.driver_csv_example")}</span>
<pre><code>name;transponder;class;brand;club;country
Anna Andersson;123456;2WD;;JMK;SE
Erik Eriksson;654321;4WD;Associated;;</code></pre>
</div>
</details>
</div>
${settingsStorageNotice ? `<p class="${settingsStorageNoticeIsError ? "error" : "hint"}">${escapeHtml(settingsStorageNotice)}</p>` : ""}
</div>
</section>
<section class="panel mt-16 settings-danger-panel">
<div class="panel-header"><h3>${t("settings.clear_data_title")}</h3></div>
<div class="panel-body">
<p class="hint">${t("settings.clear_data_note")}</p>
<div class="actions mt-16">
<button class="btn btn-danger" type="button" data-clear-scope="drivers">${t("settings.clear_drivers")}</button>
<button class="btn btn-danger" type="button" data-clear-scope="cars">${t("settings.clear_cars")}</button>
<button class="btn btn-danger" type="button" data-clear-scope="classes">${t("settings.clear_classes")}</button>
<button class="btn btn-danger" type="button" data-clear-scope="races">${t("settings.clear_races")}</button>
<button class="btn btn-danger" type="button" data-clear-scope="training">${t("settings.clear_training")}</button>
<button class="btn btn-danger" type="button" data-clear-scope="all">${t("settings.clear_all")}</button>
</div>
</div>
</section>
<section class="panel mt-16">
<div class="panel-header"><h3>${t("settings.race_presets")}</h3></div>
<div class="panel-body">
<p>${t("settings.race_presets_note")}</p>
<p>${(state.settings.racePresets || []).length} preset(s)</p>
<div class="actions">
<button id="exportRacePresets" class="btn" type="button">${t("settings.export_presets")}</button>
<input id="importRacePresets" type="file" accept="application/json" />
</div>
</div>
</section>
</div>
`;
mountDecoderDiagnostics(document.getElementById("decoderDiagnostics"), () => state.settings);
const settingsTabButtons = Array.from(document.querySelectorAll(".settings-tab"));
const settingsTabPanels = Array.from(document.querySelectorAll("[data-settings-panel]"));
const applySettingsTab = (tabId) => {
activeSettingsTab = tabId;
settingsTabButtons.forEach((button) => button.classList.toggle("active", button.dataset.settingsTab === tabId));
settingsTabPanels.forEach((panel) => { panel.hidden = panel.dataset.settingsPanel !== tabId; });
};
settingsTabButtons.forEach((button) => {
button.addEventListener("click", () => applySettingsTab(button.dataset.settingsTab));
});
applySettingsTab(settingsTabButtons.some((button) => button.dataset.settingsTab === activeSettingsTab) ? activeSettingsTab : "connection");
if (pendingSettingsFocusId) {
document.getElementById(pendingSettingsFocusId)?.scrollIntoView({ block: "start" });
pendingSettingsFocusId = "";
}
mountAuthSettings(document.getElementById("authSettings"), { t, escapeHtml });
mountTtsSettings(document.getElementById("ttsSettings"), { state, t, escapeHtml, saveState, speakText });
const applyRaceAudioFormValues = (form) => {
const events = {};
raceAudioEventDefinitions.forEach((definition) => {
events[definition.key] = {
effect: form.get("raceAudioEffect_" + definition.key) === "on",
voice: form.get("raceAudioVoice_" + definition.key) === "on",
};
});
state.settings.raceAudio = normalizeRaceAudioSettings({
...state.settings.raceAudio,
enabled: form.get("raceAudioEnabled") === "on",
muted: form.get("raceAudioMuted") === "on",
language: String(form.get("raceAudioLanguage") || "sv"),
voiceUri: String(form.get("raceAudioVoiceUri") || ""),
voiceRate: Number(form.get("raceAudioVoiceRate")), voicePitch: Number(form.get("raceAudioVoicePitch")),
masterVolume: Number(form.get("raceAudioMaster")), effectsVolume: Number(form.get("raceAudioEffects")),
voiceVolume: Number(form.get("raceAudioVoice")), passingVolume: Number(form.get("raceAudioPassing")),
startCountdownSeconds: Number(form.get("raceAudioCountdown")), startLights: form.get("raceAudioStartLights") === "on",
randomStartDelay: form.get("raceAudioRandomDelay") === "on", minStartDelay: Number(form.get("raceAudioMinDelay")),
maxStartDelay: Number(form.get("raceAudioMaxDelay")), perCompetitorPassingTone: form.get("raceAudioPerCompetitor") === "on",
raceCallouts: String(form.get("raceAudioRaceCallouts") || "passing"),
events,
});
};
const applyRaceAudioFormValuesFromDom = () => {
const formNode = document.getElementById("settingsForm");
if (!(formNode instanceof HTMLFormElement)) return false;
applyRaceAudioFormValues(new FormData(formNode));
return true;
};
const persistRaceAudioFormValues = () => {
if (!applyRaceAudioFormValuesFromDom()) return false;
saveState();
return true;
};
const splitDecoderToggle = document.getElementById("settingsSplitEnabled");
const pitDecoderToggle = document.getElementById("settingsPitEnabled");
const updateDecoderLayoutUi = () => {
const splitEnabled = Boolean(splitDecoderToggle?.checked);
const pitEnabled = Boolean(pitDecoderToggle?.checked);
document.getElementById("settingsSplitCard")?.classList.toggle("is-disabled", !splitEnabled);
document.getElementById("settingsPitCard")?.classList.toggle("is-disabled", !pitEnabled);
document.querySelector('[data-decoder-state="split"]')?.replaceChildren(
document.createTextNode(t(splitEnabled ? "settings.role_enabled" : "settings.role_disabled"))
);
document.querySelector('[data-decoder-state="pit"]')?.replaceChildren(
document.createTextNode(t(pitEnabled ? "settings.role_enabled" : "settings.role_disabled"))
);
const activeLayout = splitEnabled && pitEnabled ? "full" : splitEnabled ? "split" : pitEnabled ? "pit" : "main";
document.querySelectorAll("[data-decoder-layout]").forEach((button) => {
button.classList.toggle("is-active", button.dataset.decoderLayout === activeLayout);
button.setAttribute("aria-pressed", String(button.dataset.decoderLayout === activeLayout));
});
};
splitDecoderToggle?.addEventListener("change", updateDecoderLayoutUi);
pitDecoderToggle?.addEventListener("change", updateDecoderLayoutUi);
document.querySelectorAll("[data-decoder-layout]").forEach((button) => {
button.addEventListener("click", () => {
const layout = button.dataset.decoderLayout;
if (splitDecoderToggle) splitDecoderToggle.checked = layout === "split" || layout === "full";
if (pitDecoderToggle) pitDecoderToggle.checked = layout === "pit" || layout === "full";
updateDecoderLayoutUi();
});
});
updateDecoderLayoutUi();
document.getElementById("settingsForm")?.addEventListener("submit", (e) => {
e.preventDefault();
const form = new FormData(e.currentTarget);
state.settings.wsUrl = String(form.get("wsUrl") || "").trim();
state.settings.splitDecoderEnabled = form.get("splitDecoderEnabled") === "on";
state.settings.splitWsUrl = String(form.get("splitWsUrl") || "").trim();
state.settings.pitDecoderEnabled = form.get("pitDecoderEnabled") === "on";
state.settings.pitEntryWsUrl = String(form.get("pitEntryWsUrl") || "").trim();
state.settings.pitExitWsUrl = String(form.get("pitExitWsUrl") || "").trim();
state.settings.backendUrl = String(form.get("backendUrl") || "").trim();
state.settings.autoReconnect = form.get("autoReconnect") === "on";
applyRaceAudioFormValues(form);
state.settings.clubName = String(form.get("clubName") || "").trim() || "JMK RB";
state.settings.clubTagline = String(form.get("clubTagline") || "").trim() || "Live Timing & Race Control";
state.settings.pdfFooter = String(form.get("pdfFooter") || "").trim() || "Generated by RC RaceCore";
state.settings.pdfTheme = ["classic", "minimal", "motorsport"].includes(String(form.get("pdfTheme") || "classic"))
? String(form.get("pdfTheme"))
: "classic";
saveState();
renderView();
});
document.getElementById("settingsConnect")?.addEventListener("click", connectDecoder);
document.getElementById("settingsUseCurrentOrigin")?.addEventListener("click", () => {
state.settings.backendUrl = pageOrigin;
saveState();
renderView();
});
document.querySelectorAll('[name^="raceAudio"]').forEach((control) => {
control.addEventListener("change", persistRaceAudioFormValues);
});
document.getElementById("settingsSaveRaceAudio")?.addEventListener("click", persistRaceAudioFormValues);
const testRaceAudio = (eventKey) => {
// Uses whatever's currently in the form (including unsaved edits) so a test preview
// matches what you're about to hear - but a test playback is not itself a reason to
// write those still-unsaved changes to the database. Use the explicit Save button for that.
applyRaceAudioFormValuesFromDom();
triggerRaceAudio(eventKey, { force: true, interrupt: true, variables: { driverName: state.drivers[0]?.name || "Driver", lapTime: "16.000", marshalPost: "1", transponder: "123", podium: "P1, P2, P3" } });
};
document.getElementById("settingsRaceAudioTest")?.addEventListener("click", () => testRaceAudio("audio_test"));
document.querySelectorAll("[data-test-race-audio]").forEach((button) => button.addEventListener("click", () => testRaceAudio(button.dataset.testRaceAudio)));
document.getElementById("settingsLaunchObs")?.addEventListener("click", () => openOverlayWindow("obs", { public: true }));
document.getElementById("settingsCopyObsUrl")?.addEventListener("click", async () => {
const url = buildOverlayUrl("obs", { public: true });
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(url).catch(() => {});
return;
}
window.prompt("Copy OBS URL", url);
});
[
["obsRows", "rows", (value) => Math.max(3, Math.min(12, Number(value) || DEFAULT_OBS_OVERLAY_SETTINGS.rows))],
["obsLayout", "layout", (value) => OBS_LAYOUTS.includes(String(value || "").toLowerCase()) ? String(value).toLowerCase() : DEFAULT_OBS_OVERLAY_SETTINGS.layout],
["obsTheme", "theme", (value) => OBS_THEMES.includes(String(value || "").toLowerCase()) ? String(value).toLowerCase() : DEFAULT_OBS_OVERLAY_SETTINGS.theme],
["obsPublicToken", "publicToken", (value) => String(value || "").trim()],
["obsShowClock", "showClock", (value, el) => Boolean(el?.checked)],
["obsShowFastest", "showFastest", (value, el) => Boolean(el?.checked)],
["obsShowGrid", "showGrid", (value, el) => Boolean(el?.checked)],
["obsShowLaps", "showLaps", (value, el) => Boolean(el?.checked)],
["obsShowResult", "showResult", (value, el) => Boolean(el?.checked)],
["obsShowBest", "showBest", (value, el) => Boolean(el?.checked)],
["obsShowGap", "showGap", (value, el) => Boolean(el?.checked)],
].forEach(([id, key, mapper]) => {
const el = document.getElementById(id);
if (!el) {
return;
}
const handler = () => {
const next = normalizeObsOverlaySettings({
...state.settings.obsOverlay,
[key]: mapper(el.value, el),
});
state.settings.obsOverlay = next;
saveState();
};
el.addEventListener("change", handler);
if (el instanceof HTMLInputElement && (el.type === "number" || el.type === "text")) {
el.addEventListener("input", handler);
}
});
document.getElementById("settingsTestBackend")?.addEventListener("click", async () => {
await pingBackend();
renderView();
});
document.getElementById("settingsSyncNow")?.addEventListener("click", async () => {
await syncStateToBackend();
renderView();
});
document.getElementById("createServerBackup")?.addEventListener("click", async (event) => {
const button = event.currentTarget;
if (button instanceof HTMLButtonElement) button.disabled = true;
try {
await createManualServerBackup();
await refreshServerBackups({ render: false });
alert(t("settings.backup_created"));
} catch (error) {
alert(t("settings.backup_failed", { msg: error instanceof Error ? error.message : String(error) }));
}
renderView();
});
document.getElementById("refreshServerBackups")?.addEventListener("click", () => refreshServerBackups());
document.getElementById("trackLayoutForm")?.addEventListener("submit", (event) => {
event.preventDefault();
const form = event.currentTarget;
if (!(form instanceof HTMLFormElement)) return;
const label = String(form.elements.namedItem("label")?.value || "").trim();
const startedAtRaw = String(form.elements.namedItem("startedAt")?.value || "");
const startedAtMs = startedAtRaw ? new Date(startedAtRaw).getTime() : Date.now();
if (!Number.isFinite(startedAtMs)) {
alert(t("settings.track_layout_invalid_date"));
return;
}
const seconds = (name) => Math.round((Number(form.elements.namedItem(name)?.value) || 0) * 1000);
const result = addTrackLayout(label, startedAtMs, { minLapMs: seconds("minLapSec"), maxLapMs: seconds("maxLapSec") });
if (!result.ok) {
alert(result.message);
return;
}
renderView();
});
document.querySelectorAll("[data-layout-lap-window]").forEach((form) => {
form.addEventListener("submit", (event) => {
event.preventDefault();
const seconds = (name) => Math.round((Number(form.elements.namedItem(name)?.value) || 0) * 1000);
const result = updateTrackLayoutLapWindow(form.dataset.layoutLapWindow, seconds("minLapSec"), seconds("maxLapSec"));
if (!result.ok) {
alert(result.message);
return;
}
renderView();
});
});
document.querySelectorAll("[data-delete-track-layout]").forEach((button) => {
button.addEventListener("click", () => {
const id = button.getAttribute("data-delete-track-layout");
const layout = (trackLayouts || []).find((item) => item.id === id);
if (!window.confirm(t("settings.track_layout_delete_confirm", { label: layout?.label || t("settings.track_layout_unnamed") }))) return;
const result = deleteTrackLayout(id);
if (!result.ok) {
alert(result.message);
return;
}
renderView();
});
});
document.getElementById("settingsCheckUpdate")?.addEventListener("click", async () => {
setUpdateNotice("");
await refreshUpdateStatus({ render: false, force: true });
renderView();
});
document.getElementById("settingsRunUpdate")?.addEventListener("click", async (event) => {
const version = updateStatus?.remoteVersion ? `v${updateStatus.remoteVersion}` : "";
let message = t("settings.update_confirm", { version });
if (state.activeSessionId) message += "\n\n" + t("settings.update_confirm_active_session");
if (!window.confirm(message)) return;
const button = event.currentTarget;
if (button instanceof HTMLButtonElement) button.disabled = true;
try {
await runUpdateNow();
setUpdateNotice(t("settings.update_started"), false);
} catch (error) {
setUpdateNotice(t("settings.update_failed", { msg: error instanceof Error ? error.message : String(error) }), true);
}
renderView();
});
document.getElementById("logoUpload")?.addEventListener("change", (event) => {
const input = event.currentTarget;
const file = input instanceof HTMLInputElement ? input.files?.[0] : null;
if (!file) {
return;
}
const reader = new FileReader();
reader.onload = () => {
state.settings.logoDataUrl = typeof reader.result === "string" ? reader.result : "";
saveState();
renderView();
};
reader.readAsDataURL(file);
});
document.getElementById("clearLogo")?.addEventListener("click", () => {
state.settings.logoDataUrl = "";
saveState();
renderView();
});
document.getElementById("exportData")?.addEventListener("click", () => {
const payload = buildDataExportPayload();
downloadJsonFile("live_rc_full_export.json", payload);
});
document.getElementById("importData")?.addEventListener("change", (event) => {
const input = event.currentTarget;
const file = input instanceof HTMLInputElement ? input.files?.[0] : null;
if (!file) {
return;
}
const reader = new FileReader();
reader.onload = async () => {
try {
const parsed = JSON.parse(String(reader.result || "{}"));
const completed = await runWithSafetyBackup(() => importDataPayload(parsed, "full"));
if (!completed && input instanceof HTMLInputElement) input.value = "";
} catch (error) {
settingsStorageNotice = t("settings.import_failed", { msg: error instanceof Error ? error.message : String(error) });
settingsStorageNoticeIsError = true;
renderView();
}
};
reader.readAsText(file);
});
document.getElementById("exportDirectoryData")?.addEventListener("click", () => {
const payload = buildDirectoryExportPayload();
downloadJsonFile("live_rc_directory_export.json", payload);
});
document.getElementById("exportDriversCsv")?.addEventListener("click", () => {
downloadCsvFile("live_rc_drivers.csv", buildDriversCsvRows());
});
document.getElementById("downloadDriversCsvTemplate")?.addEventListener("click", () => {
downloadCsvFile("racecore_driver_import_template.csv", [
["name", "transponder", "class", "brand", "spoken_name", "club", "country"],
]);
});
document.getElementById("exportCarsCsv")?.addEventListener("click", () => {
downloadCsvFile("live_rc_cars.csv", buildCarsCsvRows());
});
const bindCsvImport = (selectButtonId, inputId, type) => {
const input = document.getElementById(inputId);
document.getElementById(selectButtonId)?.addEventListener("click", () => input?.click());
input?.addEventListener("change", (event) => {
const fileInput = event.currentTarget;
const file = fileInput instanceof HTMLInputElement ? fileInput.files?.[0] : null;
if (!file) return;
const reader = new FileReader();
reader.onload = () => importDirectoryCsv(String(reader.result || ""), type);
reader.onerror = () => importDirectoryCsv("", type);
reader.readAsText(file);
});
};
bindCsvImport("selectDriversCsv", "importDriversCsv", "drivers");
bindCsvImport("selectCarsCsv", "importCarsCsv", "cars");
document.querySelectorAll("[data-clear-scope]").forEach((button) => {
button.addEventListener("click", async () => {
const scope = String(button.dataset.clearScope || "");
if (!scope || !window.confirm(t(`settings.clear_${scope}_confirm`))) return;
button.disabled = true;
const completed = await runWithSafetyBackup(() => clearDataScope(scope));
if (!completed) button.disabled = false;
});
});
document.getElementById("importDirectoryData")?.addEventListener("change", (event) => {
const input = event.currentTarget;
const file = input instanceof HTMLInputElement ? input.files?.[0] : null;
if (!file) {
return;
}
const reader = new FileReader();
reader.onload = () => {
try {
const parsed = JSON.parse(String(reader.result || "{}"));
importDataPayload(parsed, "directory");
} catch (error) {
settingsStorageNotice = t("settings.import_failed", { msg: error instanceof Error ? error.message : String(error) });
settingsStorageNoticeIsError = true;
renderView();
}
};
reader.readAsText(file);
});
document.getElementById("exportRacePresets")?.addEventListener("click", () => {
const payload = {
...buildExportMeta("race_presets"),
racePresets: (state.settings.racePresets || []).map((preset) => normalizeStoredRacePreset(preset)),
};
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "live_rc_race_presets.json";
link.click();
URL.revokeObjectURL(url);
});
document.getElementById("importRacePresets")?.addEventListener("change", (event) => {
const input = event.currentTarget;
const file = input instanceof HTMLInputElement ? input.files?.[0] : null;
if (!file) {
return;
}
const reader = new FileReader();
reader.onload = () => {
try {
const parsed = JSON.parse(String(reader.result || "{}"));
const incoming = Array.isArray(parsed?.racePresets) ? parsed.racePresets.map((preset) => normalizeStoredRacePreset(preset)).filter((preset) => preset.name) : [];
const existingById = new Map((state.settings.racePresets || []).map((preset) => [preset.id, normalizeStoredRacePreset(preset)]));
incoming.forEach((preset) => {
existingById.set(preset.id, preset);
});
state.settings.racePresets = [...existingById.values()];
saveState();
renderView();
} catch {
// ignore invalid preset file
}
};
reader.readAsText(file);
});
document.getElementById("ammcForm")?.addEventListener("submit", async (e) => {
e.preventDefault();
const form = new FormData(e.currentTarget);
const config = {
managedEnabled: form.get("managedEnabled") === "on",
autoStart: form.get("autoStart") === "on",
decoderHost: String(form.get("decoderHost") || "").trim(),
wsPort: Number(form.get("wsPort") || 9000),
splitEnabled: form.get("splitEnabled") === "on",
splitDecoderHost: String(form.get("splitDecoderHost") || "").trim(),
splitWsPort: Number(form.get("splitWsPort") || 9001),
pitEnabled: form.get("pitEnabled") === "on",
pitEntryDecoderHost: String(form.get("pitEntryDecoderHost") || "").trim(),
pitEntryWsPort: Number(form.get("pitEntryWsPort") || 9002),
pitExitDecoderHost: String(form.get("pitExitDecoderHost") || "").trim(),
pitExitWsPort: Number(form.get("pitExitWsPort") || 9003),
executablePath: String(form.get("executablePath") || "").trim(),
workingDirectory: String(form.get("workingDirectory") || "").trim(),
extraArgs: String(form.get("extraArgs") || "").trim(),
};
await saveAmmcConfigToBackend(config);
renderView();
});
document.getElementById("ammcRefresh")?.addEventListener("click", async () => {
await refreshAmmcStatus();
renderView();
});
document.getElementById("ammcStart")?.addEventListener("click", async () => {
const formElement = document.getElementById("ammcForm");
if (formElement instanceof HTMLFormElement) {
const form = new FormData(formElement);
await saveAmmcConfigToBackend({
managedEnabled: form.get("managedEnabled") === "on",
autoStart: form.get("autoStart") === "on",
decoderHost: String(form.get("decoderHost") || "").trim(),
wsPort: Number(form.get("wsPort") || 9000),
splitEnabled: form.get("splitEnabled") === "on",
splitDecoderHost: String(form.get("splitDecoderHost") || "").trim(),
splitWsPort: Number(form.get("splitWsPort") || 9001),
pitEnabled: form.get("pitEnabled") === "on",
pitEntryDecoderHost: String(form.get("pitEntryDecoderHost") || "").trim(),
pitEntryWsPort: Number(form.get("pitEntryWsPort") || 9002),
pitExitDecoderHost: String(form.get("pitExitDecoderHost") || "").trim(),
pitExitWsPort: Number(form.get("pitExitWsPort") || 9003),
executablePath: String(form.get("executablePath") || "").trim(),
workingDirectory: String(form.get("workingDirectory") || "").trim(),
extraArgs: String(form.get("extraArgs") || "").trim(),
});
}
await startManagedAmmc();
renderView();
});
document.getElementById("ammcStop")?.addEventListener("click", async () => {
await stopManagedAmmc();
renderView();
});
document.getElementById("ammcUseServerWs")?.addEventListener("click", () => {
state.settings.wsUrl = suggestedWsUrl;
state.settings.splitDecoderEnabled = Boolean(ammc.config.splitEnabled);
state.settings.splitWsUrl = suggestedSplitWsUrl;
state.settings.pitDecoderEnabled = Boolean(ammc.config.pitEnabled);
state.settings.pitEntryWsUrl = suggestedPitEntryWsUrl;
state.settings.pitExitWsUrl = suggestedPitExitWsUrl;
saveState();
renderView();
});
}