import { pageRoute } from "../routes.js"; /* Orphaned-upload administration view. */ /** Format a byte count for the administrative upload list. */ function formatFileSize(size) { const bytes = Number(size || 0); if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; } class OrphanedUploadsAdmin { /** * goal : Create the controller for uploads without current references. * pre : api performs authenticated requests and translate resolves UI keys. * post : The controller is connected to the orphaned-upload list. * result : A directly usable orphaned-upload administration controller. */ constructor(api, translate) { this.api = api; this.translate = translate; this.list = document.getElementById("orphaned-uploads-list"); } /** * goal : Load orphaned uploads and their last historical uses. * pre : The current session has administrator permission. * post : The upload list represents the current server state. * result : A promise that resolves when all upload rows have been rendered. */ async load() { const result = await this.api("/api/admin/uploads/orphaned"); const uploads = result.uploads || []; this.list.replaceChildren(); if (uploads.length === 0) { const empty = document.createElement("p"); empty.className = "muted"; empty.textContent = this.translate("no-orphaned-uploads", "No orphaned uploads."); this.list.append(empty); return; } for (const upload of uploads) this.list.append(this.#renderUpload(upload)); } /** Build one upload row with history, view link and deletion action. */ #renderUpload(upload) { const row = document.createElement("article"); row.className = "orphaned-upload-row"; const title = document.createElement("strong"); title.textContent = upload.originalName; const meta = document.createElement("div"); meta.className = "muted"; meta.textContent = `${upload.mimeType} · ${formatFileSize(upload.size)} · ` + `${this.translate("uploaded-by", "uploaded by")} ${upload.uploadedBy}`; const history = this.#uploadHistory(upload); const actions = document.createElement("div"); actions.className = "orphaned-upload-actions"; const view = document.createElement("a"); view.href = `/uploads/${encodeURIComponent(upload.ownerSlug)}/` + encodeURIComponent(upload.storedName); view.target = "_blank"; view.rel = "noopener"; view.textContent = this.translate("view", "View"); const remove = document.createElement("button"); remove.className = "danger"; remove.textContent = this.translate("delete", "Delete"); remove.addEventListener("click", () => { this.#deleteUpload(upload).catch((error) => console.error(error)); }); actions.append(view, remove); row.append(title, meta, history, actions); return row; } /** Build the historical-use portion of one upload row. */ #uploadHistory(upload) { const history = document.createElement("div"); const lastUses = Array.isArray(upload.lastUses) ? upload.lastUses : []; if (lastUses.length === 0) { history.textContent = this.translate( "never-referenced", "Never referenced by a saved page."); return history; } const label = document.createElement("div"); label.textContent = `${this.translate("last-used", "Last used")}:`; const uses = document.createElement("ul"); uses.className = "orphaned-upload-uses"; for (const use of lastUses) { const item = document.createElement("li"); const link = document.createElement("a"); link.href = pageRoute(use.slug); link.textContent = use.title || use.slug; item.append(link); if (use.version) { item.append(document.createTextNode( ` · ${this.translate("version", "Version")} ${use.version}`)); } uses.append(item); } history.append(label, uses); return history; } /** Confirm and remove one still-orphaned upload, then refresh the list. */ async #deleteUpload(upload) { const question = this.translate( "delete-orphaned-upload-confirm", "Delete this orphaned upload?"); if (!window.confirm(question)) return; await this.api(`/api/admin/uploads/orphaned/${upload.id}`, { method: "DELETE" }); await this.load(); } } export { OrphanedUploadsAdmin };