diff --git a/Overlord-Server/public/assets/build.js b/Overlord-Server/public/assets/build.js
index a8d35e8..f14a6c8 100644
--- a/Overlord-Server/public/assets/build.js
+++ b/Overlord-Server/public/assets/build.js
@@ -2,7 +2,6 @@ import { createBuildProfileManager } from "./build-profile-manager.js";
import {
createBuildHistoryManager,
formatFileSize,
- updateExpirationTimer,
} from "./build-history-manager.js";
const form = document.getElementById("build-form");
@@ -2259,7 +2258,8 @@ function showBuildFiles(files, buildId, expiresAt) {
const timer = document.createElement("span");
timer.id = "expiration-timer";
timer.className = "text-yellow-400 font-medium";
- timer.dataset.expires = String(expiresAt);
+ timer.dataset.controller = "countdown";
+ timer.dataset.countdownExpiresAtValue = String(expiresAt);
timer.textContent = "Calculating...";
right.appendChild(clockIcon);
right.appendChild(expiresLabel);
@@ -2270,9 +2270,6 @@ function showBuildFiles(files, buildId, expiresAt) {
buildInfoDiv.appendChild(infoRow);
buildFilesDiv.appendChild(buildInfoDiv);
- updateExpirationTimer(timer, expiresAt);
- setInterval(() => updateExpirationTimer(timer, expiresAt), 60000);
-
files.forEach((file) => {
const fileDiv = document.createElement("div");
fileDiv.className =
diff --git a/Overlord-Server/public/assets/data.js b/Overlord-Server/public/assets/data.js
index 4983ef2..4dbd944 100644
--- a/Overlord-Server/public/assets/data.js
+++ b/Overlord-Server/public/assets/data.js
@@ -108,6 +108,7 @@ function moveClientCardImmediately(msg) {
export function registerRenderer(fn) {
render = fn;
+ state.lastDigest = "";
}
function clientQueryParams(page = state.page) {
diff --git a/Overlord-Server/public/assets/file-share.js b/Overlord-Server/public/assets/file-share.js
index ca43d5f..041a9b4 100644
--- a/Overlord-Server/public/assets/file-share.js
+++ b/Overlord-Server/public/assets/file-share.js
@@ -23,12 +23,6 @@ uploadInfinite?.addEventListener("change", () => {
});
if (uploadMaxDownloads) uploadMaxDownloads.disabled = true;
-const passwordInfoBtn = document.getElementById("password-info-btn");
-const passwordInfoPanel = document.getElementById("password-info-panel");
-passwordInfoBtn?.addEventListener("click", () => {
- passwordInfoPanel?.classList.toggle("hidden");
-});
-
async function loadCurrentUser() {
try {
const res = await fetch("/api/auth/me");
@@ -113,12 +107,14 @@ async function loadFiles() {
${canManage ? `
` : ""}
@@ -137,57 +135,21 @@ async function loadFiles() {
})
.join("");
- attachActionListeners();
} catch (err) {
console.error("Load files error:", err);
fileList.innerHTML = ` | Failed to load files | `;
}
}
-function attachActionListeners() {
- fileList.addEventListener("click", (e) => {
- const btn = e.target.closest(".action-btn");
- if (!btn) return;
+fileList?.addEventListener("click", (e) => {
+ const btn = e.target.closest("[data-file-action]");
+ if (!btn) return;
- const action = btn.dataset.action;
- const fileId = btn.dataset.fileId;
-
- switch (action) {
- case "copy-link":
- copyDownloadLink(fileId);
- break;
- case "edit":
- openEditModal(btn);
- break;
- case "delete":
- deleteFile(fileId, btn.dataset.filename);
- break;
- }
- });
-}
-
-function copyDownloadLink(fileId) {
- const url = getDownloadUrl(fileId);
- navigator.clipboard.writeText(url).then(() => {
- if (window.showToast) {
- window.showToast("Download link copied to clipboard!", "success");
- }
- }).catch(() => {
- const input = document.createElement("input");
- input.value = url;
- document.body.appendChild(input);
- input.select();
- document.execCommand("copy");
- document.body.removeChild(input);
- if (window.showToast) {
- window.showToast("Download link copied!", "success");
- }
- });
-}
-
-async function deleteFile(fileId, filename) {
- if (!confirm(`Delete "${filename}"? This cannot be undone.`)) return;
+ if (btn.dataset.fileAction === "edit") openEditModal(btn);
+ if (btn.dataset.fileAction === "delete") deleteFile(btn.dataset.fileId);
+});
+async function deleteFile(fileId) {
try {
const res = await fetch(`/api/file-share/${fileId}`, { method: "DELETE" });
if (res.ok) {
diff --git a/Overlord-Server/public/assets/main.js b/Overlord-Server/public/assets/main.js
index 67ad20a..96505b7 100644
--- a/Overlord-Server/public/assets/main.js
+++ b/Overlord-Server/public/assets/main.js
@@ -1141,6 +1141,7 @@ function initializeRenderer() {
}
});
initDashboardStats();
+ renderCachedClients({ reorder: true, force: true });
setupDashboardThumbnailLoader();
refreshGroupFilter();
loadWithOptions();
diff --git a/Overlord-Server/public/assets/stimulus/application.js b/Overlord-Server/public/assets/stimulus/application.js
index 182fbde..80109a0 100644
--- a/Overlord-Server/public/assets/stimulus/application.js
+++ b/Overlord-Server/public/assets/stimulus/application.js
@@ -1,9 +1,19 @@
import { Application } from "/vendor/hotwired/stimulus.js";
import SessionsController from "./controllers/sessions-controller.js";
+import ClipboardController from "./controllers/clipboard-controller.js";
+import ConfirmController from "./controllers/confirm-controller.js";
+import CountdownController from "./controllers/countdown-controller.js";
+import RevealController from "./controllers/reveal-controller.js";
+import ToggleController from "./controllers/toggle-controller.js";
const application = Application.start();
application.debug = false;
application.register("sessions", SessionsController);
+application.register("clipboard", ClipboardController);
+application.register("confirm", ConfirmController);
+application.register("countdown", CountdownController);
+application.register("reveal", RevealController);
+application.register("toggle", ToggleController);
window.OverlordStimulus = application;
diff --git a/Overlord-Server/public/assets/stimulus/controllers/clipboard-controller.js b/Overlord-Server/public/assets/stimulus/controllers/clipboard-controller.js
new file mode 100644
index 0000000..1262b6b
--- /dev/null
+++ b/Overlord-Server/public/assets/stimulus/controllers/clipboard-controller.js
@@ -0,0 +1,33 @@
+import { Controller } from "/vendor/hotwired/stimulus.js";
+
+export default class extends Controller {
+ static values = {
+ text: String,
+ successMessage: { type: String, default: "Copied to clipboard" },
+ };
+
+ async copy(event) {
+ event.preventDefault();
+ if (!this.hasTextValue) return;
+
+ try {
+ await navigator.clipboard.writeText(this.textValue);
+ } catch {
+ const input = document.createElement("textarea");
+ input.value = this.textValue;
+ input.setAttribute("readonly", "");
+ input.style.position = "fixed";
+ input.style.opacity = "0";
+ document.body.appendChild(input);
+ input.select();
+ document.execCommand("copy");
+ input.remove();
+ }
+
+ window.showToast?.(this.successMessageValue, "success");
+ this.element.dispatchEvent(new CustomEvent("clipboard:copied", {
+ bubbles: true,
+ detail: { text: this.textValue },
+ }));
+ }
+}
diff --git a/Overlord-Server/public/assets/stimulus/controllers/confirm-controller.js b/Overlord-Server/public/assets/stimulus/controllers/confirm-controller.js
new file mode 100644
index 0000000..cab4a6a
--- /dev/null
+++ b/Overlord-Server/public/assets/stimulus/controllers/confirm-controller.js
@@ -0,0 +1,12 @@
+import { Controller } from "/vendor/hotwired/stimulus.js";
+
+export default class extends Controller {
+ static values = { message: String };
+
+ confirm(event) {
+ if (!this.hasMessageValue || window.confirm(this.messageValue)) return;
+
+ event.preventDefault();
+ event.stopImmediatePropagation();
+ }
+}
diff --git a/Overlord-Server/public/assets/stimulus/controllers/countdown-controller.js b/Overlord-Server/public/assets/stimulus/controllers/countdown-controller.js
new file mode 100644
index 0000000..a9630c6
--- /dev/null
+++ b/Overlord-Server/public/assets/stimulus/controllers/countdown-controller.js
@@ -0,0 +1,53 @@
+import { Controller } from "/vendor/hotwired/stimulus.js";
+
+export default class extends Controller {
+ static values = {
+ expiresAt: Number,
+ interval: { type: Number, default: 60_000 },
+ };
+
+ connect() {
+ this.update();
+ if (this.expiresAtValue > Date.now()) {
+ this.clock = window.setInterval(() => this.update(), this.intervalValue);
+ }
+ }
+
+ disconnect() {
+ window.clearInterval(this.clock);
+ }
+
+ update() {
+ const remaining = this.expiresAtValue - Date.now();
+ if (remaining <= 0) {
+ this.element.textContent = "Expired";
+ this.setTone("text-red-400");
+ window.clearInterval(this.clock);
+ return;
+ }
+
+ const days = Math.floor(remaining / 86_400_000);
+ const hours = Math.floor((remaining % 86_400_000) / 3_600_000);
+ const minutes = Math.floor((remaining % 3_600_000) / 60_000);
+ this.element.textContent = days > 0
+ ? `${days}d ${hours}h`
+ : hours > 0
+ ? `${hours}h ${minutes}m`
+ : `${minutes}m`;
+ this.setTone(days >= 3
+ ? "text-green-400"
+ : days >= 1
+ ? "text-yellow-400"
+ : "text-orange-400");
+ }
+
+ setTone(tone) {
+ this.element.classList.remove(
+ "text-red-400",
+ "text-green-400",
+ "text-yellow-400",
+ "text-orange-400",
+ );
+ this.element.classList.add(tone, "font-medium");
+ }
+}
diff --git a/Overlord-Server/public/assets/stimulus/controllers/reveal-controller.js b/Overlord-Server/public/assets/stimulus/controllers/reveal-controller.js
new file mode 100644
index 0000000..76f30bb
--- /dev/null
+++ b/Overlord-Server/public/assets/stimulus/controllers/reveal-controller.js
@@ -0,0 +1,17 @@
+import { Controller } from "/vendor/hotwired/stimulus.js";
+
+export default class extends Controller {
+ static targets = ["input", "icon"];
+
+ toggle(event) {
+ event.preventDefault();
+ const revealing = this.inputTarget.type === "password";
+ this.inputTarget.type = revealing ? "text" : "password";
+ event.currentTarget.setAttribute("aria-pressed", String(revealing));
+ event.currentTarget.setAttribute("aria-label", revealing ? "Hide value" : "Show value");
+ if (this.hasIconTarget) {
+ this.iconTarget.classList.toggle("fa-eye", !revealing);
+ this.iconTarget.classList.toggle("fa-eye-slash", revealing);
+ }
+ }
+}
diff --git a/Overlord-Server/public/assets/stimulus/controllers/sessions-controller.js b/Overlord-Server/public/assets/stimulus/controllers/sessions-controller.js
index 2eb1823..d1d411a 100644
--- a/Overlord-Server/public/assets/stimulus/controllers/sessions-controller.js
+++ b/Overlord-Server/public/assets/stimulus/controllers/sessions-controller.js
@@ -12,13 +12,6 @@ export default class extends Controller {
window.clearInterval(this.clock);
}
- confirm(event) {
- const message = event.currentTarget.dataset.confirmMessage;
- if (message && !window.confirm(message)) {
- event.preventDefault();
- }
- }
-
updateTimes() {
const now = Math.floor(Date.now() / 1000);
for (const element of this.timeTargets) {
diff --git a/Overlord-Server/public/assets/stimulus/controllers/toggle-controller.js b/Overlord-Server/public/assets/stimulus/controllers/toggle-controller.js
new file mode 100644
index 0000000..094520f
--- /dev/null
+++ b/Overlord-Server/public/assets/stimulus/controllers/toggle-controller.js
@@ -0,0 +1,11 @@
+import { Controller } from "/vendor/hotwired/stimulus.js";
+
+export default class extends Controller {
+ static targets = ["content"];
+
+ toggle(event) {
+ event.preventDefault();
+ const hidden = this.contentTarget.classList.toggle("hidden");
+ event.currentTarget.setAttribute("aria-expanded", String(!hidden));
+ }
+}
diff --git a/Overlord-Server/public/assets/turbo-navigation.js b/Overlord-Server/public/assets/turbo-navigation.js
index 5656b98..b1f56f8 100644
--- a/Overlord-Server/public/assets/turbo-navigation.js
+++ b/Overlord-Server/public/assets/turbo-navigation.js
@@ -22,6 +22,7 @@ const persistentIds = [
"chat-bubble",
"chat-panel",
"cert-trust-banner",
+ "command-menu",
];
function normalizeUrl(value, base = window.location.href) {
diff --git a/Overlord-Server/public/assets/ui.js b/Overlord-Server/public/assets/ui.js
index f572618..cba2a31 100644
--- a/Overlord-Server/public/assets/ui.js
+++ b/Overlord-Server/public/assets/ui.js
@@ -1,3 +1,5 @@
+import { runWithoutPageTracking } from "./turbo-navigation.js";
+
// ── Context menu data ─────────────────────────────────────────────────────────
const MENU_GROUPS = [
{
@@ -254,7 +256,6 @@ menuStyle.textContent = `
#ctx-main { border-radius: 8px 8px 0 0; }
}
`;
-document.head.appendChild(menuStyle);
// ── Build DOM ─────────────────────────────────────────────────────────────────
function buildItemHTML(item) {
@@ -282,9 +283,11 @@ const subPanelsHTML =
const menu = document.createElement("div");
menu.id = "command-menu";
+menu.setAttribute("data-turbo-permanent", "");
menu.setAttribute("role", "menu");
menu.setAttribute("aria-hidden", "true");
menu.innerHTML = ` ${mainRowsHTML} ${subPanelsHTML} `;
+menu.prepend(menuStyle);
document.body.appendChild(menu);
const ctxMain = menu.querySelector("#ctx-main");
@@ -363,32 +366,34 @@ function refitMobileMenu() {
}
ctxMain.querySelectorAll(".ctx-row").forEach(rowEl => {
- rowEl.addEventListener("mouseenter", () => {
+ runWithoutPageTracking(() => rowEl.addEventListener("mouseenter", () => {
if (isMobileMenu()) return;
if (rowEl.disabled || rowEl.getAttribute("aria-disabled") === "true") return;
const groupId = rowEl.dataset.groupToggle;
if (groupId) showSubmenu(groupId, rowEl);
- });
+ }));
});
// Debounced hide — prevents the 5px gap between panels from closing the submenu
function scheduleHide() { if (!isMobileMenu()) _hideTimer = setTimeout(hideSubmenu, 150); }
function cancelHide() { clearTimeout(_hideTimer); _hideTimer = null; }
-ctxMain.addEventListener("mouseleave", scheduleHide);
-ctxMain.addEventListener("mouseenter", cancelHide);
-ctxSub.addEventListener("mouseenter", cancelHide);
-ctxSub.addEventListener("mouseleave", scheduleHide);
+runWithoutPageTracking(() => {
+ ctxMain.addEventListener("mouseleave", scheduleHide);
+ ctxMain.addEventListener("mouseenter", cancelHide);
+ ctxSub.addEventListener("mouseenter", cancelHide);
+ ctxSub.addEventListener("mouseleave", scheduleHide);
+});
// Touch / mobile: tap to toggle group
ctxMain.querySelectorAll(".ctx-row").forEach(rowEl => {
- rowEl.addEventListener("click", (e) => {
+ runWithoutPageTracking(() => rowEl.addEventListener("click", (e) => {
const groupId = rowEl.dataset.groupToggle;
if (!groupId) return;
if (rowEl.disabled || rowEl.getAttribute("aria-disabled") === "true") return;
e.stopPropagation();
if (activeGroupId === groupId) { hideSubmenu(); } else { showSubmenu(groupId, rowEl); }
- });
+ }));
});
const modal = document.createElement("div");
diff --git a/Overlord-Server/public/file-share.html b/Overlord-Server/public/file-share.html
index f1800b3..428edb5 100644
--- a/Overlord-Server/public/file-share.html
+++ b/Overlord-Server/public/file-share.html
@@ -40,18 +40,23 @@
class="block w-full text-sm text-slate-300 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-medium file:bg-slate-800 file:text-slate-200 hover:file:bg-slate-700 cursor-pointer" />
-
+
-
-
-
+
+
+
+
+
Downloading password-protected files:
Use the X-Download-Password header:
curl -H "X-Download-Password: yourpass" https://host/api/file-share/<id>/download -o file.ext
@@ -127,8 +132,13 @@
diff --git a/Overlord-Server/src/server/ui/sessions-view.ts b/Overlord-Server/src/server/ui/sessions-view.ts
index 4f92f37..cda3c5c 100644
--- a/Overlord-Server/src/server/ui/sessions-view.ts
+++ b/Overlord-Server/src/server/ui/sessions-view.ts
@@ -46,7 +46,7 @@ function renderSessionRow(
|
${status}${currentBadge} |
- ${canRevoke ? ` | |