This commit is contained in:
257
www/paths.js
Normal file
257
www/paths.js
Normal file
@@ -0,0 +1,257 @@
|
||||
(() => {
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
const ICONS = {
|
||||
path: `<svg class="pathsMirIcon" width="22" height="22" viewBox="0 0 22 22" aria-hidden="true"><circle cx="5" cy="11" r="2.5" fill="currentColor"/><circle cx="17" cy="11" r="2.5" fill="currentColor"/><path d="M7.5 11h7" stroke="currentColor" stroke-width="1.5" stroke-dasharray="2 2" stroke-linecap="round"/></svg>`,
|
||||
view: `<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M1 7s2.5-4 6-4 6 4 6 4-2.5 4-6 4-6-4-6-4z" fill="none" stroke="currentColor" stroke-width="1.2"/><circle cx="7" cy="7" r="1.8" fill="none" stroke="currentColor" stroke-width="1.2"/></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("pathList");
|
||||
const emptyEl = el("pathListEmpty");
|
||||
const tableEl = el("pathsTable");
|
||||
const filterInputEl = el("pathsFilterInput");
|
||||
const filterCountEl = el("pathsFilterCount");
|
||||
const pageLabelEl = el("pathsPageLabel");
|
||||
const deleteConfirmDialogEl = el("pathDeleteConfirmDialog");
|
||||
const deleteConfirmTextEl = el("pathDeleteConfirmText");
|
||||
|
||||
const store = {
|
||||
paths: [],
|
||||
maps: [],
|
||||
sites: [],
|
||||
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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
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 [sitesData, mapsData, pathsData] = await Promise.all([
|
||||
apiJson("/api/sites"),
|
||||
apiJson("/api/maps"),
|
||||
apiJson("/api/paths"),
|
||||
]);
|
||||
store.sites = Array.isArray(sitesData.sites) ? sitesData.sites : [];
|
||||
store.maps = Array.isArray(mapsData.maps) ? mapsData.maps : [];
|
||||
store.paths = Array.isArray(pathsData.paths) ? pathsData.paths : [];
|
||||
return store.paths;
|
||||
}
|
||||
|
||||
function mapName(id) {
|
||||
return store.maps.find((m) => m.id === id)?.name || id || "—";
|
||||
}
|
||||
|
||||
function positionLabel(mapId, positionId) {
|
||||
const map = store.maps.find((m) => m.id === mapId);
|
||||
const zones = Array.isArray(map?.zones) ? map.zones : [];
|
||||
const hit = zones.find((z) => z && z.type === "position" && z.id === positionId);
|
||||
const pname = hit?.name || positionId || "—";
|
||||
return `${mapName(mapId)} / ${pname}`;
|
||||
}
|
||||
|
||||
function filteredPaths() {
|
||||
const q = store.filter.trim().toLowerCase();
|
||||
let items = [...store.paths];
|
||||
if (q) {
|
||||
items = items.filter((p) => {
|
||||
const from = positionLabel(p.map_id, p.from_position_id).toLowerCase();
|
||||
const to = positionLabel(p.map_id, p.to_position_id).toLowerCase();
|
||||
const map = mapName(p.map_id).toLowerCase();
|
||||
return from.includes(q) || to.includes(q) || map.includes(q);
|
||||
});
|
||||
}
|
||||
return items.sort((a, b) => {
|
||||
const ma = mapName(a.map_id).localeCompare(mapName(b.map_id));
|
||||
if (ma !== 0) return ma;
|
||||
return positionLabel(a.map_id, a.from_position_id).localeCompare(
|
||||
positionLabel(b.map_id, b.from_position_id),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function pageCount(total) {
|
||||
return Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||
}
|
||||
|
||||
function renderList() {
|
||||
if (!listEl) return;
|
||||
const items = filteredPaths();
|
||||
const total = items.length;
|
||||
const pages = pageCount(total);
|
||||
if (store.page > pages) store.page = pages;
|
||||
const start = (store.page - 1) * PAGE_SIZE;
|
||||
const pageItems = items.slice(start, start + PAGE_SIZE);
|
||||
|
||||
if (filterCountEl) filterCountEl.textContent = t("paths.itemsFound", { n: total });
|
||||
if (pageLabelEl) pageLabelEl.textContent = t("paths.pageOf", { page: store.page, total: pages });
|
||||
|
||||
listEl.innerHTML = "";
|
||||
if (tableEl) tableEl.hidden = total === 0;
|
||||
if (emptyEl) {
|
||||
emptyEl.hidden = total > 0;
|
||||
emptyEl.textContent = store.filter ? t("paths.emptyFilter") : t("paths.empty");
|
||||
}
|
||||
|
||||
pageItems.forEach((path) => {
|
||||
const tr = document.createElement("tr");
|
||||
tr.className = "pathsMirRow";
|
||||
tr.innerHTML = `
|
||||
<td class="pathsMirTdIcon">${ICONS.path}</td>
|
||||
<td>${escapeHtml(positionLabel(path.map_id, path.from_position_id))}</td>
|
||||
<td>${escapeHtml(positionLabel(path.map_id, path.to_position_id))}</td>
|
||||
<td>${escapeHtml(mapName(path.map_id))}</td>
|
||||
<td class="mapsMirTdFunctions">
|
||||
<button type="button" class="mapsMirIconBtn" data-view="${escapeHtml(path.id)}" title="${escapeHtml(t("paths.view"))}">${ICONS.view}</button>
|
||||
${canWrite() ? `<button type="button" class="mapsMirIconBtn mapsMirIconBtn--danger" data-delete="${escapeHtml(path.id)}" title="${escapeHtml(t("common.delete"))}">${ICONS.delete}</button>` : ""}
|
||||
</td>`;
|
||||
listEl.appendChild(tr);
|
||||
});
|
||||
|
||||
document.body.classList.toggle("auth-readonly-paths", !canWrite());
|
||||
}
|
||||
|
||||
async function viewPath(id) {
|
||||
const path = store.paths.find((p) => p.id === id);
|
||||
if (!path) return;
|
||||
let full = path;
|
||||
try {
|
||||
full = await apiJson(`/api/paths/${encodeURIComponent(id)}`);
|
||||
} catch {
|
||||
/* use list row */
|
||||
}
|
||||
if (window.MapsApp?.openEditorWithPath) {
|
||||
window.NavApp?.selectSection?.("maps", "maps");
|
||||
window.MapsApp.openEditorWithPath(full);
|
||||
} else {
|
||||
alert(t("paths.viewUnavailable"));
|
||||
}
|
||||
}
|
||||
|
||||
function openDeleteConfirm(id) {
|
||||
const path = store.paths.find((p) => p.id === id);
|
||||
if (!path) return;
|
||||
store.pendingDeleteId = id;
|
||||
if (deleteConfirmTextEl) {
|
||||
deleteConfirmTextEl.textContent = t("paths.deleteConfirmText", {
|
||||
from: positionLabel(path.map_id, path.from_position_id),
|
||||
to: positionLabel(path.map_id, path.to_position_id),
|
||||
});
|
||||
}
|
||||
deleteConfirmDialogEl?.showModal();
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
const id = store.pendingDeleteId;
|
||||
if (!id) return;
|
||||
try {
|
||||
await apiJson(`/api/paths/${encodeURIComponent(id)}`, { method: "DELETE" });
|
||||
store.pendingDeleteId = null;
|
||||
deleteConfirmDialogEl?.close();
|
||||
await refreshAll();
|
||||
renderList();
|
||||
} catch (e) {
|
||||
alert(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function bindEvents() {
|
||||
listEl?.addEventListener("click", (evt) => {
|
||||
const viewBtn = evt.target.closest("[data-view]");
|
||||
const deleteBtn = evt.target.closest("[data-delete]");
|
||||
if (viewBtn?.dataset.view) void viewPath(viewBtn.dataset.view);
|
||||
else if (deleteBtn?.dataset.delete && canWrite()) openDeleteConfirm(deleteBtn.dataset.delete);
|
||||
});
|
||||
|
||||
filterInputEl?.addEventListener("input", () => {
|
||||
store.filter = filterInputEl.value;
|
||||
store.page = 1;
|
||||
renderList();
|
||||
});
|
||||
el("pathsClearFiltersBtn")?.addEventListener("click", () => {
|
||||
store.filter = "";
|
||||
store.page = 1;
|
||||
if (filterInputEl) filterInputEl.value = "";
|
||||
renderList();
|
||||
});
|
||||
el("pathsPageFirst")?.addEventListener("click", () => {
|
||||
store.page = 1;
|
||||
renderList();
|
||||
});
|
||||
el("pathsPagePrev")?.addEventListener("click", () => {
|
||||
store.page = Math.max(1, store.page - 1);
|
||||
renderList();
|
||||
});
|
||||
el("pathsPageNext")?.addEventListener("click", () => {
|
||||
store.page += 1;
|
||||
renderList();
|
||||
});
|
||||
el("pathsPageLast")?.addEventListener("click", () => {
|
||||
store.page = pageCount(filteredPaths().length);
|
||||
renderList();
|
||||
});
|
||||
el("pathsHelpBtn")?.addEventListener("click", () => alert(t("paths.helpBody")));
|
||||
|
||||
deleteConfirmDialogEl?.addEventListener("cancel", (evt) => {
|
||||
evt.preventDefault();
|
||||
store.pendingDeleteId = null;
|
||||
deleteConfirmDialogEl?.close();
|
||||
});
|
||||
el("pathDeleteCancelBtn")?.addEventListener("click", () => {
|
||||
store.pendingDeleteId = null;
|
||||
deleteConfirmDialogEl?.close();
|
||||
});
|
||||
el("pathDeleteYesBtn")?.addEventListener("click", () => confirmDelete().catch((e) => alert(e.message)));
|
||||
|
||||
window.addEventListener("lm:locale-change", () => renderList());
|
||||
}
|
||||
|
||||
async function onPageShow() {
|
||||
if (!window.AuthApp?.canAccessPage?.("paths")) return;
|
||||
await refreshAll();
|
||||
renderList();
|
||||
}
|
||||
|
||||
function onPageHide() {}
|
||||
|
||||
function init() {
|
||||
bindEvents();
|
||||
}
|
||||
|
||||
window.PathsApp = { init, onPageShow, onPageHide, refresh: refreshAll };
|
||||
|
||||
function boot() {
|
||||
init();
|
||||
}
|
||||
|
||||
if (window.AuthApp?.isReady()) boot();
|
||||
else window.addEventListener("lm:auth-ready", boot, { once: true });
|
||||
})();
|
||||
Reference in New Issue
Block a user