(() => {
const PAGE_SIZE = 10;
const ICONS = {
map: ``,
edit: ``,
view: ``,
delete: ``,
active: ``,
};
const el = (id) => document.getElementById(id);
const t = (key, vars) => window.I18n?.t(key, vars) ?? key;
const listViewEl = el("mapsListView");
const createViewEl = el("mapsCreateView");
const editorViewEl = el("mapEditorView");
const listEl = el("mapsList");
const listEmptyEl = el("mapsListEmpty");
const tableEl = el("mapsTable");
const activeHintEl = el("mapsActiveHint");
const filterInputEl = el("mapsFilterInput");
const filterCountEl = el("mapsFilterCount");
const pageLabelEl = el("mapsPageLabel");
const sitesDialogEl = el("mapsSitesDialog");
const sitesListEl = el("mapsSitesList");
const siteFormDialogEl = el("mapsSiteFormDialog");
const deleteDialogEl = el("mapsDeleteDialog");
const createSiteSelectEl = el("mapsCreateSite");
let deleteDialogResolve = null;
const SITE_ICONS = {
chevron: ``,
edit: ``,
delete: ``,
};
const store = {
maps: [],
sites: [],
activeMapId: null,
filter: "",
page: 1,
editingSiteId: null,
sitesDialogSelectedId: null,
sitesDialogSnapshotId: null,
};
function canWrite() {
return window.AuthApp?.canWrite?.("maps") ?? true;
}
function currentUser() {
return window.AuthApp?.getUser?.() || null;
}
function canDeleteMap(map) {
if (!canWrite() || !map) return false;
const user = currentUser();
if (!user) return true;
const mapGroup = map.created_by_group;
if (mapGroup) return mapGroup === user.group_id;
return true;
}
function escapeHtml(str) {
return String(str)
.replace(/&/g, "&")
.replace(//g, ">")
.replace(/"/g, """);
}
async function api(path, opts = {}) {
const res = await fetch(path, { credentials: "include", ...opts });
if (!res.ok) {
let msg = res.statusText;
try {
const err = await res.json();
if (err.error) msg = err.error;
} catch {
/* ignore */
}
throw new Error(msg);
}
if (res.status === 204) return null;
return res.json();
}
function findMap(id) {
return store.maps.find((m) => m.id === id) || null;
}
function findSite(id) {
return store.sites.find((s) => s.id === id) || null;
}
function siteName(siteId) {
return findSite(siteId)?.name || siteId || "—";
}
function mapImageUrl(map) {
if (!map?.id || !map.image_file) return null;
return `/api/maps/${encodeURIComponent(map.id)}/image?t=${encodeURIComponent(map.updated_at || "")}`;
}
function filteredMaps() {
const q = store.filter.trim().toLowerCase();
let items = [...store.maps].sort((a, b) => {
const sa = siteName(a.site_id).localeCompare(siteName(b.site_id));
if (sa !== 0) return sa;
return (a.name || "").localeCompare(b.name || "");
});
if (q) {
items = items.filter((m) => {
const name = (m.name || "").toLowerCase();
const site = siteName(m.site_id).toLowerCase();
return name.includes(q) || site.includes(q);
});
}
return items;
}
function pageCount(total) {
return Math.max(1, Math.ceil(total / PAGE_SIZE));
}
function pagedMaps(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("maps.itemsFound", { n: totalItems });
}
if (pageLabelEl) {
pageLabelEl.textContent = t("maps.pageOf", { page: store.page, total: totalPages });
}
const atStart = store.page <= 1;
const atEnd = store.page >= totalPages;
el("mapsPageFirst")?.toggleAttribute("disabled", atStart);
el("mapsPagePrev")?.toggleAttribute("disabled", atStart);
el("mapsPageNext")?.toggleAttribute("disabled", atEnd);
el("mapsPageLast")?.toggleAttribute("disabled", atEnd);
}
async function loadSites() {
const data = await api("/api/sites");
store.sites = Array.isArray(data.sites) ? data.sites : [];
}
async function loadMaps() {
const data = await api("/api/maps");
store.maps = Array.isArray(data.maps) ? data.maps : [];
}
async function loadActiveMap() {
try {
const status = await api("/api/robot/status");
store.activeMapId = status.active_map_id || null;
} catch {
store.activeMapId = null;
}
}
function renderActiveHint() {
if (!activeHintEl) return;
const active = findMap(store.activeMapId);
if (active) {
activeHintEl.hidden = false;
activeHintEl.textContent = t("maps.activeHint", { name: active.name });
} else {
activeHintEl.hidden = true;
activeHintEl.textContent = "";
}
}
function renderSiteSelect(selectedId) {
if (!createSiteSelectEl) return;
createSiteSelectEl.innerHTML = "";
store.sites.forEach((site) => {
const opt = document.createElement("option");
opt.value = site.id;
opt.textContent = site.name || site.id;
createSiteSelectEl.appendChild(opt);
});
if (selectedId) createSiteSelectEl.value = selectedId;
else if (store.sites[0]) createSiteSelectEl.value = store.sites[0].id;
}
function renderList() {
if (!listEl) return;
const items = filteredMaps();
const pageItems = pagedMaps(items);
updatePagerUi(items.length);
listEl.innerHTML = "";
const showEmpty = items.length === 0;
if (tableEl) tableEl.hidden = showEmpty;
if (listEmptyEl) {
listEmptyEl.hidden = !showEmpty;
listEmptyEl.textContent = store.filter.trim() ? t("maps.emptyFilter") : t("maps.empty");
}
let lastSiteId = null;
pageItems.forEach((map) => {
const siteId = map.site_id || "";
if (siteId !== lastSiteId) {
lastSiteId = siteId;
const siteTr = document.createElement("tr");
siteTr.className = "mapsMirSiteRow";
siteTr.innerHTML = `
${escapeHtml(siteName(siteId))} | `;
listEl.appendChild(siteTr);
}
const isActive = map.id === store.activeMapId;
const tr = document.createElement("tr");
tr.className = "mapsMirRow";
tr.dataset.mapId = map.id;
const activeBadge = isActive
? `${ICONS.active}${escapeHtml(t("maps.activeBadge"))}`
: "";
const actions = canWrite()
? `
${canDeleteMap(map) ? `` : ""}
`
: `
`;
tr.innerHTML = `
${ICONS.map}
${activeBadge}
|
${escapeHtml(map.created_by || "—")} |
${actions} | `;
tr.querySelector("[data-open]")?.addEventListener("click", () => openEditor(map.id));
tr.querySelector("[data-edit]")?.addEventListener("click", () => openEditor(map.id));
tr.querySelector("[data-view]")?.addEventListener("click", () => openEditor(map.id, { readOnly: !canWrite() }));
tr.querySelector("[data-delete]")?.addEventListener("click", () => {
void deleteMapFromList(map.id);
});
tr.addEventListener("dblclick", () => openEditor(map.id));
listEl.appendChild(tr);
});
renderActiveHint();
}
function hideAllViews() {
[listViewEl, createViewEl, editorViewEl].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");
}
window.MapEditorApp?.close?.();
}
function showCreate() {
if (!canWrite()) return;
hideAllViews();
renderSiteSelect();
const nameEl = el("mapsCreateName");
if (nameEl) nameEl.value = "";
if (createViewEl) {
createViewEl.hidden = false;
createViewEl.removeAttribute("aria-hidden");
}
nameEl?.focus();
}
function openEditor(mapId, opts = {}) {
const map = findMap(mapId);
if (!map) return;
hideAllViews();
if (editorViewEl) {
editorViewEl.hidden = false;
editorViewEl.removeAttribute("aria-hidden");
}
window.MapEditorApp?.open?.(mapId, {
readOnly: opts.readOnly,
onMapUpdated: (updated) => {
const idx = store.maps.findIndex((m) => m.id === updated.id);
if (idx >= 0) store.maps[idx] = updated;
else store.maps.push(updated);
},
onMapDeleted: (id) => {
store.maps = store.maps.filter((m) => m.id !== id);
if (store.activeMapId === id) store.activeMapId = null;
showList();
renderList();
},
onActivated: (id) => {
store.activeMapId = id;
renderList();
},
onClose: () => {
showList();
renderList();
},
getSiteName: siteName,
getActiveMapId: () => store.activeMapId,
canWrite: canWrite(),
});
}
function confirmDeleteMap(map) {
return new Promise((resolve) => {
deleteDialogResolve = resolve;
const textEl = el("mapsDeleteDialogText");
const activeWarnEl = el("mapsDeleteDialogActiveWarn");
if (textEl) textEl.textContent = t("maps.deleteDialog.text", { name: map.name || map.id });
if (activeWarnEl) {
const isActive = map.id === store.activeMapId;
activeWarnEl.hidden = !isActive;
if (isActive) activeWarnEl.textContent = t("maps.deleteDialog.activeWarning");
}
deleteDialogEl?.showModal();
});
}
async function deleteMapFromList(mapId) {
const map = findMap(mapId);
if (!map || !canDeleteMap(map)) return;
if (!(await confirmDeleteMap(map))) return;
try {
await api(`/api/maps/${encodeURIComponent(map.id)}`, { method: "DELETE" });
} catch (e) {
alert(e.message || t("maps.deleteForbidden"));
return;
}
store.maps = store.maps.filter((m) => m.id !== map.id);
if (store.activeMapId === map.id) store.activeMapId = null;
renderList();
}
async function activateMap(mapId) {
const map = findMap(mapId);
if (!map) return;
if (!map.image_file) {
alert(t("maps.error.noImage"));
return;
}
await api("/api/robot/active_map", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ map_id: mapId }),
});
store.activeMapId = mapId;
renderList();
}
function openCreatePage() {
showCreate();
}
function renderSitesDialogList() {
if (!sitesListEl) return;
sitesListEl.innerHTML = "";
if (store.sites.length === 0) {
const empty = document.createElement("li");
empty.className = "mapsMirSitesEmpty";
empty.textContent = t("maps.sitesDialog.empty");
sitesListEl.appendChild(empty);
return;
}
store.sites.forEach((site) => {
const li = document.createElement("li");
li.className = "mapsMirSitesItem";
if (site.id === store.sitesDialogSelectedId) li.classList.add("is-selected");
li.dataset.siteId = site.id;
li.innerHTML = `
`;
li.querySelector("[data-select-site]")?.addEventListener("click", () => {
store.sitesDialogSelectedId = site.id;
renderSitesDialogList();
});
li.querySelector("[data-edit-site]")?.addEventListener("click", (evt) => {
evt.stopPropagation();
openSiteFormDialog(site.id);
});
li.querySelector("[data-delete-site]")?.addEventListener("click", (evt) => {
evt.stopPropagation();
void deleteSite(site.id);
});
sitesListEl.appendChild(li);
});
}
async function openSitesDialog() {
await loadSites();
store.sitesDialogSnapshotId = createSiteSelectEl?.value || store.sites[0]?.id || null;
store.sitesDialogSelectedId = store.sitesDialogSnapshotId;
renderSitesDialogList();
sitesDialogEl?.showModal();
}
function closeSitesDialog(apply) {
if (apply && store.sitesDialogSelectedId) {
renderSiteSelect(store.sitesDialogSelectedId);
}
sitesDialogEl?.close();
}
function openSiteFormDialog(siteId) {
store.editingSiteId = siteId || null;
const site = siteId ? findSite(siteId) : null;
const titleEl = el("mapsSiteFormTitle");
const nameEl = el("mapsSiteName");
if (titleEl) {
titleEl.textContent = site ? t("maps.siteForm.edit") : t("maps.siteForm.create");
}
if (nameEl) nameEl.value = site?.name || "";
siteFormDialogEl?.showModal();
nameEl?.focus();
}
async function saveSite(evt) {
evt.preventDefault();
const name = el("mapsSiteName")?.value.trim();
if (!name) return;
if (store.editingSiteId) {
const updated = await api(`/api/sites/${encodeURIComponent(store.editingSiteId)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
});
const idx = store.sites.findIndex((s) => s.id === store.editingSiteId);
if (idx >= 0) store.sites[idx] = updated;
} else {
const created = await api("/api/sites", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
});
store.sites.push(created);
store.sitesDialogSelectedId = created.id;
}
siteFormDialogEl?.close();
renderSitesDialogList();
renderList();
}
async function deleteSite(siteId) {
const site = findSite(siteId);
if (!site) return;
if (!confirm(t("maps.sitesDialog.deleteConfirm", { name: site.name }))) return;
try {
await api(`/api/sites/${encodeURIComponent(siteId)}`, { method: "DELETE" });
} catch (e) {
alert(e.message);
return;
}
store.sites = store.sites.filter((s) => s.id !== siteId);
if (store.sitesDialogSelectedId === siteId) {
store.sitesDialogSelectedId = store.sites[0]?.id || null;
}
renderSitesDialogList();
renderList();
}
async function createMap(evt) {
evt.preventDefault();
const name = el("mapsCreateName")?.value.trim();
const site_id = createSiteSelectEl?.value;
if (!name || !site_id) return;
const user = window.AuthApp?.getUser?.();
const created = await api("/api/maps", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name,
site_id,
created_by: user?.display_name || user?.username || "",
created_by_user: user?.id || "",
created_by_group: user?.group_id || "",
resolution: 0.05,
origin_x: 0,
origin_y: 0,
origin_yaw: 0,
}),
});
store.maps.push(created);
store.filter = "";
if (filterInputEl) filterInputEl.value = "";
store.page = 1;
renderList();
openEditor(created.id);
}
function clearFilters() {
store.filter = "";
store.page = 1;
if (filterInputEl) filterInputEl.value = "";
renderList();
}
function goToPage(page) {
const total = pageCount(filteredMaps().length);
store.page = Math.min(Math.max(1, page), total);
renderList();
}
function bindEvents() {
el("mapsCreateOpenBtn")?.addEventListener("click", openCreatePage);
el("mapsCreateGoBackBtn")?.addEventListener("click", showList);
el("mapsCreateCancelBtn")?.addEventListener("click", showList);
el("mapsCreateHelpBtn")?.addEventListener("click", () => alert(t("maps.createPage.helpText")));
el("mapsImportSiteBtn")?.addEventListener("click", () => alert(t("maps.importComingSoon")));
el("mapsClearFiltersBtn")?.addEventListener("click", clearFilters);
el("mapsHelpBtn")?.addEventListener("click", () => alert(t("maps.helpText")));
filterInputEl?.addEventListener("input", () => {
store.filter = filterInputEl.value;
store.page = 1;
renderList();
});
el("mapsPageFirst")?.addEventListener("click", () => goToPage(1));
el("mapsPagePrev")?.addEventListener("click", () => goToPage(store.page - 1));
el("mapsPageNext")?.addEventListener("click", () => goToPage(store.page + 1));
el("mapsPageLast")?.addEventListener("click", () => goToPage(pageCount(filteredMaps().length)));
el("mapsCreateForm")?.addEventListener("submit", (evt) => {
createMap(evt).catch((e) => alert(e.message));
});
el("mapsDeleteYesBtn")?.addEventListener("click", () => {
deleteDialogEl?.close();
deleteDialogResolve?.(true);
deleteDialogResolve = null;
});
el("mapsDeleteNoBtn")?.addEventListener("click", () => {
deleteDialogEl?.close();
deleteDialogResolve?.(false);
deleteDialogResolve = null;
});
deleteDialogEl?.addEventListener("cancel", (evt) => {
evt.preventDefault();
deleteDialogEl.close();
deleteDialogResolve?.(false);
deleteDialogResolve = null;
});
el("mapsSiteForm")?.addEventListener("submit", (evt) => {
saveSite(evt).catch((e) => alert(e.message));
});
el("mapsCreateSiteBtn")?.addEventListener("click", () => {
openSitesDialog().catch((e) => alert(e.message));
});
el("mapsSitesCreateBtn")?.addEventListener("click", () => openSiteFormDialog(null));
el("mapsSitesOkBtn")?.addEventListener("click", () => closeSitesDialog(true));
el("mapsSitesCancelBtn")?.addEventListener("click", () => closeSitesDialog(false));
sitesDialogEl?.addEventListener("cancel", (evt) => {
evt.preventDefault();
closeSitesDialog(false);
});
document.querySelectorAll("[data-close-dialog]").forEach((btn) => {
btn.addEventListener("click", () => {
const id = btn.getAttribute("data-close-dialog");
el(id)?.close();
});
});
}
function applyReadOnly() {
document.body.classList.toggle("auth-readonly-maps-page", !canWrite());
}
async function refresh() {
await Promise.all([loadSites(), loadMaps(), loadActiveMap()]);
renderList();
}
async function init() {
applyReadOnly();
showList();
bindEvents();
try {
await refresh();
} catch (e) {
if (listEmptyEl) {
listEmptyEl.hidden = false;
listEmptyEl.textContent = t("common.error", { msg: e.message });
}
if (tableEl) tableEl.hidden = true;
}
}
window.MapsApp = {
init,
refresh,
onPageShow() {
applyReadOnly();
showList();
refresh().catch(() => {});
},
getMaps: () => [...store.maps],
getMapById: findMap,
activateMap,
};
function boot() {
init();
}
if (window.AuthApp?.isReady()) boot();
else window.addEventListener("lm:auth-ready", boot, { once: true });
window.addEventListener("lm:locale-change", () => {
renderList();
renderActiveHint();
});
})();