385 lines
14 KiB
JavaScript
385 lines
14 KiB
JavaScript
const $ = (selector) => document.querySelector(selector);
|
|
const $$ = (selector) => [...document.querySelectorAll(selector)];
|
|
|
|
const INTRO_MINIMUM_MS = 900;
|
|
const REQUIRED_EXPANSIONS = {
|
|
royalty: { name: "Royalty", art: "/art/NotOwned_Royalty.png" },
|
|
ideology: { name: "Ideology", art: "/art/NotOwned_Ideology.png" },
|
|
biotech: { name: "Biotech", art: "/art/NotOwned_Biotech.png" },
|
|
anomaly: { name: "Anomaly", art: "/art/NotOwned_Anomaly.png" },
|
|
odyssey: { name: "Odyssey", art: null }
|
|
};
|
|
const INSTALL_PATHS = {
|
|
windows: {
|
|
label: "Windows",
|
|
path: "%USERPROFILE%\\AppData\\LocalLow\\Ludeon Studios\\RimWorld by Ludeon Studios\\ModLists"
|
|
},
|
|
macos: {
|
|
label: "macOS",
|
|
path: "~/Library/Application Support/RimWorld/ModLists"
|
|
},
|
|
linux: {
|
|
label: "Linux",
|
|
path: "~/.config/unity3d/Ludeon Studios/RimWorld by Ludeon Studios/ModLists"
|
|
}
|
|
};
|
|
const state = {
|
|
currentStep: 0,
|
|
listType: "standard",
|
|
sortAcknowledged: false,
|
|
polling: null,
|
|
rmlReady: false
|
|
};
|
|
|
|
function detectedOperatingSystem() {
|
|
const platform = String(navigator.userAgentData?.platform || navigator.platform || navigator.userAgent || "").toLowerCase();
|
|
if (platform.includes("mac")) return "macos";
|
|
if (platform.includes("linux") || platform.includes("x11")) return "linux";
|
|
return "windows";
|
|
}
|
|
|
|
function renderInstallLocation() {
|
|
const install = INSTALL_PATHS[detectedOperatingSystem()];
|
|
$("#detected-os-name").textContent = install.label;
|
|
$("#detected-os-path").textContent = install.path;
|
|
}
|
|
|
|
function resetListType() {
|
|
const standard = $('input[name="list-type"][value="standard"]');
|
|
const acknowledgement = $("#acknowledge-autosort");
|
|
standard.checked = true;
|
|
state.listType = "standard";
|
|
state.sortAcknowledged = false;
|
|
$("#homebrew-settings").hidden = true;
|
|
$("#autosort-confirmation").classList.remove("acknowledged");
|
|
acknowledgement.setAttribute("aria-pressed", "false");
|
|
acknowledgement.textContent = "Yes, I understand";
|
|
setMessage("", "", "#wizard-message");
|
|
updateDownloadAvailability();
|
|
}
|
|
|
|
function formatDate(value, fallback = "Not available") {
|
|
if (!value) return fallback;
|
|
return new Intl.DateTimeFormat(undefined, {
|
|
month: "long",
|
|
day: "numeric",
|
|
year: "numeric",
|
|
hour: "numeric",
|
|
minute: "2-digit"
|
|
}).format(new Date(value));
|
|
}
|
|
|
|
function formatRelativeDate(value) {
|
|
if (!value) return "Waiting for the first refresh";
|
|
const elapsedSeconds = Math.max(0, Math.round((Date.now() - new Date(value).getTime()) / 1000));
|
|
if (elapsedSeconds < 60) return "Updated just now";
|
|
const minutes = Math.floor(elapsedSeconds / 60);
|
|
if (minutes < 60) return `Updated ${minutes} minute${minutes === 1 ? "" : "s"} ago`;
|
|
const hours = Math.floor(minutes / 60);
|
|
if (hours < 24) return `Updated ${hours} hour${hours === 1 ? "" : "s"} ago`;
|
|
const days = Math.floor(hours / 24);
|
|
return `Updated ${days} day${days === 1 ? "" : "s"} ago`;
|
|
}
|
|
|
|
function setMessage(text, kind = "", selector = "#action-message") {
|
|
const element = $(selector);
|
|
element.textContent = text;
|
|
element.className = `${element.dataset.baseClass || element.className.split(" ")[0]} ${kind}`.trim();
|
|
}
|
|
|
|
async function api(path, options = {}) {
|
|
const response = await fetch(path, options);
|
|
const contentType = response.headers.get("content-type") || "";
|
|
if (!response.ok) {
|
|
let detail = `${response.status} ${response.statusText}`;
|
|
if (contentType.includes("json")) {
|
|
const body = await response.json();
|
|
detail = typeof body.detail === "string" ? body.detail : body.detail?.message || detail;
|
|
}
|
|
throw new Error(detail);
|
|
}
|
|
return contentType.includes("json") ? response.json() : response;
|
|
}
|
|
|
|
function renderServiceState(data) {
|
|
const element = $("#service-state");
|
|
const label = element.querySelector("span:last-child");
|
|
const total = Number(data.totals?.total_mods || 0);
|
|
const ready = Number(data.totals?.ready_mods || 0);
|
|
const skipped = Number(data.totals?.skipped_mods || 0);
|
|
const resolved = ready + skipped;
|
|
element.className = "service-state";
|
|
|
|
const progress = data.scheduler?.progress || {};
|
|
const progressPercent = Number(progress.percent || 0);
|
|
|
|
if (data.scheduler?.initial_download) {
|
|
element.classList.add("working");
|
|
label.textContent = progress.total_mods
|
|
? `Downloading first-time mods · ${Math.round(progressPercent)}%`
|
|
: "Downloading first-time mods";
|
|
} else if (data.scheduler?.running) {
|
|
element.classList.add("working");
|
|
label.textContent = progress.total_mods
|
|
? `Updating now · ${Math.round(progressPercent)}%`
|
|
: "Updating now";
|
|
} else if (data.latest_run?.status === "failed") {
|
|
element.classList.add("failed");
|
|
label.textContent = "Update needs attention";
|
|
} else if (!data.steamcmd_available) {
|
|
element.classList.add("failed");
|
|
label.textContent = "Downloads unavailable";
|
|
} else if (resolved < total) {
|
|
element.classList.add("working");
|
|
label.textContent = "Preparing RML";
|
|
} else {
|
|
element.classList.add("ready");
|
|
label.textContent = "Ready";
|
|
}
|
|
}
|
|
|
|
function updateDownloadAvailability() {
|
|
const downloadButton = $("#download-list");
|
|
const availability = $("#rml-availability");
|
|
const needsFile = state.listType === "homebrew" && !$("#mods-config").files[0];
|
|
|
|
downloadButton.disabled = !state.rmlReady || needsFile || !state.sortAcknowledged;
|
|
if (!state.rmlReady) {
|
|
availability.textContent = "The current RML is still being prepared. You can review every step now and download when it is ready.";
|
|
availability.className = "availability waiting";
|
|
} else if (needsFile) {
|
|
availability.textContent = "Choose ModsConfig.xml to finish your homebrew list.";
|
|
availability.className = "availability waiting";
|
|
} else if (!state.sortAcknowledged) {
|
|
availability.textContent = "Confirm the auto-sort step above to unlock your download.";
|
|
availability.className = "availability waiting";
|
|
} else {
|
|
availability.textContent = state.listType === "homebrew"
|
|
? "Your homebrew list is ready to build."
|
|
: "The current Progression list is ready to download.";
|
|
availability.className = "availability ready";
|
|
}
|
|
}
|
|
|
|
function renderDashboard(data) {
|
|
renderServiceState(data);
|
|
const totals = data.totals || {};
|
|
const total = Number(totals.total_mods || 0);
|
|
const ready = Number(totals.ready_mods || 0);
|
|
const skipped = Number(totals.skipped_mods || 0);
|
|
const resolved = ready + skipped;
|
|
const counts = Object.fromEntries(
|
|
(data.collections || []).map(collection => [String(collection.name).toLowerCase(), Number(collection.item_count || 0)])
|
|
);
|
|
|
|
$("#count-total").textContent = total ? total.toLocaleString() : "—";
|
|
for (const category of ["core", "content", "cosmetics"]) {
|
|
const formatted = counts[category]?.toLocaleString() || "—";
|
|
$(`#count-${category}`).textContent = formatted;
|
|
$(`[data-collection-count="${category}"]`).textContent = formatted;
|
|
}
|
|
|
|
const updatedAt = data.rml_updated_at;
|
|
const progress = data.scheduler?.progress || {};
|
|
const initialDownload = Boolean(data.scheduler?.initial_download);
|
|
$("#last-updated").textContent = initialDownload
|
|
? `Downloading ${Math.round(Number(progress.percent || 0))}%`
|
|
: (data.scheduler?.running ? "Updating now" : formatRelativeDate(updatedAt));
|
|
$("#last-updated").title = formatDate(updatedAt);
|
|
|
|
state.rmlReady = total > 0 && resolved === total && !data.scheduler?.running;
|
|
const nextRefresh = data.scheduler?.next_refresh_at;
|
|
if (initialDownload) {
|
|
const completed = Number(progress.completed_mods || 0).toLocaleString();
|
|
const target = Number(progress.total_mods || 0).toLocaleString();
|
|
$("#update-detail").textContent = progress.total_mods
|
|
? `First-time setup: ${completed} of ${target} entries resolved · ${ready.toLocaleString()} mods ready. Hourly checks begin after it finishes.`
|
|
: "Preparing the first-time Workshop download. Hourly checks are paused.";
|
|
} else if (data.scheduler?.running) {
|
|
$("#update-detail").textContent = "The hourly Workshop refresh is currently running.";
|
|
} else if (!state.rmlReady) {
|
|
$("#update-detail").textContent = "The current RML is being prepared from the latest Workshop data.";
|
|
} else {
|
|
$("#update-detail").textContent = nextRefresh
|
|
? `Next automatic check ${formatDate(nextRefresh)}.`
|
|
: "Workshop data refreshes every hour.";
|
|
}
|
|
updateDownloadAvailability();
|
|
}
|
|
|
|
async function loadDashboard() {
|
|
try {
|
|
renderDashboard(await api("/api/status"));
|
|
} catch (error) {
|
|
const element = $("#service-state");
|
|
element.className = "service-state failed";
|
|
element.querySelector("span:last-child").textContent = "Backend unavailable";
|
|
$("#last-updated").textContent = "Unavailable";
|
|
$("#update-detail").textContent = "The service could not be reached.";
|
|
state.rmlReady = false;
|
|
updateDownloadAvailability();
|
|
setMessage(error.message, "error");
|
|
}
|
|
}
|
|
|
|
function showStep(step) {
|
|
const panels = $$(".wizard-step");
|
|
const progressItems = $$(".wizard-progress li");
|
|
state.currentStep = Math.max(0, Math.min(step, panels.length - 1));
|
|
|
|
panels.forEach((panel, index) => {
|
|
const active = index === state.currentStep;
|
|
panel.hidden = !active;
|
|
panel.classList.toggle("active", active);
|
|
});
|
|
|
|
progressItems.forEach((item, index) => {
|
|
item.classList.toggle("active", index === state.currentStep);
|
|
item.classList.toggle("complete", index < state.currentStep);
|
|
if (index === state.currentStep) item.setAttribute("aria-current", "step");
|
|
else item.removeAttribute("aria-current");
|
|
});
|
|
|
|
if (state.currentStep === 2) updateExpansionRequirements();
|
|
}
|
|
|
|
function updateExpansionRequirements() {
|
|
const inputs = $$("#dlc-options input");
|
|
const missing = inputs
|
|
.filter(input => !input.checked)
|
|
.map(input => REQUIRED_EXPANSIONS[input.value]);
|
|
const warning = $("#expansion-warning");
|
|
const iconList = $("#missing-expansion-icons");
|
|
|
|
inputs.forEach(input => {
|
|
const isMissing = !input.checked;
|
|
input.closest("label").classList.toggle("missing", isMissing);
|
|
input.setAttribute("aria-invalid", String(isMissing));
|
|
});
|
|
|
|
warning.hidden = missing.length === 0;
|
|
$("#expansions-next").disabled = missing.length > 0;
|
|
if (!missing.length) {
|
|
iconList.replaceChildren();
|
|
$("#expansion-warning-text").textContent = "";
|
|
return;
|
|
}
|
|
|
|
const names = missing.map(expansion => expansion.name);
|
|
$("#expansion-warning-text").textContent = `${names.join(", ")} ${missing.length === 1 ? "is" : "are"} required before you can continue.`;
|
|
iconList.innerHTML = missing.map(expansion => expansion.art
|
|
? `<img src="${expansion.art}" alt="">`
|
|
: `<span class="missing-expansion-fallback"><b>!</b><small>ODYSSEY</small><em>REQUIRED</em></span>`
|
|
).join("");
|
|
}
|
|
|
|
function selectedDlcQuery() {
|
|
return $$("#dlc-options input:checked")
|
|
.map(input => `dlc=${encodeURIComponent(input.value)}`)
|
|
.join("&");
|
|
}
|
|
|
|
async function downloadResponse(responsePromise, filename) {
|
|
const response = await responsePromise;
|
|
const blob = await response.blob();
|
|
const url = URL.createObjectURL(blob);
|
|
const link = document.createElement("a");
|
|
link.href = url;
|
|
link.download = filename;
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
link.remove();
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
$("#start-wizard").addEventListener("click", () => {
|
|
resetListType();
|
|
showStep(0);
|
|
$("#download-wizard").showModal();
|
|
});
|
|
|
|
$("#close-wizard").addEventListener("click", () => $("#download-wizard").close());
|
|
|
|
$("#download-wizard").addEventListener("click", event => {
|
|
if (event.target === event.currentTarget) event.currentTarget.close();
|
|
});
|
|
|
|
$$('[data-next]').forEach(button => button.addEventListener("click", () => showStep(state.currentStep + 1)));
|
|
$$('[data-back]').forEach(button => button.addEventListener("click", () => showStep(state.currentStep - 1)));
|
|
|
|
$("#setup-confirm").addEventListener("change", event => {
|
|
$("#setup-next").disabled = !event.target.checked;
|
|
});
|
|
|
|
$$("#dlc-options input").forEach(input => input.addEventListener("change", updateExpansionRequirements));
|
|
|
|
$("#show-all-paths").addEventListener("click", event => {
|
|
const paths = $("#all-install-paths");
|
|
const show = paths.hidden;
|
|
paths.hidden = !show;
|
|
event.currentTarget.setAttribute("aria-expanded", String(show));
|
|
event.currentTarget.textContent = show ? "Hide other OS" : "Show other OS";
|
|
});
|
|
|
|
$("#acknowledge-autosort").addEventListener("click", event => {
|
|
state.sortAcknowledged = true;
|
|
$("#autosort-confirmation").classList.add("acknowledged");
|
|
event.currentTarget.setAttribute("aria-pressed", "true");
|
|
event.currentTarget.textContent = "Understood ✓";
|
|
updateDownloadAvailability();
|
|
});
|
|
|
|
$$('input[name="list-type"]').forEach(input => input.addEventListener("change", event => {
|
|
state.listType = event.target.value;
|
|
$("#homebrew-settings").hidden = state.listType !== "homebrew";
|
|
setMessage("", "", "#wizard-message");
|
|
updateDownloadAvailability();
|
|
}));
|
|
|
|
$("#mods-config").addEventListener("change", event => {
|
|
const file = event.target.files[0];
|
|
$("#file-label").textContent = file?.name || "Choose ModsConfig.xml";
|
|
$(".file-picker").classList.toggle("has-file", Boolean(file));
|
|
updateDownloadAvailability();
|
|
});
|
|
|
|
$("#download-list").addEventListener("click", async () => {
|
|
const button = $("#download-list");
|
|
button.disabled = true;
|
|
setMessage("Building your mod list…", "", "#wizard-message");
|
|
|
|
try {
|
|
if (state.listType === "homebrew") {
|
|
const file = $("#mods-config").files[0];
|
|
if (!file) throw new Error("Choose ModsConfig.xml before building a homebrew list.");
|
|
const form = new FormData();
|
|
form.append("mods_config", file);
|
|
await downloadResponse(api("/api/modlists/homebrew", { method: "POST", body: form }), "ProgressionHomebrew.rml");
|
|
setMessage("ProgressionHomebrew.rml is ready. Auto-sort it after loading.", "success", "#wizard-message");
|
|
} else {
|
|
const query = selectedDlcQuery();
|
|
await downloadResponse(api(`/api/modlists/vanilla${query ? `?${query}` : ""}`), "ProgressionVanilla.rml");
|
|
setMessage("ProgressionVanilla.rml is ready.", "success", "#wizard-message");
|
|
}
|
|
} catch (error) {
|
|
setMessage(error.message, "error", "#wizard-message");
|
|
} finally {
|
|
updateDownloadAvailability();
|
|
}
|
|
});
|
|
|
|
async function start() {
|
|
$("#action-message").dataset.baseClass = "action-message";
|
|
$("#wizard-message").dataset.baseClass = "wizard-message";
|
|
renderInstallLocation();
|
|
const minimumIntro = new Promise(resolve => window.setTimeout(resolve, INTRO_MINIMUM_MS));
|
|
await Promise.all([loadDashboard(), minimumIntro]);
|
|
document.body.classList.add("is-ready");
|
|
window.setTimeout(() => {
|
|
$("#intro").hidden = true;
|
|
}, 850);
|
|
}
|
|
|
|
start();
|
|
state.polling = window.setInterval(loadDashboard, 60_000);
|