Modularize overlays, settings and race setup UI
This commit is contained in:
857
src/app.js
857
src/app.js
File diff suppressed because it is too large
Load Diff
216
src/race_setup_ui.js
Normal file
216
src/race_setup_ui.js
Normal file
@@ -0,0 +1,216 @@
|
||||
export function renderRaceFormatField(labelKey, hintKey, controlHtml, options = {}, { t }) {
|
||||
const extraClass = options.checkbox ? " field-card-checkbox" : "";
|
||||
return `
|
||||
<label class="field-card${extraClass}">
|
||||
<span class="field-label">${t(labelKey)}</span>
|
||||
<span class="field-hint">${t(hintKey)}</span>
|
||||
${controlHtml}
|
||||
</label>
|
||||
`;
|
||||
}
|
||||
|
||||
export function renderRaceFormatContextCard(titleKey, hintKey, { t }) {
|
||||
return `
|
||||
<article class="field-card race-format-context-card">
|
||||
<span class="field-label">${t(titleKey)}</span>
|
||||
<span class="field-hint">${t(hintKey)}</span>
|
||||
</article>
|
||||
`;
|
||||
}
|
||||
|
||||
export function getRaceSummaryItems(event, sessions, raceDrivers, selectedPreset, { t, getStartModeLabel }) {
|
||||
const cfg = event.raceConfig || {};
|
||||
const participantCount = cfg.participantsConfigured ? (cfg.driverIds || []).length : raceDrivers.length;
|
||||
const practiceCount = sessions.filter((session) => ["practice", "free_practice", "open_practice"].includes(session.type)).length;
|
||||
const qualCount = sessions.filter((session) => session.type === "qualification").length;
|
||||
const finalCount = sessions.filter((session) => session.type === "final").length;
|
||||
const teamCount = sessions.filter((session) => session.type === "team_race").length;
|
||||
const scoringLabel = cfg.qualifyingScoring === "best" ? t("events.qualifying_scoring_best") : t("events.qualifying_scoring_points");
|
||||
const lapWindow = `${((cfg.minLapMs || 0) / 1000).toFixed(1)}s / ${((cfg.maxLapMs || 0) / 1000).toFixed(1)}s`;
|
||||
const isEndurancePreset = selectedPreset?.id === "endurance";
|
||||
if (isEndurancePreset) {
|
||||
return [
|
||||
{ label: t("events.race_summary_preset"), value: selectedPreset?.label || t("events.preset_custom") },
|
||||
{ label: t("events.race_summary_focus"), value: `${t("events.team_race")} · ${getStartModeLabel("mass")}` },
|
||||
{ label: t("events.race_summary_participants"), value: String(participantCount || 0) },
|
||||
{ label: t("events.race_summary_created_sessions"), value: `P ${practiceCount} · T ${teamCount}` },
|
||||
{ label: t("events.race_summary_generation"), value: `${teamCount ? t("events.team_race") : t("events.wizard_use_team_race")} · ${cfg.finalDurationMin || 0} min` },
|
||||
{ label: t("events.race_summary_validation"), value: lapWindow },
|
||||
{ label: t("events.race_summary_follow_up"), value: `${cfg.followUpSec || 0}s` },
|
||||
];
|
||||
}
|
||||
return [
|
||||
{ label: t("events.race_summary_preset"), value: selectedPreset?.label || t("events.preset_custom") },
|
||||
{ label: t("events.race_summary_participants"), value: String(participantCount || 0) },
|
||||
{ label: t("events.race_summary_created_sessions"), value: `P ${practiceCount} · Q ${qualCount} · F ${finalCount} · T ${teamCount}` },
|
||||
{ label: t("events.race_summary_qualifying"), value: `${cfg.qualifyingRounds || 0} x ${cfg.qualDurationMin || 0} min · ${cfg.carsPerHeat || 0}/heat · ${scoringLabel}` },
|
||||
{ label: t("events.race_summary_finals"), value: `${cfg.carsPerFinal || 0}/main · ${cfg.finalLegs || 0} leg · ${getStartModeLabel(cfg.finalStartMode || "position")}` },
|
||||
{ label: t("events.race_summary_generation"), value: `${cfg.finalsSource === "practice" ? t("events.finals_from_practice") : t("events.finals_from_qualifying")} · bump ${cfg.bumpCount || 0}` },
|
||||
{ label: t("events.race_summary_validation"), value: lapWindow },
|
||||
{ label: t("events.race_summary_follow_up"), value: `${cfg.followUpSec || 0}s` },
|
||||
];
|
||||
}
|
||||
|
||||
export function getRaceSummaryWarnings(event, sessions, raceDrivers, raceTeams, selectedPreset, { t }) {
|
||||
const cfg = event.raceConfig || {};
|
||||
const participantCount = cfg.participantsConfigured ? (cfg.driverIds || []).length : raceDrivers.length;
|
||||
const warnings = [];
|
||||
const isEndurancePreset = selectedPreset?.id === "endurance";
|
||||
const hasQualifying = sessions.some((session) => session.type === "qualification");
|
||||
const hasFinals = sessions.some((session) => session.type === "final");
|
||||
const hasAnySession = sessions.length > 0;
|
||||
if (participantCount <= 0) warnings.push({ message: t("events.summary_warning_no_participants"), target: "manage-setup-participants" });
|
||||
if (!hasAnySession) warnings.push({ message: t("events.summary_warning_no_sessions"), target: "manage-session-plan" });
|
||||
if (!isEndurancePreset && !hasQualifying) warnings.push({ message: t("events.summary_warning_no_qualifying"), target: "manage-generation" });
|
||||
if (!isEndurancePreset && !hasFinals) warnings.push({ message: t("events.summary_warning_no_finals"), target: "manage-generation" });
|
||||
if (isEndurancePreset && raceTeams.length <= 0) warnings.push({ message: t("events.summary_warning_no_teams"), target: "manage-setup-teams" });
|
||||
const minLapMs = Math.max(0, Number(cfg.minLapMs || 0) || 0);
|
||||
const maxLapMs = Math.max(0, Number(cfg.maxLapMs || 0) || 0);
|
||||
if (maxLapMs > 0 && maxLapMs <= minLapMs) warnings.push({ message: t("events.summary_warning_invalid_lap_window"), target: "manage-format" });
|
||||
return warnings;
|
||||
}
|
||||
|
||||
export function renderManageStatusBadge(status, { t }) {
|
||||
return `<span class="status-chip status-chip-${status}">${t(`events.status_${status}`)}</span>`;
|
||||
}
|
||||
|
||||
export function renderRaceWizardSteps({ t, raceWizardStep, escapeHtml }) {
|
||||
const steps = [t("events.wizard_step_1"), t("events.wizard_step_2"), t("events.wizard_step_3"), t("events.wizard_step_4")];
|
||||
return steps
|
||||
.map(
|
||||
(label, index) => `
|
||||
<article class="wizard-step ${index + 1 === raceWizardStep ? "wizard-step-active" : ""} ${index + 1 < raceWizardStep ? "wizard-step-complete" : ""}">
|
||||
<span>${index + 1}</span>
|
||||
<strong>${escapeHtml(label)}</strong>
|
||||
</article>
|
||||
`
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
|
||||
export function renderRaceWizardContent(draft, classOptions, wizardDrivers, preset, { t, escapeHtml, raceWizardStep, getRaceFormatPresets, getClassName, getRaceWizardSessionPlan }) {
|
||||
if (raceWizardStep === 1) {
|
||||
return `
|
||||
<form id="raceWizardStepForm" class="form-grid cols-4">
|
||||
<input required name="name" value="${escapeHtml(draft.name || "")}" placeholder="${t("events.name_placeholder")}" />
|
||||
<input required type="date" name="date" value="${escapeHtml(draft.date || "")}" />
|
||||
<select name="classId">${classOptions}</select>
|
||||
<select name="presetId">${getRaceFormatPresets()
|
||||
.filter((item) => item.id !== "custom")
|
||||
.map((item) => `<option value="${item.id}" ${item.id === draft.presetId ? "selected" : ""}>${escapeHtml(item.label)}</option>`)
|
||||
.join("")}</select>
|
||||
</form>
|
||||
`;
|
||||
}
|
||||
if (raceWizardStep === 2) {
|
||||
return `
|
||||
<div class="actions">
|
||||
<button id="wizardSelectAllParticipants" class="btn" type="button">${t("events.select_all_participants")}</button>
|
||||
<button id="wizardClearParticipants" class="btn btn-danger" type="button">${t("events.clear_participants")}</button>
|
||||
</div>
|
||||
${wizardDrivers.length ? `
|
||||
<div class="check-grid mt-16">
|
||||
${wizardDrivers
|
||||
.map(
|
||||
(driver) => `
|
||||
<label class="check-card">
|
||||
<input type="checkbox" class="wizard-participant" value="${driver.id}" ${draft.driverIds.includes(driver.id) ? "checked" : ""} />
|
||||
<span>${escapeHtml(driver.name)}${driver.transponder ? ` (${escapeHtml(driver.transponder)})` : ""}</span>
|
||||
</label>
|
||||
`
|
||||
)
|
||||
.join("")}
|
||||
</div>
|
||||
` : `<p class="hint">${t("events.wizard_no_class_drivers")}</p>`}
|
||||
`;
|
||||
}
|
||||
if (raceWizardStep === 3) {
|
||||
const isEndurance = preset.id === "endurance";
|
||||
return `
|
||||
<form id="raceWizardPlanForm" class="form-grid cols-2">
|
||||
<label class="field-card">
|
||||
<span class="field-label">${t("events.wizard_use_practice")}</span>
|
||||
<span class="field-hint">${t("events.free_practice_note")}</span>
|
||||
<label class="toggle"><input type="checkbox" name="createPractice" ${draft.createPractice ? "checked" : ""} /><span>${t("events.wizard_use_practice")}</span></label>
|
||||
</label>
|
||||
<label class="field-card">
|
||||
<span class="field-label">${t("events.wizard_practice_sessions")}</span>
|
||||
<span class="field-hint">${t("events.practice_standings")}</span>
|
||||
<input type="number" min="0" step="1" name="practiceSessions" value="${draft.practiceSessions}" ${draft.createPractice ? "" : "disabled"} />
|
||||
</label>
|
||||
${!isEndurance ? `
|
||||
<label class="field-card">
|
||||
<span class="field-label">${t("events.wizard_use_qualifying")}</span>
|
||||
<span class="field-hint">${t("events.qualifying_rounds_hint")}</span>
|
||||
<label class="toggle"><input type="checkbox" name="createQualifying" ${draft.createQualifying ? "checked" : ""} /><span>${t("events.wizard_use_qualifying")}</span></label>
|
||||
</label>
|
||||
<label class="field-card">
|
||||
<span class="field-label">${t("events.wizard_qualifying_rounds")}</span>
|
||||
<span class="field-hint">${t("events.qual_duration_hint")}</span>
|
||||
<input type="number" min="0" step="1" name="qualifyingRounds" value="${draft.qualifyingRounds}" ${draft.createQualifying ? "" : "disabled"} />
|
||||
</label>
|
||||
` : `
|
||||
<label class="field-card">
|
||||
<span class="field-label">${t("events.wizard_use_team_race")}</span>
|
||||
<span class="field-hint">${t("events.team_race_intro")}</span>
|
||||
<label class="toggle"><input type="checkbox" name="createTeamRace" ${draft.createTeamRace ? "checked" : ""} /><span>${t("events.wizard_use_team_race")}</span></label>
|
||||
</label>
|
||||
<label class="field-card">
|
||||
<span class="field-label">${t("events.wizard_team_duration")}</span>
|
||||
<span class="field-hint">${t("events.final_duration_hint")}</span>
|
||||
<input type="number" min="1" step="1" name="teamRaceDurationMin" value="${draft.teamRaceDurationMin}" ${draft.createTeamRace ? "" : "disabled"} />
|
||||
</label>
|
||||
`}
|
||||
</form>
|
||||
<p class="hint mt-16">${t("events.wizard_finals_note")}</p>
|
||||
`;
|
||||
}
|
||||
const selectedClassName = getClassName(draft.classId);
|
||||
const selectedDrivers = draft.driverIds.length ? draft.driverIds.length : wizardDrivers.length;
|
||||
return `
|
||||
<div class="wizard-summary-grid">
|
||||
<article class="race-summary-item">
|
||||
<span>${t("events.wizard_summary_title")}</span>
|
||||
<strong>${escapeHtml(draft.name || "-")}</strong>
|
||||
</article>
|
||||
<article class="race-summary-item">
|
||||
<span>${t("table.class")}</span>
|
||||
<strong>${escapeHtml(selectedClassName || "-")}</strong>
|
||||
</article>
|
||||
<article class="race-summary-item">
|
||||
<span>${t("events.race_summary_preset")}</span>
|
||||
<strong>${escapeHtml(preset?.label || t("events.preset_custom"))}</strong>
|
||||
</article>
|
||||
<article class="race-summary-item">
|
||||
<span>${t("events.race_summary_participants")}</span>
|
||||
<strong>${selectedDrivers}</strong>
|
||||
</article>
|
||||
<article class="race-summary-item wizard-summary-item-wide">
|
||||
<span>${t("events.wizard_summary_sessions")}</span>
|
||||
<strong>${escapeHtml(getRaceWizardSessionPlan(draft).join(" • ") || "-")}</strong>
|
||||
</article>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
export function renderRaceStandingsTable(rows, emptyLabel, { t, renderTable, escapeHtml }) {
|
||||
if (!rows.length) {
|
||||
return `<p>${emptyLabel}</p>`;
|
||||
}
|
||||
|
||||
return renderTable(
|
||||
[t("table.pos"), t("table.driver"), t("table.score")],
|
||||
rows.map(
|
||||
(row) => `
|
||||
<tr>
|
||||
<td>${row.rank}</td>
|
||||
<td>${escapeHtml(row.driverName || t("common.unknown_driver"))}</td>
|
||||
<td>
|
||||
<div class="table-primary">${escapeHtml(row.score || "-")}</div>
|
||||
${row.scoreNote ? `<div class="table-subnote">${escapeHtml(row.scoreNote)}</div>` : ""}
|
||||
</td>
|
||||
</tr>
|
||||
`
|
||||
)
|
||||
);
|
||||
}
|
||||
531
src/settings.js
Normal file
531
src/settings.js
Normal file
@@ -0,0 +1,531 @@
|
||||
export function renderSettings(deps) {
|
||||
const {
|
||||
dom,
|
||||
ammc,
|
||||
state,
|
||||
t,
|
||||
backend,
|
||||
settingsStorageNotice,
|
||||
settingsStorageNoticeIsError,
|
||||
getManagedWsUrl,
|
||||
getBackendUrl,
|
||||
getDefaultBackendUrl,
|
||||
escapeHtml,
|
||||
saveState,
|
||||
renderView,
|
||||
connectDecoder,
|
||||
playPassingBeep,
|
||||
playFinishSiren,
|
||||
openOverlayWindow,
|
||||
buildOverlayUrl,
|
||||
normalizeObsOverlaySettings,
|
||||
DEFAULT_OBS_OVERLAY_SETTINGS,
|
||||
OBS_LAYOUTS,
|
||||
OBS_THEMES,
|
||||
pingBackend,
|
||||
syncStateToBackend,
|
||||
buildDataExportPayload,
|
||||
downloadJsonFile,
|
||||
importDataPayload,
|
||||
buildDirectoryExportPayload,
|
||||
downloadCsvFile,
|
||||
buildDriversCsvRows,
|
||||
buildCarsCsvRows,
|
||||
buildExportMeta,
|
||||
normalizeStoredRacePreset,
|
||||
saveAmmcConfigToBackend,
|
||||
refreshAmmcStatus,
|
||||
startManagedAmmc,
|
||||
stopManagedAmmc,
|
||||
} = deps;
|
||||
const ammcStatus = ammc.status || {};
|
||||
const ammcOutput = Array.isArray(ammcStatus.lastOutput) ? ammcStatus.lastOutput : [];
|
||||
const suggestedWsUrl = getManagedWsUrl();
|
||||
const running = Boolean(ammcStatus.running);
|
||||
|
||||
dom.view.innerHTML = `
|
||||
<section class="panel">
|
||||
<div class="panel-header"><h3>${t("settings.decoder")}</h3></div>
|
||||
<form id="settingsForm" class="panel-body form-grid cols-3 settings-grid">
|
||||
<input name="wsUrl" value="${escapeHtml(state.settings.wsUrl)}" placeholder="ws://127.0.0.1:9000" />
|
||||
<input name="backendUrl" value="${escapeHtml(
|
||||
state.settings.backendUrl || getDefaultBackendUrl()
|
||||
)}" placeholder="http://127.0.0.1:8081" />
|
||||
<label class="toggle">
|
||||
<input type="checkbox" name="autoReconnect" ${state.settings.autoReconnect ? "checked" : ""} />
|
||||
<span>${t("settings.auto_reconnect")}</span>
|
||||
</label>
|
||||
<button class="btn btn-primary" type="submit">${t("settings.save")}</button>
|
||||
<button id="settingsConnect" class="btn" type="button">${t("settings.connect_now")}</button>
|
||||
</form>
|
||||
<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>
|
||||
|
||||
<section class="panel mt-16">
|
||||
<div class="panel-header"><h3>${t("settings.audio")}</h3></div>
|
||||
<div class="panel-body form-grid cols-4 settings-grid settings-grid-toggles">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" name="audioEnabled" form="settingsForm" ${state.settings.audioEnabled ? "checked" : ""} />
|
||||
<span>${t("settings.audio_enabled")}</span>
|
||||
</label>
|
||||
<select name="passingSoundMode" form="settingsForm">
|
||||
<option value="off" ${state.settings.passingSoundMode === "off" ? "selected" : ""}>${t("settings.passing_sound_off")}</option>
|
||||
<option value="beep" ${state.settings.passingSoundMode === "beep" ? "selected" : ""}>${t("settings.passing_sound_beep")}</option>
|
||||
<option value="name" ${state.settings.passingSoundMode === "name" ? "selected" : ""}>${t("settings.passing_sound_name")}</option>
|
||||
</select>
|
||||
<label class="toggle">
|
||||
<input type="checkbox" name="finishVoiceEnabled" form="settingsForm" ${state.settings.finishVoiceEnabled ? "checked" : ""} />
|
||||
<span>${t("settings.finish_voice")}</span>
|
||||
</label>
|
||||
<button id="settingsTestAudio" class="btn" type="button">${t("settings.test_audio")}</button>
|
||||
<label class="toggle">
|
||||
<input type="checkbox" name="speakerPassingCueEnabled" form="settingsForm" ${state.settings.speakerPassingCueEnabled ? "checked" : ""} />
|
||||
<span>${t("settings.speaker_passing_cue")}</span>
|
||||
</label>
|
||||
<label class="toggle">
|
||||
<input type="checkbox" name="speakerLeaderCueEnabled" form="settingsForm" ${state.settings.speakerLeaderCueEnabled ? "checked" : ""} />
|
||||
<span>${t("settings.speaker_leader_cue")}</span>
|
||||
</label>
|
||||
<label class="toggle">
|
||||
<input type="checkbox" name="speakerFinishCueEnabled" form="settingsForm" ${state.settings.speakerFinishCueEnabled ? "checked" : ""} />
|
||||
<span>${t("settings.speaker_finish_cue")}</span>
|
||||
</label>
|
||||
<label class="toggle">
|
||||
<input type="checkbox" name="speakerBestLapCueEnabled" form="settingsForm" ${state.settings.speakerBestLapCueEnabled ? "checked" : ""} />
|
||||
<span>${t("settings.speaker_bestlap_cue")}</span>
|
||||
</label>
|
||||
<label class="toggle">
|
||||
<input type="checkbox" name="speakerTop3CueEnabled" form="settingsForm" ${state.settings.speakerTop3CueEnabled ? "checked" : ""} />
|
||||
<span>${t("settings.speaker_top3_cue")}</span>
|
||||
</label>
|
||||
<label class="toggle">
|
||||
<input type="checkbox" name="speakerSessionStartCueEnabled" form="settingsForm" ${state.settings.speakerSessionStartCueEnabled ? "checked" : ""} />
|
||||
<span>${t("settings.speaker_start_cue")}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<p>${t("settings.audio_note")}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel mt-16">
|
||||
<div class="panel-header"><h3>${t("settings.branding")}</h3></div>
|
||||
<div class="panel-body form-grid cols-3 settings-grid">
|
||||
<input name="clubName" form="settingsForm" value="${escapeHtml(state.settings.clubName || "")}" placeholder="${t("settings.club_name")}" />
|
||||
<input name="clubTagline" form="settingsForm" value="${escapeHtml(state.settings.clubTagline || "")}" placeholder="${t("settings.club_tagline")}" />
|
||||
<input name="pdfFooter" form="settingsForm" value="${escapeHtml(state.settings.pdfFooter || "")}" placeholder="${t("settings.pdf_footer")}" />
|
||||
<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>
|
||||
</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>
|
||||
</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("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>
|
||||
|
||||
<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">
|
||||
<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>
|
||||
<input name="decoderHost" value="${escapeHtml(ammc.config.decoderHost || "")}" placeholder="${t("settings.decoder_host")}" />
|
||||
<input name="wsPort" value="${escapeHtml(String(ammc.config.wsPort || 9000))}" placeholder="9000" />
|
||||
<input name="executablePath" value="${escapeHtml(ammc.config.executablePath || "")}" placeholder="${t("settings.executable_path")}" />
|
||||
<input name="workingDirectory" value="${escapeHtml(ammc.config.workingDirectory || "")}" placeholder="${t("settings.working_dir")}" />
|
||||
<input name="extraArgs" value="${escapeHtml(ammc.config.extraArgs || "")}" placeholder="${t("settings.extra_args")}" />
|
||||
<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>
|
||||
<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.stream}: ${entry.line}`).join("\n")
|
||||
: "-"
|
||||
)}</pre>
|
||||
</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="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="exportCarsCsv" class="btn" type="button">${t("settings.export_cars_csv")}</button>
|
||||
</div>
|
||||
</div>
|
||||
${settingsStorageNotice ? `<p class="${settingsStorageNoticeIsError ? "error" : "hint"}">${escapeHtml(settingsStorageNotice)}</p>` : ""}
|
||||
</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>
|
||||
`;
|
||||
|
||||
document.getElementById("settingsForm")?.addEventListener("submit", (e) => {
|
||||
e.preventDefault();
|
||||
const form = new FormData(e.currentTarget);
|
||||
state.settings.wsUrl = String(form.get("wsUrl") || "").trim();
|
||||
state.settings.backendUrl = String(form.get("backendUrl") || "").trim();
|
||||
state.settings.autoReconnect = form.get("autoReconnect") === "on";
|
||||
state.settings.audioEnabled = form.get("audioEnabled") === "on";
|
||||
state.settings.passingSoundMode = String(form.get("passingSoundMode") || "beep");
|
||||
state.settings.finishVoiceEnabled = form.get("finishVoiceEnabled") === "on";
|
||||
state.settings.speakerPassingCueEnabled = form.get("speakerPassingCueEnabled") === "on";
|
||||
state.settings.speakerLeaderCueEnabled = form.get("speakerLeaderCueEnabled") === "on";
|
||||
state.settings.speakerFinishCueEnabled = form.get("speakerFinishCueEnabled") === "on";
|
||||
state.settings.speakerBestLapCueEnabled = form.get("speakerBestLapCueEnabled") === "on";
|
||||
state.settings.speakerTop3CueEnabled = form.get("speakerTop3CueEnabled") === "on";
|
||||
state.settings.speakerSessionStartCueEnabled = form.get("speakerSessionStartCueEnabled") === "on";
|
||||
state.settings.clubName = String(form.get("clubName") || "").trim() || "JMK RB RaceController";
|
||||
state.settings.clubTagline = String(form.get("clubTagline") || "").trim() || "RC Timing System";
|
||||
state.settings.pdfFooter = String(form.get("pdfFooter") || "").trim() || "Generated by JMK RB RaceController";
|
||||
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("settingsTestAudio")?.addEventListener("click", () => {
|
||||
playPassingBeep();
|
||||
setTimeout(() => {
|
||||
if (state.settings.finishVoiceEnabled) {
|
||||
playFinishSiren();
|
||||
}
|
||||
}, 180);
|
||||
});
|
||||
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("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 = () => {
|
||||
try {
|
||||
const parsed = JSON.parse(String(reader.result || "{}"));
|
||||
importDataPayload(parsed, "full");
|
||||
} 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("exportCarsCsv")?.addEventListener("click", () => {
|
||||
downloadCsvFile("live_rc_cars.csv", buildCarsCsvRows());
|
||||
});
|
||||
|
||||
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),
|
||||
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),
|
||||
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 = getManagedWsUrl();
|
||||
saveState();
|
||||
renderView();
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user