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>
1060 lines
60 KiB
JavaScript
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();
|
|
});
|
|
}
|