update sounds ang transtions
Some checks failed
Test / test (push) Has been cancelled

This commit is contained in:
2026-06-22 17:16:36 +07:00
parent d70a4e45af
commit 523f98b74c
28 changed files with 2449 additions and 138 deletions

View File

@@ -11,6 +11,7 @@ const pageMapsEl = el("pageMaps");
const pageMissionsEl = el("pageMissions");
const pageIntegrationsEl = el("pageIntegrations");
const pageSoundsEl = el("pageSounds");
const pageTransitionsEl = el("pageTransitions");
const pageMonitoringEl = el("pageMonitoring");
const pageHelpEl = el("pageHelp");
const contentEl = document.querySelector(".content");
@@ -125,7 +126,7 @@ const state = {
};
function setActivePage(page) {
const valid = ["dashboard", "config", "maps", "missions", "sounds", "integrations", "monitoring", "help"];
const valid = ["dashboard", "config", "maps", "missions", "sounds", "transitions", "integrations", "monitoring", "help"];
let p = valid.includes(page) ? page : "missions";
if (window.AuthApp && !window.AuthApp.canAccessPage(p)) {
const fallback = valid.find((v) => window.AuthApp.canAccessPage(v));
@@ -137,6 +138,7 @@ function setActivePage(page) {
if (pageMapsEl) pageMapsEl.hidden = p !== "maps";
if (pageMissionsEl) pageMissionsEl.hidden = p !== "missions";
if (pageSoundsEl) pageSoundsEl.hidden = p !== "sounds";
if (pageTransitionsEl) pageTransitionsEl.hidden = p !== "transitions";
if (pageIntegrationsEl) pageIntegrationsEl.hidden = p !== "integrations";
if (pageMonitoringEl) pageMonitoringEl.hidden = p !== "monitoring";
if (pageHelpEl) pageHelpEl.hidden = p !== "help";
@@ -148,6 +150,7 @@ function setActivePage(page) {
contentEl.classList.toggle("content--maps", p === "maps");
contentEl.classList.toggle("content--missions", p === "missions");
contentEl.classList.toggle("content--sounds", p === "sounds");
contentEl.classList.toggle("content--transitions", p === "transitions");
contentEl.classList.toggle("content--integrations", p === "integrations");
contentEl.classList.toggle("content--monitoring", p === "monitoring");
contentEl.classList.toggle("content--help", p === "help");
@@ -157,6 +160,8 @@ function setActivePage(page) {
if (p === "maps" && window.MapsApp) window.MapsApp.onPageShow();
if (p === "sounds" && window.SoundsApp) window.SoundsApp.onPageShow();
else if (window.SoundsApp?.onPageHide) window.SoundsApp.onPageHide();
if (p === "transitions" && window.TransitionsApp) window.TransitionsApp.onPageShow();
else if (window.TransitionsApp?.onPageHide) window.TransitionsApp.onPageHide();
if (p === "dashboard" && window.DashboardApp) window.DashboardApp.onPageShow();
else if (window.DashboardApp?.onPageHide) window.DashboardApp.onPageHide();
if (p === "integrations" && window.IntegrationsApp) window.IntegrationsApp.onPageShow();

View File

@@ -149,7 +149,8 @@
dashboard: "dashboard",
maps: "maps",
missions: "missions",
sounds: "integrations",
sounds: "sounds",
transitions: "maps",
integrations: "integrations",
};
const resource = map[page];
@@ -168,6 +169,7 @@
document.body.classList.toggle("auth-readonly-config", !canWrite("config"));
document.body.classList.toggle("auth-readonly-maps", !canWrite("maps"));
document.body.classList.toggle("auth-readonly-missions", !canWrite("missions"));
document.body.classList.toggle("auth-readonly-sounds", !canWrite("sounds") && !canWrite("integrations"));
document.body.classList.toggle("auth-readonly-integrations", !canWrite("integrations"));
}

View File

@@ -73,6 +73,7 @@
"nav.missions": "Missions",
"nav.maps": "Maps",
"nav.sounds": "Sounds",
"nav.transitions": "Transitions",
"nav.build-robot": "Build Robot",
"nav.monitoring-log": "System log",
"nav.integrations": "Tích hợp",
@@ -486,22 +487,73 @@
"maps.menu.save": "Lưu map",
"sounds.title": "Sounds",
"sounds.subtitle": "Setup → Sounds — upload và quản lý âm thanh robot cho sound zones.",
"sounds.subtitle": "Tạo và chỉnh sửa sounds.",
"sounds.helpTitle": "Trợ giúp Sounds",
"sounds.helpBody": "Upload âm thanh cho sound zones trên map. System sounds (Beep, Horn, Chime) không thể xóa. Dùng Listen hoặc Play on robot để kiểm tra volume.",
"sounds.create": "Tạo sound",
"sounds.clearFilters": "Xóa bộ lọc",
"sounds.filterLabel": "Lọc:",
"sounds.filterPlaceholder": "Lọc theo tên...",
"sounds.itemsFound": "{n} mục",
"sounds.pageOf": "Trang {page} / {total}",
"sounds.empty": "Chưa có sound.",
"sounds.emptyFilter": "Không có sound khớp bộ lọc.",
"sounds.createTitle": "Tạo sound",
"sounds.editTitle": "Sửa sound",
"sounds.empty": "Chưa có sound. Tạo mới để dùng trong sound zones.",
"sounds.name": "Tên",
"sounds.note": "Ghi chú",
"sounds.description": "Mô tả",
"sounds.enabled": "Bật",
"sounds.volume": "Volume (0100)",
"sounds.volumeHint": "100% ≈ 80 dB. Kiểm tra volume bằng Play on robot.",
"sounds.volumeShort": "{volume}%",
"sounds.file": "File âm thanh",
"sounds.noFile": "Chưa có file",
"sounds.systemNoFile": "Âm thanh hệ thống (phát trên robot)",
"sounds.systemBadge": "System sound",
"sounds.systemShort": "System",
"sounds.upload": "Upload file…",
"sounds.listen": "Listen",
"sounds.playOnRobot": "Play on robot",
"sounds.play": "Phát",
"sounds.playFailed": "Không phát được file.",
"sounds.fileMeta": "{name} · {duration}",
"sounds.saveChanges": "Lưu thay đổi",
"sounds.nameRequired": "Nhập tên sound.",
"sounds.nameDuplicate": "Đã có sound cùng tên. Hãy Edit bản ghi hiện có.",
"sounds.reservedName": "Beep, Horn, Chime là system sounds — dùng Edit trên dòng System.",
"sounds.deleteConfirm": "Xóa sound này?",
"sounds.deleteConfirmTitle": "Xóa sound?",
"sounds.deleteConfirmText": "Xóa \"{name}\"? Sound zones đang dùng có thể bị ảnh hưởng.",
"transitions.title": "Transitions",
"transitions.subtitle": "Tạo và chỉnh sửa transitions.",
"transitions.helpTitle": "Trợ giúp Transitions",
"transitions.helpBody": "Transition nối hai map trong cùng site. Start position trên map nguồn, Goal position trên map đích (cùng vị trí vật lý). Mission phải chứa các bước switch map.",
"transitions.create": "Tạo transition",
"transitions.clearFilters": "Xóa bộ lọc",
"transitions.filterLabel": "Lọc:",
"transitions.filterPlaceholder": "Lọc theo position hoặc mission...",
"transitions.itemsFound": "{n} mục",
"transitions.pageOf": "Trang {page} / {total}",
"transitions.colStart": "Start",
"transitions.colGoal": "Goal",
"transitions.colMission": "Mission",
"transitions.colCreatedBy": "Created by",
"transitions.colFunctions": "Functions",
"transitions.empty": "Chưa có transition.",
"transitions.emptyFilter": "Không có transition khớp bộ lọc.",
"transitions.createTitle": "Tạo transition",
"transitions.editTitle": "Sửa transition",
"transitions.site": "Site",
"transitions.fromMap": "From map",
"transitions.toMap": "To map",
"transitions.startPosition": "Start position",
"transitions.goalPosition": "Goal position",
"transitions.mission": "Mission",
"transitions.deleteTitle": "Xóa transition?",
"transitions.deleteConfirmText": "Xóa transition {from} → {to}?",
"transitions.error.missing": "Thiếu trường bắt buộc.",
"missions.title": "Missions",
"missions.subtitle": "Setup → Missions — danh sách nhiệm vụ robot.",
@@ -571,6 +623,7 @@
"missions.action.drop_cart": "Drop cart",
"missions.action.user_log": "User log",
"missions.action.play_sound": "Play sound",
"missions.action.switch_map": "Switch map",
"missions.error.nameRequired": "Tên mission không được trống.",
"missions.error.nameDuplicate": "Tên mission đã tồn tại.",
"missions.error.nameEmpty": "Tên không được trống.",
@@ -691,6 +744,7 @@
"nav.missions": "Missions",
"nav.maps": "Maps",
"nav.sounds": "Sounds",
"nav.transitions": "Transitions",
"nav.build-robot": "Build Robot",
"nav.monitoring-log": "System log",
"nav.integrations": "Integrations",
@@ -1104,22 +1158,73 @@
"maps.menu.save": "Save map",
"sounds.title": "Sounds",
"sounds.subtitle": "Setup → Sounds — upload and manage robot sounds for sound zones.",
"sounds.subtitle": "Create and edit sounds.",
"sounds.helpTitle": "Sounds help",
"sounds.helpBody": "Upload audio for sound zones on maps. System sounds (Beep, Horn, Chime) cannot be deleted. Use Listen or Play on robot to verify volume.",
"sounds.create": "Create sound",
"sounds.clearFilters": "Clear filters",
"sounds.filterLabel": "Filter:",
"sounds.filterPlaceholder": "Filter by name...",
"sounds.itemsFound": "{n} item(s) found",
"sounds.pageOf": "Page {page} of {total}",
"sounds.empty": "No sounds yet.",
"sounds.emptyFilter": "No sounds match the filter.",
"sounds.createTitle": "Create sound",
"sounds.editTitle": "Edit sound",
"sounds.empty": "No sounds yet. Create one to use in sound zones.",
"sounds.name": "Name",
"sounds.note": "Note",
"sounds.description": "Description",
"sounds.enabled": "Enabled",
"sounds.volume": "Volume (0100)",
"sounds.volumeHint": "100% is approximately 80 dB. Verify volume by playing on the robot.",
"sounds.volumeShort": "{volume}%",
"sounds.file": "Audio file",
"sounds.noFile": "No file uploaded",
"sounds.systemNoFile": "Built-in system sound (plays on robot)",
"sounds.systemBadge": "System sound",
"sounds.systemShort": "System",
"sounds.upload": "Upload file…",
"sounds.listen": "Listen",
"sounds.playOnRobot": "Play on robot",
"sounds.play": "Play",
"sounds.playFailed": "Could not play file.",
"sounds.fileMeta": "{name} · {duration}",
"sounds.saveChanges": "Save changes",
"sounds.nameRequired": "Enter a sound name.",
"sounds.nameDuplicate": "A sound with this name already exists. Edit the existing entry instead.",
"sounds.reservedName": "Beep, Horn, and Chime are built-in system sounds — use Edit on the System row.",
"sounds.deleteConfirm": "Delete this sound?",
"sounds.deleteConfirmTitle": "Delete sound?",
"sounds.deleteConfirmText": "Delete \"{name}\"? Sound zones using it may be affected.",
"transitions.title": "Transitions",
"transitions.subtitle": "Create and edit transitions.",
"transitions.helpTitle": "Transitions help",
"transitions.helpBody": "A transition links two maps in the same site. Start position is on the source map; Goal position is on the destination map (same physical location). The mission should include switch map actions.",
"transitions.create": "Create transition",
"transitions.clearFilters": "Clear filters",
"transitions.filterLabel": "Filter:",
"transitions.filterPlaceholder": "Filter by position or mission...",
"transitions.itemsFound": "{n} item(s) found",
"transitions.pageOf": "Page {page} of {total}",
"transitions.colStart": "Start",
"transitions.colGoal": "Goal",
"transitions.colMission": "Mission",
"transitions.colCreatedBy": "Created by",
"transitions.colFunctions": "Functions",
"transitions.empty": "No transitions yet.",
"transitions.emptyFilter": "No transitions match the filter.",
"transitions.createTitle": "Create transition",
"transitions.editTitle": "Edit transition",
"transitions.site": "Site",
"transitions.fromMap": "From map",
"transitions.toMap": "To map",
"transitions.startPosition": "Start position",
"transitions.goalPosition": "Goal position",
"transitions.mission": "Mission",
"transitions.deleteTitle": "Delete transition?",
"transitions.deleteConfirmText": "Delete transition {from} → {to}?",
"transitions.error.missing": "Missing required fields.",
"missions.title": "Missions",
"missions.subtitle": "Setup → Missions — robot task list.",
@@ -1189,6 +1294,7 @@
"missions.action.drop_cart": "Drop cart",
"missions.action.user_log": "User log",
"missions.action.play_sound": "Play sound",
"missions.action.switch_map": "Switch map",
"missions.error.nameRequired": "Mission name cannot be empty.",
"missions.error.nameDuplicate": "Mission name already exists.",
"missions.error.nameEmpty": "Name cannot be empty.",

View File

@@ -864,53 +864,209 @@
</div>
<div class="page" id="pageSounds" data-page-content="sounds" hidden>
<div class="soundsPage">
<section class="card">
<div class="cardHeader">
<div>
<div class="cardTitle" data-i18n="sounds.title">Sounds</div>
<div class="cardSub" data-i18n="sounds.subtitle">Setup → Sounds — upload and manage robot sounds for sound zones.</div>
</div>
<button id="soundCreateBtn" type="button" class="btn primary" data-i18n="sounds.create">Create sound</button>
<div id="soundsListView" class="mapsMirPage">
<header class="mapsMirHeader">
<div class="mapsMirHeaderText">
<h1 class="mapsMirTitle" data-i18n="sounds.title">Sounds</h1>
<p class="mapsMirSubtitle">
<span data-i18n="sounds.subtitle">Create and edit sounds.</span>
<button type="button" class="mapsMirHelpBtn" id="soundsHelpBtn" data-i18n-title="sounds.helpTitle" aria-label="Help">
<svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true"><circle cx="8" cy="8" r="7" fill="none" stroke="currentColor" stroke-width="1.5"/><text x="8" y="11.5" text-anchor="middle" font-size="10" font-weight="700" fill="currentColor">?</text></svg>
</button>
</p>
</div>
<div class="cardBody">
<div id="soundListEmpty" class="mutedNote" hidden data-i18n="sounds.empty">No sounds yet. Create one to use in sound zones.</div>
<div id="soundList" class="missionList"></div>
<div class="mapsMirHeaderActions">
<button type="button" class="mapsMirBtn mapsMirBtn--green" id="soundCreateBtn">
<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M7 1v12M1 7h12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
<span data-i18n="sounds.create">Create sound</span>
</button>
<button type="button" class="mapsMirBtn mapsMirBtn--outline" id="soundsClearFiltersBtn">
<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><circle cx="7" cy="7" r="5.5" fill="none" stroke="currentColor" stroke-width="1.3"/><path d="M4.5 4.5l5 5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>
<span data-i18n="sounds.clearFilters">Clear filters</span>
</button>
</div>
</section>
</header>
<div class="mapsMirFilterBar">
<label class="mapsMirFilterLabel" for="soundsFilterInput" data-i18n="sounds.filterLabel">Filter:</label>
<input type="search" id="soundsFilterInput" class="mapsMirFilterInput" data-i18n-placeholder="sounds.filterPlaceholder" placeholder="Filter by name..." autocomplete="off" />
<span id="soundsFilterCount" class="mapsMirFilterCount">0 item(s) found</span>
<div class="mapsMirPager">
<button type="button" class="mapsMirPageBtn" id="soundsPageFirst" aria-label="First page">&laquo;</button>
<button type="button" class="mapsMirPageBtn" id="soundsPagePrev" aria-label="Previous page">&lsaquo;</button>
<span id="soundsPageLabel" class="mapsMirPageLabel">Page 1 of 1</span>
<button type="button" class="mapsMirPageBtn" id="soundsPageNext" aria-label="Next page">&rsaquo;</button>
<button type="button" class="mapsMirPageBtn" id="soundsPageLast" aria-label="Last page">&raquo;</button>
</div>
</div>
<div class="soundsMirListWrap">
<div id="soundList" class="soundsMirList" role="list"></div>
<div id="soundListEmpty" class="mapsMirEmpty" hidden data-i18n="sounds.empty">No sounds yet.</div>
</div>
</div>
<dialog id="soundEditDialog" class="mapsMirDialog">
<form id="soundEditForm" method="dialog">
<h2 class="mapsMirDialogTitle" id="soundEditTitle" data-i18n="sounds.createTitle">Create sound</h2>
<p id="soundEditSystemBadge" class="soundSystemBadge" hidden data-i18n="sounds.systemBadge">System sound</p>
<label class="mapsMirField">
<span class="mapsMirFieldLabel" data-i18n="sounds.name">Name</span>
<input type="text" id="soundEditName" autocomplete="off" required />
</label>
<label class="mapsMirField">
<span class="mapsMirFieldLabel" data-i18n="sounds.description">Description</span>
<span class="mapsMirFieldLabel" data-i18n="sounds.note">Note</span>
<textarea id="soundEditDescription" rows="2"></textarea>
</label>
<label class="mapsMirField">
<span class="mapsMirFieldLabel" data-i18n="sounds.volume">Volume (0100)</span>
<div class="soundVolumeRow">
<input type="range" id="soundEditVolume" min="0" max="100" step="1" value="100" />
<output id="soundEditVolumeOut" for="soundEditVolume">100</output>
</div>
<span class="mapsMirFieldHint" data-i18n="sounds.volumeHint">100% is approximately 80 dB. Verify volume by playing on the robot.</span>
</label>
<label class="mapsMirField mapsMirField--checkbox">
<input type="checkbox" id="soundEditEnabled" checked />
<span data-i18n="sounds.enabled">Enabled</span>
</label>
<div class="mapsMirField">
<div class="mapsMirField" id="soundEditFileSection">
<span class="mapsMirFieldLabel" data-i18n="sounds.file">Audio file</span>
<p id="soundEditFileMeta" class="mutedNote"></p>
<div class="mapsMirDialogFooter mapsMirDialogFooter--inline">
<div class="mapsMirDialogFooter mapsMirDialogFooter--inline soundEditAudioActions">
<button type="button" class="mapsMirBtn mapsMirBtn--outline soundListenBtn" id="soundEditListenBtn" disabled title="Listen">
<svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true"><path fill="currentColor" d="M8 2a4 4 0 0 0-4 4v3a4 4 0 0 0 8 0V6a4 4 0 0 0-4-4zm0 12a5 5 0 0 0 5-5H3a5 5 0 0 0 5 5z"/></svg>
<span data-i18n="sounds.listen">Listen</span>
</button>
<button type="button" class="mapsMirBtn mapsMirBtn--outline" id="soundEditPlayRobotBtn" disabled data-i18n="sounds.playOnRobot">Play on robot</button>
<button type="button" class="mapsMirBtn mapsMirBtn--outline" id="soundEditUploadBtn" data-i18n="sounds.upload">Upload file…</button>
<button type="button" class="mapsMirBtn mapsMirBtn--outline" id="soundEditPlayBtn" disabled data-i18n="sounds.play">Play</button>
</div>
<input type="file" id="soundEditUploadInput" accept="audio/*,.wav,.mp3,.ogg" hidden />
</div>
<div class="mapsMirDialogFooter">
<button type="button" class="mapsMirBtn mapsMirBtn--outline" id="soundEditCancelBtn" data-i18n="common.cancel">Cancel</button>
<button type="button" class="mapsMirBtn mapsMirBtn--danger" id="soundEditDeleteBtn" hidden data-i18n="common.delete">Delete</button>
<button type="submit" class="mapsMirBtn mapsMirBtn--primary" data-i18n="common.save">Save</button>
<button type="submit" class="mapsMirBtn mapsMirBtn--green" data-i18n="sounds.saveChanges">Save changes</button>
</div>
</form>
</dialog>
<dialog id="soundDeleteConfirmDialog" class="mapsMirDialog">
<div class="mapsMirDialogPanel">
<h2 class="mapsMirDialogTitle" data-i18n="sounds.deleteConfirmTitle">Delete sound?</h2>
<p id="soundDeleteConfirmText" class="mapsMirDialogText"></p>
<div class="mapsMirDialogFooter">
<button type="button" class="mapsMirBtn mapsMirBtn--outline" id="soundDeleteCancelBtn" data-i18n="common.no">No</button>
<button type="button" class="mapsMirBtn mapsMirBtn--danger" id="soundDeleteYesBtn" data-i18n="common.yes">Yes</button>
</div>
</div>
</dialog>
</div>
<div class="page" id="pageTransitions" data-page-content="transitions" hidden>
<div id="transitionsListView" class="mapsMirPage">
<header class="mapsMirHeader">
<div class="mapsMirHeaderText">
<h1 class="mapsMirTitle" data-i18n="transitions.title">Transitions</h1>
<p class="mapsMirSubtitle">
<span data-i18n="transitions.subtitle">Create and edit transitions.</span>
<button type="button" class="mapsMirHelpBtn" id="transitionsHelpBtn" data-i18n-title="transitions.helpTitle" aria-label="Help">
<svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true"><circle cx="8" cy="8" r="7" fill="none" stroke="currentColor" stroke-width="1.5"/><text x="8" y="11.5" text-anchor="middle" font-size="10" font-weight="700" fill="currentColor">?</text></svg>
</button>
</p>
</div>
<div class="mapsMirHeaderActions">
<button type="button" class="mapsMirBtn mapsMirBtn--green" id="transitionCreateBtn">
<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M7 1v12M1 7h12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
<span data-i18n="transitions.create">Create transition</span>
</button>
<button type="button" class="mapsMirBtn mapsMirBtn--outline" id="transitionsClearFiltersBtn">
<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><circle cx="7" cy="7" r="5.5" fill="none" stroke="currentColor" stroke-width="1.3"/><path d="M4.5 4.5l5 5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>
<span data-i18n="transitions.clearFilters">Clear filters</span>
</button>
</div>
</header>
<div class="mapsMirFilterBar">
<label class="mapsMirFilterLabel" for="transitionsFilterInput" data-i18n="transitions.filterLabel">Filter:</label>
<input type="search" id="transitionsFilterInput" class="mapsMirFilterInput" data-i18n-placeholder="transitions.filterPlaceholder" placeholder="Filter by position or mission..." autocomplete="off" />
<span id="transitionsFilterCount" class="mapsMirFilterCount">0 item(s) found</span>
<div class="mapsMirPager">
<button type="button" class="mapsMirPageBtn" id="transitionsPageFirst" aria-label="First page">&laquo;</button>
<button type="button" class="mapsMirPageBtn" id="transitionsPagePrev" aria-label="Previous page">&lsaquo;</button>
<span id="transitionsPageLabel" class="mapsMirPageLabel">Page 1 of 1</span>
<button type="button" class="mapsMirPageBtn" id="transitionsPageNext" aria-label="Next page">&rsaquo;</button>
<button type="button" class="mapsMirPageBtn" id="transitionsPageLast" aria-label="Last page">&raquo;</button>
</div>
</div>
<div class="mapsMirTableWrap">
<table class="mapsMirTable mapsMirTable--transitions" id="transitionsTable">
<thead>
<tr>
<th class="transMirThIcon" aria-hidden="true"></th>
<th data-i18n="transitions.colStart">Start</th>
<th data-i18n="transitions.colGoal">Goal</th>
<th data-i18n="transitions.colMission">Mission</th>
<th data-i18n="transitions.colCreatedBy">Created by</th>
<th class="mapsMirThFunctions" data-i18n="transitions.colFunctions">Functions</th>
</tr>
</thead>
<tbody id="transitionList"></tbody>
</table>
<div id="transitionListEmpty" class="mapsMirEmpty" hidden data-i18n="transitions.empty">No transitions yet.</div>
</div>
</div>
<dialog id="transitionEditDialog" class="mapsMirDialog">
<form id="transitionEditForm" method="dialog">
<h2 class="mapsMirDialogTitle" id="transitionEditTitle" data-i18n="transitions.createTitle">Create transition</h2>
<label class="mapsMirField">
<span class="mapsMirFieldLabel" data-i18n="transitions.site">Site</span>
<select id="transitionEditSite" required></select>
</label>
<div class="mapsMirFieldRow">
<label class="mapsMirField">
<span class="mapsMirFieldLabel" data-i18n="transitions.fromMap">From map</span>
<select id="transitionEditFromMap" required></select>
</label>
<label class="mapsMirField">
<span class="mapsMirFieldLabel" data-i18n="transitions.toMap">To map</span>
<select id="transitionEditToMap" required></select>
</label>
</div>
<div class="mapsMirFieldRow">
<label class="mapsMirField">
<span class="mapsMirFieldLabel" data-i18n="transitions.startPosition">Start position</span>
<select id="transitionEditStartPos" required></select>
</label>
<label class="mapsMirField">
<span class="mapsMirFieldLabel" data-i18n="transitions.goalPosition">Goal position</span>
<select id="transitionEditGoalPos" required></select>
</label>
</div>
<label class="mapsMirField">
<span class="mapsMirFieldLabel" data-i18n="transitions.mission">Mission</span>
<select id="transitionEditMission" required></select>
</label>
<div class="mapsMirDialogFooter">
<button type="button" class="mapsMirBtn mapsMirBtn--outline" id="transitionEditCancelBtn" data-i18n="common.cancel">Cancel</button>
<button type="button" class="mapsMirBtn mapsMirBtn--danger" id="transitionEditDeleteBtn" hidden data-i18n="common.delete">Delete</button>
<button type="submit" class="mapsMirBtn mapsMirBtn--green" data-i18n="common.save">Save</button>
</div>
</form>
</dialog>
<dialog id="transitionDeleteConfirmDialog" class="mapsMirDialog">
<div class="mapsMirDialogPanel">
<h2 class="mapsMirDialogTitle" data-i18n="transitions.deleteTitle">Delete transition?</h2>
<p id="transitionDeleteConfirmText" class="mapsMirDialogText"></p>
<div class="mapsMirDialogFooter">
<button type="button" class="mapsMirBtn mapsMirBtn--outline" id="transitionDeleteCancelBtn" data-i18n="common.no">No</button>
<button type="button" class="mapsMirBtn mapsMirBtn--danger" id="transitionDeleteYesBtn" data-i18n="common.yes">Yes</button>
</div>
</div>
</dialog>
</div>
<div class="page" id="pageMaps" data-page-content="maps" hidden>
@@ -2069,6 +2225,7 @@ GET /api/v2.0.0/status</pre>
<script src="/i18n.js"></script>
<script src="/auth.js"></script>
<script src="/nav.js"></script>
<script src="/sounds.js"></script>
<script src="/missions.js"></script>
<script src="/map-geo.js"></script>
<script src="/map-occupancy-canvas.js"></script>
@@ -2080,6 +2237,7 @@ GET /api/v2.0.0/status</pre>
<script src="/map-yaml.js"></script>
<script src="/maps.js"></script>
<script src="/sounds.js"></script>
<script src="/transitions.js"></script>
<script src="/map-editor.js"></script>
<script src="/topbar.js"></script>
<script src="/dashboard.js"></script>

View File

@@ -6,6 +6,7 @@
{ type: "move_to_position", label: "Go to position" },
{ type: "move_to_marker", label: "Go to marker" },
{ type: "adjust_localization", label: "Adjust localization" },
{ type: "switch_map", label: "Switch map" },
{ type: "wait", label: "Wait" },
{ type: "set_speed", label: "Set speed" },
],
@@ -38,6 +39,60 @@
const SAMPLE_IO_MODULES = ["GPIO module 1", "PLC I/O 1"];
const SAMPLE_CARTS = ["Any valid cart", "Cart A", "Cart B"];
let missionPositionCatalog = [];
function buildPositionCatalogFromMaps(maps) {
const out = [];
(maps || []).forEach((map) => {
if (!map?.id) return;
const zones = Array.isArray(map.zones) ? map.zones : [];
zones
.filter((z) => z && z.type === "position" && typeof z.id === "string" && z.id)
.forEach((z) => {
const mapName = map.name || map.id;
const pname = z.name || z.id;
out.push({
position_id: z.id,
map_id: map.id,
name: pname,
label: `${mapName} / ${pname}`,
});
});
});
return out;
}
function positionOptions() {
return missionPositionCatalog.map((p) => ({ value: p.position_id, label: p.label }));
}
function positionIds() {
return missionPositionCatalog.map((p) => p.position_id);
}
function positionLabel(ref) {
if (!ref) return "—";
const hit = missionPositionCatalog.find((p) => p.position_id === ref || p.name === ref);
return hit?.label || ref;
}
function positionSelectOptions() {
const opts = positionOptions();
if (opts.length) return opts;
return SAMPLE_POSITIONS.map((n) => ({ value: n, label: n }));
}
function syncPositionFieldDefs() {
const ids = positionIds();
["move_to_position", "adjust_localization", "if", "pick_cart", "drop_cart"].forEach((type) => {
const defs = VARIABLE_FIELD_DEFS[type];
if (!defs) return;
defs.forEach((def) => {
if (def.key === "position") def.options = ids.length ? ids : SAMPLE_POSITIONS;
});
});
}
const el = (id) => document.getElementById(id);
const t = (key, vars) => window.I18n?.t(key, vars) ?? key;
@@ -75,6 +130,7 @@
{ key: "cart", label: "Cart", options: SAMPLE_CARTS },
],
drop_cart: [{ key: "position", label: "Position", options: SAMPLE_POSITIONS }],
switch_map: [{ key: "map_id", label: "Map", options: [] }],
};
const store = {
@@ -102,17 +158,19 @@
function defaultParams(type) {
switch (type) {
case "move_to_position":
return { position: SAMPLE_POSITIONS[0], check_free: true };
return { position: positionIds()[0] || SAMPLE_POSITIONS[0], check_free: true };
case "move_to_marker":
return { marker: SAMPLE_MARKERS[0] };
case "adjust_localization":
return { position: SAMPLE_POSITIONS[0] };
return { position: positionIds()[0] || SAMPLE_POSITIONS[0] };
case "wait":
return { seconds: 1 };
case "set_speed":
return { speed: "normal" };
case "switch_map":
return { map_id: "", entry_position_id: "" };
case "if":
return { condition: "position_free", position: SAMPLE_POSITIONS[0] };
return { condition: "position_free", position: positionIds()[0] || SAMPLE_POSITIONS[0] };
case "loop":
return { count: 1, mode: "count" };
case "set_digital_output":
@@ -122,13 +180,13 @@
case "set_plc_register":
return { register: 1, action: "set", value: 0 };
case "pick_cart":
return { position: SAMPLE_POSITIONS[0], cart: SAMPLE_CARTS[0] };
return { position: positionIds()[0] || SAMPLE_POSITIONS[0], cart: SAMPLE_CARTS[0] };
case "drop_cart":
return { position: SAMPLE_POSITIONS[0], collision_check: true };
return { position: positionIds()[0] || SAMPLE_POSITIONS[0], collision_check: true };
case "user_log":
return { message: "Mission step" };
case "play_sound":
return { sound: "beep" };
return { sound: "sys_beep", volume: 100 };
default:
return {};
}
@@ -277,7 +335,7 @@
const fmtVar = (key, val) => (p[`${key}_var`] ? `${val} (biến)` : val);
switch (action.type) {
case "move_to_position":
return `Position: ${fmtVar("position", p.position)}${p.check_free ? " • kiểm tra trống" : ""}`;
return `Position: ${fmtVar("position", positionLabel(p.position))}${p.check_free ? " • kiểm tra trống" : ""}`;
case "move_to_marker":
return `Marker: ${fmtVar("marker", p.marker)}`;
case "wait":
@@ -287,7 +345,7 @@
case "loop":
return p.mode === "endless" ? "Lặp vô hạn" : `Lặp ${p.count} lần • ${action.children?.length || 0} bước`;
case "if":
return `If ${p.condition} @ ${p.position || "—"}`;
return `If ${p.condition} @ ${positionLabel(p.position)}`;
case "set_digital_output":
return `${p.module} pin ${p.pin}${p.value ? "ON" : "OFF"}`;
case "wait_digital_input":
@@ -296,11 +354,18 @@
return `Reg ${p.register}: ${p.action} ${p.value}`;
case "pick_cart":
case "drop_cart":
return `${action.type === "pick_cart" ? "Pick" : "Drop"} @ ${fmtVar("position", p.position)}${action.type === "pick_cart" ? `${fmtVar("cart", p.cart)}` : ""}`;
return `${action.type === "pick_cart" ? "Pick" : "Drop"} @ ${fmtVar("position", positionLabel(p.position))}${action.type === "pick_cart" ? `${fmtVar("cart", p.cart)}` : ""}`;
case "user_log":
return p.message || "—";
case "play_sound":
return p.sound || "—";
case "switch_map":
return `${p.map_id || "—"}`;
case "play_sound": {
const soundName = (() => {
const s = (window.SoundsApp?.getSounds?.() || []).find((x) => x.id === p.sound);
return s?.name || p.sound || "—";
})();
return `${soundName} · ${p.volume != null ? p.volume : 100}%`;
}
default:
return action.label;
}
@@ -679,9 +744,11 @@
sel.dataset.varKey = v.key;
v.options.forEach((opt) => {
const o = document.createElement("option");
o.value = opt;
o.textContent = opt;
if (opt === v.default) o.selected = true;
const value = opt?.value ?? opt;
const label = opt?.label ?? positionLabel(value);
o.value = value;
o.textContent = label;
if (value === v.default) o.selected = true;
sel.appendChild(o);
});
row.appendChild(lab);
@@ -1170,6 +1237,19 @@
return select;
};
const selectInputLabeled = (key, value, options) => {
const select = document.createElement("select");
select.dataset.param = key;
options.forEach((opt) => {
const o = document.createElement("option");
o.value = opt.value;
o.textContent = opt.label;
if (opt.value === value) o.selected = true;
select.appendChild(o);
});
return select;
};
const addVariableToggle = (paramKey, fieldLabel) => {
const chk = document.createElement("label");
chk.innerHTML = `<input type="checkbox" data-param="${paramKey}_var" ${p[`${paramKey}_var`] ? "checked" : ""} /> Biến — hỏi khi thêm vào queue`;
@@ -1179,7 +1259,7 @@
switch (action.type) {
case "move_to_position":
case "adjust_localization":
addField("Position", selectInput("position", p.position, SAMPLE_POSITIONS));
addField("Position", selectInputLabeled("position", p.position, positionSelectOptions()));
addVariableToggle("position", "Position");
if (action.type === "move_to_position") {
const chk = document.createElement("label");
@@ -1203,7 +1283,7 @@
break;
case "if":
addField("Điều kiện", selectInput("condition", p.condition, ["position_free", "position_occupied", "register_equals"]));
addField("Position", selectInput("position", p.position, SAMPLE_POSITIONS));
addField("Position", selectInputLabeled("position", p.position, positionSelectOptions()));
addVariableToggle("position", "Position");
break;
case "set_digital_output":
@@ -1231,13 +1311,13 @@
addField("Giá trị", textInput("value", p.value, "number"));
break;
case "pick_cart":
addField("Position", selectInput("position", p.position, SAMPLE_POSITIONS));
addField("Position", selectInputLabeled("position", p.position, positionSelectOptions()));
addVariableToggle("position", "Position");
addField("Cart", selectInput("cart", p.cart, SAMPLE_CARTS));
addVariableToggle("cart", "Cart");
break;
case "drop_cart":
addField("Position", selectInput("position", p.position, SAMPLE_POSITIONS));
addField("Position", selectInputLabeled("position", p.position, positionSelectOptions()));
addVariableToggle("position", "Position");
{
const chk = document.createElement("label");
@@ -1248,9 +1328,25 @@
case "user_log":
addField("Message", textInput("message", p.message));
break;
case "play_sound":
addField("Sound", selectInput("sound", p.sound, ["beep", "horn", "chime"]));
case "play_sound": {
const catalog = (window.SoundsApp?.getSounds?.() || []).filter((s) => s.enabled !== false);
const options = catalog.length
? catalog.map((s) => ({ value: s.id, label: s.name }))
: [
{ value: "sys_beep", label: "Beep" },
{ value: "sys_horn", label: "Horn" },
{ value: "sys_chime", label: "Chime" },
];
addField("Sound", selectInputLabeled("sound", p.sound || options[0].value, options));
addField("Volume (0100)", textInput("volume", p.volume != null ? p.volume : 100, "number"));
break;
}
case "switch_map": {
const options = (VARIABLE_FIELD_DEFS.switch_map?.[0]?.options || []).map((id) => ({ value: id, label: id }));
addField("Map", selectInputLabeled("map_id", p.map_id || options[0]?.value || "", options));
addField("Entry position id", textInput("entry_position_id", p.entry_position_id || ""));
break;
}
default:
grid.innerHTML = `<p class="mutedNote">Action này không có tham số cấu hình.</p>`;
}
@@ -1361,6 +1457,26 @@
async function init() {
loadStore();
if (window.SoundsApp?.refreshSounds) {
try {
await window.SoundsApp.refreshSounds();
} catch {
/* ignore */
}
}
try {
const maps = await fetch("/api/maps", { credentials: "include" });
if (maps.ok) {
const data = await maps.json();
const list = Array.isArray(data.maps) ? data.maps : [];
missionPositionCatalog = buildPositionCatalogFromMaps(list);
syncPositionFieldDefs();
const ids = list.map((m) => m.id).filter(Boolean);
VARIABLE_FIELD_DEFS.switch_map[0].options = ids;
}
} catch {
/* ignore */
}
await loadStoreFromBackend();
bindEvents();
renderMissionList();

View File

@@ -16,6 +16,7 @@
{ section: "missions", page: "missions" },
{ section: "maps", page: "maps" },
{ section: "sounds", page: "sounds" },
{ section: "transitions", page: "transitions" },
{ section: "build-robot", page: "config" },
],
},
@@ -36,6 +37,7 @@
maps: { module: "setup", section: "maps" },
missions: { module: "setup", section: "missions" },
sounds: { module: "setup", section: "sounds" },
transitions: { module: "setup", section: "transitions" },
integrations: { module: "system", section: "integrations" },
monitoring: { module: "monitoring", section: "monitoring-log" },
help: { module: "help", section: "help-api" },

View File

@@ -1,31 +1,49 @@
(() => {
const PAGE_SIZE = 10;
const ICONS = {
listen: `<svg width="14" height="14" viewBox="0 0 16 16" aria-hidden="true"><path fill="currentColor" d="M8 2a4 4 0 0 0-4 4v3a4 4 0 0 0 8 0V6a4 4 0 0 0-4-4zm0 12a5 5 0 0 0 5-5H3a5 5 0 0 0 5 5z"/></svg>`,
};
const el = (id) => document.getElementById(id);
const t = (key, vars) => window.I18n?.t(key, vars) ?? key;
const listEl = el("soundList");
const emptyEl = el("soundListEmpty");
const createBtnEl = el("soundCreateBtn");
const filterInputEl = el("soundsFilterInput");
const filterCountEl = el("soundsFilterCount");
const pageLabelEl = el("soundsPageLabel");
const dialogEl = el("soundEditDialog");
const formEl = el("soundEditForm");
const titleEl = el("soundEditTitle");
const systemBadgeEl = el("soundEditSystemBadge");
const nameEl = el("soundEditName");
const descEl = el("soundEditDescription");
const volumeEl = el("soundEditVolume");
const volumeOutEl = el("soundEditVolumeOut");
const enabledEl = el("soundEditEnabled");
const fileMetaEl = el("soundEditFileMeta");
const fileSectionEl = el("soundEditFileSection");
const uploadInputEl = el("soundEditUploadInput");
const uploadBtnEl = el("soundEditUploadBtn");
const playBtnEl = el("soundEditPlayBtn");
const listenBtnEl = el("soundEditListenBtn");
const playRobotBtnEl = el("soundEditPlayRobotBtn");
const deleteBtnEl = el("soundEditDeleteBtn");
const deleteConfirmDialogEl = el("soundDeleteConfirmDialog");
const deleteConfirmTextEl = el("soundDeleteConfirmText");
const store = {
sounds: [],
editingId: null,
previewAudio: null,
filter: "",
page: 1,
};
function canWrite() {
if (!window.AuthApp?.canWrite) return true;
return window.AuthApp.canWrite("integrations");
return window.AuthApp.canWrite("sounds") || window.AuthApp.canWrite("integrations");
}
function escapeHtml(str) {
@@ -66,38 +84,124 @@
return `${sec}s`;
}
function currentEditingSound() {
return store.editingId ? store.sounds.find((s) => s.id === store.editingId) : null;
}
function canListen(sound) {
return !!(sound?.file_name);
}
function sortedSounds() {
return [...store.sounds].sort((a, b) => {
if (!!a.is_system !== !!b.is_system) return a.is_system ? -1 : 1;
return (a.name || "").localeCompare(b.name || "");
});
}
function filteredSounds() {
const q = store.filter.trim().toLowerCase();
let items = sortedSounds();
if (q) {
items = items.filter((sound) => {
const name = (sound.name || "").toLowerCase();
const file = (sound.file_name || "").toLowerCase();
const desc = (sound.description || "").toLowerCase();
return name.includes(q) || file.includes(q) || desc.includes(q);
});
}
return items;
}
function pageCount(total) {
return Math.max(1, Math.ceil(total / PAGE_SIZE));
}
function pagedItems(items) {
const totalPages = pageCount(items.length);
if (store.page > totalPages) store.page = totalPages;
if (store.page < 1) store.page = 1;
const start = (store.page - 1) * PAGE_SIZE;
return items.slice(start, start + PAGE_SIZE);
}
function updatePagerUi(totalItems) {
const totalPages = pageCount(totalItems);
if (filterCountEl) filterCountEl.textContent = t("sounds.itemsFound", { n: totalItems });
if (pageLabelEl) pageLabelEl.textContent = t("sounds.pageOf", { page: store.page, total: totalPages });
const atStart = store.page <= 1;
const atEnd = store.page >= totalPages;
el("soundsPageFirst")?.toggleAttribute("disabled", atStart);
el("soundsPagePrev")?.toggleAttribute("disabled", atStart);
el("soundsPageNext")?.toggleAttribute("disabled", atEnd);
el("soundsPageLast")?.toggleAttribute("disabled", atEnd);
}
function soundMetaLine(sound) {
const hasFile = !!sound.file_name;
const parts = [
sound.enabled === false ? t("common.disabled") : t("common.enabled"),
t("sounds.volumeShort", { volume: sound.volume != null ? sound.volume : 100 }),
hasFile ? sound.file_name : t("sounds.noFile"),
];
if (sound.duration_ms != null) parts.push(formatDuration(sound.duration_ms));
if (sound.is_system) parts.push(t("sounds.systemShort"));
return parts.map((p) => escapeHtml(p)).join(" · ");
}
function renderList() {
if (!listEl) return;
listEl.innerHTML = "";
if (emptyEl) emptyEl.hidden = store.sounds.length > 0;
const items = filteredSounds();
const pageItems = pagedItems(items);
updatePagerUi(items.length);
listEl.innerHTML = "";
const showEmpty = items.length === 0;
if (listEl) listEl.hidden = showEmpty;
if (emptyEl) {
emptyEl.hidden = !showEmpty;
emptyEl.textContent = store.filter.trim() ? t("sounds.emptyFilter") : t("sounds.empty");
}
pageItems.forEach((sound) => {
const row = document.createElement("article");
row.className = "soundsMirRow";
row.setAttribute("role", "listitem");
row.dataset.id = sound.id;
const listenDisabled = !canListen(sound);
const editDisabled = !canWrite();
store.sounds.forEach((sound) => {
const row = document.createElement("div");
row.className = "missionListItem soundListItem";
const hasFile = !!sound.file_name;
row.innerHTML = `
<div>
<div class="missionListItemTitle">${escapeHtml(sound.name || sound.id)}</div>
<div class="missionListItemMeta">
${sound.enabled === false ? t("common.disabled") : t("common.enabled")}
· ${hasFile ? escapeHtml(sound.file_name) : t("sounds.noFile")}
${sound.duration_ms != null ? ` · ${formatDuration(sound.duration_ms)}` : ""}
</div>
<div class="soundsMirRowMain">
<button type="button" class="soundsMirRowTitle soundEditBtn" data-id="${escapeHtml(sound.id)}" ${editDisabled ? "disabled" : ""}>
${escapeHtml(sound.name || sound.id)}
</button>
<div class="soundsMirRowMeta">${soundMetaLine(sound)}</div>
</div>
<div class="missionListItemActions">
<button type="button" class="btn subtle soundPlayBtn" data-id="${escapeHtml(sound.id)}" ${hasFile ? "" : "disabled"}>${t("sounds.play")}</button>
<button type="button" class="btn subtle soundEditBtn" data-id="${escapeHtml(sound.id)}">${t("common.edit")}</button>
</div>
`;
<div class="soundsMirRowActions">
<button type="button" class="mapsMirIconBtn soundListenBtn" data-id="${escapeHtml(sound.id)}" ${listenDisabled ? "disabled" : ""} title="${escapeHtml(t("sounds.listen"))}">
${ICONS.listen}
</button>
<button type="button" class="mapsMirBtn mapsMirBtn--outline soundEditBtn" data-id="${escapeHtml(sound.id)}" ${editDisabled ? "disabled" : ""}>
${escapeHtml(t("common.edit"))}
</button>
</div>`;
row.querySelectorAll(".soundEditBtn").forEach((btn) => {
btn.addEventListener("click", () => {
if (!canWrite()) return;
openDialog(btn.dataset.id);
});
});
row.querySelector(".soundListenBtn")?.addEventListener("click", () => listenSound(sound.id));
row.addEventListener("dblclick", () => {
if (canWrite()) openDialog(sound.id);
});
listEl.appendChild(row);
});
listEl.querySelectorAll(".soundEditBtn").forEach((btn) => {
btn.addEventListener("click", () => openDialog(btn.dataset.id));
});
listEl.querySelectorAll(".soundPlayBtn").forEach((btn) => {
btn.addEventListener("click", () => playSound(btn.dataset.id));
});
if (createBtnEl) createBtnEl.hidden = !canWrite();
}
function stopPreview() {
@@ -107,13 +211,23 @@
}
}
function playSound(id) {
function listenSound(id) {
const sound = store.sounds.find((s) => s.id === id);
if (!canListen(sound)) return;
stopPreview();
const audio = new Audio(`/api/sounds/${encodeURIComponent(id)}/file`);
store.previewAudio = audio;
audio.play().catch(() => alert(t("sounds.playFailed")));
}
function readVolume() {
return Number(volumeEl?.value) || 0;
}
function syncVolumeOut() {
if (volumeOutEl) volumeOutEl.textContent = String(readVolume());
}
function updateFileMeta(sound) {
if (!fileMetaEl) return;
if (sound?.file_name) {
@@ -121,45 +235,84 @@
name: sound.file_name,
duration: formatDuration(sound.duration_ms),
});
} else if (sound?.is_system) {
fileMetaEl.textContent = t("sounds.systemNoFile");
} else {
fileMetaEl.textContent = t("sounds.noFile");
}
if (playBtnEl) playBtnEl.disabled = !sound?.file_name;
const listenOk = canListen(sound);
if (listenBtnEl) listenBtnEl.disabled = !listenOk;
if (playRobotBtnEl) playRobotBtnEl.disabled = !sound?.id || sound.enabled === false;
}
function applyDialogPermissions(sound) {
const ro = !canWrite();
const isSystem = !!sound?.is_system;
if (nameEl) nameEl.readOnly = ro || isSystem;
if (descEl) descEl.readOnly = ro;
if (volumeEl) volumeEl.disabled = ro;
if (enabledEl) enabledEl.disabled = ro;
if (uploadBtnEl) uploadBtnEl.hidden = isSystem || ro;
if (fileSectionEl) fileSectionEl.hidden = false;
if (deleteBtnEl) deleteBtnEl.hidden = !sound || isSystem || ro;
if (systemBadgeEl) systemBadgeEl.hidden = !isSystem;
}
function openDialog(id = null) {
store.editingId = id;
const existing = id ? store.sounds.find((s) => s.id === id) : null;
if (titleEl) {
titleEl.textContent = existing ? t("sounds.editTitle") : t("sounds.createTitle");
}
if (titleEl) titleEl.textContent = existing ? t("sounds.editTitle") : t("sounds.createTitle");
if (nameEl) nameEl.value = existing?.name || "";
if (descEl) descEl.value = existing?.description || "";
if (enabledEl) enabledEl.checked = existing?.enabled !== false;
if (volumeEl) volumeEl.value = existing?.volume != null ? existing.volume : 100;
syncVolumeOut();
updateFileMeta(existing);
if (deleteBtnEl) deleteBtnEl.hidden = !existing || !canWrite();
if (uploadBtnEl) uploadBtnEl.disabled = !canWrite();
if (nameEl) nameEl.readOnly = !canWrite();
if (descEl) descEl.readOnly = !canWrite();
if (enabledEl) enabledEl.disabled = !canWrite();
applyDialogPermissions(existing);
dialogEl?.showModal();
}
function readPayload() {
return {
name: nameEl?.value.trim() || "",
description: descEl?.value.trim() || "",
enabled: enabledEl?.checked !== false,
volume: readVolume(),
};
}
function isReservedName(name) {
return ["beep", "horn", "chime"].includes(String(name || "").trim().toLowerCase());
}
function nameExists(name, exceptId = null) {
const lower = String(name || "").trim().toLowerCase();
return store.sounds.some(
(s) => s.id !== exceptId && String(s.name || "").trim().toLowerCase() === lower,
);
}
async function saveDialog() {
if (!canWrite()) return;
const name = nameEl?.value.trim() || "";
if (!name) {
const soundId = store.editingId;
const payload = readPayload();
if (!payload.name) {
alert(t("sounds.nameRequired"));
return;
}
const payload = {
name,
description: descEl?.value.trim() || "",
enabled: enabledEl?.checked !== false,
};
if (!soundId) {
if (isReservedName(payload.name)) {
alert(t("sounds.reservedName"));
return;
}
if (nameExists(payload.name)) {
alert(t("sounds.nameDuplicate"));
return;
}
}
try {
if (store.editingId) {
await apiJson(`/api/sounds/${encodeURIComponent(store.editingId)}`, {
if (soundId) {
await apiJson(`/api/sounds/${encodeURIComponent(soundId)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
@@ -174,8 +327,9 @@
}
await refreshSounds();
renderList();
const updated = store.sounds.find((s) => s.id === store.editingId);
const updated = currentEditingSound();
updateFileMeta(updated);
applyDialogPermissions(updated);
if (!uploadInputEl?.files?.length) {
dialogEl?.close();
}
@@ -186,6 +340,8 @@
async function uploadFile() {
if (!canWrite() || !store.editingId) return;
const sound = currentEditingSound();
if (sound?.is_system) return;
const file = uploadInputEl?.files?.[0];
if (!file) return;
const form = new FormData();
@@ -198,17 +354,26 @@
uploadInputEl.value = "";
await refreshSounds();
renderList();
updateFileMeta(store.sounds.find((s) => s.id === store.editingId));
updateFileMeta(currentEditingSound());
} catch (e) {
alert(e.message);
}
}
async function deleteSound() {
function openDeleteConfirm() {
const sound = currentEditingSound();
if (!sound || sound.is_system || !canWrite()) return;
if (deleteConfirmTextEl) {
deleteConfirmTextEl.textContent = t("sounds.deleteConfirmText", { name: sound.name || sound.id });
}
deleteConfirmDialogEl?.showModal();
}
async function confirmDelete() {
if (!canWrite() || !store.editingId) return;
if (!confirm(t("sounds.deleteConfirm"))) return;
try {
await apiJson(`/api/sounds/${encodeURIComponent(store.editingId)}`, { method: "DELETE" });
deleteConfirmDialogEl?.close();
dialogEl?.close();
store.editingId = null;
await refreshSounds();
@@ -218,6 +383,26 @@
}
}
async function playOnRobot() {
if (!store.editingId) return;
try {
await apiJson(`/api/sounds/${encodeURIComponent(store.editingId)}/play`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ volume: readVolume() }),
});
} catch (e) {
alert(e.message);
}
}
function clearFilters() {
store.filter = "";
store.page = 1;
if (filterInputEl) filterInputEl.value = "";
renderList();
}
function bindEvents() {
createBtnEl?.addEventListener("click", () => {
if (!canWrite()) return;
@@ -229,26 +414,65 @@
});
el("soundEditCancelBtn")?.addEventListener("click", () => {
stopPreview();
store.editingId = null;
dialogEl?.close();
});
dialogEl?.addEventListener("cancel", (evt) => {
evt.preventDefault();
stopPreview();
store.editingId = null;
dialogEl?.close();
});
volumeEl?.addEventListener("input", syncVolumeOut);
uploadBtnEl?.addEventListener("click", () => uploadInputEl?.click());
uploadInputEl?.addEventListener("change", () => {
saveDialog().then(() => uploadFile());
});
playBtnEl?.addEventListener("click", () => {
if (store.editingId) playSound(store.editingId);
listenBtnEl?.addEventListener("click", () => {
if (store.editingId) listenSound(store.editingId);
});
deleteBtnEl?.addEventListener("click", () => deleteSound());
playRobotBtnEl?.addEventListener("click", () => playOnRobot());
deleteBtnEl?.addEventListener("click", () => openDeleteConfirm());
el("soundDeleteCancelBtn")?.addEventListener("click", () => deleteConfirmDialogEl?.close());
el("soundDeleteYesBtn")?.addEventListener("click", () => confirmDelete());
deleteConfirmDialogEl?.addEventListener("cancel", (evt) => {
evt.preventDefault();
deleteConfirmDialogEl?.close();
});
filterInputEl?.addEventListener("input", () => {
store.filter = filterInputEl.value;
store.page = 1;
renderList();
});
el("soundsClearFiltersBtn")?.addEventListener("click", clearFilters);
el("soundsPageFirst")?.addEventListener("click", () => {
store.page = 1;
renderList();
});
el("soundsPagePrev")?.addEventListener("click", () => {
store.page = Math.max(1, store.page - 1);
renderList();
});
el("soundsPageNext")?.addEventListener("click", () => {
store.page += 1;
renderList();
});
el("soundsPageLast")?.addEventListener("click", () => {
store.page = pageCount(filteredSounds().length);
renderList();
});
el("soundsHelpBtn")?.addEventListener("click", () => {
alert(t("sounds.helpBody"));
});
window.addEventListener("lm:locale-change", () => renderList());
}
async function onPageShow() {
stopPreview();
if (createBtnEl) createBtnEl.hidden = !canWrite();
document.body.classList.toggle("auth-readonly-sounds", !canWrite());
try {
await refreshSounds();
renderList();
@@ -257,12 +481,14 @@
emptyEl.hidden = false;
emptyEl.textContent = e.message;
}
if (listEl) listEl.hidden = true;
}
}
function onPageHide() {
stopPreview();
dialogEl?.close();
deleteConfirmDialogEl?.close();
}
function getSounds() {

View File

@@ -1051,6 +1051,22 @@ canvas {
min-height: 0;
height: 100%;
}
.content.content--transitions {
grid-template-columns: minmax(0, 1fr);
align-items: stretch;
align-content: stretch;
max-width: none;
width: 100%;
padding: 0;
overflow: hidden;
background: #fff;
}
.content.content--transitions > #pageTransitions {
width: 100%;
min-width: 0;
min-height: 0;
height: 100%;
}
.content.content--dashboard {
grid-template-columns: minmax(0, 1fr);
max-width: none;
@@ -1070,8 +1086,169 @@ canvas {
grid-template-columns: var(--leftPaneW, 460px) 10px 1fr;
}
.content.content--sounds {
grid-template-columns: minmax(0, 1fr);
align-items: stretch;
align-content: stretch;
max-width: none;
width: 100%;
padding: 0;
overflow: hidden;
background: #fff;
}
.content.content--sounds > #pageSounds {
width: 100%;
min-width: 0;
min-height: 0;
height: 100%;
}
.missionsPage { min-width: 0; width: 100%; }
.soundsPage { min-width: 0; width: 100%; }
.soundVolumeRow {
display: flex;
align-items: center;
gap: 12px;
}
.soundVolumeRow input[type="range"] {
flex: 1;
min-width: 0;
}
.soundVolumeRow output {
min-width: 2.5em;
font-size: 14px;
font-weight: 600;
text-align: right;
}
.mapsMirFieldHint {
font-size: 12px;
color: #888;
line-height: 1.4;
}
.soundSystemBadge {
margin: -8px 0 12px;
padding: 4px 8px;
display: inline-block;
font-size: 12px;
font-weight: 600;
color: #555;
background: #f0f0f0;
border: 1px solid #ddd;
}
.soundListenBtn {
display: inline-flex;
align-items: center;
gap: 6px;
}
.soundEditAudioActions {
flex-wrap: wrap;
}
body.auth-readonly-sounds #soundCreateBtn { display: none !important; }
body.auth-readonly-sounds .soundsMirRow .soundEditBtn { pointer-events: none; opacity: 0.55; }
#pageSounds {
position: relative;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
#soundsListView {
flex: 1;
min-height: 0;
}
.soundsMirListWrap {
width: 100%;
align-self: stretch;
border: 1px solid #ddd;
background: #fff;
box-sizing: border-box;
}
.soundsMirList {
display: flex;
flex-direction: column;
}
.soundsMirRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 12px 16px;
border-top: 1px solid #f0f0f0;
min-height: 56px;
box-sizing: border-box;
}
.soundsMirRow:first-child {
border-top: none;
}
.soundsMirRow:hover {
background: #f9fbff;
}
.soundsMirRowMain {
min-width: 0;
flex: 1;
}
.soundsMirRowTitle {
display: block;
margin: 0;
padding: 0;
border: none;
background: none;
font: inherit;
font-size: 15px;
font-weight: 700;
color: #222;
text-align: left;
cursor: pointer;
}
.soundsMirRowTitle:hover:not(:disabled) {
color: var(--mir-blue, #337ab7);
text-decoration: underline;
}
.soundsMirRowTitle:disabled {
cursor: default;
color: #222;
text-decoration: none;
}
.soundsMirRowMeta {
margin-top: 4px;
font-size: 13px;
color: #777;
line-height: 1.35;
}
.soundsMirRowActions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.soundsMirRowActions .mapsMirBtn--outline {
min-width: 72px;
padding: 6px 14px;
font-size: 13px;
}
.missionList { display: grid; gap: 10px; }
.missionListItem {
display: grid;
@@ -2951,6 +3128,19 @@ body.auth-readonly-integrations .integrationToolbar .btn.primary { pointer-event
overflow: hidden;
}
#pageTransitions {
position: relative;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
#transitionsListView {
flex: 1;
min-height: 0;
}
#mapsListView[hidden],
#mapsCreateView[hidden],
#mapEditorView[hidden] {
@@ -3268,6 +3458,13 @@ body.auth-readonly-integrations .integrationToolbar .btn.primary { pointer-event
.mapsMirBtn--green:hover { background: var(--mir-green-hover, #4cae4c); }
.mapsMirBtn--primary {
background: var(--mir-green, #5cb85c);
color: #fff;
}
.mapsMirBtn--primary:hover { background: var(--mir-green-hover, #4cae4c); }
.mapsMirBtn--outline {
background: #fff;
color: #555;
@@ -3387,6 +3584,28 @@ body.auth-readonly-integrations .integrationToolbar .btn.primary { pointer-event
.mapsMirTable thead th:nth-child(2) { width: 22%; }
.mapsMirThFunctions { width: 148px; text-align: right !important; }
.mapsMirTable--transitions thead th.transMirThIcon { width: 52px; }
.mapsMirTable--transitions thead th:nth-child(2) { width: 20%; }
.mapsMirTable--transitions thead th:nth-child(3) { width: 20%; }
.mapsMirTable--transitions thead th:nth-child(4) { width: 22%; }
.mapsMirTable--transitions thead th:nth-child(5) { width: 16%; }
.transMirIcon {
color: var(--mir-green, #5cb85c);
display: block;
}
.transMirCellIcon {
width: 52px;
text-align: center;
padding-left: 12px !important;
padding-right: 8px !important;
}
.transMirNameLink {
font-weight: 600;
}
.mapsMirSiteRow td {
padding: 12px 16px 8px;
font-weight: 700;
@@ -4912,14 +5131,11 @@ body.auth-readonly-maps-page #mapsImportSiteBtn { display: none !important; }
}
/* Setup → Sounds: MiR-style square corners */
#pageSounds .card,
#pageSounds .missionListItem,
#pageSounds .btn,
#pageSounds button,
#pageSounds input,
#pageSounds textarea,
#pageSounds #soundEditDialog.mapsMirDialog,
#pageSounds .soundsMirRow,
#pageSounds .soundsMirListWrap,
#pageSounds .mapsMirBtn,
#pageSounds .mapsMirIconBtn,
#pageSounds #soundEditDialog.mapsMirDialog,
#pageSounds .mapsMirField input,
#pageSounds .mapsMirField textarea {
border-radius: 0;

456
www/transitions.js Normal file
View File

@@ -0,0 +1,456 @@
(() => {
const PAGE_SIZE = 10;
const ICONS = {
transition: `<svg class="transMirIcon" width="22" height="22" viewBox="0 0 22 22" aria-hidden="true"><circle cx="5" cy="11" r="3.5" fill="none" stroke="currentColor" stroke-width="1.5"/><circle cx="17" cy="11" r="3.5" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M8.5 11h5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>`,
edit: `<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M9.5 2.5l2 2L5 11H3v-2L9.5 2.5z" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/></svg>`,
delete: `<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M4 4l6 6M10 4l-6 6" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>`,
};
const el = (id) => document.getElementById(id);
const t = (key, vars) => window.I18n?.t(key, vars) ?? key;
const listEl = el("transitionList");
const emptyEl = el("transitionListEmpty");
const tableEl = el("transitionsTable");
const createBtnEl = el("transitionCreateBtn");
const filterInputEl = el("transitionsFilterInput");
const filterCountEl = el("transitionsFilterCount");
const pageLabelEl = el("transitionsPageLabel");
const dialogEl = el("transitionEditDialog");
const formEl = el("transitionEditForm");
const titleEl = el("transitionEditTitle");
const deleteBtnEl = el("transitionEditDeleteBtn");
const deleteConfirmDialogEl = el("transitionDeleteConfirmDialog");
const deleteConfirmTextEl = el("transitionDeleteConfirmText");
const fields = {
site: el("transitionEditSite"),
fromMap: el("transitionEditFromMap"),
toMap: el("transitionEditToMap"),
startPos: el("transitionEditStartPos"),
goalPos: el("transitionEditGoalPos"),
mission: el("transitionEditMission"),
};
const store = {
transitions: [],
sites: [],
maps: [],
missions: [],
editingId: null,
pendingDeleteId: null,
filter: "",
page: 1,
};
function canWrite() {
if (!window.AuthApp?.canWrite) return true;
return window.AuthApp.canWrite("maps");
}
function escapeHtml(str) {
return String(str)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
async function apiJson(url, opts = {}) {
const res = await fetch(url, { credentials: "include", ...opts });
const text = await res.text();
let data = null;
try {
data = text ? JSON.parse(text) : null;
} catch {
data = null;
}
if (!res.ok) throw new Error((data && data.error) || text || res.statusText);
return data;
}
async function refreshAll() {
const [sites, maps, missions, transitions] = await Promise.all([
apiJson("/api/sites"),
apiJson("/api/maps"),
apiJson("/api/missions"),
apiJson("/api/transitions"),
]);
store.sites = Array.isArray(sites.sites) ? sites.sites : [];
store.maps = Array.isArray(maps.maps) ? maps.maps : [];
store.missions = Array.isArray(missions.missions) ? missions.missions : [];
store.transitions = Array.isArray(transitions.transitions) ? transitions.transitions : [];
}
function siteName(id) {
const s = store.sites.find((x) => x.id === id);
return s?.name || id || "—";
}
function mapName(id) {
const m = store.maps.find((x) => x.id === id);
return m?.name || id || "—";
}
function missionName(id) {
const m = store.missions.find((x) => x.id === id);
return m?.name || id || "—";
}
function positionName(mapId, positionId) {
const m = store.maps.find((x) => x.id === mapId);
const zones = Array.isArray(m?.zones) ? m.zones : [];
const hit = zones.find((z) => z && z.type === "position" && z.id === positionId);
return hit?.name || positionId || "—";
}
function positionOptionsForMap(mapId) {
const m = store.maps.find((x) => x.id === mapId);
const zones = Array.isArray(m?.zones) ? m.zones : [];
const positions = zones.filter((z) => z && z.type === "position" && typeof z.id === "string");
return positions.map((p) => ({ value: p.id, label: p.name || p.id }));
}
function fillSelect(selectEl, options, value) {
if (!selectEl) return;
selectEl.innerHTML = "";
options.forEach((opt) => {
const o = document.createElement("option");
o.value = opt.value;
o.textContent = opt.label;
if (opt.value === value) o.selected = true;
selectEl.appendChild(o);
});
}
function filteredTransitions() {
const q = store.filter.trim().toLowerCase();
let items = [...store.transitions].sort((a, b) => {
const sa = siteName(a.site_id).localeCompare(siteName(b.site_id));
if (sa !== 0) return sa;
const startA = positionName(a.from_map_id, a.start_position_id);
const startB = positionName(b.from_map_id, b.start_position_id);
return startA.localeCompare(startB);
});
if (q) {
items = items.filter((tr) => {
const start = positionName(tr.from_map_id, tr.start_position_id).toLowerCase();
const goal = positionName(tr.to_map_id, tr.goal_position_id).toLowerCase();
const mission = missionName(tr.mission_id).toLowerCase();
const fromMap = mapName(tr.from_map_id).toLowerCase();
const toMap = mapName(tr.to_map_id).toLowerCase();
const site = siteName(tr.site_id).toLowerCase();
return (
start.includes(q) ||
goal.includes(q) ||
mission.includes(q) ||
fromMap.includes(q) ||
toMap.includes(q) ||
site.includes(q)
);
});
}
return items;
}
function pageCount(total) {
return Math.max(1, Math.ceil(total / PAGE_SIZE));
}
function pagedItems(items) {
const totalPages = pageCount(items.length);
if (store.page > totalPages) store.page = totalPages;
if (store.page < 1) store.page = 1;
const start = (store.page - 1) * PAGE_SIZE;
return items.slice(start, start + PAGE_SIZE);
}
function updatePagerUi(totalItems) {
const totalPages = pageCount(totalItems);
if (filterCountEl) filterCountEl.textContent = t("transitions.itemsFound", { n: totalItems });
if (pageLabelEl) pageLabelEl.textContent = t("transitions.pageOf", { page: store.page, total: totalPages });
const atStart = store.page <= 1;
const atEnd = store.page >= totalPages;
el("transitionsPageFirst")?.toggleAttribute("disabled", atStart);
el("transitionsPagePrev")?.toggleAttribute("disabled", atStart);
el("transitionsPageNext")?.toggleAttribute("disabled", atEnd);
el("transitionsPageLast")?.toggleAttribute("disabled", atEnd);
}
function renderList() {
if (!listEl) return;
const items = filteredTransitions();
const pageItems = pagedItems(items);
updatePagerUi(items.length);
listEl.innerHTML = "";
const showEmpty = items.length === 0;
if (tableEl) tableEl.hidden = showEmpty;
if (emptyEl) {
emptyEl.hidden = !showEmpty;
emptyEl.textContent = store.filter.trim() ? t("transitions.emptyFilter") : t("transitions.empty");
}
let lastSiteId = null;
pageItems.forEach((tr) => {
const siteId = tr.site_id || "";
if (siteId !== lastSiteId) {
lastSiteId = siteId;
const siteTr = document.createElement("tr");
siteTr.className = "mapsMirSiteRow";
siteTr.innerHTML = `<td colspan="6">${escapeHtml(siteName(siteId))}</td>`;
listEl.appendChild(siteTr);
}
const startLabel = positionName(tr.from_map_id, tr.start_position_id);
const goalLabel = positionName(tr.to_map_id, tr.goal_position_id);
const missionLabel = missionName(tr.mission_id);
const createdBy = tr.created_by || "—";
const trEl = document.createElement("tr");
trEl.className = "mapsMirRow transMirRow";
trEl.dataset.id = tr.id;
const actions = canWrite()
? `<div class="mapsMirRowActions">
<button type="button" class="mapsMirIconBtn" data-edit="${escapeHtml(tr.id)}" title="${escapeHtml(t("common.edit"))}">${ICONS.edit}</button>
<button type="button" class="mapsMirIconBtn mapsMirIconBtn--danger" data-delete="${escapeHtml(tr.id)}" title="${escapeHtml(t("common.delete"))}">${ICONS.delete}</button>
</div>`
: "";
trEl.innerHTML = `
<td class="transMirCellIcon">${ICONS.transition}</td>
<td class="transMirCellStart">
<button type="button" class="mapsMirNameLink transMirNameLink" data-edit="${escapeHtml(tr.id)}">${escapeHtml(startLabel)}</button>
</td>
<td class="transMirCellGoal">${escapeHtml(goalLabel)}</td>
<td class="transMirCellMission">${escapeHtml(missionLabel)}</td>
<td class="mapsMirCellCreatedBy">${escapeHtml(createdBy)}</td>
<td class="mapsMirCellActions">${actions}</td>`;
trEl.querySelectorAll("[data-edit]").forEach((btn) => {
btn.addEventListener("click", () => openDialog(btn.dataset.edit));
});
trEl.querySelector("[data-delete]")?.addEventListener("click", () => openDeleteConfirm(tr.id));
trEl.addEventListener("dblclick", () => {
if (canWrite()) openDialog(tr.id);
});
listEl.appendChild(trEl);
});
if (createBtnEl) createBtnEl.hidden = !canWrite();
}
function currentEditingTransition() {
return store.editingId ? store.transitions.find((x) => x.id === store.editingId) : null;
}
function readPayload() {
return {
site_id: fields.site?.value || "",
from_map_id: fields.fromMap?.value || "",
to_map_id: fields.toMap?.value || "",
start_position_id: fields.startPos?.value || "",
goal_position_id: fields.goalPos?.value || "",
mission_id: fields.mission?.value || "",
};
}
function updateDependentSelects() {
const fromMapId = fields.fromMap?.value || "";
const toMapId = fields.toMap?.value || "";
fillSelect(fields.startPos, positionOptionsForMap(fromMapId), fields.startPos?.value);
fillSelect(fields.goalPos, positionOptionsForMap(toMapId), fields.goalPos?.value);
}
function refreshMapSelectsForSite(existing) {
const siteOpts = store.sites.map((s) => ({ value: s.id, label: s.name || s.id }));
const defaultSite = existing?.site_id || store.maps[0]?.site_id || siteOpts[0]?.value || "";
fillSelect(fields.site, siteOpts, fields.site?.value || defaultSite);
const mapsForSite = store.maps.filter((m) => (m.site_id || "") === (fields.site?.value || ""));
const mapOpts = mapsForSite.map((m) => ({ value: m.id, label: m.name || m.id }));
fillSelect(fields.fromMap, mapOpts, existing?.from_map_id || mapOpts[0]?.value);
fillSelect(fields.toMap, mapOpts, existing?.to_map_id || mapOpts[0]?.value);
updateDependentSelects();
}
function openDialog(id = null) {
store.editingId = id;
const existing = id ? store.transitions.find((x) => x.id === id) : null;
if (titleEl) titleEl.textContent = existing ? t("transitions.editTitle") : t("transitions.createTitle");
refreshMapSelectsForSite(existing);
if (fields.startPos && existing?.start_position_id) fields.startPos.value = existing.start_position_id;
if (fields.goalPos && existing?.goal_position_id) fields.goalPos.value = existing.goal_position_id;
const missionOpts = store.missions.map((m) => ({ value: m.id, label: m.name || m.id }));
fillSelect(fields.mission, missionOpts, existing?.mission_id || missionOpts[0]?.value);
const ro = !canWrite();
Object.values(fields).forEach((node) => {
if (!node) return;
node.disabled = ro;
});
if (deleteBtnEl) deleteBtnEl.hidden = !existing || ro;
dialogEl?.showModal();
}
async function saveDialog() {
if (!canWrite()) return;
const payload = readPayload();
if (
!payload.site_id ||
!payload.from_map_id ||
!payload.to_map_id ||
!payload.start_position_id ||
!payload.goal_position_id ||
!payload.mission_id
) {
alert(t("transitions.error.missing"));
return;
}
try {
if (store.editingId) {
await apiJson(`/api/transitions/${encodeURIComponent(store.editingId)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
} else {
await apiJson("/api/transitions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
}
await refreshAll();
renderList();
dialogEl?.close();
} catch (e) {
alert(e.message);
}
}
function openDeleteConfirm(id) {
const tr = store.transitions.find((x) => x.id === id);
if (!tr || !canWrite()) return;
store.pendingDeleteId = id;
if (deleteConfirmTextEl) {
deleteConfirmTextEl.textContent = t("transitions.deleteConfirmText", {
from: positionName(tr.from_map_id, tr.start_position_id),
to: positionName(tr.to_map_id, tr.goal_position_id),
});
}
deleteConfirmDialogEl?.showModal();
}
function openDeleteConfirmFromDialog() {
const tr = currentEditingTransition();
if (!tr) return;
openDeleteConfirm(tr.id);
}
async function confirmDelete() {
const id = store.pendingDeleteId || store.editingId;
if (!id || !canWrite()) return;
try {
await apiJson(`/api/transitions/${encodeURIComponent(id)}`, { method: "DELETE" });
deleteConfirmDialogEl?.close();
dialogEl?.close();
store.editingId = null;
store.pendingDeleteId = null;
await refreshAll();
renderList();
} catch (e) {
alert(e.message);
}
}
function clearFilters() {
store.filter = "";
store.page = 1;
if (filterInputEl) filterInputEl.value = "";
renderList();
}
function bindEvents() {
createBtnEl?.addEventListener("click", () => openDialog(null));
formEl?.addEventListener("submit", (evt) => {
evt.preventDefault();
saveDialog();
});
el("transitionEditCancelBtn")?.addEventListener("click", () => dialogEl?.close());
dialogEl?.addEventListener("cancel", (evt) => {
evt.preventDefault();
dialogEl?.close();
});
fields.site?.addEventListener("change", () => {
const existing = currentEditingTransition();
refreshMapSelectsForSite(existing);
});
fields.fromMap?.addEventListener("change", updateDependentSelects);
fields.toMap?.addEventListener("change", updateDependentSelects);
deleteBtnEl?.addEventListener("click", openDeleteConfirmFromDialog);
el("transitionDeleteCancelBtn")?.addEventListener("click", () => {
store.pendingDeleteId = null;
deleteConfirmDialogEl?.close();
});
el("transitionDeleteYesBtn")?.addEventListener("click", confirmDelete);
deleteConfirmDialogEl?.addEventListener("cancel", (evt) => {
evt.preventDefault();
store.pendingDeleteId = null;
deleteConfirmDialogEl?.close();
});
filterInputEl?.addEventListener("input", () => {
store.filter = filterInputEl.value;
store.page = 1;
renderList();
});
el("transitionsClearFiltersBtn")?.addEventListener("click", clearFilters);
el("transitionsPageFirst")?.addEventListener("click", () => {
store.page = 1;
renderList();
});
el("transitionsPagePrev")?.addEventListener("click", () => {
store.page = Math.max(1, store.page - 1);
renderList();
});
el("transitionsPageNext")?.addEventListener("click", () => {
store.page += 1;
renderList();
});
el("transitionsPageLast")?.addEventListener("click", () => {
store.page = pageCount(filteredTransitions().length);
renderList();
});
el("transitionsHelpBtn")?.addEventListener("click", () => {
alert(t("transitions.helpBody"));
});
window.addEventListener("lm:locale-change", () => renderList());
}
async function onPageShow() {
try {
await refreshAll();
renderList();
} catch (e) {
if (emptyEl) {
emptyEl.hidden = false;
emptyEl.textContent = e.message;
}
if (tableEl) tableEl.hidden = true;
}
}
function onPageHide() {
dialogEl?.close();
deleteConfirmDialogEl?.close();
}
bindEvents();
window.TransitionsApp = { onPageShow, onPageHide };
})();