Мод-менеджер с галочками и коллекции мастерской
Вместо двух отдельных списков (Workshop ID и Mod ID) — один список модов: галочка включает мод, перетаскивание задаёт порядок загрузки, кнопка убирает пакет вместе со всеми его модами. Чтобы галочкам было за что зацепиться, панель читает mod.info уже скачанных модов: серверу в Mods нужен именно Mod ID, а он лежит внутри пакета мастерской и вручную его приходилось искать в описании мода. Пока мод не скачан, строка помечена и галочка недоступна — Mod ID неизвестен. Один пакет мастерской может содержать несколько модов; теперь они видны отдельными строками и включаются по отдельности. Коллекции: кнопка разворачивает коллекцию Steam в список входящих в неё модов, сохраняя порядок со страницы коллекции. И мод, и коллекция принимаются как ссылкой, так и голым идентификатором. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+253
-54
@@ -612,7 +612,17 @@ $("#btn-sandbox-save").addEventListener("click", async () => {
|
||||
// Моды
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let mods = { workshop: [], modIDs: [], maps: [] };
|
||||
// В Project Zomboid список модов задаётся двумя строками конфига:
|
||||
// WorkshopItems — что скачать из мастерской, Mods — что включить и в каком
|
||||
// порядке. Панель показывает их одним списком: строка на мод, галочка =
|
||||
// присутствие в Mods, порядок строк = порядок в Mods.
|
||||
let mods = {
|
||||
workshop: [], // WorkshopItems
|
||||
enabled: [], // Mods, порядок важен
|
||||
maps: [],
|
||||
installed: [], // что реально лежит на диске (из mod.info)
|
||||
details: {}, // сведения из мастерской по Workshop ID
|
||||
};
|
||||
|
||||
async function loadMods() {
|
||||
const data = await get("/api/mods");
|
||||
@@ -625,100 +635,289 @@ async function loadMods() {
|
||||
|
||||
mods = {
|
||||
workshop: data.workshop_items || [],
|
||||
modIDs: data.mods || [],
|
||||
enabled: data.mods || [],
|
||||
maps: data.map || [],
|
||||
installed: data.installed || [],
|
||||
details: mods.details,
|
||||
};
|
||||
$("#map-list").value = mods.maps.join("\n");
|
||||
renderMods();
|
||||
lookupWorkshopTitles().catch(() => {
|
||||
/* без интернета останутся одни идентификаторы — это не ошибка панели */
|
||||
/* без интернета останутся идентификаторы — это не ошибка панели */
|
||||
});
|
||||
}
|
||||
|
||||
function renderMods(details = {}) {
|
||||
$("#workshop-list").innerHTML = mods.workshop
|
||||
.map((id, index) => {
|
||||
const info = details[id] || {};
|
||||
const title = info.error ? `<span class="error">${escapeHTML(info.error)}</span>` : escapeHTML(info.title || "…");
|
||||
// buildModRows сводит три источника в один список строк: каталог с диска,
|
||||
// включённые моды и пакеты мастерской, файлы которых ещё не скачаны.
|
||||
function buildModRows() {
|
||||
const rows = [];
|
||||
const byModID = new Map();
|
||||
|
||||
const push = (row) => {
|
||||
byModID.set(row.modID, row);
|
||||
rows.push(row);
|
||||
};
|
||||
|
||||
// 1. Моды, найденные на диске. Показываем только те, чей пакет есть в
|
||||
// списке скачиваемых, плюс локальные (без Workshop ID).
|
||||
const wanted = new Set(mods.workshop);
|
||||
mods.installed.forEach((mod) => {
|
||||
if (mod.workshop_id && !wanted.has(mod.workshop_id)) return;
|
||||
push({
|
||||
modID: mod.mod_id,
|
||||
workshopID: mod.workshop_id,
|
||||
name: mod.name || mod.mod_id,
|
||||
note: mod.workshop_id ? "" : "локальный мод",
|
||||
installed: true,
|
||||
});
|
||||
});
|
||||
|
||||
// 2. Включённые моды, файлов которых мы не нашли: либо сервер их ещё не
|
||||
// скачал, либо мод пропал из мастерской.
|
||||
mods.enabled.forEach((modID) => {
|
||||
if (byModID.has(modID)) return;
|
||||
push({
|
||||
modID,
|
||||
workshopID: "",
|
||||
name: modID,
|
||||
note: "файлы не найдены — сервер скачает их при запуске",
|
||||
installed: false,
|
||||
});
|
||||
});
|
||||
|
||||
// 3. Пакеты мастерской, из которых пока ничего не установлено.
|
||||
const covered = new Set(rows.map((row) => row.workshopID).filter(Boolean));
|
||||
mods.workshop.forEach((workshopID) => {
|
||||
if (covered.has(workshopID)) return;
|
||||
const info = mods.details[workshopID] || {};
|
||||
rows.push({
|
||||
modID: "",
|
||||
workshopID,
|
||||
name: info.title || `Мод ${workshopID}`,
|
||||
note: info.error || "ещё не скачан — включить можно после запуска сервера",
|
||||
installed: false,
|
||||
});
|
||||
});
|
||||
|
||||
// Включённые идут первыми и в том порядке, в каком их грузит игра.
|
||||
const order = (row) => {
|
||||
const index = mods.enabled.indexOf(row.modID);
|
||||
return index === -1 ? Number.MAX_SAFE_INTEGER : index;
|
||||
};
|
||||
rows.sort((a, b) => {
|
||||
const diff = order(a) - order(b);
|
||||
return diff !== 0 ? diff : a.name.localeCompare(b.name, "ru");
|
||||
});
|
||||
return rows;
|
||||
}
|
||||
|
||||
function renderMods() {
|
||||
const filter = $("#mod-filter").value.trim().toLowerCase();
|
||||
const onlyEnabled = $("#mod-only-enabled").checked;
|
||||
const rows = buildModRows();
|
||||
|
||||
const visible = rows.filter((row) => {
|
||||
if (onlyEnabled && !mods.enabled.includes(row.modID)) return false;
|
||||
if (!filter) return true;
|
||||
return `${row.name} ${row.modID} ${row.workshopID}`.toLowerCase().includes(filter);
|
||||
});
|
||||
|
||||
$("#mod-summary").textContent =
|
||||
`Модов: ${rows.length}, включено: ${mods.enabled.length}, пакетов мастерской: ${mods.workshop.length}`;
|
||||
|
||||
$("#mod-list").innerHTML = visible
|
||||
.map((row) => {
|
||||
const on = mods.enabled.includes(row.modID);
|
||||
const info = mods.details[row.workshopID] || {};
|
||||
const preview = info.preview
|
||||
? `<img src="${escapeHTML(info.preview)}" alt="">`
|
||||
: `<span class="mod-thumb"></span>`;
|
||||
const noteClass = row.note.includes("не найден") ? "error" : "muted";
|
||||
const workshopLink = row.workshopID
|
||||
? `<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=${escapeHTML(row.workshopID)}"
|
||||
target="_blank" rel="noopener noreferrer">${escapeHTML(row.workshopID)}</a>`
|
||||
: "";
|
||||
return `<li>
|
||||
|
||||
// Перетаскивать можно только включённые моды: порядок хранится в Mods.
|
||||
return `<li class="mod-row${on ? " enabled" : ""}" data-mod="${escapeHTML(row.modID)}"
|
||||
data-workshop="${escapeHTML(row.workshopID)}" ${on ? 'draggable="true"' : ""}>
|
||||
<span class="drag-handle" title="${on ? "Перетащите, чтобы изменить порядок" : "Порядок задаётся у включённых модов"}">⠿</span>
|
||||
<input type="checkbox" class="mod-toggle" ${on ? "checked" : ""}
|
||||
${row.modID ? "" : "disabled"} title="Включить мод на сервере">
|
||||
${preview}
|
||||
<div class="mod-title"><b>${title}</b><small>${escapeHTML(id)}</small></div>
|
||||
<button class="btn btn-sm btn-ghost" data-move="workshop" data-index="${index}" data-dir="-1">↑</button>
|
||||
<button class="btn btn-sm btn-ghost" data-move="workshop" data-index="${index}" data-dir="1">↓</button>
|
||||
<button class="btn btn-sm btn-danger" data-remove="workshop" data-index="${index}">Убрать</button>
|
||||
<div class="mod-title">
|
||||
<b>${escapeHTML(row.name)}</b>
|
||||
<small>${row.modID ? escapeHTML(row.modID) : "Mod ID неизвестен"}${workshopLink ? " · " + workshopLink : ""}</small>
|
||||
${row.note ? `<small class="${noteClass}">${escapeHTML(row.note)}</small>` : ""}
|
||||
</div>
|
||||
<button class="btn btn-sm btn-ghost" data-move="-1" ${on ? "" : "disabled"} title="Выше">↑</button>
|
||||
<button class="btn btn-sm btn-ghost" data-move="1" ${on ? "" : "disabled"} title="Ниже">↓</button>
|
||||
<button class="btn btn-sm btn-danger" data-remove="1">Убрать</button>
|
||||
</li>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
$("#modid-list").innerHTML = mods.modIDs
|
||||
.map(
|
||||
(id, index) => `<li>
|
||||
<div class="mod-title"><b>${escapeHTML(id)}</b></div>
|
||||
<button class="btn btn-sm btn-ghost" data-move="mod" data-index="${index}" data-dir="-1">↑</button>
|
||||
<button class="btn btn-sm btn-ghost" data-move="mod" data-index="${index}" data-dir="1">↓</button>
|
||||
<button class="btn btn-sm btn-danger" data-remove="mod" data-index="${index}">Убрать</button>
|
||||
</li>`
|
||||
)
|
||||
.join("");
|
||||
.join("") || `<li class="muted">Ничего не найдено</li>`;
|
||||
}
|
||||
|
||||
async function lookupWorkshopTitles() {
|
||||
if (!mods.workshop.length) return;
|
||||
const data = await post("/api/mods/lookup", { ids: mods.workshop });
|
||||
const details = {};
|
||||
(data.items || []).forEach((item) => {
|
||||
details[item.id] = item;
|
||||
mods.details[item.id] = item;
|
||||
});
|
||||
renderMods(details);
|
||||
renderMods();
|
||||
}
|
||||
|
||||
// Обе кнопки — перемещение и удаление — обрабатываем одним слушателем на списке.
|
||||
$("#mods-body").addEventListener("click", (event) => {
|
||||
const button = event.target.closest("button[data-move], button[data-remove]");
|
||||
if (!button) return;
|
||||
// moveEnabled двигает мод в списке загрузки на delta позиций.
|
||||
function moveEnabled(modID, delta) {
|
||||
const from = mods.enabled.indexOf(modID);
|
||||
const to = from + delta;
|
||||
if (from === -1 || to < 0 || to >= mods.enabled.length) return;
|
||||
mods.enabled.splice(to, 0, mods.enabled.splice(from, 1)[0]);
|
||||
renderMods();
|
||||
}
|
||||
|
||||
const index = Number(button.dataset.index);
|
||||
const list = (button.dataset.move || button.dataset.remove) === "workshop" ? mods.workshop : mods.modIDs;
|
||||
$("#mod-list").addEventListener("change", (event) => {
|
||||
const toggle = event.target.closest(".mod-toggle");
|
||||
if (!toggle) return;
|
||||
|
||||
if (button.dataset.move) {
|
||||
const target = index + Number(button.dataset.dir);
|
||||
if (target < 0 || target >= list.length) return;
|
||||
[list[index], list[target]] = [list[target], list[index]];
|
||||
const modID = toggle.closest(".mod-row").dataset.mod;
|
||||
if (!modID) return;
|
||||
if (toggle.checked) {
|
||||
// Новый мод грузится последним: обычно так и надо, а порядок правится
|
||||
// перетаскиванием.
|
||||
if (!mods.enabled.includes(modID)) mods.enabled.push(modID);
|
||||
} else {
|
||||
list.splice(index, 1);
|
||||
mods.enabled = mods.enabled.filter((id) => id !== modID);
|
||||
}
|
||||
renderMods();
|
||||
});
|
||||
|
||||
$("#mod-list").addEventListener("click", (event) => {
|
||||
const row = event.target.closest(".mod-row");
|
||||
if (!row) return;
|
||||
|
||||
const move = event.target.closest("button[data-move]");
|
||||
if (move) {
|
||||
moveEnabled(row.dataset.mod, Number(move.dataset.move));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!event.target.closest("button[data-remove]")) return;
|
||||
|
||||
const { mod: modID, workshop: workshopID } = row.dataset;
|
||||
// Убираем и пакет мастерской, и все включённые моды из него: иначе сервер
|
||||
// продолжит скачивать то, что уже не нужно.
|
||||
if (workshopID) {
|
||||
mods.workshop = mods.workshop.filter((id) => id !== workshopID);
|
||||
const fromPackage = mods.installed
|
||||
.filter((mod) => mod.workshop_id === workshopID)
|
||||
.map((mod) => mod.mod_id);
|
||||
mods.enabled = mods.enabled.filter((id) => id !== modID && !fromPackage.includes(id));
|
||||
} else {
|
||||
mods.enabled = mods.enabled.filter((id) => id !== modID);
|
||||
}
|
||||
renderMods();
|
||||
});
|
||||
|
||||
// Перетаскивание порядка загрузки.
|
||||
let dragged = null;
|
||||
|
||||
$("#mod-list").addEventListener("dragstart", (event) => {
|
||||
const row = event.target.closest(".mod-row");
|
||||
if (!row || !row.classList.contains("enabled")) return;
|
||||
dragged = row.dataset.mod;
|
||||
row.classList.add("dragging");
|
||||
event.dataTransfer.effectAllowed = "move";
|
||||
});
|
||||
|
||||
$("#mod-list").addEventListener("dragend", () => {
|
||||
dragged = null;
|
||||
$$(".mod-row").forEach((row) => row.classList.remove("dragging", "drop-target"));
|
||||
});
|
||||
|
||||
$("#mod-list").addEventListener("dragover", (event) => {
|
||||
if (!dragged) return;
|
||||
const row = event.target.closest(".mod-row.enabled");
|
||||
if (!row || row.dataset.mod === dragged) return;
|
||||
event.preventDefault();
|
||||
$$(".mod-row").forEach((el) => el.classList.toggle("drop-target", el === row));
|
||||
});
|
||||
|
||||
$("#mod-list").addEventListener("drop", (event) => {
|
||||
if (!dragged) return;
|
||||
const row = event.target.closest(".mod-row.enabled");
|
||||
if (!row) return;
|
||||
event.preventDefault();
|
||||
|
||||
const from = mods.enabled.indexOf(dragged);
|
||||
const to = mods.enabled.indexOf(row.dataset.mod);
|
||||
if (from === -1 || to === -1) return;
|
||||
mods.enabled.splice(to, 0, mods.enabled.splice(from, 1)[0]);
|
||||
dragged = null;
|
||||
renderMods();
|
||||
});
|
||||
|
||||
$("#mod-filter").addEventListener("input", renderMods);
|
||||
$("#mod-only-enabled").addEventListener("change", renderMods);
|
||||
|
||||
// extractWorkshopID принимает как голый идентификатор, так и ссылку на
|
||||
// страницу мастерской.
|
||||
function extractWorkshopID(input) {
|
||||
const match = input.match(/(\d{5,})/);
|
||||
return match ? match[1] : "";
|
||||
}
|
||||
|
||||
$("#mod-add-form").addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
const id = extractWorkshopID($("#mod-input").value.trim());
|
||||
if (!id) {
|
||||
toast("Не похоже на ссылку или ID мода", "err");
|
||||
return;
|
||||
}
|
||||
if (mods.workshop.includes(id)) {
|
||||
toast("Этот мод уже в списке");
|
||||
return;
|
||||
}
|
||||
mods.workshop.push(id);
|
||||
$("#mod-input").value = "";
|
||||
renderMods();
|
||||
lookupWorkshopTitles().catch(() => {});
|
||||
});
|
||||
|
||||
$("#mod-add-form").addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
const workshopID = $("#mod-workshop-id").value.trim();
|
||||
const modID = $("#mod-mod-id").value.trim();
|
||||
|
||||
if (workshopID && !/^\d+$/.test(workshopID)) {
|
||||
toast("Workshop ID — это число из ссылки на мод", "err");
|
||||
$("#btn-add-collection").addEventListener("click", async () => {
|
||||
const input = $("#mod-input").value.trim();
|
||||
if (!input) {
|
||||
toast("Вставьте ссылку на коллекцию или её ID", "err");
|
||||
return;
|
||||
}
|
||||
if (!workshopID && !modID) return;
|
||||
try {
|
||||
const data = await post("/api/mods/collection", { id: input });
|
||||
const items = data.items || [];
|
||||
|
||||
if (workshopID && !mods.workshop.includes(workshopID)) mods.workshop.push(workshopID);
|
||||
if (modID && !mods.modIDs.includes(modID)) mods.modIDs.push(modID);
|
||||
let added = 0;
|
||||
items.forEach((item) => {
|
||||
mods.details[item.id] = item;
|
||||
if (!mods.workshop.includes(item.id)) {
|
||||
mods.workshop.push(item.id);
|
||||
added++;
|
||||
}
|
||||
});
|
||||
|
||||
$("#mod-workshop-id").value = "";
|
||||
$("#mod-mod-id").value = "";
|
||||
renderMods();
|
||||
lookupWorkshopTitles().catch(() => {});
|
||||
$("#mod-input").value = "";
|
||||
renderMods();
|
||||
ok(added
|
||||
? `Из коллекции добавлено модов: ${added} (всего в ней ${items.length})`
|
||||
: `Все ${items.length} модов коллекции уже были в списке`);
|
||||
} catch (error) {
|
||||
fail(error);
|
||||
}
|
||||
});
|
||||
|
||||
$("#btn-mods-save").addEventListener("click", async () => {
|
||||
const maps = $("#map-list").value.split("\n").map((line) => line.trim()).filter(Boolean);
|
||||
try {
|
||||
await put("/api/mods", {
|
||||
mods: mods.modIDs,
|
||||
mods: mods.enabled,
|
||||
workshop_items: mods.workshop,
|
||||
map: maps,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user