diff --git a/web/static/app.js b/web/static/app.js index 7d15476..6543bb8 100644 --- a/web/static/app.js +++ b/web/static/app.js @@ -471,6 +471,7 @@ async function loadServerConfig() { if (!data.exists) { $("#config-empty").textContent = data.hint; + refreshFieldSaveState(SAVE_TARGETS[0]); return; } serverConfigValues = data.values; @@ -479,6 +480,7 @@ async function loadServerConfig() { container.appendChild(buildConfigField(key, data.values[key], CONFIG_HINTS[key])); }); applyFieldFilter("#config-filter", "#config-fields"); + refreshFieldSaveState(SAVE_TARGETS[0]); } // buildConfigField выбирает тип поля по текущему значению: булевы значения @@ -539,12 +541,35 @@ function collectChanged(containerSelector) { return changed; } +// Кнопка «Сохранить» включается, только когда есть что сохранять: на пустой +// вкладке (файл ещё не создан игрой) или без правок нажимать её незачем. +const SAVE_TARGETS = [ + { fields: "#config-fields", button: "#btn-config-save" }, + { fields: "#sandbox-fields", button: "#btn-sandbox-save" }, +]; + +function refreshFieldSaveState(target) { + const count = Object.keys(collectChanged(target.fields)).length; + const button = $(target.button); + button.disabled = count === 0; + button.textContent = count ? `Сохранить (${count})` : "Сохранить"; + button.title = count ? `Изменено параметров: ${count}` : "Изменений нет"; +} + +SAVE_TARGETS.forEach((target) => { + // Слушаем на контейнере: поля пересоздаются при каждой загрузке вкладки. + ["input", "change"].forEach((event) => + $(target.fields).addEventListener(event, () => refreshFieldSaveState(target))); +}); + function markSaved(containerSelector) { $$(`${containerSelector} .cfg-field`).forEach((field) => { const input = field.querySelector("input, select"); if (input) input.dataset.original = input.value; field.classList.remove("changed"); }); + const target = SAVE_TARGETS.find((t) => t.fields === containerSelector); + if (target) refreshFieldSaveState(target); } $("#btn-config-save").addEventListener("click", async () => { @@ -584,6 +609,7 @@ async function loadSandbox() { if (!data.exists) { $("#sandbox-empty").textContent = data.hint; + refreshFieldSaveState(SAVE_TARGETS[1]); return; } const container = $("#sandbox-fields"); @@ -591,6 +617,7 @@ async function loadSandbox() { container.appendChild(buildConfigField(entry.path, entry.value, null)); }); applyFieldFilter("#sandbox-filter", "#sandbox-fields"); + refreshFieldSaveState(SAVE_TARGETS[1]); } $("#btn-sandbox-save").addEventListener("click", async () => { @@ -616,6 +643,10 @@ $("#btn-sandbox-save").addEventListener("click", async () => { // WorkshopItems — что скачать из мастерской, Mods — что включить и в каком // порядке. Панель показывает их одним списком: строка на мод, галочка = // присутствие в Mods, порядок строк = порядок в Mods. +// Снимок списков на момент загрузки: по нему видно, есть ли несохранённые +// правки. Пустая строка означает, что конфига ещё нет и сохранять нечего. +let modsSnapshot = ""; + let mods = { workshop: [], // WorkshopItems enabled: [], // Mods, порядок важен @@ -630,6 +661,8 @@ async function loadMods() { $("#mods-body").hidden = !data.exists; if (!data.exists) { $("#mods-empty").textContent = data.hint; + modsSnapshot = ""; + refreshModsSaveState(); return; } @@ -641,6 +674,7 @@ async function loadMods() { details: mods.details, }; $("#map-list").value = mods.maps.join("\n"); + modsSnapshot = modsState(); renderMods(); lookupWorkshopTitles().catch(() => { /* без интернета останутся идентификаторы — это не ошибка панели */ @@ -711,6 +745,23 @@ function buildModRows() { return rows; } +// modsState сводит списки к строке — так проще всего заметить и переключённую +// галочку, и переставленный порядок загрузки. +function modsState() { + return JSON.stringify({ + enabled: mods.enabled, + workshop: mods.workshop, + maps: $("#map-list").value.split("\n").map((line) => line.trim()).filter(Boolean), + }); +} + +function refreshModsSaveState() { + const button = $("#btn-mods-save"); + const changed = modsSnapshot !== "" && modsState() !== modsSnapshot; + button.disabled = !changed; + button.title = changed ? "Есть несохранённые изменения" : "Изменений нет"; +} + function renderMods() { const filter = $("#mod-filter").value.trim().toLowerCase(); const onlyEnabled = $("#mod-only-enabled").checked; @@ -725,6 +776,8 @@ function renderMods() { $("#mod-summary").textContent = `Модов: ${rows.length}, включено: ${mods.enabled.length}, пакетов мастерской: ${mods.workshop.length}`; + refreshModsSaveState(); + $("#mod-list").innerHTML = visible .map((row) => { const on = mods.enabled.includes(row.modID); @@ -857,6 +910,7 @@ $("#mod-list").addEventListener("drop", (event) => { renderMods(); }); +$("#map-list").addEventListener("input", refreshModsSaveState); $("#mod-filter").addEventListener("input", renderMods); $("#mod-only-enabled").addEventListener("change", renderMods); @@ -921,6 +975,8 @@ $("#btn-mods-save").addEventListener("click", async () => { workshop_items: mods.workshop, map: maps, }); + modsSnapshot = modsState(); + refreshModsSaveState(); ok("Список модов сохранён. Перезапустите сервер, чтобы он их подтянул."); } catch (error) { fail(error); @@ -1302,6 +1358,7 @@ async function loadSettings() { } else { input.value = config[key] ?? ""; } + input.dataset.original = type === "checkbox" ? String(input.checked) : input.value; wrapper.appendChild(input); if (hint) { @@ -1318,6 +1375,7 @@ async function loadSettings() { pathNote.textContent = `Файл конфигурации: ${config.config_path}`; form.appendChild(pathNote); + refreshSettingsSaveState(); await loadUsers(); // Проверка обновления ходит в интернет и может не ответить — она не должна // мешать показу остальных настроек. @@ -1326,6 +1384,21 @@ async function loadSettings() { }); } +function refreshSettingsSaveState() { + const changed = $$("#settings-form input").filter((input) => { + const current = input.type === "checkbox" ? String(input.checked) : input.value; + return current !== input.dataset.original; + }).length; + + const button = $("#btn-settings-save"); + button.disabled = changed === 0; + button.textContent = changed ? `Сохранить настройки (${changed})` : "Сохранить настройки"; + button.title = changed ? `Изменено полей: ${changed}` : "Изменений нет"; +} + +["input", "change"].forEach((event) => + $("#settings-form").addEventListener(event, refreshSettingsSaveState)); + $("#btn-settings-save").addEventListener("click", async () => { const payload = {}; SETTINGS_FIELDS.forEach(([key, , type]) => { diff --git a/web/static/index.html b/web/static/index.html index a34c709..380aaa1 100644 --- a/web/static/index.html +++ b/web/static/index.html @@ -145,7 +145,7 @@
Галочка включает мод, перетаскивание задаёт порядок загрузки. Сервер сам скачивает моды из мастерской при запуске.
- + @@ -290,7 +290,7 @@