457 lines
16 KiB
JavaScript
457 lines
16 KiB
JavaScript
(() => {
|
|
const PAGE_SIZE = 10;
|
|
|
|
const ICONS = {
|
|
transition: `<svg class="transMirIcon" width="22" height="22" viewBox="0 0 22 22" aria-hidden="true"><circle cx="5" cy="11" r="3.5" fill="none" stroke="currentColor" stroke-width="1.5"/><circle cx="17" cy="11" r="3.5" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M8.5 11h5" 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>`,
|
|
};
|
|
|
|
const el = (id) => document.getElementById(id);
|
|
const t = (key, vars) => window.I18n?.t(key, vars) ?? key;
|
|
|
|
const listEl = el("transitionList");
|
|
const emptyEl = el("transitionListEmpty");
|
|
const tableEl = el("transitionsTable");
|
|
const createBtnEl = el("transitionCreateBtn");
|
|
const filterInputEl = el("transitionsFilterInput");
|
|
const filterCountEl = el("transitionsFilterCount");
|
|
const pageLabelEl = el("transitionsPageLabel");
|
|
const dialogEl = el("transitionEditDialog");
|
|
const formEl = el("transitionEditForm");
|
|
const titleEl = el("transitionEditTitle");
|
|
const deleteBtnEl = el("transitionEditDeleteBtn");
|
|
const deleteConfirmDialogEl = el("transitionDeleteConfirmDialog");
|
|
const deleteConfirmTextEl = el("transitionDeleteConfirmText");
|
|
|
|
const fields = {
|
|
site: el("transitionEditSite"),
|
|
fromMap: el("transitionEditFromMap"),
|
|
toMap: el("transitionEditToMap"),
|
|
startPos: el("transitionEditStartPos"),
|
|
goalPos: el("transitionEditGoalPos"),
|
|
mission: el("transitionEditMission"),
|
|
};
|
|
|
|
const store = {
|
|
transitions: [],
|
|
sites: [],
|
|
maps: [],
|
|
missions: [],
|
|
editingId: null,
|
|
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 [sites, maps, missions, transitions] = await Promise.all([
|
|
apiJson("/api/sites"),
|
|
apiJson("/api/maps"),
|
|
apiJson("/api/missions"),
|
|
apiJson("/api/transitions"),
|
|
]);
|
|
store.sites = Array.isArray(sites.sites) ? sites.sites : [];
|
|
store.maps = Array.isArray(maps.maps) ? maps.maps : [];
|
|
store.missions = Array.isArray(missions.missions) ? missions.missions : [];
|
|
store.transitions = Array.isArray(transitions.transitions) ? transitions.transitions : [];
|
|
}
|
|
|
|
function siteName(id) {
|
|
const s = store.sites.find((x) => x.id === id);
|
|
return s?.name || id || "—";
|
|
}
|
|
|
|
function mapName(id) {
|
|
const m = store.maps.find((x) => x.id === id);
|
|
return m?.name || id || "—";
|
|
}
|
|
|
|
function missionName(id) {
|
|
const m = store.missions.find((x) => x.id === id);
|
|
return m?.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) {
|
|
const m = store.maps.find((x) => x.id === mapId);
|
|
const zones = Array.isArray(m?.zones) ? m.zones : [];
|
|
const positions = zones.filter((z) => z && z.type === "position" && typeof z.id === "string");
|
|
return positions.map((p) => ({ value: p.id, label: p.name || p.id }));
|
|
}
|
|
|
|
function fillSelect(selectEl, options, value) {
|
|
if (!selectEl) return;
|
|
selectEl.innerHTML = "";
|
|
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 filteredTransitions() {
|
|
const q = store.filter.trim().toLowerCase();
|
|
let items = [...store.transitions].sort((a, b) => {
|
|
const sa = siteName(a.site_id).localeCompare(siteName(b.site_id));
|
|
if (sa !== 0) return sa;
|
|
const startA = positionName(a.from_map_id, a.start_position_id);
|
|
const startB = positionName(b.from_map_id, b.start_position_id);
|
|
return startA.localeCompare(startB);
|
|
});
|
|
if (q) {
|
|
items = items.filter((tr) => {
|
|
const start = positionName(tr.from_map_id, tr.start_position_id).toLowerCase();
|
|
const goal = positionName(tr.to_map_id, tr.goal_position_id).toLowerCase();
|
|
const mission = missionName(tr.mission_id).toLowerCase();
|
|
const fromMap = mapName(tr.from_map_id).toLowerCase();
|
|
const toMap = mapName(tr.to_map_id).toLowerCase();
|
|
const site = siteName(tr.site_id).toLowerCase();
|
|
return (
|
|
start.includes(q) ||
|
|
goal.includes(q) ||
|
|
mission.includes(q) ||
|
|
fromMap.includes(q) ||
|
|
toMap.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("transitions.itemsFound", { n: totalItems });
|
|
if (pageLabelEl) pageLabelEl.textContent = t("transitions.pageOf", { page: store.page, total: totalPages });
|
|
const atStart = store.page <= 1;
|
|
const atEnd = store.page >= totalPages;
|
|
el("transitionsPageFirst")?.toggleAttribute("disabled", atStart);
|
|
el("transitionsPagePrev")?.toggleAttribute("disabled", atStart);
|
|
el("transitionsPageNext")?.toggleAttribute("disabled", atEnd);
|
|
el("transitionsPageLast")?.toggleAttribute("disabled", atEnd);
|
|
}
|
|
|
|
function renderList() {
|
|
if (!listEl) return;
|
|
const items = filteredTransitions();
|
|
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("transitions.emptyFilter") : t("transitions.empty");
|
|
}
|
|
|
|
let lastSiteId = null;
|
|
pageItems.forEach((tr) => {
|
|
const siteId = tr.site_id || "";
|
|
if (siteId !== lastSiteId) {
|
|
lastSiteId = siteId;
|
|
const siteTr = document.createElement("tr");
|
|
siteTr.className = "mapsMirSiteRow";
|
|
siteTr.innerHTML = `<td colspan="6">${escapeHtml(siteName(siteId))}</td>`;
|
|
listEl.appendChild(siteTr);
|
|
}
|
|
|
|
const startLabel = positionName(tr.from_map_id, tr.start_position_id);
|
|
const goalLabel = positionName(tr.to_map_id, tr.goal_position_id);
|
|
const missionLabel = missionName(tr.mission_id);
|
|
const createdBy = tr.created_by || "—";
|
|
|
|
const trEl = document.createElement("tr");
|
|
trEl.className = "mapsMirRow transMirRow";
|
|
trEl.dataset.id = tr.id;
|
|
|
|
const actions = canWrite()
|
|
? `<div class="mapsMirRowActions">
|
|
<button type="button" class="mapsMirIconBtn" data-edit="${escapeHtml(tr.id)}" title="${escapeHtml(t("common.edit"))}">${ICONS.edit}</button>
|
|
<button type="button" class="mapsMirIconBtn mapsMirIconBtn--danger" data-delete="${escapeHtml(tr.id)}" title="${escapeHtml(t("common.delete"))}">${ICONS.delete}</button>
|
|
</div>`
|
|
: "";
|
|
|
|
trEl.innerHTML = `
|
|
<td class="transMirCellIcon">${ICONS.transition}</td>
|
|
<td class="transMirCellStart">
|
|
<button type="button" class="mapsMirNameLink transMirNameLink" data-edit="${escapeHtml(tr.id)}">${escapeHtml(startLabel)}</button>
|
|
</td>
|
|
<td class="transMirCellGoal">${escapeHtml(goalLabel)}</td>
|
|
<td class="transMirCellMission">${escapeHtml(missionLabel)}</td>
|
|
<td class="mapsMirCellCreatedBy">${escapeHtml(createdBy)}</td>
|
|
<td class="mapsMirCellActions">${actions}</td>`;
|
|
|
|
trEl.querySelectorAll("[data-edit]").forEach((btn) => {
|
|
btn.addEventListener("click", () => openDialog(btn.dataset.edit));
|
|
});
|
|
trEl.querySelector("[data-delete]")?.addEventListener("click", () => openDeleteConfirm(tr.id));
|
|
trEl.addEventListener("dblclick", () => {
|
|
if (canWrite()) openDialog(tr.id);
|
|
});
|
|
listEl.appendChild(trEl);
|
|
});
|
|
|
|
if (createBtnEl) createBtnEl.hidden = !canWrite();
|
|
}
|
|
|
|
function currentEditingTransition() {
|
|
return store.editingId ? store.transitions.find((x) => x.id === store.editingId) : null;
|
|
}
|
|
|
|
function readPayload() {
|
|
return {
|
|
site_id: fields.site?.value || "",
|
|
from_map_id: fields.fromMap?.value || "",
|
|
to_map_id: fields.toMap?.value || "",
|
|
start_position_id: fields.startPos?.value || "",
|
|
goal_position_id: fields.goalPos?.value || "",
|
|
mission_id: fields.mission?.value || "",
|
|
};
|
|
}
|
|
|
|
function updateDependentSelects() {
|
|
const fromMapId = fields.fromMap?.value || "";
|
|
const toMapId = fields.toMap?.value || "";
|
|
fillSelect(fields.startPos, positionOptionsForMap(fromMapId), fields.startPos?.value);
|
|
fillSelect(fields.goalPos, positionOptionsForMap(toMapId), fields.goalPos?.value);
|
|
}
|
|
|
|
function refreshMapSelectsForSite(existing) {
|
|
const siteOpts = store.sites.map((s) => ({ value: s.id, label: s.name || s.id }));
|
|
const defaultSite = existing?.site_id || store.maps[0]?.site_id || siteOpts[0]?.value || "";
|
|
fillSelect(fields.site, siteOpts, fields.site?.value || defaultSite);
|
|
|
|
const mapsForSite = store.maps.filter((m) => (m.site_id || "") === (fields.site?.value || ""));
|
|
const mapOpts = mapsForSite.map((m) => ({ value: m.id, label: m.name || m.id }));
|
|
fillSelect(fields.fromMap, mapOpts, existing?.from_map_id || mapOpts[0]?.value);
|
|
fillSelect(fields.toMap, mapOpts, existing?.to_map_id || mapOpts[0]?.value);
|
|
updateDependentSelects();
|
|
}
|
|
|
|
function openDialog(id = null) {
|
|
store.editingId = id;
|
|
const existing = id ? store.transitions.find((x) => x.id === id) : null;
|
|
if (titleEl) titleEl.textContent = existing ? t("transitions.editTitle") : t("transitions.createTitle");
|
|
|
|
refreshMapSelectsForSite(existing);
|
|
if (fields.startPos && existing?.start_position_id) fields.startPos.value = existing.start_position_id;
|
|
if (fields.goalPos && existing?.goal_position_id) fields.goalPos.value = existing.goal_position_id;
|
|
|
|
const missionOpts = store.missions.map((m) => ({ value: m.id, label: m.name || m.id }));
|
|
fillSelect(fields.mission, missionOpts, existing?.mission_id || missionOpts[0]?.value);
|
|
|
|
const ro = !canWrite();
|
|
Object.values(fields).forEach((node) => {
|
|
if (!node) return;
|
|
node.disabled = ro;
|
|
});
|
|
if (deleteBtnEl) deleteBtnEl.hidden = !existing || ro;
|
|
dialogEl?.showModal();
|
|
}
|
|
|
|
async function saveDialog() {
|
|
if (!canWrite()) return;
|
|
const payload = readPayload();
|
|
if (
|
|
!payload.site_id ||
|
|
!payload.from_map_id ||
|
|
!payload.to_map_id ||
|
|
!payload.start_position_id ||
|
|
!payload.goal_position_id ||
|
|
!payload.mission_id
|
|
) {
|
|
alert(t("transitions.error.missing"));
|
|
return;
|
|
}
|
|
try {
|
|
if (store.editingId) {
|
|
await apiJson(`/api/transitions/${encodeURIComponent(store.editingId)}`, {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
} else {
|
|
await apiJson("/api/transitions", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
}
|
|
await refreshAll();
|
|
renderList();
|
|
dialogEl?.close();
|
|
} catch (e) {
|
|
alert(e.message);
|
|
}
|
|
}
|
|
|
|
function openDeleteConfirm(id) {
|
|
const tr = store.transitions.find((x) => x.id === id);
|
|
if (!tr || !canWrite()) return;
|
|
store.pendingDeleteId = id;
|
|
if (deleteConfirmTextEl) {
|
|
deleteConfirmTextEl.textContent = t("transitions.deleteConfirmText", {
|
|
from: positionName(tr.from_map_id, tr.start_position_id),
|
|
to: positionName(tr.to_map_id, tr.goal_position_id),
|
|
});
|
|
}
|
|
deleteConfirmDialogEl?.showModal();
|
|
}
|
|
|
|
function openDeleteConfirmFromDialog() {
|
|
const tr = currentEditingTransition();
|
|
if (!tr) return;
|
|
openDeleteConfirm(tr.id);
|
|
}
|
|
|
|
async function confirmDelete() {
|
|
const id = store.pendingDeleteId || store.editingId;
|
|
if (!id || !canWrite()) return;
|
|
try {
|
|
await apiJson(`/api/transitions/${encodeURIComponent(id)}`, { method: "DELETE" });
|
|
deleteConfirmDialogEl?.close();
|
|
dialogEl?.close();
|
|
store.editingId = null;
|
|
store.pendingDeleteId = null;
|
|
await refreshAll();
|
|
renderList();
|
|
} catch (e) {
|
|
alert(e.message);
|
|
}
|
|
}
|
|
|
|
function clearFilters() {
|
|
store.filter = "";
|
|
store.page = 1;
|
|
if (filterInputEl) filterInputEl.value = "";
|
|
renderList();
|
|
}
|
|
|
|
function bindEvents() {
|
|
createBtnEl?.addEventListener("click", () => openDialog(null));
|
|
formEl?.addEventListener("submit", (evt) => {
|
|
evt.preventDefault();
|
|
saveDialog();
|
|
});
|
|
el("transitionEditCancelBtn")?.addEventListener("click", () => dialogEl?.close());
|
|
dialogEl?.addEventListener("cancel", (evt) => {
|
|
evt.preventDefault();
|
|
dialogEl?.close();
|
|
});
|
|
fields.site?.addEventListener("change", () => {
|
|
const existing = currentEditingTransition();
|
|
refreshMapSelectsForSite(existing);
|
|
});
|
|
fields.fromMap?.addEventListener("change", updateDependentSelects);
|
|
fields.toMap?.addEventListener("change", updateDependentSelects);
|
|
deleteBtnEl?.addEventListener("click", openDeleteConfirmFromDialog);
|
|
el("transitionDeleteCancelBtn")?.addEventListener("click", () => {
|
|
store.pendingDeleteId = null;
|
|
deleteConfirmDialogEl?.close();
|
|
});
|
|
el("transitionDeleteYesBtn")?.addEventListener("click", confirmDelete);
|
|
deleteConfirmDialogEl?.addEventListener("cancel", (evt) => {
|
|
evt.preventDefault();
|
|
store.pendingDeleteId = null;
|
|
deleteConfirmDialogEl?.close();
|
|
});
|
|
|
|
filterInputEl?.addEventListener("input", () => {
|
|
store.filter = filterInputEl.value;
|
|
store.page = 1;
|
|
renderList();
|
|
});
|
|
el("transitionsClearFiltersBtn")?.addEventListener("click", clearFilters);
|
|
el("transitionsPageFirst")?.addEventListener("click", () => {
|
|
store.page = 1;
|
|
renderList();
|
|
});
|
|
el("transitionsPagePrev")?.addEventListener("click", () => {
|
|
store.page = Math.max(1, store.page - 1);
|
|
renderList();
|
|
});
|
|
el("transitionsPageNext")?.addEventListener("click", () => {
|
|
store.page += 1;
|
|
renderList();
|
|
});
|
|
el("transitionsPageLast")?.addEventListener("click", () => {
|
|
store.page = pageCount(filteredTransitions().length);
|
|
renderList();
|
|
});
|
|
el("transitionsHelpBtn")?.addEventListener("click", () => {
|
|
alert(t("transitions.helpBody"));
|
|
});
|
|
|
|
window.addEventListener("lm:locale-change", () => renderList());
|
|
}
|
|
|
|
async function onPageShow() {
|
|
try {
|
|
await refreshAll();
|
|
renderList();
|
|
} catch (e) {
|
|
if (emptyEl) {
|
|
emptyEl.hidden = false;
|
|
emptyEl.textContent = e.message;
|
|
}
|
|
if (tableEl) tableEl.hidden = true;
|
|
}
|
|
}
|
|
|
|
function onPageHide() {
|
|
dialogEl?.close();
|
|
deleteConfirmDialogEl?.close();
|
|
}
|
|
|
|
bindEvents();
|
|
window.TransitionsApp = { onPageShow, onPageHide };
|
|
})();
|