modularize build and file browser and add more precautions for downloading from file transfer.
This commit is contained in:
@@ -1,3 +1,10 @@
|
||||
import { createBuildProfileManager } from "./build-profile-manager.js";
|
||||
import {
|
||||
createBuildHistoryManager,
|
||||
formatFileSize,
|
||||
updateExpirationTimer,
|
||||
} from "./build-history-manager.js";
|
||||
|
||||
const form = document.getElementById("build-form");
|
||||
const buildBtn = document.getElementById("build-btn");
|
||||
const buildStatus = document.getElementById("build-status");
|
||||
@@ -39,6 +46,7 @@ const platformInputs = document.querySelectorAll('input[name="platform"]');
|
||||
|
||||
let currentServerVersion = null;
|
||||
let currentUserRole = null;
|
||||
let showAllBuilds = false;
|
||||
|
||||
async function loadServerVersion() {
|
||||
try {
|
||||
@@ -115,9 +123,6 @@ function updateWindowsSectionVisibility() {
|
||||
}
|
||||
|
||||
const BUILD_SETTINGS_KEY = "overlord_build_settings";
|
||||
const PROFILE_EXPORT_MAGIC = "overlord-build-profile";
|
||||
|
||||
let buildProfiles = [];
|
||||
|
||||
function collectFormSettings() {
|
||||
return {
|
||||
@@ -285,130 +290,35 @@ function restoreFormSettings() {
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeProfileName(name) {
|
||||
if (typeof name !== "string") return "";
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed || trimmed.length > 64) return "";
|
||||
if (!/^[A-Za-z0-9 _.-]+$/.test(trimmed)) return "";
|
||||
return trimmed;
|
||||
}
|
||||
const buildProfileManager = createBuildProfileManager({
|
||||
elements: {
|
||||
profileSelect,
|
||||
profileNameInput,
|
||||
profileSaveBtn,
|
||||
profileLoadBtn,
|
||||
profileDeleteBtn,
|
||||
profileExportBtn,
|
||||
profileImportBtn,
|
||||
profileImportFile,
|
||||
},
|
||||
collectFormSettings,
|
||||
applyFormSettings,
|
||||
saveFormSettings,
|
||||
});
|
||||
const { loadBuildProfiles } = buildProfileManager;
|
||||
|
||||
function renderProfileOptions(selectedName = "") {
|
||||
if (!profileSelect) return;
|
||||
profileSelect.innerHTML = "";
|
||||
|
||||
const placeholder = document.createElement("option");
|
||||
placeholder.value = "";
|
||||
placeholder.textContent = "Select a saved profile...";
|
||||
profileSelect.appendChild(placeholder);
|
||||
|
||||
buildProfiles.forEach((profile) => {
|
||||
const option = document.createElement("option");
|
||||
option.value = profile.name;
|
||||
option.textContent = profile.name;
|
||||
if (selectedName && selectedName === profile.name) option.selected = true;
|
||||
profileSelect.appendChild(option);
|
||||
});
|
||||
}
|
||||
|
||||
function getSelectedProfile() {
|
||||
if (!profileSelect || !profileSelect.value) return null;
|
||||
return buildProfiles.find((profile) => profile.name === profileSelect.value) || null;
|
||||
}
|
||||
|
||||
async function loadBuildProfiles(selectedName = "") {
|
||||
try {
|
||||
const res = await fetch("/api/build/profiles", { credentials: "include" });
|
||||
if (!res.ok) {
|
||||
buildProfiles = [];
|
||||
renderProfileOptions();
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
buildProfiles = Array.isArray(data?.profiles) ? data.profiles : [];
|
||||
renderProfileOptions(selectedName || profileSelect?.value || "");
|
||||
} catch (err) {
|
||||
console.error("Failed to load build profiles:", err);
|
||||
buildProfiles = [];
|
||||
renderProfileOptions();
|
||||
}
|
||||
}
|
||||
|
||||
async function saveCurrentProfile() {
|
||||
const inputName = profileNameInput?.value || "";
|
||||
const selectedName = profileSelect?.value || "";
|
||||
const profileName = sanitizeProfileName(inputName || selectedName);
|
||||
if (!profileName) {
|
||||
alert("Enter a valid profile name (1-64 chars; letters, numbers, spaces, . _ -)");
|
||||
profileNameInput?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
const config = collectFormSettings();
|
||||
|
||||
const res = await fetch("/api/build/profiles", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ name: profileName, config }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error || "Failed to save profile");
|
||||
}
|
||||
|
||||
if (profileNameInput) profileNameInput.value = profileName;
|
||||
await loadBuildProfiles(profileName);
|
||||
}
|
||||
|
||||
function exportProfileToFile(name, config) {
|
||||
const payload = {
|
||||
type: PROFILE_EXPORT_MAGIC,
|
||||
version: 1,
|
||||
name,
|
||||
exportedAt: Date.now(),
|
||||
config,
|
||||
};
|
||||
const json = JSON.stringify(payload, null, 2);
|
||||
const blob = new Blob([json], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
const safeName = (name || "build-profile").replace(/[^A-Za-z0-9._-]/g, "_");
|
||||
a.href = url;
|
||||
a.download = `${safeName}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
async function importProfileFromFile(file) {
|
||||
const text = await file.text();
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch {
|
||||
throw new Error("Invalid JSON file");
|
||||
}
|
||||
|
||||
const fallbackName = file.name.replace(/\.json$/i, "");
|
||||
const importedName = sanitizeProfileName(parsed?.name || fallbackName);
|
||||
if (!importedName) {
|
||||
throw new Error("Invalid profile name in imported file");
|
||||
}
|
||||
|
||||
const importedConfig = parsed?.config;
|
||||
if (!importedConfig || typeof importedConfig !== "object" || Array.isArray(importedConfig)) {
|
||||
throw new Error("Imported profile is missing a valid config object");
|
||||
}
|
||||
|
||||
applyFormSettings(importedConfig);
|
||||
saveFormSettings();
|
||||
|
||||
if (profileNameInput) profileNameInput.value = importedName;
|
||||
await saveCurrentProfile();
|
||||
}
|
||||
const buildHistoryManager = createBuildHistoryManager({
|
||||
buildResults,
|
||||
buildFilesDiv,
|
||||
getCurrentServerVersion: () => currentServerVersion,
|
||||
getCurrentUserRole: () => currentUserRole,
|
||||
getShowAllBuilds: () => showAllBuilds,
|
||||
});
|
||||
const {
|
||||
displayBuild,
|
||||
loadSavedBuilds,
|
||||
saveBuildToStorage,
|
||||
} = buildHistoryManager;
|
||||
|
||||
const CRYPTABLE_DISABLE_TARGETS = [
|
||||
'input[name="enable-persistence"]',
|
||||
@@ -1419,94 +1329,7 @@ if (logoutBtn && !logoutBtn.dataset.boundLogout) {
|
||||
});
|
||||
}
|
||||
|
||||
if (profileSelect) {
|
||||
profileSelect.addEventListener("change", () => {
|
||||
const profile = getSelectedProfile();
|
||||
if (profileNameInput) {
|
||||
profileNameInput.value = profile?.name || "";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (profileSaveBtn) {
|
||||
profileSaveBtn.addEventListener("click", async () => {
|
||||
try {
|
||||
await saveCurrentProfile();
|
||||
alert("Profile saved.");
|
||||
} catch (err) {
|
||||
alert(err?.message || "Failed to save profile");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (profileLoadBtn) {
|
||||
profileLoadBtn.addEventListener("click", () => {
|
||||
const profile = getSelectedProfile();
|
||||
if (!profile) {
|
||||
alert("Select a profile to load.");
|
||||
return;
|
||||
}
|
||||
applyFormSettings(profile.config || {});
|
||||
saveFormSettings();
|
||||
if (profileNameInput) profileNameInput.value = profile.name;
|
||||
alert(`Loaded profile: ${profile.name}`);
|
||||
});
|
||||
}
|
||||
|
||||
if (profileDeleteBtn) {
|
||||
profileDeleteBtn.addEventListener("click", async () => {
|
||||
const profile = getSelectedProfile();
|
||||
if (!profile) {
|
||||
alert("Select a profile to delete.");
|
||||
return;
|
||||
}
|
||||
if (!confirm(`Delete profile \"${profile.name}\"?`)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`/api/build/profiles/${encodeURIComponent(profile.name)}`, {
|
||||
method: "DELETE",
|
||||
credentials: "include",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error || "Failed to delete profile");
|
||||
}
|
||||
await loadBuildProfiles();
|
||||
if (profileNameInput) profileNameInput.value = "";
|
||||
alert("Profile deleted.");
|
||||
} catch (err) {
|
||||
alert(err?.message || "Failed to delete profile");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (profileExportBtn) {
|
||||
profileExportBtn.addEventListener("click", () => {
|
||||
const profile = getSelectedProfile();
|
||||
if (profile) {
|
||||
exportProfileToFile(profile.name, profile.config || {});
|
||||
return;
|
||||
}
|
||||
const fallbackName = sanitizeProfileName(profileNameInput?.value || "") || "current-build-profile";
|
||||
exportProfileToFile(fallbackName, collectFormSettings());
|
||||
});
|
||||
}
|
||||
|
||||
if (profileImportBtn && profileImportFile) {
|
||||
profileImportBtn.addEventListener("click", () => profileImportFile.click());
|
||||
profileImportFile.addEventListener("change", async () => {
|
||||
const file = profileImportFile.files?.[0];
|
||||
profileImportFile.value = "";
|
||||
if (!file) return;
|
||||
try {
|
||||
await importProfileFromFile(file);
|
||||
alert("Profile imported and saved.");
|
||||
} catch (err) {
|
||||
alert(err?.message || "Failed to import profile");
|
||||
}
|
||||
});
|
||||
}
|
||||
buildProfileManager.bindProfileControls();
|
||||
|
||||
form?.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
@@ -2078,8 +1901,8 @@ function showBuildFiles(files, buildId, expiresAt) {
|
||||
buildInfoDiv.appendChild(infoRow);
|
||||
buildFilesDiv.appendChild(buildInfoDiv);
|
||||
|
||||
updateExpirationTimer();
|
||||
setInterval(updateExpirationTimer, 60000);
|
||||
updateExpirationTimer(timer, expiresAt);
|
||||
setInterval(() => updateExpirationTimer(timer, expiresAt), 60000);
|
||||
|
||||
files.forEach((file) => {
|
||||
const fileDiv = document.createElement("div");
|
||||
@@ -2112,333 +1935,6 @@ function showBuildFiles(files, buildId, expiresAt) {
|
||||
});
|
||||
}
|
||||
|
||||
function updateExpirationTimer(timerEl, expiresAt) {
|
||||
if (!timerEl) return;
|
||||
|
||||
const now = Date.now();
|
||||
const remaining = expiresAt - now;
|
||||
|
||||
if (remaining <= 0) {
|
||||
timerEl.textContent = "Expired";
|
||||
timerEl.className = "text-red-400 font-medium";
|
||||
return;
|
||||
}
|
||||
|
||||
const days = Math.floor(remaining / (1000 * 60 * 60 * 24));
|
||||
const hours = Math.floor(
|
||||
(remaining % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60),
|
||||
);
|
||||
const minutes = Math.floor((remaining % (1000 * 60 * 60)) / (1000 * 60));
|
||||
|
||||
if (days > 0) {
|
||||
timerEl.textContent = `${days}d ${hours}h`;
|
||||
} else if (hours > 0) {
|
||||
timerEl.textContent = `${hours}h ${minutes}m`;
|
||||
} else {
|
||||
timerEl.textContent = `${minutes}m`;
|
||||
}
|
||||
|
||||
if (days >= 3) {
|
||||
timerEl.className = "text-green-400 font-medium";
|
||||
} else if (days >= 1) {
|
||||
timerEl.className = "text-yellow-400 font-medium";
|
||||
} else {
|
||||
timerEl.className = "text-orange-400 font-medium";
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteBuild(buildId) {
|
||||
if (!confirm("Are you sure you want to delete this build?")) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/build/${encodeURIComponent(buildId)}/delete`, {
|
||||
method: "DELETE",
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error || "Failed to delete build");
|
||||
}
|
||||
|
||||
const buildElement = document.getElementById(`build-${buildId}`);
|
||||
if (buildElement) {
|
||||
buildElement.remove();
|
||||
}
|
||||
|
||||
removeBuildFromStorage(buildId);
|
||||
|
||||
if (buildFilesDiv.children.length === 0) {
|
||||
buildResults.classList.add("hidden");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to delete build:", err);
|
||||
alert("Failed to delete build. Please try again.");
|
||||
}
|
||||
}
|
||||
|
||||
function formatFileSize(bytes) {
|
||||
if (bytes === 0) return "0 B";
|
||||
const k = 1024;
|
||||
const sizes = ["B", "KB", "MB", "GB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + " " + sizes[i];
|
||||
}
|
||||
|
||||
function formatStubVersion(version) {
|
||||
if (typeof version === "string" && version.trim()) {
|
||||
return version.trim();
|
||||
}
|
||||
return "unknown (legacy build)";
|
||||
}
|
||||
|
||||
function isVersionMismatch(versionValue) {
|
||||
if (!currentServerVersion) return false;
|
||||
return versionValue !== currentServerVersion;
|
||||
}
|
||||
|
||||
function saveBuildToStorage(buildId, buildData) {
|
||||
try {
|
||||
const builds = JSON.parse(localStorage.getItem("overlord_builds") || "[]");
|
||||
const existingIndex = builds.findIndex((b) => b.id === buildId);
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
builds[existingIndex] = buildData;
|
||||
} else {
|
||||
builds.push(buildData);
|
||||
}
|
||||
|
||||
if (builds.length > 20) {
|
||||
builds.splice(0, builds.length - 20);
|
||||
}
|
||||
|
||||
localStorage.setItem("overlord_builds", JSON.stringify(builds));
|
||||
} catch (err) {
|
||||
console.error("Failed to save build to localStorage:", err);
|
||||
}
|
||||
}
|
||||
|
||||
function getBuildFromStorage(buildId) {
|
||||
try {
|
||||
const builds = JSON.parse(localStorage.getItem("overlord_builds") || "[]");
|
||||
return builds.find((b) => b.id === buildId);
|
||||
} catch (err) {
|
||||
console.error("Failed to get build from localStorage:", err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getAllBuildsFromStorage() {
|
||||
try {
|
||||
const builds = JSON.parse(localStorage.getItem("overlord_builds") || "[]");
|
||||
|
||||
return builds.sort((a, b) => b.startTime - a.startTime);
|
||||
} catch (err) {
|
||||
console.error("Failed to get builds from localStorage:", err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function removeBuildFromStorage(buildId) {
|
||||
try {
|
||||
const builds = JSON.parse(localStorage.getItem("overlord_builds") || "[]");
|
||||
const filtered = builds.filter((b) => b.id !== buildId);
|
||||
localStorage.setItem("overlord_builds", JSON.stringify(filtered));
|
||||
} catch (err) {
|
||||
console.error("Failed to remove build from localStorage:", err);
|
||||
}
|
||||
}
|
||||
|
||||
let showAllBuilds = false;
|
||||
|
||||
async function loadSavedBuilds() {
|
||||
try {
|
||||
const queryParam = showAllBuilds && currentUserRole === "admin" ? "?all=true" : "";
|
||||
const res = await fetch(`/api/build/list${queryParam}`, {
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
console.error("Failed to fetch builds from server");
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
const builds = data.builds || [];
|
||||
|
||||
const now = Date.now();
|
||||
const validBuilds = builds.filter((build) => {
|
||||
if (build.expiresAt && build.expiresAt <= now) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
if (validBuilds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
buildResults.classList.remove("hidden");
|
||||
|
||||
for (const build of validBuilds) {
|
||||
displayBuild(build);
|
||||
|
||||
saveBuildToStorage(build.id, build);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to load builds:", err);
|
||||
|
||||
const builds = getAllBuildsFromStorage();
|
||||
const now = Date.now();
|
||||
const validBuilds = builds.filter((build) => {
|
||||
if (build.expiresAt && build.expiresAt <= now) {
|
||||
removeBuildFromStorage(build.id);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
if (validBuilds.length > 0) {
|
||||
buildResults.classList.remove("hidden");
|
||||
validBuilds.forEach((build) => displayBuild(build));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function displayBuild(build) {
|
||||
const buildContainer = document.createElement("div");
|
||||
buildContainer.className =
|
||||
"build-result-item mb-6 pb-6 border-b border-gray-700 last:border-b-0";
|
||||
buildContainer.id = `build-${build.id}`;
|
||||
const header = document.createElement("div");
|
||||
header.className = "flex items-center justify-between mb-3";
|
||||
|
||||
const left = document.createElement("div");
|
||||
left.className = "flex items-center gap-3";
|
||||
const boxIcon = document.createElement("i");
|
||||
boxIcon.className = "fa-solid fa-box text-blue-400";
|
||||
const buildLabel = document.createElement("span");
|
||||
buildLabel.className = "text-gray-300 font-medium";
|
||||
buildLabel.textContent = `Build ID: ${build.id.substring(0, 8)}`;
|
||||
const sep = document.createElement("span");
|
||||
sep.className = "text-gray-500";
|
||||
sep.textContent = "•";
|
||||
const startedAt = document.createElement("span");
|
||||
startedAt.className = "text-sm text-gray-400";
|
||||
startedAt.textContent = new Date(build.startTime).toLocaleString();
|
||||
left.appendChild(boxIcon);
|
||||
left.appendChild(buildLabel);
|
||||
left.appendChild(sep);
|
||||
left.appendChild(startedAt);
|
||||
|
||||
const right = document.createElement("div");
|
||||
right.className = "flex items-center gap-3";
|
||||
const timerWrap = document.createElement("div");
|
||||
timerWrap.className = "flex items-center gap-2";
|
||||
const clockIcon = document.createElement("i");
|
||||
clockIcon.className = "fa-solid fa-clock text-gray-400";
|
||||
const timer = document.createElement("span");
|
||||
timer.id = `timer-${build.id}`;
|
||||
timer.className = "text-gray-300 font-medium";
|
||||
timer.textContent = "Loading...";
|
||||
timerWrap.appendChild(clockIcon);
|
||||
timerWrap.appendChild(timer);
|
||||
|
||||
const deleteBtn = document.createElement("button");
|
||||
deleteBtn.id = `delete-btn-${build.id}`;
|
||||
deleteBtn.className =
|
||||
"px-3 py-1.5 bg-red-500/20 hover:bg-red-500/30 text-red-400 rounded-lg transition-colors flex items-center gap-2 text-sm";
|
||||
deleteBtn.title = "Delete build";
|
||||
const deleteIcon = document.createElement("i");
|
||||
deleteIcon.className = "fa-solid fa-trash";
|
||||
const deleteText = document.createElement("span");
|
||||
deleteText.textContent = "Delete";
|
||||
deleteBtn.appendChild(deleteIcon);
|
||||
deleteBtn.appendChild(deleteText);
|
||||
deleteBtn.addEventListener("click", () => deleteBuild(build.id));
|
||||
|
||||
right.appendChild(timerWrap);
|
||||
right.appendChild(deleteBtn);
|
||||
|
||||
header.appendChild(left);
|
||||
header.appendChild(right);
|
||||
|
||||
const filesContainer = document.createElement("div");
|
||||
filesContainer.id = `files-${build.id}`;
|
||||
filesContainer.className = "space-y-2";
|
||||
|
||||
buildContainer.appendChild(header);
|
||||
buildContainer.appendChild(filesContainer);
|
||||
|
||||
buildFilesDiv.appendChild(buildContainer);
|
||||
|
||||
showBuildFilesForContainer(build, `files-${build.id}`, `timer-${build.id}`);
|
||||
}
|
||||
|
||||
function showBuildFilesForContainer(build, containerId, timerId) {
|
||||
const container = document.getElementById(containerId);
|
||||
const timerEl = document.getElementById(timerId);
|
||||
|
||||
if (!container || !timerEl) return;
|
||||
|
||||
build.files.forEach((file) => {
|
||||
const fileDiv = document.createElement("div");
|
||||
fileDiv.className =
|
||||
"flex items-center justify-between bg-gray-700/50 p-4 rounded-lg hover:bg-gray-700 transition-colors";
|
||||
|
||||
const fileMeta = document.createElement("div");
|
||||
fileMeta.className = "flex items-center gap-3";
|
||||
const fileIcon = document.createElement("i");
|
||||
fileIcon.className = "fa-solid fa-file text-blue-400";
|
||||
const fileText = document.createElement("div");
|
||||
const fileName = document.createElement("div");
|
||||
fileName.className = "text-white font-medium";
|
||||
fileName.textContent = file.filename;
|
||||
const filePlatform = document.createElement("div");
|
||||
filePlatform.className = "text-sm text-gray-400";
|
||||
const versionValue = formatStubVersion(file.version);
|
||||
const platformText = document.createElement("span");
|
||||
platformText.textContent = `${file.platform} | `;
|
||||
const versionText = document.createElement("span");
|
||||
versionText.className = isVersionMismatch(versionValue)
|
||||
? "server-version-number-mismatch"
|
||||
: "server-version-number";
|
||||
versionText.textContent =
|
||||
versionValue === "unknown (legacy build)" ? versionValue : `v${versionValue}`;
|
||||
filePlatform.appendChild(platformText);
|
||||
filePlatform.appendChild(versionText);
|
||||
fileText.appendChild(fileName);
|
||||
fileText.appendChild(filePlatform);
|
||||
fileMeta.appendChild(fileIcon);
|
||||
fileMeta.appendChild(fileText);
|
||||
|
||||
const download = document.createElement("a");
|
||||
download.href = `/api/build/download/${encodeURIComponent(file.filename)}`;
|
||||
download.download = "";
|
||||
download.className =
|
||||
"px-4 py-2 bg-blue-500 hover:bg-blue-600 text-white rounded-lg transition-colors flex items-center gap-2";
|
||||
const downloadIcon = document.createElement("i");
|
||||
downloadIcon.className = "fa-solid fa-download";
|
||||
const downloadText = document.createElement("span");
|
||||
downloadText.textContent = "Download";
|
||||
download.appendChild(downloadIcon);
|
||||
download.appendChild(downloadText);
|
||||
|
||||
fileDiv.appendChild(fileMeta);
|
||||
fileDiv.appendChild(download);
|
||||
|
||||
container.appendChild(fileDiv);
|
||||
});
|
||||
|
||||
if (build.expiresAt) {
|
||||
updateExpirationTimer(timerEl, build.expiresAt);
|
||||
|
||||
setInterval(() => updateExpirationTimer(timerEl, build.expiresAt), 60000);
|
||||
}
|
||||
}
|
||||
|
||||
const buildUpdateAllBtn = document.getElementById("build-update-all-btn");
|
||||
const updateAllModal = document.getElementById("update-all-modal");
|
||||
const updateAllModalBody = document.getElementById("update-all-modal-body");
|
||||
|
||||
Reference in New Issue
Block a user