Files
App/www/transitions.js
HiepLM 523f98b74c
Some checks failed
Test / test (push) Has been cancelled
update sounds ang transtions
2026-06-22 17:16:36 +07:00

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, "&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, 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 };
})();