finish setup
Some checks failed
Test / test (push) Has been cancelled

This commit is contained in:
2026-06-23 16:46:31 +02:00
parent 2ce8a23ce9
commit 773341fc84
16 changed files with 1780 additions and 4 deletions

693
www/path-guides.js Normal file
View File

@@ -0,0 +1,693 @@
(() => {
const PAGE_SIZE = 10;
const ICONS = {
guide: `<svg class="pathGuideMirIcon" width="22" height="22" viewBox="0 0 22 22" aria-hidden="true"><circle cx="4" cy="11" r="2.5" fill="none" stroke="currentColor" stroke-width="1.5"/><circle cx="11" cy="11" r="2.5" fill="none" stroke="currentColor" stroke-width="1.5"/><circle cx="18" cy="11" r="2.5" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M6.5 11h2M13.5 11h2" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>`,
edit: `<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M9.5 2.5l2 2L5 11H3v-2L9.5 2.5z" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/></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>`,
drag: `<svg class="pathGuideDragIcon" width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M5 3h1v1H5V3zm3 0h1v1H8V3zM5 6h1v1H5V6zm3 0h1v1H8V6zm-3 3h1v1H5V9zm3 0h1v1H8V9z" fill="currentColor"/></svg>`,
remove: `<svg width="12" height="12" viewBox="0 0 12 12" aria-hidden="true"><path d="M3 3l6 6M9 3l-6 6" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>`,
};
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, "&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 [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
? `<span class="pathGuidePosDrag" title="${escapeHtml(t("pathGuides.dragHandle"))}">${ICONS.drag}</span>`
: "";
li.innerHTML = `${dragHtml}<span class="pathGuidePosCardLabel">${escapeHtml(label)}</span>
<button type="button" class="pathGuidePosRemoveBtn" data-remove-kind="${removeKind}" data-remove-key="${escapeHtml(String(removeKey))}" title="${escapeHtml(t("common.delete"))}">${ICONS.remove}</button>`;
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()
? `<div class="mapsMirRowActions">
<button type="button" class="mapsMirIconBtn" data-edit="${escapeHtml(guide.id)}" title="${escapeHtml(t("common.edit"))}">${ICONS.edit}</button>
${canDeleteGuide(guide) ? `<button type="button" class="mapsMirIconBtn mapsMirIconBtn--danger" data-delete="${escapeHtml(guide.id)}" title="${escapeHtml(t("common.delete"))}">${ICONS.delete}</button>` : ""}
</div>`
: "";
tr.innerHTML = `
<td class="pathGuideMirCellIcon">${ICONS.guide}</td>
<td>
<button type="button" class="mapsMirNameLink" data-edit="${escapeHtml(guide.id)}">${escapeHtml(guide.name || "—")}</button>
</td>
<td>${escapeHtml(mapName(guide.map_id))}</td>
<td class="pathGuideMirCellNum">${guide.starts_count ?? 0}</td>
<td class="pathGuideMirCellNum">${guide.vias_count ?? 0}</td>
<td class="pathGuideMirCellNum">${guide.goals_count ?? 0}</td>
<td class="mapsMirCellActions">${actions}</td>`;
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 };
})();