Files
App/www/paths.js
HiepLM 2ce8a23ce9
Some checks failed
Test / test (push) Has been cancelled
thêm phần paths
2026-06-23 17:51:54 +07:00

258 lines
8.8 KiB
JavaScript

(() => {
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, "&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 [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 });
})();