(() => { 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() ? `
${canDeleteGuide(guide) ? `` : ""}
` : ""; tr.innerHTML = ` ${ICONS.guide} ${escapeHtml(mapName(guide.map_id))} ${guide.starts_count ?? 0} ${guide.vias_count ?? 0} ${guide.goals_count ?? 0} ${actions}`; 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 }; })();