finish setup
Some checks failed
Test / test (push) Has been cancelled

This commit is contained in:
2026-06-23 16:46:31 +02:00
parent 2ce8a23ce9
commit 773341fc84
16 changed files with 1780 additions and 4 deletions

View File

@@ -16,6 +16,7 @@ const pageUsersEl = el("pageUsers");
const pageUserGroupsEl = el("pageUserGroups");
const pageIoModulesEl = el("pageIoModules");
const pagePathsEl = el("pagePaths");
const pagePathGuidesEl = el("pagePathGuides");
const pageMonitoringEl = el("pageMonitoring");
const pageHelpEl = el("pageHelp");
const contentEl = document.querySelector(".content");
@@ -130,7 +131,7 @@ const state = {
};
function setActivePage(page) {
const valid = ["dashboard", "config", "maps", "missions", "sounds", "transitions", "user-groups", "io-modules", "paths", "users", "integrations", "monitoring", "help"];
const valid = ["dashboard", "config", "maps", "missions", "sounds", "transitions", "user-groups", "io-modules", "paths", "path-guides", "users", "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));
@@ -146,6 +147,7 @@ function setActivePage(page) {
if (pageUserGroupsEl) pageUserGroupsEl.hidden = p !== "user-groups";
if (pageIoModulesEl) pageIoModulesEl.hidden = p !== "io-modules";
if (pagePathsEl) pagePathsEl.hidden = p !== "paths";
if (pagePathGuidesEl) pagePathGuidesEl.hidden = p !== "path-guides";
if (pageUsersEl) pageUsersEl.hidden = p !== "users";
if (pageIntegrationsEl) pageIntegrationsEl.hidden = p !== "integrations";
if (pageMonitoringEl) pageMonitoringEl.hidden = p !== "monitoring";
@@ -162,6 +164,7 @@ function setActivePage(page) {
contentEl.classList.toggle("content--user-groups", p === "user-groups");
contentEl.classList.toggle("content--io-modules", p === "io-modules");
contentEl.classList.toggle("content--paths", p === "paths");
contentEl.classList.toggle("content--path-guides", p === "path-guides");
contentEl.classList.toggle("content--users", p === "users");
contentEl.classList.toggle("content--integrations", p === "integrations");
contentEl.classList.toggle("content--monitoring", p === "monitoring");
@@ -180,6 +183,8 @@ function setActivePage(page) {
else if (window.IoModulesApp?.onPageHide) window.IoModulesApp.onPageHide();
if (p === "paths" && window.PathsApp) window.PathsApp.onPageShow();
else if (window.PathsApp?.onPageHide) window.PathsApp.onPageHide();
if (p === "path-guides" && window.PathGuidesApp) window.PathGuidesApp.onPageShow();
else if (window.PathGuidesApp?.onPageHide) window.PathGuidesApp.onPageHide();
if (p === "users" && window.UsersApp) window.UsersApp.onPageShow();
else if (window.UsersApp?.onPageHide) window.UsersApp.onPageHide();
if (p === "dashboard" && window.DashboardApp) window.DashboardApp.onPageShow();

View File

@@ -154,6 +154,7 @@
"user-groups": "users",
"io-modules": "integrations",
paths: "maps",
"path-guides": "maps",
users: "users",
integrations: "integrations",
};
@@ -179,6 +180,7 @@
document.body.classList.toggle("auth-readonly-user-groups", !canWrite("users"));
document.body.classList.toggle("auth-readonly-io-modules", !canWrite("integrations"));
document.body.classList.toggle("auth-readonly-paths", !canWrite("maps"));
document.body.classList.toggle("auth-readonly-path-guides", !canWrite("maps"));
}
function updateUserMenu() {

View File

@@ -77,6 +77,7 @@
"nav.user-groups": "User groups",
"nav.io-modules": "I/O modules",
"nav.paths": "Paths",
"nav.path-guides": "Path guides",
"nav.users": "Users",
"nav.build-robot": "Build Robot",
"nav.monitoring-log": "System log",
@@ -714,6 +715,60 @@
"paths.deleteTitle": "Xóa path?",
"paths.deleteConfirmText": "Xóa path từ \"{from}\" đến \"{to}\"? Path sẽ được tạo lại khi robot chạy lại giữa hai position này.",
"pathGuides.title": "Path guides",
"pathGuides.subtitle": "Định nghĩa tuyến cố định giữa các position qua waypoint.",
"pathGuides.helpTitle": "Trợ giúp Path guides",
"pathGuides.helpBody": "Path guide buộc robot đi theo chuỗi start → waypoint → goal trên map. Tạo position trên map editor trước, sau đó thêm start, waypoint (có thứ tự) và goal. Robot tự dùng path guide khi mission nhắm tới các position đó (Phase D).",
"pathGuides.create": "Tạo path guide",
"pathGuides.clearFilters": "Xóa bộ lọc",
"pathGuides.filterLabel": "Lọc:",
"pathGuides.filterPlaceholder": "Lọc theo tên hoặc map...",
"pathGuides.itemsFound": "{n} mục",
"pathGuides.pageOf": "Trang {page} / {total}",
"pathGuides.colName": "Tên",
"pathGuides.colMap": "Map",
"pathGuides.colStarts": "Starts",
"pathGuides.colVias": "Waypoints",
"pathGuides.colGoals": "Goals",
"pathGuides.colFunctions": "Functions",
"pathGuides.empty": "Chưa có path guide. Tạo waypoint trên map trước, sau đó thêm path guide.",
"pathGuides.emptyFilter": "Không có path guide khớp bộ lọc.",
"pathGuides.createTitle": "Tạo path guide",
"pathGuides.editTitle": "Sửa path guide",
"pathGuides.name": "Tên",
"pathGuides.site": "Site",
"pathGuides.map": "Map",
"pathGuides.mapHint": "Tạo position trên map editor trước khi thêm start, waypoint và goal.",
"pathGuides.starts": "Start positions",
"pathGuides.vias": "Waypoints (có thứ tự)",
"pathGuides.goals": "Goal positions",
"pathGuides.addStart": "Thêm start",
"pathGuides.addVia": "Thêm waypoint",
"pathGuides.addGoal": "Thêm goal",
"pathGuides.selectPosition": "Chọn position…",
"pathGuides.noStarts": "Chưa có start",
"pathGuides.noVias": "Chưa có waypoint",
"pathGuides.noGoals": "Chưa có goal",
"pathGuides.moveUp": "Lên",
"pathGuides.moveDown": "Xuống",
"pathGuides.deleteTitle": "Xóa path guide?",
"pathGuides.deleteConfirmText": "Xóa path guide \"{name}\" trên map \"{map}\"?",
"pathGuides.error.missing": "Thiếu tên, site hoặc map.",
"pathGuides.error.positions": "Cần ít nhất một start và một goal.",
"pathGuides.error.noPositions": "Không có position nào trên map này. Tạo position trong map editor trước.",
"pathGuides.dragHandle": "Kéo để đổi thứ tự",
"pathGuides.createPage.title": "Tạo path guide",
"pathGuides.createPage.subtitle": "Nhập tên và chọn map cho path guide.",
"pathGuides.createPage.goBack": "Quay lại",
"pathGuides.createPage.namePlaceholder": "Nhập tên path guide...",
"pathGuides.createPage.continue": "Tiếp tục",
"pathGuides.editPage.title": "Sửa vị trí path guide",
"pathGuides.editPage.subtitle": "Chỉnh start, waypoint và goal của path guide.",
"pathGuides.editPage.helpTitle": "Trợ giúp vị trí path guide",
"pathGuides.editPage.helpBody": "Thêm start, waypoint (kéo để đổi thứ tự) và goal. Robot sẽ đi theo chuỗi này khi mission dùng các position tương ứng.",
"pathGuides.editPage.goBack": "Quay lại",
"pathGuides.editPage.meta": "{name} — {map}",
"missions.title": "Missions",
"missions.subtitle": "Setup → Missions — danh sách nhiệm vụ robot.",
"missions.create": "Tạo mission",
@@ -910,6 +965,7 @@
"nav.user-groups": "User groups",
"nav.io-modules": "I/O modules",
"nav.paths": "Paths",
"nav.path-guides": "Path guides",
"nav.users": "Users",
"nav.build-robot": "Build Robot",
"nav.monitoring-log": "System log",
@@ -1547,6 +1603,60 @@
"paths.deleteTitle": "Delete path?",
"paths.deleteConfirmText": "Delete path from \"{from}\" to \"{to}\"? It will be recreated when the robot drives between these positions again.",
"pathGuides.title": "Path guides",
"pathGuides.subtitle": "Define fixed routes between positions using waypoints.",
"pathGuides.helpTitle": "Path guides help",
"pathGuides.helpBody": "A path guide forces the robot to follow a start → waypoint → goal chain on the map. Create positions in the map editor first, then add starts, ordered waypoints, and goals. The robot will use the guide automatically when missions target those positions (planner integration in a later phase).",
"pathGuides.create": "Create path guide",
"pathGuides.clearFilters": "Clear filters",
"pathGuides.filterLabel": "Filter:",
"pathGuides.filterPlaceholder": "Filter by name or map...",
"pathGuides.itemsFound": "{n} item(s) found",
"pathGuides.pageOf": "Page {page} of {total}",
"pathGuides.colName": "Name",
"pathGuides.colMap": "Map",
"pathGuides.colStarts": "Starts",
"pathGuides.colVias": "Waypoints",
"pathGuides.colGoals": "Goals",
"pathGuides.colFunctions": "Functions",
"pathGuides.empty": "No path guides yet. Create waypoint positions on the map first, then add a path guide.",
"pathGuides.emptyFilter": "No path guides match the filter.",
"pathGuides.createTitle": "Create path guide",
"pathGuides.editTitle": "Edit path guide",
"pathGuides.name": "Name",
"pathGuides.site": "Site",
"pathGuides.map": "Map",
"pathGuides.mapHint": "Create positions on the map editor before adding starts, waypoints, and goals.",
"pathGuides.starts": "Start positions",
"pathGuides.vias": "Waypoints (ordered)",
"pathGuides.goals": "Goal positions",
"pathGuides.addStart": "Add start",
"pathGuides.addVia": "Add waypoint",
"pathGuides.addGoal": "Add goal",
"pathGuides.selectPosition": "Select position…",
"pathGuides.noStarts": "No starts yet",
"pathGuides.noVias": "No waypoints yet",
"pathGuides.noGoals": "No goals yet",
"pathGuides.moveUp": "Move up",
"pathGuides.moveDown": "Move down",
"pathGuides.deleteTitle": "Delete path guide?",
"pathGuides.deleteConfirmText": "Delete path guide \"{name}\" on map \"{map}\"?",
"pathGuides.error.missing": "Name, site, and map are required.",
"pathGuides.error.positions": "At least one start and one goal are required.",
"pathGuides.error.noPositions": "No positions on this map. Create positions in the map editor first.",
"pathGuides.dragHandle": "Drag to reorder",
"pathGuides.createPage.title": "Create path guide",
"pathGuides.createPage.subtitle": "Enter a name and select the map for this path guide.",
"pathGuides.createPage.goBack": "Go back",
"pathGuides.createPage.namePlaceholder": "Enter path guide name...",
"pathGuides.createPage.continue": "Continue",
"pathGuides.editPage.title": "Edit path guide positions",
"pathGuides.editPage.subtitle": "Edit the path guide's positions.",
"pathGuides.editPage.helpTitle": "Path guide positions help",
"pathGuides.editPage.helpBody": "Add starts, waypoints (drag to reorder), and goals. The robot follows this chain when missions use the matching positions.",
"pathGuides.editPage.goBack": "Go back",
"pathGuides.editPage.meta": "{name} — {map}",
"missions.title": "Missions",
"missions.subtitle": "Setup → Missions — robot task list.",
"missions.create": "Create mission",

View File

@@ -1334,6 +1334,164 @@
</dialog>
</div>
<div class="page" id="pagePathGuides" data-page-content="path-guides" hidden>
<div id="pathGuidesListView" class="mapsMirPage">
<header class="mapsMirHeader">
<div class="mapsMirHeaderText">
<h1 class="mapsMirTitle" data-i18n="pathGuides.title">Path guides</h1>
<p class="mapsMirSubtitle">
<span data-i18n="pathGuides.subtitle">Define fixed routes between positions using waypoints.</span>
<button type="button" class="mapsMirHelpBtn" id="pathGuidesHelpBtn" data-i18n-title="pathGuides.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="pathGuideCreateBtn">
<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="pathGuides.create">Create path guide</span>
</button>
<button type="button" class="mapsMirBtn mapsMirBtn--outline" id="pathGuidesClearFiltersBtn">
<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="pathGuides.clearFilters">Clear filters</span>
</button>
</div>
</header>
<div class="mapsMirFilterBar">
<label class="mapsMirFilterLabel" for="pathGuidesFilterInput" data-i18n="pathGuides.filterLabel">Filter:</label>
<input type="search" id="pathGuidesFilterInput" class="mapsMirFilterInput" data-i18n-placeholder="pathGuides.filterPlaceholder" placeholder="Filter by name or map..." autocomplete="off" />
<span id="pathGuidesFilterCount" class="mapsMirFilterCount">0 item(s) found</span>
<div class="mapsMirPager">
<button type="button" class="mapsMirPageBtn" id="pathGuidesPageFirst" aria-label="First page">&laquo;</button>
<button type="button" class="mapsMirPageBtn" id="pathGuidesPagePrev" aria-label="Previous page">&lsaquo;</button>
<span id="pathGuidesPageLabel" class="mapsMirPageLabel">Page 1 of 1</span>
<button type="button" class="mapsMirPageBtn" id="pathGuidesPageNext" aria-label="Next page">&rsaquo;</button>
<button type="button" class="mapsMirPageBtn" id="pathGuidesPageLast" aria-label="Last page">&raquo;</button>
</div>
</div>
<div class="mapsMirTableWrap">
<table class="mapsMirTable mapsMirTable--path-guides" id="pathGuidesTable">
<thead>
<tr>
<th class="pathGuideMirThIcon" aria-hidden="true"></th>
<th data-i18n="pathGuides.colName">Name</th>
<th data-i18n="pathGuides.colMap">Map</th>
<th data-i18n="pathGuides.colStarts">Starts</th>
<th data-i18n="pathGuides.colVias">Waypoints</th>
<th data-i18n="pathGuides.colGoals">Goals</th>
<th class="mapsMirThFunctions" data-i18n="pathGuides.colFunctions">Functions</th>
</tr>
</thead>
<tbody id="pathGuideList"></tbody>
</table>
<div id="pathGuideListEmpty" class="mapsMirEmpty" hidden data-i18n="pathGuides.empty">No path guides yet. Create waypoint positions on the map first, then add a path guide.</div>
</div>
</div>
<div id="pathGuideCreateView" class="mapsMirCreatePage" hidden>
<header class="mapsMirCreateHeader">
<div class="mapsMirCreateHeaderIntro">
<h1 class="mapsMirCreateTitle" data-i18n="pathGuides.createPage.title">Create path guide</h1>
<p class="mapsMirCreateSubtitle">
<span data-i18n="pathGuides.createPage.subtitle">Enter a name and select the map for this path guide.</span>
<button type="button" class="mapsMirHelpBtn" id="pathGuideCreateHelpBtn" data-i18n-title="pathGuides.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>
<button type="button" class="mapsMirGoBackBtn" id="pathGuideCreateGoBackBtn">
<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M9 3L5 7l4 4" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>
<span data-i18n="pathGuides.createPage.goBack">Go back</span>
</button>
</header>
<form id="pathGuideCreateForm" class="mapsMirCreateForm">
<div class="mapsMirCreatePanel">
<label class="mapsMirCreateField" for="pathGuideCreateName">
<span class="mapsMirCreateFieldLabel" data-i18n="pathGuides.name">Name</span>
<input type="text" id="pathGuideCreateName" required autocomplete="off" data-i18n-placeholder="pathGuides.createPage.namePlaceholder" placeholder="Enter path guide name..." />
</label>
<label class="mapsMirCreateField" for="pathGuideCreateSite">
<span class="mapsMirCreateFieldLabel" data-i18n="pathGuides.site">Site</span>
<select id="pathGuideCreateSite" required></select>
</label>
<label class="mapsMirCreateField" for="pathGuideCreateMap">
<span class="mapsMirCreateFieldLabel" data-i18n="pathGuides.map">Map</span>
<select id="pathGuideCreateMap" required></select>
</label>
<p class="mapsMirHint" data-i18n="pathGuides.mapHint">Create positions on the map editor before adding starts, waypoints, and goals.</p>
<div class="mapsMirCreateActions">
<button type="submit" class="mapsMirCreateSubmitBtn">
<span class="mapsMirCircleIcon mapsMirCircleIcon--ok" aria-hidden="true"></span>
<span data-i18n="pathGuides.createPage.continue">Continue</span>
</button>
<button type="button" class="mapsMirCreateCancelBtn" id="pathGuideCreateCancelBtn">
<span class="mapsMirCircleIcon" aria-hidden="true"></span>
<span data-i18n="common.cancel">Cancel</span>
</button>
</div>
</div>
</form>
</div>
<div id="pathGuideEditView" class="pathGuideEditPage" hidden>
<header class="pathGuideEditHeader">
<div class="pathGuideEditHeaderIntro">
<h1 class="pathGuideEditTitle" id="pathGuideEditPageTitle" data-i18n="pathGuides.editPage.title">Edit path guide positions</h1>
<p class="pathGuideEditSubtitle">
<span data-i18n="pathGuides.editPage.subtitle">Edit the path guide's positions.</span>
<button type="button" class="mapsMirHelpBtn" id="pathGuideEditHelpBtn" data-i18n-title="pathGuides.editPage.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>
<p class="pathGuideEditMeta" id="pathGuideEditMeta" hidden></p>
</div>
<button type="button" class="mapsMirGoBackBtn" id="pathGuideEditGoBackBtn">
<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M9 3L5 7l4 4" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>
<span data-i18n="pathGuides.editPage.goBack">Go back</span>
</button>
</header>
<div class="pathGuidePosGrid pathGuidePosGrid--page">
<section class="pathGuidePosPanel">
<h2 class="pathGuidePosTitle" data-i18n="pathGuides.starts">Start positions</h2>
<ul id="pathGuideStartsList" class="pathGuidePosList"></ul>
<select id="pathGuideAddStartSelect" class="pathGuidePosPicker" hidden aria-hidden="true" tabindex="-1"></select>
<button type="button" class="pathGuidePosAddBtn" id="pathGuideAddStartBtn" data-i18n="pathGuides.addStart">Add start</button>
</section>
<section class="pathGuidePosPanel">
<h2 class="pathGuidePosTitle" data-i18n="pathGuides.vias">Waypoints</h2>
<ul id="pathGuideViasList" class="pathGuidePosList pathGuidePosList--vias"></ul>
<select id="pathGuideAddViaSelect" class="pathGuidePosPicker" hidden aria-hidden="true" tabindex="-1"></select>
<button type="button" class="pathGuidePosAddBtn" id="pathGuideAddViaBtn" data-i18n="pathGuides.addVia">Add waypoint</button>
</section>
<section class="pathGuidePosPanel">
<h2 class="pathGuidePosTitle" data-i18n="pathGuides.goals">Goal positions</h2>
<ul id="pathGuideGoalsList" class="pathGuidePosList"></ul>
<select id="pathGuideAddGoalSelect" class="pathGuidePosPicker" hidden aria-hidden="true" tabindex="-1"></select>
<button type="button" class="pathGuidePosAddBtn" id="pathGuideAddGoalBtn" data-i18n="pathGuides.addGoal">Add goal</button>
</section>
</div>
<div class="pathGuideEditFooter">
<button type="button" class="mapsMirBtn mapsMirBtn--danger" id="pathGuideEditDeleteBtn" hidden data-i18n="common.delete">Delete</button>
<button type="button" class="mapsMirBtn mapsMirBtn--green" id="pathGuideEditSaveBtn" data-i18n="common.save">Save</button>
</div>
</div>
<dialog id="pathGuideDeleteConfirmDialog" class="mapsMirDialog">
<div class="mapsMirDialogPanel">
<h2 class="mapsMirDialogTitle" data-i18n="pathGuides.deleteTitle">Delete path guide?</h2>
<p id="pathGuideDeleteConfirmText" class="mapsMirDialogText"></p>
<div class="mapsMirDialogFooter">
<button type="button" class="mapsMirBtn mapsMirBtn--outline" id="pathGuideDeleteCancelBtn" data-i18n="common.no">No</button>
<button type="button" class="mapsMirBtn mapsMirBtn--danger" id="pathGuideDeleteYesBtn" data-i18n="common.yes">Yes</button>
</div>
</div>
</dialog>
</div>
<div class="page" id="pageUsers" data-page-content="users" hidden>
<div id="usersListView" class="mapsMirPage">
<header class="mapsMirHeader">
@@ -2620,6 +2778,7 @@ GET /api/v2.0.0/status</pre>
<script src="/user-groups.js"></script>
<script src="/io-modules.js"></script>
<script src="/paths.js"></script>
<script src="/path-guides.js"></script>
<script src="/map-editor.js"></script>
<script src="/topbar.js"></script>
<script src="/dashboard.js"></script>

View File

@@ -17,10 +17,11 @@
{ section: "maps", page: "maps" },
{ section: "sounds", page: "sounds" },
{ section: "transitions", page: "transitions" },
{ section: "users", page: "users" },
{ section: "user-groups", page: "user-groups" },
{ section: "io-modules", page: "io-modules" },
{ section: "paths", page: "paths" },
{ section: "users", page: "users" },
{ section: "path-guides", page: "path-guides" },
{ section: "build-robot", page: "config" },
],
},
@@ -45,6 +46,7 @@
"user-groups": { module: "setup", section: "user-groups" },
"io-modules": { module: "setup", section: "io-modules" },
paths: { module: "setup", section: "paths" },
"path-guides": { module: "setup", section: "path-guides" },
users: { module: "setup", section: "users" },
integrations: { module: "system", section: "integrations" },
monitoring: { module: "monitoring", section: "monitoring-log" },

693
www/path-guides.js Normal file
View File

@@ -0,0 +1,693 @@
(() => {
const PAGE_SIZE = 10;
const ICONS = {
guide: `<svg class="pathGuideMirIcon" width="22" height="22" viewBox="0 0 22 22" aria-hidden="true"><circle cx="4" cy="11" r="2.5" fill="none" stroke="currentColor" stroke-width="1.5"/><circle cx="11" cy="11" r="2.5" fill="none" stroke="currentColor" stroke-width="1.5"/><circle cx="18" cy="11" r="2.5" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M6.5 11h2M13.5 11h2" 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>`,
drag: `<svg class="pathGuideDragIcon" width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M5 3h1v1H5V3zm3 0h1v1H8V3zM5 6h1v1H5V6zm3 0h1v1H8V6zm-3 3h1v1H5V9zm3 0h1v1H8V9z" fill="currentColor"/></svg>`,
remove: `<svg width="12" height="12" viewBox="0 0 12 12" aria-hidden="true"><path d="M3 3l6 6M9 3l-6 6" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>`,
};
const el = (id) => document.getElementById(id);
const t = (key, vars) => window.I18n?.t(key, vars) ?? key;
const listViewEl = el("pathGuidesListView");
const createViewEl = el("pathGuideCreateView");
const editViewEl = el("pathGuideEditView");
const listEl = el("pathGuideList");
const emptyEl = el("pathGuideListEmpty");
const tableEl = el("pathGuidesTable");
const createBtnEl = el("pathGuideCreateBtn");
const filterInputEl = el("pathGuidesFilterInput");
const filterCountEl = el("pathGuidesFilterCount");
const pageLabelEl = el("pathGuidesPageLabel");
const deleteConfirmDialogEl = el("pathGuideDeleteConfirmDialog");
const deleteConfirmTextEl = el("pathGuideDeleteConfirmText");
const editMetaEl = el("pathGuideEditMeta");
const deleteBtnEl = el("pathGuideEditDeleteBtn");
const saveBtnEl = el("pathGuideEditSaveBtn");
const createFields = {
name: el("pathGuideCreateName"),
site: el("pathGuideCreateSite"),
map: el("pathGuideCreateMap"),
};
const pickers = {
start: el("pathGuideAddStartSelect"),
via: el("pathGuideAddViaSelect"),
goal: el("pathGuideAddGoalSelect"),
};
const lists = {
starts: el("pathGuideStartsList"),
vias: el("pathGuideViasList"),
goals: el("pathGuideGoalsList"),
};
const store = {
guides: [],
sites: [],
maps: [],
editingId: null,
pendingDeleteId: null,
filter: "",
page: 1,
draft: { starts: [], vias: [], goals: [] },
meta: { name: "", site_id: "", map_id: "" },
dragViaIndex: null,
};
function canWrite() {
if (!window.AuthApp?.canWrite) return true;
return window.AuthApp.canWrite("maps");
}
function currentUser() {
return window.AuthApp?.getUser?.() || null;
}
function canDeleteGuide(guide) {
if (!canWrite() || !guide) return false;
const user = currentUser();
if (!user) return true;
const group = guide.created_by_group;
if (group) return group === user.group_id;
return true;
}
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, guides] = await Promise.all([
apiJson("/api/sites"),
apiJson("/api/maps"),
apiJson("/api/path_guides"),
]);
store.sites = Array.isArray(sites.sites) ? sites.sites : [];
store.maps = Array.isArray(maps.maps) ? maps.maps : [];
store.guides = Array.isArray(guides.path_guides) ? guides.path_guides : [];
}
function siteName(id) {
return store.sites.find((x) => x.id === id)?.name || id || "—";
}
function mapName(id) {
return store.maps.find((x) => x.id === id)?.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, excludeIds = new Set()) {
const m = store.maps.find((x) => x.id === mapId);
const zones = Array.isArray(m?.zones) ? m.zones : [];
return zones
.filter((z) => z && z.type === "position" && typeof z.id === "string" && !excludeIds.has(z.id))
.map((p) => ({ value: p.id, label: p.name || p.id }));
}
function fillSelect(selectEl, options, value, { allowBlank = false, blankLabel = "" } = {}) {
if (!selectEl) return;
selectEl.innerHTML = "";
if (allowBlank) {
const blank = document.createElement("option");
blank.value = "";
blank.textContent = blankLabel || t("pathGuides.selectPosition");
selectEl.appendChild(blank);
}
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 parseGuidePositions(guide) {
const positions = Array.isArray(guide?.positions) ? guide.positions : [];
const starts = [];
const vias = [];
const goals = [];
positions.forEach((p) => {
if (!p || typeof p.position_id !== "string") return;
const role = String(p.role || "").toLowerCase();
if (role === "start") starts.push(p.position_id);
else if (role === "via") vias.push({ position_id: p.position_id, priority: Number(p.priority) || vias.length + 1 });
else if (role === "goal") goals.push(p.position_id);
});
vias.sort((a, b) => a.priority - b.priority);
return { starts, vias, goals };
}
function draftToPositions() {
const out = [];
store.draft.starts.forEach((id) => out.push({ position_id: id, role: "start" }));
store.draft.vias.forEach((v, idx) => out.push({ position_id: v.position_id, role: "via", priority: idx + 1 }));
store.draft.goals.forEach((id) => out.push({ position_id: id, role: "goal" }));
return out;
}
function usedPositionIds() {
const ids = new Set();
store.draft.starts.forEach((id) => ids.add(id));
store.draft.vias.forEach((v) => ids.add(v.position_id));
store.draft.goals.forEach((id) => ids.add(id));
return ids;
}
function currentMapId() {
return store.meta.map_id || "";
}
function refreshPickers() {
const mapId = currentMapId();
const used = usedPositionIds();
const opts = positionOptionsForMap(mapId, used);
fillSelect(pickers.start, opts, "", { allowBlank: true });
fillSelect(pickers.via, opts, "", { allowBlank: true });
fillSelect(pickers.goal, opts, "", { allowBlank: true });
}
function renderPosCard(mapId, label, { draggable = false, viaIndex = -1, removeKind, removeKey }) {
const li = document.createElement("li");
li.className = "pathGuidePosCard";
if (draggable) {
li.draggable = true;
li.dataset.viaIndex = String(viaIndex);
li.addEventListener("dragstart", (evt) => {
store.dragViaIndex = viaIndex;
evt.dataTransfer?.setData("text/plain", String(viaIndex));
li.classList.add("pathGuidePosCard--dragging");
});
li.addEventListener("dragend", () => {
store.dragViaIndex = null;
li.classList.remove("pathGuidePosCard--dragging");
});
li.addEventListener("dragover", (evt) => {
evt.preventDefault();
li.classList.add("pathGuidePosCard--over");
});
li.addEventListener("dragleave", () => li.classList.remove("pathGuidePosCard--over"));
li.addEventListener("drop", (evt) => {
evt.preventDefault();
li.classList.remove("pathGuidePosCard--over");
const from = store.dragViaIndex;
const to = viaIndex;
if (from == null || from === to) return;
const copy = [...store.draft.vias];
const [item] = copy.splice(from, 1);
copy.splice(to, 0, item);
store.draft.vias = copy;
renderDraftLists();
});
}
const dragHtml = draggable
? `<span class="pathGuidePosDrag" title="${escapeHtml(t("pathGuides.dragHandle"))}">${ICONS.drag}</span>`
: "";
li.innerHTML = `${dragHtml}<span class="pathGuidePosCardLabel">${escapeHtml(label)}</span>
<button type="button" class="pathGuidePosRemoveBtn" data-remove-kind="${removeKind}" data-remove-key="${escapeHtml(String(removeKey))}" title="${escapeHtml(t("common.delete"))}">${ICONS.remove}</button>`;
li.querySelector("[data-remove-kind]")?.addEventListener("click", () => {
if (removeKind === "start") store.draft.starts = store.draft.starts.filter((x) => x !== removeKey);
else if (removeKind === "via") store.draft.vias = store.draft.vias.filter((_, i) => i !== removeKey);
else if (removeKind === "goal") store.draft.goals = store.draft.goals.filter((x) => x !== removeKey);
renderDraftLists();
});
return li;
}
function renderDraftLists() {
const mapId = currentMapId();
if (lists.starts) {
lists.starts.innerHTML = "";
store.draft.starts.forEach((id) => {
lists.starts.appendChild(renderPosCard(mapId, positionName(mapId, id), { removeKind: "start", removeKey: id }));
});
}
if (lists.vias) {
lists.vias.innerHTML = "";
store.draft.vias.forEach((v, idx) => {
lists.vias.appendChild(
renderPosCard(mapId, positionName(mapId, v.position_id), {
draggable: canWrite(),
viaIndex: idx,
removeKind: "via",
removeKey: idx,
}),
);
});
}
if (lists.goals) {
lists.goals.innerHTML = "";
store.draft.goals.forEach((id) => {
lists.goals.appendChild(renderPosCard(mapId, positionName(mapId, id), { removeKind: "goal", removeKey: id }));
});
}
refreshPickers();
}
function hideAllViews() {
[listViewEl, createViewEl, editViewEl].forEach((view) => {
if (!view) return;
view.hidden = true;
view.setAttribute("aria-hidden", "true");
});
}
function showList() {
hideAllViews();
if (listViewEl) {
listViewEl.hidden = false;
listViewEl.removeAttribute("aria-hidden");
}
store.editingId = null;
renderList();
}
function refreshCreateMapSelect() {
const siteId = createFields.site?.value || "";
const mapsForSite = store.maps.filter((m) => (m.site_id || "") === siteId);
const mapOpts = mapsForSite.map((m) => ({ value: m.id, label: m.name || m.id }));
fillSelect(createFields.map, mapOpts, createFields.map?.value || mapOpts[0]?.value);
}
function showCreate() {
if (!canWrite()) return;
hideAllViews();
const siteOpts = store.sites.map((s) => ({ value: s.id, label: s.name || s.id }));
fillSelect(createFields.site, siteOpts, store.maps[0]?.site_id || siteOpts[0]?.value);
refreshCreateMapSelect();
if (createFields.name) createFields.name.value = "";
if (createViewEl) {
createViewEl.hidden = false;
createViewEl.removeAttribute("aria-hidden");
}
createFields.name?.focus();
}
function showEditView({ id = null, meta = null, draft = null } = {}) {
hideAllViews();
store.editingId = id;
const existing = id ? store.guides.find((x) => x.id === id) : null;
if (existing) {
store.meta = { name: existing.name || "", site_id: existing.site_id || "", map_id: existing.map_id || "" };
store.draft = parseGuidePositions(existing);
} else if (meta) {
store.meta = { ...meta };
store.draft = draft || { starts: [], vias: [], goals: [] };
}
if (editMetaEl) {
const label = t("pathGuides.editPage.meta", {
name: store.meta.name || "—",
map: mapName(store.meta.map_id),
});
editMetaEl.textContent = label;
editMetaEl.hidden = !store.meta.name;
}
const ro = !canWrite();
if (deleteBtnEl) {
deleteBtnEl.hidden = !existing || ro || !canDeleteGuide(existing);
}
if (saveBtnEl) saveBtnEl.hidden = ro;
[el("pathGuideAddStartBtn"), el("pathGuideAddViaBtn"), el("pathGuideAddGoalBtn")].forEach((btn) => {
btn?.toggleAttribute("disabled", ro);
});
renderDraftLists();
if (editViewEl) {
editViewEl.hidden = false;
editViewEl.removeAttribute("aria-hidden");
}
}
function filteredGuides() {
const q = store.filter.trim().toLowerCase();
let items = [...store.guides].sort((a, b) => {
const ma = mapName(a.map_id).localeCompare(mapName(b.map_id));
if (ma !== 0) return ma;
return String(a.name || "").localeCompare(String(b.name || ""));
});
if (q) {
items = items.filter((g) => {
const name = String(g.name || "").toLowerCase();
const map = mapName(g.map_id).toLowerCase();
const site = siteName(g.site_id).toLowerCase();
return name.includes(q) || map.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("pathGuides.itemsFound", { n: totalItems });
if (pageLabelEl) pageLabelEl.textContent = t("pathGuides.pageOf", { page: store.page, total: totalPages });
const atStart = store.page <= 1;
const atEnd = store.page >= totalPages;
el("pathGuidesPageFirst")?.toggleAttribute("disabled", atStart);
el("pathGuidesPagePrev")?.toggleAttribute("disabled", atStart);
el("pathGuidesPageNext")?.toggleAttribute("disabled", atEnd);
el("pathGuidesPageLast")?.toggleAttribute("disabled", atEnd);
}
function renderList() {
if (!listEl) return;
const items = filteredGuides();
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("pathGuides.emptyFilter") : t("pathGuides.empty");
}
pageItems.forEach((guide) => {
const tr = document.createElement("tr");
tr.className = "mapsMirRow pathGuideMirRow";
const actions = canWrite()
? `<div class="mapsMirRowActions">
<button type="button" class="mapsMirIconBtn" data-edit="${escapeHtml(guide.id)}" title="${escapeHtml(t("common.edit"))}">${ICONS.edit}</button>
${canDeleteGuide(guide) ? `<button type="button" class="mapsMirIconBtn mapsMirIconBtn--danger" data-delete="${escapeHtml(guide.id)}" title="${escapeHtml(t("common.delete"))}">${ICONS.delete}</button>` : ""}
</div>`
: "";
tr.innerHTML = `
<td class="pathGuideMirCellIcon">${ICONS.guide}</td>
<td>
<button type="button" class="mapsMirNameLink" data-edit="${escapeHtml(guide.id)}">${escapeHtml(guide.name || "—")}</button>
</td>
<td>${escapeHtml(mapName(guide.map_id))}</td>
<td class="pathGuideMirCellNum">${guide.starts_count ?? 0}</td>
<td class="pathGuideMirCellNum">${guide.vias_count ?? 0}</td>
<td class="pathGuideMirCellNum">${guide.goals_count ?? 0}</td>
<td class="mapsMirCellActions">${actions}</td>`;
tr.querySelectorAll("[data-edit]").forEach((btn) => {
btn.addEventListener("click", () => showEditView({ id: btn.dataset.edit }));
});
tr.querySelector("[data-delete]")?.addEventListener("click", () => openDeleteConfirm(guide.id));
listEl.appendChild(tr);
});
if (createBtnEl) createBtnEl.hidden = !canWrite();
document.body.classList.toggle("auth-readonly-path-guides", !canWrite());
}
function readPayload() {
return {
name: store.meta.name?.trim() || "",
site_id: store.meta.site_id || "",
map_id: store.meta.map_id || "",
positions: draftToPositions(),
};
}
async function saveEdit() {
if (!canWrite()) return;
const payload = readPayload();
if (!payload.name || !payload.site_id || !payload.map_id) {
alert(t("pathGuides.error.missing"));
return;
}
if (payload.positions.filter((p) => p.role === "start").length < 1 || payload.positions.filter((p) => p.role === "goal").length < 1) {
alert(t("pathGuides.error.positions"));
return;
}
try {
if (store.editingId) {
await apiJson(`/api/path_guides/${encodeURIComponent(store.editingId)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
} else {
await apiJson("/api/path_guides", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
}
await refreshAll();
showList();
} catch (e) {
alert(e.message);
}
}
function openDeleteConfirm(id) {
const guide = store.guides.find((x) => x.id === id);
if (!guide || !canDeleteGuide(guide)) return;
store.pendingDeleteId = id;
if (deleteConfirmTextEl) {
deleteConfirmTextEl.textContent = t("pathGuides.deleteConfirmText", {
name: guide.name || guide.id,
map: mapName(guide.map_id),
});
}
deleteConfirmDialogEl?.showModal();
}
async function confirmDelete() {
const id = store.pendingDeleteId || store.editingId;
if (!id || !canWrite()) return;
try {
await apiJson(`/api/path_guides/${encodeURIComponent(id)}`, { method: "DELETE" });
deleteConfirmDialogEl?.close();
store.pendingDeleteId = null;
store.editingId = null;
await refreshAll();
showList();
} catch (e) {
alert(e.message);
}
}
async function pickAndAdd(selectEl, onAdd) {
if (!canWrite() || !selectEl) return;
refreshPickers();
const options = Array.from(selectEl.options).filter((o) => o.value);
if (options.length === 0) {
alert(t("pathGuides.error.noPositions"));
return;
}
if (typeof selectEl.showPicker === "function") {
selectEl.showPicker();
await new Promise((resolve) => {
const done = () => {
selectEl.removeEventListener("change", done);
selectEl.removeEventListener("cancel", done);
resolve();
};
selectEl.addEventListener("change", done, { once: true });
selectEl.addEventListener("cancel", done, { once: true });
});
} else {
selectEl.hidden = false;
selectEl.removeAttribute("aria-hidden");
selectEl.tabIndex = 0;
selectEl.focus();
return;
}
const id = selectEl.value;
if (!id) return;
onAdd(id);
selectEl.value = "";
}
function addStart(id) {
if (!id || store.draft.starts.includes(id)) return;
store.draft.starts.push(id);
renderDraftLists();
}
function addVia(id) {
if (!id || store.draft.vias.some((v) => v.position_id === id)) return;
store.draft.vias.push({ position_id: id, priority: store.draft.vias.length + 1 });
renderDraftLists();
}
function addGoal(id) {
if (!id || store.draft.goals.includes(id)) return;
store.draft.goals.push(id);
renderDraftLists();
}
function submitCreateForm(evt) {
evt.preventDefault();
const name = createFields.name?.value?.trim() || "";
const site_id = createFields.site?.value || "";
const map_id = createFields.map?.value || "";
if (!name || !site_id || !map_id) {
alert(t("pathGuides.error.missing"));
return;
}
showEditView({
meta: { name, site_id, map_id },
draft: { starts: [], vias: [], goals: [] },
});
}
function clearFilters() {
store.filter = "";
store.page = 1;
if (filterInputEl) filterInputEl.value = "";
renderList();
}
function bindEvents() {
createBtnEl?.addEventListener("click", showCreate);
el("pathGuideCreateForm")?.addEventListener("submit", submitCreateForm);
el("pathGuideCreateGoBackBtn")?.addEventListener("click", showList);
el("pathGuideCreateCancelBtn")?.addEventListener("click", showList);
el("pathGuideCreateHelpBtn")?.addEventListener("click", () => alert(t("pathGuides.helpBody")));
el("pathGuideEditGoBackBtn")?.addEventListener("click", showList);
el("pathGuideEditHelpBtn")?.addEventListener("click", () => alert(t("pathGuides.editPage.helpBody")));
saveBtnEl?.addEventListener("click", saveEdit);
deleteBtnEl?.addEventListener("click", () => {
if (store.editingId) openDeleteConfirm(store.editingId);
});
el("pathGuideAddStartBtn")?.addEventListener("click", () => {
void pickAndAdd(pickers.start, addStart);
});
el("pathGuideAddViaBtn")?.addEventListener("click", () => {
void pickAndAdd(pickers.via, addVia);
});
el("pathGuideAddGoalBtn")?.addEventListener("click", () => {
void pickAndAdd(pickers.goal, addGoal);
});
pickers.start?.addEventListener("change", () => {
if (pickers.start.value) addStart(pickers.start.value);
pickers.start.value = "";
pickers.start.hidden = true;
pickers.start.setAttribute("aria-hidden", "true");
pickers.start.tabIndex = -1;
});
pickers.via?.addEventListener("change", () => {
if (pickers.via.value) addVia(pickers.via.value);
pickers.via.value = "";
pickers.via.hidden = true;
pickers.via.setAttribute("aria-hidden", "true");
pickers.via.tabIndex = -1;
});
pickers.goal?.addEventListener("change", () => {
if (pickers.goal.value) addGoal(pickers.goal.value);
pickers.goal.value = "";
pickers.goal.hidden = true;
pickers.goal.setAttribute("aria-hidden", "true");
pickers.goal.tabIndex = -1;
});
el("pathGuideDeleteCancelBtn")?.addEventListener("click", () => {
store.pendingDeleteId = null;
deleteConfirmDialogEl?.close();
});
el("pathGuideDeleteYesBtn")?.addEventListener("click", confirmDelete);
deleteConfirmDialogEl?.addEventListener("cancel", (evt) => {
evt.preventDefault();
store.pendingDeleteId = null;
deleteConfirmDialogEl?.close();
});
createFields.site?.addEventListener("change", refreshCreateMapSelect);
filterInputEl?.addEventListener("input", () => {
store.filter = filterInputEl.value;
store.page = 1;
renderList();
});
el("pathGuidesClearFiltersBtn")?.addEventListener("click", clearFilters);
el("pathGuidesPageFirst")?.addEventListener("click", () => {
store.page = 1;
renderList();
});
el("pathGuidesPagePrev")?.addEventListener("click", () => {
store.page = Math.max(1, store.page - 1);
renderList();
});
el("pathGuidesPageNext")?.addEventListener("click", () => {
store.page += 1;
renderList();
});
el("pathGuidesPageLast")?.addEventListener("click", () => {
store.page = pageCount(filteredGuides().length);
renderList();
});
el("pathGuidesHelpBtn")?.addEventListener("click", () => alert(t("pathGuides.helpBody")));
window.addEventListener("lm:locale-change", () => {
if (!editViewEl?.hidden) renderDraftLists();
if (!listViewEl?.hidden) renderList();
});
}
async function onPageShow() {
try {
await refreshAll();
showList();
} catch (e) {
if (emptyEl) {
emptyEl.hidden = false;
emptyEl.textContent = e.message;
}
if (tableEl) tableEl.hidden = true;
}
}
function onPageHide() {
deleteConfirmDialogEl?.close();
showList();
}
bindEvents();
window.PathGuidesApp = { onPageShow, onPageHide };
})();

View File

@@ -5553,3 +5553,208 @@ body.auth-readonly-maps-page #mapsImportSiteBtn { display: none !important; }
border-radius: 0;
}
.content.content--path-guides {
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--path-guides > #pagePathGuides {
width: 100%;
min-width: 0;
min-height: 0;
height: 100%;
}
.mapsMirTable--path-guides thead th.pathGuideMirThIcon { width: 52px; }
.pathGuideMirIcon { color: var(--mir-accent, #0d9488); }
.pathGuideMirCellNum { text-align: center; width: 72px; }
.pathGuidePosGrid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
margin: 12px 0 16px;
}
.pathGuidePosPanel {
border: 1px solid #ddd;
padding: 10px;
min-height: 180px;
display: flex;
flex-direction: column;
gap: 8px;
}
.pathGuidePosTitle {
margin: 0;
font-size: 13px;
font-weight: 600;
}
.pathGuidePosList {
list-style: none;
margin: 0;
padding: 0;
flex: 1;
min-height: 80px;
}
.pathGuidePosItem {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 0;
font-size: 13px;
}
.pathGuidePosOrder {
color: #666;
min-width: 1.2em;
}
.pathGuidePosLabel { flex: 1; }
.pathGuidePosActions { display: flex; gap: 2px; margin-left: auto; }
.pathGuidePosEmpty {
color: #888;
font-size: 12px;
padding: 8px 0;
}
.pathGuidePosAdd {
display: flex;
gap: 6px;
align-items: center;
}
.pathGuidePosAdd select { flex: 1; min-width: 0; }
body.auth-readonly-path-guides .pathGuideMirRow .mapsMirIconBtn--danger { pointer-events: none; opacity: 0.55; }
body.auth-readonly-path-guides #pathGuideCreateBtn { display: none !important; }
.pathGuideEditPage {
width: 100%;
min-width: 0;
flex: 1;
min-height: 0;
padding: 24px 32px 40px;
background: #fff;
overflow: auto;
box-sizing: border-box;
}
.pathGuideEditHeader {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 20px;
flex-wrap: wrap;
margin-bottom: 28px;
}
.pathGuideEditTitle {
margin: 0;
font-size: 32px;
font-weight: 400;
color: #222;
letter-spacing: -0.02em;
}
.pathGuideEditSubtitle {
margin: 6px 0 0;
font-size: 15px;
color: #555;
display: flex;
align-items: center;
gap: 8px;
}
.pathGuideEditMeta {
margin: 8px 0 0;
font-size: 14px;
color: #666;
}
.pathGuidePosGrid--page {
gap: 20px;
margin: 0;
}
.pathGuidePosGrid--page .pathGuidePosPanel {
border: none;
padding: 0;
min-height: 0;
background: transparent;
}
.pathGuidePosGrid--page .pathGuidePosTitle {
font-size: 22px;
font-weight: 400;
margin: 0 0 16px;
color: #222;
}
.pathGuidePosCard {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
margin-bottom: 10px;
background: linear-gradient(180deg, #f8f8f8 0%, #ececec 100%);
border: 1px solid #d8d8d8;
border-radius: 0;
list-style: none;
}
.pathGuidePosCard--dragging { opacity: 0.55; }
.pathGuidePosCard--over { outline: 2px solid var(--mir-accent, #0d9488); }
.pathGuidePosDrag {
color: #888;
cursor: grab;
flex-shrink: 0;
}
.pathGuidePosCardLabel {
flex: 1;
font-size: 15px;
color: #333;
}
.pathGuidePosRemoveBtn {
border: none;
background: #e74c3c;
color: #fff;
width: 28px;
height: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
border-radius: 0;
flex-shrink: 0;
}
.pathGuidePosRemoveBtn:hover { background: #c0392b; }
.pathGuidePosAddBtn {
display: block;
width: 100%;
margin-top: 8px;
padding: 10px 14px;
border: none;
border-radius: 0;
background: #337ab7;
color: #fff;
font-size: 14px;
font-weight: 600;
cursor: pointer;
text-align: center;
}
.pathGuidePosAddBtn:hover { background: #286090; }
.pathGuidePosAddBtn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.pathGuideEditFooter {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 28px;
padding-top: 16px;
border-top: 1px solid #e5e5e5;
}
.pathGuidePosList {
list-style: none;
margin: 0;
padding: 0;
min-height: 40px;
}
#pagePathGuides .mapsMirGoBackBtn,
#pagePathGuides .pathGuidePosCard,
#pagePathGuides .pathGuidePosAddBtn,
#pagePathGuides .mapsMirCreatePanel,
#pagePathGuides .mapsMirCreateSubmitBtn,
#pagePathGuides .mapsMirCreateCancelBtn {
border-radius: 0;
}