This commit is contained in:
456
www/transitions.js
Normal file
456
www/transitions.js
Normal file
@@ -0,0 +1,456 @@
|
||||
(() => {
|
||||
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 };
|
||||
})();
|
||||
Reference in New Issue
Block a user