(() => { const PAGE_SIZE = 10; const ICONS = { guide: ``, edit: ``, delete: ``, drag: ``, remove: ``, }; 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, "&") .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 [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 ? `${ICONS.drag}` : ""; li.innerHTML = `${dragHtml}${escapeHtml(label)} `; 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() ? `