} */
const base = searchModeActive ? getSearchBaselineResults() : getBaselineResults();
// Empty/placeholder state
if (!base || base.length === 0) {
const empty = document.createElement("div");
empty.className = "tree-empty";
empty.textContent = searchModeActive ? "No search results yet." : "No files to show.";
tree.appendChild(empty);
// Update banner name for My Files context
const nm = document.getElementById("sidebar-name");
if (nm && !searchModeActive) {
nm.textContent = userName || "(not authenticated)";
}
return;
}
// Helper: build a leaf line (with avatar in Search mode)
function createLeafLine(r, parentEl) {
const leaf = document.createElement("div");
leaf.className = "tree-node";
const lline = document.createElement("div");
lline.className = "tree-line";
lline.setAttribute("role", "treeitem");
if (searchModeActive) {
const av = document.createElement("span");
av.className = "tree-avatar";
av.title = r.name || "";
attachAvatarInto(av, r.name || "");
lline.appendChild(av);
} else {
lline.appendChild(document.createElement("span"));
}
const llabel = document.createElement("span");
llabel.className = "tree-label";
llabel.textContent =
r.identifier === undefined || r.identifier === null || r.identifier === ""
? "default"
: r.identifier;
llabel.title = r.identifier || "";
llabel.addEventListener("click", () => {
openPreviewPage({
service: r.service,
identifier: r.identifier,
name: r.name || userName,
});
});
lline.appendChild(llabel);
parentEl.appendChild(lline);
}
// Helper: prefix computation for default (service-first) layout
function firstSepIndexAny(str) {
const u = str.indexOf("_");
const d = str.indexOf("-");
if (u === -1) {
return d;
}
if (d === -1) {
return u;
}
return Math.min(u, d);
}
function computePrefix(id) {
if (!id) {
return "";
}
const idx1 = firstSepIndexAny(id);
if (idx1 <= 0) {
return "";
}
let pfx = id.slice(0, idx1);
if (pfx === "q") {
const rest = id.slice(idx1 + 1);
const idx2 = firstSepIndexAny(rest);
if (idx2 > 0) {
return id.slice(0, idx1 + 1 + idx2);
}
return id;
}
return pfx;
}
// If grouping by Name (Search mode), render name -> service -> prefixes/leaves
if (searchModeActive && searchGroupByName) {
const byName = {};
for (const r of base) {
const nm = r.name || "";
(byName[nm] = byName[nm] || []).push(r);
}
const names = Object.keys(byName).sort();
for (const nmKey of names) {
const itemsForName = byName[nmKey];
if (!itemsForName || itemsForName.length === 0) {
continue;
}
const nameNode = document.createElement("div");
nameNode.className = "tree-node";
const line = document.createElement("div");
line.className = "tree-line";
line.setAttribute("role", "treeitem");
line.setAttribute("aria-expanded", "false");
const toggle = document.createElement("button");
toggle.type = "button";
toggle.className = "tree-toggle";
toggle.innerHTML =
'';
const children = document.createElement("div");
children.className = "tree-children";
children.setAttribute("role", "group");
toggle.addEventListener("click", (e) => {
e.stopPropagation();
const open = children.classList.toggle("expanded");
line.setAttribute("aria-expanded", String(open));
// visual state handled via CSS rotation on [aria-expanded]
});
const nAvatar = document.createElement("span");
nAvatar.className = "tree-avatar";
nAvatar.title = nmKey || "";
attachAvatarInto(nAvatar, nmKey || "");
const label = document.createElement("span");
label.className = "tree-label";
label.textContent = nmKey || "(no name)";
label.addEventListener("click", () => {
searchCurrentNameFilter = nmKey;
searchSelectedServices = new Set();
searchCurrentPrefixFilter = null;
searchCurrentIdentifierFilter = null;
applySearchFilter();
renderSearchResults();
showSection("search");
try {
updateSidebarBanner();
} catch {}
});
const count = document.createElement("span");
count.className = "tree-count";
count.textContent = String(itemsForName.length);
line.appendChild(toggle);
line.appendChild(nAvatar);
line.appendChild(label);
line.appendChild(count);
nameNode.appendChild(line);
nameNode.appendChild(children);
const bySvc = {};
for (const r of itemsForName) {
const svc = r.service || "UNKNOWN";
(bySvc[svc] = bySvc[svc] || []).push(r);
}
const services = Object.keys(bySvc).sort();
for (const svc of services) {
const svcItems = bySvc[svc];
const svcNode = document.createElement("div");
svcNode.className = "tree-node";
const sline = document.createElement("div");
sline.className = "tree-line";
sline.setAttribute("role", "treeitem");
sline.setAttribute("aria-expanded", "false");
const stoggle = document.createElement("button");
stoggle.type = "button";
stoggle.className = "tree-toggle";
stoggle.innerHTML =
'';
const schildren = document.createElement("div");
schildren.className = "tree-children";
schildren.setAttribute("role", "group");
stoggle.addEventListener("click", (e) => {
e.stopPropagation();
const open = schildren.classList.toggle("expanded");
sline.setAttribute("aria-expanded", String(open));
// visual state handled via CSS rotation on [aria-expanded]
});
const slabel = document.createElement("span");
slabel.className = "tree-label";
slabel.textContent = svc;
slabel.addEventListener("click", () => {
searchCurrentNameFilter = nmKey;
searchSelectedServices = new Set([svc]);
searchCurrentPrefixFilter = null;
searchCurrentIdentifierFilter = null;
applySearchFilter();
renderSearchResults();
showSection("search");
try {
updateSidebarBanner();
} catch {}
});
const scount = document.createElement("span");
scount.className = "tree-count";
scount.textContent = String(svcItems.length);
sline.appendChild(stoggle);
sline.appendChild(slabel);
sline.appendChild(scount);
svcNode.appendChild(sline);
svcNode.appendChild(schildren);
children.appendChild(svcNode);
// Prefix grouping under this name+service
const byPrefix = {};
for (const r of svcItems) {
const id = r.identifier || "";
const pfx = computePrefix(id);
(byPrefix[pfx] = byPrefix[pfx] || []).push(r);
}
const prefixes = Object.keys(byPrefix).sort();
const groups = prefixes.filter((p) => p && byPrefix[p].length > 1);
const singlePrefixCoversAll =
groups.length === 1 && byPrefix[groups[0]].length === svcItems.length;
if (singlePrefixCoversAll) {
svcItems.slice(0, 2000).forEach((r) => createLeafLine(r, schildren));
} else {
const leaves = [];
for (const pfx of prefixes) {
const arr = byPrefix[pfx];
if (!pfx || arr.length === 1) {
leaves.push(...arr);
}
}
leaves.slice(0, 2000).forEach((r) => createLeafLine(r, schildren));
for (const pfx of groups) {
const arr = byPrefix[pfx];
const pNode = document.createElement("div");
pNode.className = "tree-node";
const pline = document.createElement("div");
pline.className = "tree-line";
pline.setAttribute("role", "treeitem");
pline.setAttribute("aria-expanded", "false");
const ptoggle = document.createElement("button");
ptoggle.type = "button";
ptoggle.className = "tree-toggle";
ptoggle.innerHTML =
'';
const pchildren = document.createElement("div");
pchildren.className = "tree-children";
pchildren.setAttribute("role", "group");
ptoggle.addEventListener("click", (e) => {
e.stopPropagation();
const open = pchildren.classList.toggle("expanded");
pline.setAttribute("aria-expanded", String(open));
// visual state handled via CSS rotation on [aria-expanded]
});
const plabel = document.createElement("span");
plabel.className = "tree-label";
plabel.textContent = pfx;
plabel.addEventListener("click", () => {
searchCurrentNameFilter = nmKey;
searchSelectedServices = new Set([svc]);
searchCurrentPrefixFilter = pfx;
searchCurrentIdentifierFilter = null;
applySearchFilter();
renderSearchResults();
showSection("search");
try {
updateSidebarBanner();
} catch {}
});
const pcount = document.createElement("span");
pcount.className = "tree-count";
pcount.textContent = String(arr.length);
pline.appendChild(ptoggle);
pline.appendChild(plabel);
pline.appendChild(pcount);
pNode.appendChild(pline);
pNode.appendChild(pchildren);
schildren.appendChild(pNode);
arr.slice(0, 2000).forEach((r) => createLeafLine(r, pchildren));
}
}
}
tree.appendChild(nameNode);
}
// Update banner name text (auth context still shown)
const nmEl = document.getElementById("sidebar-name");
if (nmEl) {
nmEl.textContent = userName || "(not authenticated)";
}
return;
}
// Build service -> [items]
const byService = {};
for (const r of base) {
const svc = r.service || "UNKNOWN";
(byService[svc] = byService[svc] || []).push(r);
}
const services = Object.keys(byService).sort();
for (const svc of services) {
const items = byService[svc];
if (!items || items.length === 0) {
continue;
}
const svcNode = document.createElement("div");
svcNode.className = "tree-node";
const line = document.createElement("div");
line.className = "tree-line";
line.setAttribute("role", "treeitem");
line.setAttribute("aria-expanded", "false");
const toggle = document.createElement("button");
toggle.type = "button";
toggle.className = "tree-toggle";
toggle.innerHTML =
'';
toggle.addEventListener("click", (e) => {
e.stopPropagation();
const open = children.classList.toggle("expanded");
line.setAttribute("aria-expanded", String(open));
// visual state handled via CSS rotation on [aria-expanded]
});
const label = document.createElement("span");
label.className = "tree-label";
label.textContent = svc;
label.addEventListener("click", async () => {
if (searchModeActive) {
// Search mode: filter the search table by service
searchSelectedServices = new Set([svc]);
searchCurrentNameFilter = null;
searchCurrentPrefixFilter = null;
searchCurrentIdentifierFilter = null;
applySearchFilter();
renderSearchResults();
showSection("search");
try {
updateSidebarBanner();
} catch {}
return;
}
// My Files: filter to this service
selectedServices = new Set([svc]);
clearTreeFilters();
applyServiceFilter();
showSpinner();
contentPage.style.display = "none";
try {
await fetchPage();
} finally {
showSection("content");
hideSpinner();
}
});
const count = document.createElement("span");
count.className = "tree-count";
count.textContent = String(items.length);
const children = document.createElement("div");
children.className = "tree-children";
children.setAttribute("role", "group");
// Group by identifier prefix (support '_' and '-') with 'q' special-case
const byPrefix = {};
for (const r of items) {
const id = r.identifier || "";
const pfx = computePrefix(id);
(byPrefix[pfx] = byPrefix[pfx] || []).push(r);
}
const prefixes = Object.keys(byPrefix).sort();
const groups = prefixes.filter((p) => p && byPrefix[p].length > 1);
const singlePrefixCoversAll =
groups.length === 1 && byPrefix[groups[0]].length === items.length;
if (singlePrefixCoversAll) {
// Flatten: render all items directly
items.slice(0, 2000).forEach((r) => {
createLeafLine(r, children);
});
} else {
// Render leaves (no prefix or single-item groups) first
const leaves = [];
for (const pfx of prefixes) {
const arr = byPrefix[pfx];
if (!pfx || arr.length === 1) {
leaves.push(...arr);
}
}
leaves.slice(0, 2000).forEach((r) => {
createLeafLine(r, children);
});
// Then render multi-item groups
for (const pfx of groups) {
const arr = byPrefix[pfx];
const pNode = document.createElement("div");
pNode.className = "tree-node";
const pline = document.createElement("div");
pline.className = "tree-line";
pline.setAttribute("role", "treeitem");
pline.setAttribute("aria-expanded", "false");
const ptoggle = document.createElement("button");
ptoggle.type = "button";
ptoggle.className = "tree-toggle";
ptoggle.innerHTML =
'';
ptoggle.addEventListener("click", (e) => {
e.stopPropagation();
const open = pchildren.classList.toggle("expanded");
pline.setAttribute("aria-expanded", String(open));
// visual state handled via CSS rotation on [aria-expanded]
});
const plabel = document.createElement("span");
plabel.className = "tree-label";
plabel.textContent = pfx;
plabel.addEventListener("click", async () => {
if (searchModeActive) {
// Search mode: filter to service + prefix
searchSelectedServices = new Set([svc]);
searchCurrentPrefixFilter = pfx;
searchCurrentIdentifierFilter = null;
searchCurrentNameFilter = null;
applySearchFilter();
renderSearchResults();
showSection("search");
try {
updateSidebarBanner();
} catch {}
return;
}
// My Files: apply service + prefix filter
selectedServices = new Set([svc]);
currentPrefixFilter = pfx;
currentIdentifierFilter = null;
applyServiceFilter();
showSpinner();
contentPage.style.display = "none";
try {
await fetchPage();
} finally {
showSection("content");
hideSpinner();
}
});
const pcount = document.createElement("span");
pcount.className = "tree-count";
pcount.textContent = String(arr.length);
const pchildren = document.createElement("div");
pchildren.className = "tree-children";
pchildren.setAttribute("role", "group");
arr.slice(0, 2000).forEach((r) => {
createLeafLine(r, pchildren);
});
pline.appendChild(ptoggle);
pline.appendChild(plabel);
pline.appendChild(pcount);
pNode.appendChild(pline);
pNode.appendChild(pchildren);
children.appendChild(pNode);
}
}
line.appendChild(toggle);
line.appendChild(label);
line.appendChild(count);
svcNode.appendChild(line);
svcNode.appendChild(children);
tree.appendChild(svcNode);
}
// Update banner
const nm = document.getElementById("sidebar-name");
if (nm) {
nm.textContent = userName || "(not authenticated)";
}
}
// Show inline preview on the content page (used when desired; currently using full Preview page)
async function _showInlinePreview(ctx) {
try {
const container = document.getElementById("inline-viewer");
if (!container) {
return;
}
container.style.display = "block";
await loadPreviewInto(container, ctx);
try {
container.scrollIntoView({ behavior: "smooth", block: "start" });
} catch {}
} catch (e) {
console.error("Inline preview failed", e);
const container = document.getElementById("inline-viewer");
if (container) {
container.textContent = "Preview failed: " + ((e && e.message) || e);
}
}
}
// Auth UI updater (visibility + labels)
function updateAuthUI() {
if (isAuthenticated) {
authButton.style.display = "none";
nameSwitcherEl.style.display = "flex";
nameSelectEl.innerHTML = "";
for (const n of allNames) {
const opt = document.createElement("option");
opt.value = n.name;
opt.textContent = n.name || "(no name)";
if (n.name === userName) {
opt.selected = true;
}
nameSelectEl.appendChild(opt);
}
} else {
authButton.style.display = "inline-block";
nameSwitcherEl.style.display = "none";
}
authButton.disabled = authStatus === "loading";
authButton.textContent = authStatus === "loading" ? "Authenticating..." : "Authenticate";
try {
updatePublishMenuUI();
} catch {}
}
// Wire listeners
authButton.addEventListener("click", () => {
if (authStatus === "loading") {
return;
}
accountLogin();
});
nameSelectEl.addEventListener("change", (e) => {
switchActiveName(e.target.value);
});
document.getElementById("items-per-page-dropdown").addEventListener("change", async function () {
showSpinner();
contentPage.style.display = "none";
try {
itemsPerPage = parseInt(this.value, 10);
try {
localStorage.setItem(LS_ITEMS_PER_PAGE_KEY, String(itemsPerPage));
} catch {}
currentPage = 1;
setPageHash(currentPage);
await fetchPage();
} finally {
showSection("content");
hideSpinner();
}
});
// === Auto-auth on load (runs once) ===
(function autoAuthOnce() {
// Ensure DOM is ready before trying to auth
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", autoAuthOnce, { once: true });
return;
}
// Guard: don't double-trigger
if (!isAuthenticated && authStatus !== "loading") {
accountLogin().catch((err) => console.error("Auto-auth failed:", err));
}
})();
initPaginationPrefs();
updateAuthUI();
updateSidebarBanner();
updateSearchButtonUI();
initHideDeletedUI();
initPublishMenuUI();
updatePublishMenuUI();
function initHideDeletedUI() {
const btn = document.getElementById("toggle-deleted");
if (!btn) {
return;
}
function updateToggleUI() {
btn.setAttribute("aria-pressed", String(hideDeleted));
const on = hideDeleted;
btn.setAttribute("title", on ? "Hiding deleted content" : "Showing deleted content");
btn.setAttribute("aria-label", on ? "Hide deleted content: ON" : "Hide deleted content: OFF");
}
// Set initial state each time
updateToggleUI();
// Bind once
if (btn.dataset.bound === "1") {
return;
}
btn.dataset.bound = "1";
btn.addEventListener("click", async () => {
hideDeleted = !hideDeleted;
try {
localStorage.setItem("hideDeleted", String(hideDeleted));
} catch {}
updateToggleUI();
showSpinner();
contentPage.style.display = "none";
try {
if (searchModeActive) {
// In Search mode, rebuild sidebar and refresh filtered results
applySearchFilter();
renderSearchResults();
buildSidebarTree();
} else {
// My Files: rebuild chips, sidebar, filters, and current page
recomputeServiceCounts();
buildServiceChips();
buildSidebarTree();
applyServiceFilter();
await fetchPage();
}
} finally {
contentPage.style.display = "block";
hideSpinner();
}
});
}
// ===== Publish menu (auth-gated) =====
function updatePublishMenuUI() {
const wrap = document.getElementById("publish-menu");
const btn = document.getElementById("publish-button");
if (!wrap || !btn) {
return;
}
wrap.style.display = isAuthenticated ? "inline-block" : "none";
btn.setAttribute("aria-expanded", "false");
wrap.classList.remove("open");
}
function closePublishDropdown() {
const wrap = document.getElementById("publish-menu");
const btn = document.getElementById("publish-button");
const dd = document.getElementById("publish-dropdown");
if (wrap && btn && dd) {
wrap.classList.remove("open");
btn.setAttribute("aria-expanded", "false");
dd.setAttribute("aria-hidden", "true");
}
}
function togglePublishDropdown() {
const wrap = document.getElementById("publish-menu");
const btn = document.getElementById("publish-button");
const dd = document.getElementById("publish-dropdown");
if (!wrap || !btn || !dd) {
return;
}
const open = !wrap.classList.contains("open");
if (open) {
wrap.classList.add("open");
btn.setAttribute("aria-expanded", "true");
dd.setAttribute("aria-hidden", "false");
} else {
closePublishDropdown();
}
}
function initPublishMenuUI() {
const wrap = document.getElementById("publish-menu");
const btn = document.getElementById("publish-button");
const itemFile = document.getElementById("publish-add-file");
const itemFolder = document.getElementById("publish-add-folder");
const itemText = document.getElementById("publish-new-text");
if (!wrap || !btn || !itemFile || !itemFolder || !itemText) {
return;
}
if (wrap.dataset.bound === "1") {
return;
}
wrap.dataset.bound = "1";
btn.addEventListener("click", (e) => {
e.stopPropagation();
if (!isAuthenticated) {
return;
}
togglePublishDropdown();
});
document.addEventListener("click", (e) => {
const t = e.target instanceof Element ? e.target : null;
if (t && t.closest && t.closest("#publish-menu")) {
return;
}
closePublishDropdown();
});
itemFile.addEventListener("click", async () => {
closePublishDropdown();
await handlePublishAddFile();
});
itemFolder.addEventListener("click", async () => {
closePublishDropdown();
await handlePublishAddFolder();
});
itemText.addEventListener("click", async () => {
closePublishDropdown();
await openComposePage({});
});
}
// ===== QDN Preview helpers =====
async function openPreviewPage(ctx) {
showSection("preview");
currentPreviewCtx = ctx;
const titleEl = document.getElementById("preview-title");
if (titleEl) {
const ident = ctx.identifier || "default";
const nm = ctx.name || "";
// Build with inline avatar placeholder; will upgrade after fetch
titleEl.innerHTML = ``;
const svcSpan = document.createElement("span");
svcSpan.textContent = `${ctx.service} - ${ident} - `;
const nameWrap = document.createElement("span");
nameWrap.className = "name-with-avatar";
nameWrap.setAttribute("data-name", nm);
const av = document.createElement("span");
av.className = "avatar-img";
av.style.display = "inline-block";
av.style.borderRadius = "50%";
av.style.width = "24px";
av.style.height = "24px";
av.style.background = "#1f2c49";
av.style.color = "#c9d2d9";
av.style.textAlign = "center";
av.style.lineHeight = "24px";
av.style.fontSize = "14px";
av.textContent = initialForName(nm);
const nmSpan = document.createElement("span");
nmSpan.className = "name-text";
nmSpan.textContent = nm;
nameWrap.appendChild(av);
nameWrap.appendChild(nmSpan);
titleEl.appendChild(svcSpan);
titleEl.appendChild(nameWrap);
// Attempt to upgrade avatar to image
getAvatarForName(nm)
.then((res) => {
if (!res || !res.url) {
return;
}
const img = document.createElement("img");
img.className = "avatar-img";
img.src = res.url;
img.alt = nm;
av.replaceWith(img);
})
.catch(() => {});
}
// Inject a simple filepath control for multi-file services (WEBSITE/APP/etc.)
(function () {
const host = document.getElementById("preview-actions");
if (!host) {
return;
}
// Remove any prior controls to avoid duplicates
const old = host.querySelector(".preview-path-controls");
if (old) {
try {
host.removeChild(old);
} catch {}
}
if (!isMultiFileService(ctx.service)) {
return;
}
const wrap = document.createElement("div");
wrap.className = "preview-path-controls";
wrap.style.display = "inline-flex";
wrap.style.gap = "6px";
wrap.style.marginLeft = "12px";
const label = document.createElement("label");
label.textContent = "Path:";
label.style.alignSelf = "center";
label.setAttribute("for", "preview-filepath-input");
const input = document.createElement("input");
input.type = "text";
input.id = "preview-filepath-input";
input.placeholder = "index.html";
input.style.minWidth = "220px";
input.value = (ctx && ctx.filepath) || "";
const go = document.createElement("button");
go.type = "button";
go.textContent = "Open";
go.addEventListener("click", async () => {
const path = input.value.trim();
currentPreviewCtx = { ...currentPreviewCtx, filepath: path };
const container = document.getElementById("preview-container");
if (container) {
container.innerHTML = "Loading preview...";
await loadPreviewInto(container, currentPreviewCtx);
}
});
input.addEventListener("keydown", async (e) => {
if (e.key === "Enter") {
e.preventDefault();
go.click();
}
});
wrap.appendChild(label);
wrap.appendChild(input);
wrap.appendChild(go);
host.appendChild(wrap);
})();
// Preview header actions wiring (bind once to avoid duplicates across navigations)
(function () {
const previewEditBtn = document.getElementById("preview-edit");
const previewReplaceBtn = document.getElementById("preview-replace");
const previewDeleteBtn = document.getElementById("preview-delete");
function hasCtx() {
return !!(typeof currentPreviewCtx !== "undefined" && currentPreviewCtx);
}
function identOrDefault(x) {
return x && x.identifier ? x.identifier : "default";
}
function refresh() {
if (typeof updatePreviewActionsState === "function") {
updatePreviewActionsState();
}
}
// Bind each button at most once; handlers read currentPreviewCtx dynamically.
if (previewEditBtn && previewEditBtn.dataset.bound !== "1") {
previewEditBtn.dataset.bound = "1";
previewEditBtn.addEventListener("click", async () => {
if (!hasCtx()) {
return;
}
await editContent(currentPreviewCtx.service, identOrDefault(currentPreviewCtx));
refresh();
});
}
if (previewReplaceBtn && previewReplaceBtn.dataset.bound !== "1") {
previewReplaceBtn.dataset.bound = "1";
previewReplaceBtn.addEventListener("click", async () => {
if (!hasCtx()) {
return;
}
await replaceContent(currentPreviewCtx.service, identOrDefault(currentPreviewCtx));
refresh();
});
}
if (previewDeleteBtn && previewDeleteBtn.dataset.bound !== "1") {
previewDeleteBtn.dataset.bound = "1";
previewDeleteBtn.addEventListener("click", async () => {
if (!hasCtx()) {
return;
}
await deleteContent(currentPreviewCtx.service, identOrDefault(currentPreviewCtx));
refresh();
});
}
})();
const container = document.getElementById("preview-container");
if (container) {
// If a media element is already playing in the preview container, promote it
try {
const av = container.querySelector("video, audio");
const shouldPromote =
av && (av.paused === false || ((av.currentTime || 0) > 0 && av.ended === false));
if (shouldPromote) {
console.debug("[Q-Edit] promoting media before new preview", {
service: av.getAttribute("data-service"),
identifier: av.getAttribute("data-identifier"),
name: av.getAttribute("data-name"),
});
promoteMedia(av, {
service: av.getAttribute("data-service") || "",
identifier: av.getAttribute("data-identifier") || "default",
name: av.getAttribute("data-name") || userName,
});
}
} catch {}
container.innerHTML = "Loading preview...";
await loadPreviewInto(container, ctx);
}
updatePreviewActionsState();
}
function buildQdnParams(base) {
const p = { ...base };
if (!p.identifier || p.identifier === "" || p.identifier === "default") {
delete p.identifier;
}
return p;
}
function b64ToBytes(b64) {
const bin = atob(b64);
const len = bin.length;
const bytes = new Uint8Array(len);
for (let i = 0; i < len; i++) {
bytes[i] = bin.charCodeAt(i);
}
return bytes;
}
function detectMimeFromBytes(bytes) {
const h = bytes;
const h0 = h[0],
h1 = h[1],
h2 = h[2],
h3 = h[3],
h4 = h[4],
h5 = h[5],
h6 = h[6],
h7 = h[7],
h8 = h[8],
h9 = h[9],
h10 = h[10],
h11 = h[11];
// Images
if (h0 === 0xff && h1 === 0xd8 && h2 === 0xff) {
return "image/jpeg";
}
if (h0 === 0x89 && h1 === 0x50 && h2 === 0x4e && h3 === 0x47) {
return "image/png";
}
if (h0 === 0x47 && h1 === 0x49 && h2 === 0x46 && h3 === 0x38) {
return "image/gif";
}
if (
h0 === 0x52 &&
h1 === 0x49 &&
h2 === 0x46 &&
h3 === 0x46 &&
h8 === 0x57 &&
h9 === 0x45 &&
h10 === 0x42 &&
h11 === 0x50
) {
return "image/webp";
}
// PDF
if (h0 === 0x25 && h1 === 0x50 && h2 === 0x44 && h3 === 0x46) {
return "application/pdf";
}
// ZIP/OOXML/EPUB containers (PK\x03\x04)
if (h0 === 0x50 && h1 === 0x4b && h2 === 0x03 && h3 === 0x04) {
return "application/zip";
}
// Audio
if (h0 === 0x49 && h1 === 0x44 && h2 === 0x33) {
return "audio/mpeg";
}
if (h0 === 0xff && (h1 & 0xe0) === 0xe0) {
return "audio/mpeg";
}
if (h0 === 0x4f && h1 === 0x67 && h2 === 0x67 && h3 === 0x53) {
return "audio/ogg";
}
if (
h0 === 0x52 &&
h1 === 0x49 &&
h2 === 0x46 &&
h3 === 0x46 &&
h8 === 0x57 &&
h9 === 0x41 &&
h10 === 0x56 &&
h11 === 0x45
) {
return "audio/wav";
}
// Video
if (h0 === 0x1a && h1 === 0x45 && h2 === 0xdf && h3 === 0xa3) {
return "video/webm";
}
if (
(h4 === 0x66 && h5 === 0x74 && h6 === 0x79 && h7 === 0x70) ||
(h0 === 0x00 &&
h1 === 0x00 &&
h2 === 0x00 &&
(h3 === 0x18 || h3 === 0x20) &&
h4 === 0x66 &&
h5 === 0x74 &&
h6 === 0x79 &&
h7 === 0x70)
) {
return "video/mp4";
}
if (h0 === 0x4f && h1 === 0x67 && h2 === 0x67 && h3 === 0x53) {
return "video/ogg";
}
return null;
}
function guessMimeFromName(name, fallback) {
const lower = (name || "").toLowerCase();
if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) {
return "image/jpeg";
}
if (lower.endsWith(".png")) {
return "image/png";
}
if (lower.endsWith(".gif")) {
return "image/gif";
}
if (lower.endsWith(".webp")) {
return "image/webp";
}
if (lower.endsWith(".mp4") || lower.endsWith(".m4v")) {
return "video/mp4";
}
if (lower.endsWith(".webm")) {
return "video/webm";
}
if (lower.endsWith(".ogg") || lower.endsWith(".ogv")) {
return "video/ogg";
}
if (lower.endsWith(".mp3")) {
return "audio/mpeg";
}
if (lower.endsWith(".wav")) {
return "audio/wav";
}
if (lower.endsWith(".m4a") || lower.endsWith(".aac")) {
return "audio/aac";
}
if (lower.endsWith(".pdf")) {
return "application/pdf";
}
if (
lower.endsWith(".txt") ||
lower.endsWith(".log") ||
lower.endsWith(".csv") ||
lower.endsWith(".md")
) {
return lower.endsWith(".md") ? "text/markdown" : "text/plain";
}
if (lower.endsWith(".json")) {
return "application/json";
}
if (lower.endsWith(".zip")) {
return "application/zip";
}
if (lower.endsWith(".epub")) {
return "application/epub+zip";
}
if (lower.endsWith(".docx")) {
return "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
}
if (lower.endsWith(".xlsx")) {
return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
}
if (lower.endsWith(".pptx")) {
return "application/vnd.openxmlformats-officedocument.presentationml.presentation";
}
if (lower.endsWith(".doc")) {
return "application/msword";
}
if (lower.endsWith(".xls")) {
return "application/vnd.ms-excel";
}
if (lower.endsWith(".ppt")) {
return "application/vnd.ms-powerpoint";
}
if (lower.endsWith(".odt")) {
return "application/vnd.oasis.opendocument.text";
}
if (lower.endsWith(".ods")) {
return "application/vnd.oasis.opendocument.spreadsheet";
}
return fallback || "application/octet-stream";
}
// Heuristic: decide if bytes look like decodable UTF-8 text
function isLikelyText(bytes) {
if (!bytes || bytes.length === 0) {
return false;
}
let nonAscii = 0;
let zeroes = 0;
const len = Math.min(bytes.length, 4096);
for (let i = 0; i < len; i++) {
const b = bytes[i];
if (b === 0x00) {
zeroes++;
}
if (b < 0x09 || (b > 0x0d && b < 0x20)) {
nonAscii++;
}
}
if (zeroes > 0) {
return false;
} // binary
if (nonAscii / len > 0.3) {
return false;
}
try {
const dec = new TextDecoder("utf-8", { fatal: false }).decode(bytes.slice(0, len));
// If decoding yields lots of replacement chars, not text
if ((dec.match(/\uFFFD/g) || []).length > 3) {
return false;
}
} catch {
return false;
}
return true;
}
function isMultiFileService(service) {
const s = (service || "").toUpperCase();
return (
s === "WEBSITE" ||
s === "APP" ||
s === "FILES" ||
s === "GIT_REPOSITORY" ||
s === "GIF_REPOSITORY" ||
s === "GAME" ||
s === "DATABASE" ||
s === "SNAPSHOT"
);
}
function buildArbitraryUrl({ service, name, identifier, filepath }) {
// Always include identifier; default to 'default' when empty
const id = identifier && identifier !== "" ? identifier : "default";
const base = `/arbitrary/${service}/${encodeURIComponent(name)}/${encodeURIComponent(id)}`;
if (filepath && filepath !== "/") {
return `${base}/${filepath.replace(/^\/+/, "")}`;
}
// No implicit index.html - let QDN route to index for APP/WEBSITE
return base;
}
function buildQortalEmbedUrl({ service, name, identifier, filepath }) {
const svc = String(service || "").toUpperCase();
let url = `qortal://${svc}/${encodeURIComponent(name || "")}`;
// Omit identifier when default/blank
if (identifier && identifier !== "default") {
url += `/${encodeURIComponent(identifier)}`;
}
if (filepath && filepath !== "/") {
url += `/${String(filepath).replace(/^\/+/, "")}`;
}
return url;
}
function openImageOverlayFromDataUrl(dataUrl) {
let overlay = document.getElementById("image-viewer-overlay");
if (!overlay) {
overlay = document.createElement("div");
overlay.id = "image-viewer-overlay";
const img = document.createElement("img");
overlay.appendChild(img);
const hint = document.createElement("div");
hint.className = "close-hint";
hint.textContent = "Click anywhere or press Esc to close";
overlay.appendChild(hint);
overlay.addEventListener("click", () => {
overlay.style.display = "none";
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") {
overlay.style.display = "none";
}
});
document.body.appendChild(overlay);
}
overlay.querySelector("img").src = dataUrl;
overlay.style.display = "flex";
}
// ===== Auth & Names =====
async function accountLogin() {
try {
showSpinner();
authStatus = "loading";
updateAuthUI();
updateSidebarBanner();
initHideDeletedUI();
const account = await qortalRequest({ action: "GET_USER_ACCOUNT" });
contentPage.style.display = "none";
document.getElementById("account-details").innerHTML = "Loading...";
userAddress = account.address ? account.address : "Address unavailable";
_userPublicKey = account.publicKey ? account.publicKey : "Public key unavailable";
let names = [];
if (userAddress && userAddress !== "Address unavailable") {
try {
_namesStatus = "loading";
const res = await qortalRequest({ action: "GET_ACCOUNT_NAMES", address: userAddress });
names = Array.isArray(res) ? res : [];
allNames = names.map((n) => ({
name: typeof n?.name === "string" ? n.name : "",
owner: typeof n?.owner === "string" ? n.owner : userAddress,
}));
let primary = "";
try {
const maybePrimary = await qortalRequest({
action: "GET_PRIMARY_NAME",
address: userAddress,
});
primary = typeof maybePrimary === "string" ? maybePrimary : "";
} catch {}
if (primary) {
userName = primary;
} else if (allNames.length > 0 && allNames[0].name) {
userName = allNames[0].name;
} else {
userName = "Name unavailable";
}
_namesStatus = "succeeded";
} catch {
allNames = [{ name: "", owner: userAddress }];
userName = "Name unavailable";
_namesStatus = "failed";
}
} else {
allNames = [{ name: "", owner: userAddress }];
userName = "Name unavailable";
_namesStatus = "failed";
}
isAuthenticated = true;
document.getElementById("info-details").innerHTML = infoDetails;
document.getElementById("account-details").innerHTML = `${userAddress}
${userName}`;
updateAuthUI();
updateSidebarBanner();
initHideDeletedUI();
currentServiceFilter = "ALL";
await loadAllResults();
buildSidebarTree();
await fetchPage();
showSection("content");
authStatus = "succeeded";
updateAuthUI();
updateSidebarBanner();
initHideDeletedUI();
hideSpinner();
} catch (error) {
console.error("Error fetching account details:", error);
authStatus = "failed";
updateAuthUI();
updateSidebarBanner();
initHideDeletedUI();
document.getElementById("account-details").innerHTML =
`Error fetching account details: ${error}`;
hideSpinner();
}
}
async function switchActiveName(newName) {
if (!newName || newName === userName) {
return;
}
showSpinner();
contentPage.style.display = "none";
userName = newName;
currentServiceFilter = "ALL";
currentPage = 1;
document.getElementById("account-details").innerHTML = `${userAddress}
${userName}`;
updateAuthUI();
updateSidebarBanner();
initHideDeletedUI();
try {
await loadAllResults();
buildSidebarTree();
await fetchPage();
} finally {
showSection("content");
hideSpinner();
}
}
function tinyThresholdFor(service) {
// Bigger threshold for image-like services to account for overhead
const s = (service || "").toUpperCase();
if (s === "IMAGE" || s === "QCHAT_IMAGE" || s.includes("THUMBNAIL")) {
return 8192;
}
return typeof TINY_SIZE_THRESHOLD !== "undefined" ? TINY_SIZE_THRESHOLD : 256;
}
async function markDeletedEntries(results) {
// Initialize flag
for (const r of results) {
r.__isDeleted = false;
}
// Legacy 0-byte deletions
for (const r of results) {
if (isExcludedFromDeletionCheck(r.service)) {
continue;
}
const sz = Number(r.size || 0);
if (sz === 0) {
r.__isDeleted = true;
}
}
// Tiny-file check (1..4 bytes), verify bytes are only whitespace/BOM
const tiny = results.filter((r) => {
if (isExcludedFromDeletionCheck(r.service)) {
return false;
}
if (r.__isDeleted) {
return false;
}
const sizeVal = Number(r.size);
const unknown = !Number.isFinite(sizeVal);
const thr = tinyThresholdFor(r.service);
return unknown || sizeVal <= thr;
});
for (const r of tiny) {
try {
const name = r.name || userName;
const service = r.service;
const identifier = r.identifier === undefined ? "default" : r.identifier;
let b64 = await qortalRequest(
buildQdnParams({
action: "FETCH_QDN_RESOURCE",
name,
service,
identifier,
encoding: "base64",
rebuild: false,
})
);
if (isPrivateService(service)) {
b64 = await qortalRequest({ action: "DECRYPT_DATA", encryptedData: b64 });
}
const bytes = b64ToBytes(b64);
// Strip UTF-8 BOM
let i = 0;
if (bytes.length >= 3 && bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) {
i = 3;
}
let hasNonWhitespace = false;
for (; i < bytes.length; i++) {
// Handle UTF-8 NBSP (0xC2 0xA0) as whitespace pair
if (bytes[i] === 0xc2 && i + 1 < bytes.length && bytes[i + 1] === 0xa0) {
i++;
continue;
}
const b = bytes[i];
if (b === 0x00 || b === 0x09 || b === 0x0a || b === 0x0d || b === 0x20) {
continue;
} else {
hasNonWhitespace = true;
break;
}
}
if (!hasNonWhitespace) {
r.__isDeleted = true;
}
} catch (e) {
// If we cannot fetch/decrypt, be conservative: do not mark deleted
console.warn("Tiny-file check failed for", r.service, r.identifier, e);
}
}
// Enforce service exclusions
for (const r of results) {
if (isExcludedFromDeletionCheck(r.service)) {
r.__isDeleted = false;
}
}
}
async function loadAllResults() {
// Fetch all resources for the active name, including metadata, unfiltered
const resp = await fetch(
`/arbitrary/resources/search?name=${encodeURIComponent(userName)}&includemetadata=true&exactmatchnames=true&mode=ALL`
);
if (!resp.ok) {
throw new Error("Failed loading resources");
}
masterResults = await resp.json();
// Sort newest first (created/updated)
masterResults.sort((a, b) => (b.updated || b.created || 0) - (a.updated || a.created || 0));
await markDeletedEntries(masterResults);
// Build counts (respect hideDeleted)
recomputeServiceCounts();
// Reset selection (none selected => show all)
selectedServices = new Set();
clearTreeFilters();
applyServiceFilter();
buildServiceChips();
}
function buildServiceChips() {
const container = document.getElementById("service-chips-container");
if (!container) {
return;
}
container.innerHTML = "";
const services = Object.keys(serviceCounts).sort();
services.forEach((svc) => {
const chip = document.createElement("div");
chip.className = "chip" + (selectedServices.has(svc) ? " selected" : "");
chip.setAttribute("data-service", svc);
const label = document.createElement("span");
label.className = "label";
label.textContent = svc;
const count = document.createElement("span");
count.className = "count";
count.textContent = serviceCounts[svc];
chip.appendChild(label);
chip.appendChild(count);
chip.addEventListener("click", async () => {
// Toggle selection
if (selectedServices.has(svc)) {
selectedServices.delete(svc);
} else {
selectedServices.add(svc);
}
// Visual state
chip.classList.toggle("selected");
// Apply filter + re-render
showSpinner();
contentPage.style.display = "none";
try {
applyServiceFilter();
currentPage = 1;
await fetchPage();
} finally {
contentPage.style.display = "block";
hideSpinner();
}
});
container.appendChild(chip);
});
}
function applyServiceFilter() {
const base = getBaselineResults();
// Service filter
let tmp =
selectedServices.size === 0
? base.slice()
: base.filter((r) => selectedServices.has(r.service));
// Identifier prefix filter (if set)
if (currentPrefixFilter) {
const pfx = currentPrefixFilter;
tmp = tmp.filter((r) => {
const id = r.identifier || "";
return id.startsWith(pfx + "_") || id.startsWith(pfx + "-");
});
}
// Exact identifier filter (if set)
if (currentIdentifierFilter) {
tmp = tmp.filter((r) => (r.identifier || "") === currentIdentifierFilter);
}
filteredResults = tmp;
totalResults = filteredResults.length;
totalSize = filteredResults.reduce((acc, r) => acc + (r.size || 0), 0);
}
// ===== Data fetchers =====
async function fetchPage() {
try {
if (!userName || userName === "Name unavailable") {
return;
}
// Client-side pagination using filteredResults
const contentDetails = document.getElementById("content-details");
contentDetails.innerHTML = "Loading...
";
const start = (currentPage - 1) * itemsPerPage;
const sortedAll = filteredResults.slice().sort(compareResultsMyFiles);
const pageItems = sortedAll.slice(start, start + itemsPerPage);
buildContentTable(pageItems);
} catch (error) {
console.error("Error fetching page:", error);
document.getElementById("content-details").innerHTML = `Error: ${error.message}
`;
}
}
// ===== Table & pagination =====
function buildIdentifierCellHTML(result, identifier) {
const svc = result.service;
const key = selKey(svc, identifier);
const checkbox = ``;
const deleteIcon = `
`;
const editIcon = `
`;
const embedIcon = isEmbedService(svc)
? ``
: "";
return `${checkbox}${deleteIcon}${editIcon}${identifier}${embedIcon}`;
}
function setBulkMode(on) {
bulkDeleteMode = !!on;
document.body.classList.toggle("bulk-mode", bulkDeleteMode);
updateBulkControlsUI();
}
// ===== Bulk delete UI wiring =====
function updateBulkControlsUI() {
const btn = document.getElementById("bulk-delete-toggle");
if (btn) {
const count = getSelectedCount();
if (!bulkDeleteMode) {
btn.textContent = "Delete Files";
} else {
btn.textContent = count > 0 ? `Delete ${count} Files` : "Deleting Files";
}
}
const selAll = document.getElementById("select-all-checkbox");
if (selAll) {
// Determine if all visible are checked
const boxes = Array.from(document.querySelectorAll(".bulk-select"));
const allChecked = boxes.length > 0 && boxes.every((cb) => cb.checked);
selAll.checked = allChecked;
selAll.indeterminate = boxes.some((cb) => cb.checked) && !allChecked;
}
}
async function bulkDeleteSelected() {
try {
if (!userName || userName === "Name unavailable") {
return;
}
const entries = Array.from(selectedForDeletion).map((k) => {
const [svc, ident] = k.split("||");
return { service: svc, identifier: ident === "default" ? undefined : ident };
});
if (entries.length === 0) {
return;
}
showPublishModal("Deleting selected files...");
// Build a tiny non-empty file to mark as deleted
const emptyFile = new Blob(["\n"], { type: "application/octet-stream" });
const resourceArray = entries.map((e) => ({
name: userName,
service: e.service,
identifier: e.identifier || "default",
file: emptyFile,
}));
const response = await qortalRequest({
action: "PUBLISH_MULTIPLE_QDN_RESOURCES",
resources: resourceArray,
});
console.log("Bulk delete response:", response);
// Remove deleted from masterResults
const keySet = new Set(resourceArray.map((r) => selKey(r.service, r.identifier)));
masterResults = masterResults.filter((r) => !keySet.has(selKey(r.service, r.identifier)));
// Recompute derived collections
recomputeServiceCounts();
buildSidebarTree();
applyServiceFilter();
selectedForDeletion.clear();
setBulkMode(false);
await fetchPage();
} catch (err) {
alert("Bulk delete failed: " + (err?.message || err));
} finally {
closePublishModal();
}
}
function buildContentTable(results) {
const contentDetailsDiv = document.getElementById("content-details");
const contentSummaryDiv = document.getElementById("content-summary");
if (results.length === 0) {
contentDetailsDiv.innerHTML = "No results found.
";
contentSummaryDiv.innerHTML = "";
document.getElementById("pagination-top").innerHTML = "";
document.getElementById("pagination-bottom").innerHTML = "";
return;
}
// Results arrive pre-sorted by fetchPage across the full dataset.
let tableHtml = '`;
const startItem = (currentPage - 1) * itemsPerPage + 1;
const endItem = Math.min(startItem + itemsPerPage - 1, totalResults);
const selectedCount = getSelectedCount();
contentSummaryDiv.innerHTML = `${startItem}-${endItem} of ${totalResults} resultsTotal Size: ${formatSize(totalSize)}${selectedCount > 0 ? '' : ""}
`;
const paginationHTML = buildPaginationControls();
renderIntoCompat(document.getElementById("pagination-top"), paginationHTML, "replace");
renderIntoCompat(document.getElementById("pagination-bottom"), paginationHTML, "replace");
/* promote media before rerender */
const playing = contentDetailsDiv.querySelector("video, audio");
if (playing) {
const shouldPromote =
playing.paused === false || ((playing.currentTime || 0) > 0 && playing.ended === false);
if (shouldPromote) {
promoteMedia(playing, {
service: playing.getAttribute("data-service") || "",
identifier: playing.getAttribute("data-identifier") || "default",
name: playing.getAttribute("data-name") || userName,
});
}
}
contentDetailsDiv.innerHTML = tableHtml;
// Wire bulk selection checkboxes
if (bulkDeleteMode) {
document.querySelectorAll(".bulk-select").forEach((cb) => {
cb.addEventListener("change", (_e) => {
const svc = cb.getAttribute("data-service");
const ident = cb.getAttribute("data-identifier") || "default";
const key = selKey(svc, ident);
if (cb.checked) {
selectedForDeletion.add(key);
} else {
selectedForDeletion.delete(key);
}
updateBulkControlsUI();
});
});
const selAll = document.getElementById("select-all-checkbox");
if (selAll) {
selAll.addEventListener("change", () => {
const boxes = Array.from(document.querySelectorAll(".bulk-select"));
boxes.forEach((cb) => {
cb.checked = selAll.checked;
const svc = cb.getAttribute("data-service");
const ident = cb.getAttribute("data-identifier") || "default";
const key = selKey(svc, ident);
if (cb.checked) {
selectedForDeletion.add(key);
} else {
selectedForDeletion.delete(key);
}
});
updateBulkControlsUI();
});
}
}
// Wire summary bar buttons
const bulkBtn = document.getElementById("bulk-delete-toggle");
if (bulkBtn) {
bulkBtn.addEventListener("click", async () => {
if (!bulkDeleteMode) {
setBulkMode(true);
await fetchPage();
return;
}
const count = getSelectedCount();
if (count === 0) {
setBulkMode(false);
await fetchPage();
} else {
await bulkDeleteSelected();
}
});
}
const clearBtn = document.getElementById("clear-selected-btn");
if (clearBtn) {
clearBtn.addEventListener("click", () => {
clearSelected();
fetchPage();
});
}
// Initialize bulk UI state
updateBulkControlsUI();
initPreviews();
document.querySelectorAll(".copy-embed-icon").forEach((el) => {
el.addEventListener("click", async function () {
const nm = this.getAttribute("data-name") || "";
const svc = this.getAttribute("data-service") || "";
const ident = this.getAttribute("data-identifier") || "default";
const url = `qortal://use-embed/IMAGE?name=${encodeURIComponent(nm)}&identifier=${encodeURIComponent(ident)}&service=${encodeURIComponent(svc)}`;
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(url);
} else {
const ta = document.createElement("textarea");
ta.value = url;
document.body.appendChild(ta);
ta.select();
document.execCommand("copy");
document.body.removeChild(ta);
}
// Remove prior toast (if any) to avoid stacking
const prev = this.parentElement.querySelector(".copy-embed-toast");
if (prev) {
prev.remove();
}
const msg = document.createElement("span");
msg.className = "copy-embed-toast";
msg.textContent = "Embed link copied!";
msg.style.marginLeft = "6px";
msg.style.fontSize = "12px";
msg.style.color = "#1f6feb";
this.parentElement.appendChild(msg);
setTimeout(() => {
if (msg && msg.parentElement) {
msg.parentElement.removeChild(msg);
}
}, 1800);
} catch (e) {
alert("Could not copy link: " + (e?.message || e));
}
});
});
document.querySelectorAll(".clickable-delete").forEach((el) => {
el.addEventListener("click", function () {
deleteContent(this.getAttribute("data-service"), this.getAttribute("data-identifier"));
});
});
document.querySelectorAll(".clickable-edit").forEach((el) => {
el.addEventListener("click", function () {
editContent(this.getAttribute("data-service"), this.getAttribute("data-identifier"));
});
});
document.querySelectorAll(".clickable-metadata").forEach((el) => {
el.addEventListener("click", function () {
const idx = parseInt(this.getAttribute("data-metadata-index"), 10);
if (!isNaN(idx) && idx >= 0) {
openMetadataDialog(metadataArray[idx]);
} else {
alert("No metadata available.");
}
});
});
addPaginationEventHandlers();
document.querySelectorAll(".identifier-text").forEach((el) => {
el.addEventListener("click", () => {
const svc = el.getAttribute("data-service");
const ident = el.getAttribute("data-identifier") || "default";
const nm = el.getAttribute("data-name") || userName;
openPreviewPage({ service: svc, identifier: ident, name: nm });
});
});
}
function buildPaginationControls() {
if (window.QEditPagination && typeof window.QEditPagination.build === "function") {
return window.QEditPagination.build({ currentPage, itemsPerPage, totalResults });
}
const totalPages = Math.ceil(totalResults / itemsPerPage);
if (totalPages <= 1) {
return "";
}
let html = '";
return html;
}
function addPaginationEventHandlers() {
document.querySelectorAll(".pagination-link").forEach((link) => {
link.addEventListener("click", async function () {
const newPage = parseInt(this.getAttribute("data-page"), 10);
if (!isNaN(newPage)) {
showSpinner();
contentPage.style.display = "none";
try {
currentPage = newPage;
setPageHash(currentPage);
await fetchPage();
} finally {
showSection("content");
hideSpinner();
}
}
});
});
document.querySelectorAll(".jump-btn").forEach((btn) => {
btn.addEventListener("click", async function () {
const container = this.closest(".pagination-controls") || document;
const inp = container.querySelector(".jump-input");
if (!inp) {
return;
}
const v = parseInt(inp.value, 10);
if (!isNaN(v)) {
const totalPages = Math.ceil(totalResults / itemsPerPage);
const newPage = Math.min(Math.max(1, v), totalPages);
showSpinner();
contentPage.style.display = "none";
try {
currentPage = newPage;
setPageHash(currentPage);
await fetchPage();
} finally {
showSection("content");
hideSpinner();
}
}
});
});
document.querySelectorAll(".jump-input").forEach((inp) => {
inp.addEventListener("keydown", async function (e) {
if (e.key === "Enter") {
e.preventDefault();
const totalPages = Math.ceil(totalResults / itemsPerPage);
const v = parseInt(this.value, 10);
if (!isNaN(v)) {
const newPage = Math.min(Math.max(1, v), totalPages);
showSpinner();
contentPage.style.display = "none";
try {
currentPage = newPage;
setPageHash(currentPage);
await fetchPage();
} finally {
showSection("content");
hideSpinner();
}
}
}
});
});
}
function formatSize(size) {
if (size > 1024 * 1024 * 1024 * 1024) {
return (size / (1024 * 1024 * 1024 * 1024)).toFixed(2) + " TB";
}
if (size > 1024 * 1024 * 1024) {
return (size / (1024 * 1024 * 1024)).toFixed(2) + " GB";
}
if (size > 1024 * 1024) {
return (size / (1024 * 1024)).toFixed(2) + " MB";
}
if (size > 1024) {
return (size / 1024).toFixed(2) + " KB";
}
return (size || 0) + " B";
}
function formatTimeAgo(ts) {
const t = Number(ts);
if (!Number.isFinite(t) || t <= 0) {
return "Unknown";
}
const now = Date.now();
let diff = Math.max(0, now - t);
const sec = diff / 1000;
if (sec < 60) {
const s = Math.floor(sec);
return `${s} sec ago`;
}
const min = sec / 60;
if (min < 60) {
const m = Math.floor(min);
return `${m} min ago`;
}
const hr = min / 60;
if (hr < 24) {
return `${hr.toFixed(1)} hours ago`;
}
const days = hr / 24;
if (days < 365) {
return `${days.toFixed(1)} days ago`;
}
const years = days / 365;
return `${years.toFixed(1)} years ago`;
}
// ===== Preview rendering =====
function generatePreviewHTML(result, userName, identifier) {
const safeName = (result.name || userName || "").replace(/"/g, """);
const safeService = (result.service || "").replace(/"/g, """);
const safeIdent = (identifier || "default").replace(/"/g, """);
return `0% Loaded
`;
}
function initPreviews(root) {
const scope = root && root.querySelectorAll ? root : document;
const holders = scope.querySelectorAll(".preview-holder");
holders.forEach((el) => {
const svc = (el.getAttribute("data-service") || "").toUpperCase();
const ident = el.getAttribute("data-identifier") || "default";
const nm = el.getAttribute("data-name") || userName;
loadPreviewInto(el, { service: svc, identifier: ident, name: nm });
});
}
function isPrivateService(service) {
const s = (service || "").toUpperCase();
return (
s.endsWith("_PRIVATE") ||
[
"QCHAT_ATTACHMENT_PRIVATE",
"ATTACHMENT_PRIVATE",
"FILE_PRIVATE",
"IMAGE_PRIVATE",
"VIDEO_PRIVATE",
"AUDIO_PRIVATE",
"VOICE_PRIVATE",
"DOCUMENT_PRIVATE",
"MAIL_PRIVATE",
"MESSAGE_PRIVATE",
].includes(s)
);
}
// Validate a plausible public key string
function isValidPublicKey(pk) {
if (!pk || typeof pk !== "string") {
return false;
}
const s = pk.trim();
if (!s || s.toLowerCase().includes("unavailable")) {
return false;
}
// Qortal public keys are Base58; length typically ~44-48 chars
return s.length >= 30;
}
// Ensure publish params include encryption to the user's public key when service is private
async function ensureEncryptionForPublishParams(publishParams) {
try {
if (!publishParams || typeof publishParams !== "object") {
return publishParams;
}
const action = String(publishParams.action || "");
// Helper to attach enc fields
const attachEnc = async (svc, target) => {
if (!isPrivateService(svc)) {
return;
}
if (!isValidPublicKey(_userPublicKey)) {
try {
const account = await qortalRequest({ action: "GET_USER_ACCOUNT" });
if (account && account.publicKey) {
_userPublicKey = account.publicKey;
}
} catch (e) {
// ignore; will fail validation below
}
}
if (!isValidPublicKey(_userPublicKey)) {
throw new Error("Unable to determine your public key for private publish");
}
target.encrypt = true;
target.publicKeys = [_userPublicKey];
};
if (action === "PUBLISH_QDN_RESOURCE") {
await attachEnc(publishParams.service, publishParams);
} else if (
action === "PUBLISH_MULTIPLE_QDN_RESOURCES" &&
Array.isArray(publishParams.resources)
) {
for (const r of publishParams.resources) {
if (r && typeof r === "object") {
await attachEnc(r.service, r);
}
}
}
} catch (e) {
// Surface error to caller
throw e;
}
return publishParams;
}
function isExcludedFromDeletionCheck(service) {
const s = (service || "").toUpperCase();
return s === "CHAIN_DATA" || s === "CHAIN_COMMENT";
}
function isEmbedService(service) {
const s = (service || "").toUpperCase();
return s === "IMAGE" || s === "QCHAT_IMAGE" || s.includes("THUMBNAIL");
}
function getBaseServiceKind(service) {
const s = (service || "").toUpperCase();
if (s.includes("IMAGE") || s.includes("THUMBNAIL")) {
return "image";
}
if (s.includes("VIDEO")) {
return "video";
}
if (s.includes("AUDIO") || s.includes("VOICE") || s.includes("PODCAST")) {
return "audio";
}
if (
s.includes("DOCUMENT") ||
s.includes("BLOG") ||
s.includes("COMMENT") ||
s.includes("JSON") ||
s.includes("CODE")
) {
return "text";
}
if (s.includes("FILE") || s.includes("ATTACHMENT")) {
return "file";
}
return "file";
}
// ===== Avatars =====
const AVATAR_SERVICE = "THUMBNAIL";
const AVATAR_IDENTIFIER = "qortal_avatar";
const _avatarCache = new Map(); // name -> Promise<{ url: string } | null>
function initialForName(nm) {
const s = nm || "";
return s.length > 0 ? s[0] : "?";
}
async function fetchAvatarUrl(name) {
try {
const b64 = await qortalRequest(
buildQdnParams({
action: "FETCH_QDN_RESOURCE",
name,
service: AVATAR_SERVICE,
identifier: AVATAR_IDENTIFIER,
encoding: "base64",
rebuild: false,
})
);
const bytes = b64ToBytes(b64);
const mime = detectMimeFromBytes(bytes) || "image/png";
const blob = new Blob([bytes], { type: mime });
const url = URL.createObjectURL(blob);
return { url };
} catch (_e) {
return null;
}
}
function getAvatarForName(name) {
if (!name) {
return Promise.resolve(null);
}
if (_avatarCache.has(name)) {
return _avatarCache.get(name);
}
const p = fetchAvatarUrl(name);
_avatarCache.set(name, p);
return p;
}
function attachAvatarInto(container, name) {
if (!container) {
return;
}
// Start with initial
container.textContent = initialForName(name);
getAvatarForName(name)
.then((res) => {
if (!res || !res.url) {
return;
}
// Replace text with image
try {
container.textContent = "";
const img = document.createElement("img");
img.className = container.classList.contains("tree-avatar")
? "tree-avatar-img"
: "avatar-img";
img.src = res.url;
img.alt = name || "";
container.appendChild(img);
} catch {}
})
.catch(() => {});
}
async function waitForResourceReady({
name,
service,
identifier,
initialBuild = true,
timeoutMs = 60000,
intervalMs = 800,
onProgress,
}) {
const start = Date.now();
while (true) {
try {
const status = await qortalRequest(
buildQdnParams({ action: "GET_QDN_RESOURCE_STATUS", name, service, identifier })
);
let percent = 0;
if (typeof status?.percentLoaded === "number") {
percent = status.percentLoaded;
} else if (status?.localChunkCount && status?.totalChunkCount) {
percent = Math.floor((status.localChunkCount / status.totalChunkCount) * 100);
}
if (onProgress && Number.isFinite(percent)) {
onProgress(Math.max(0, Math.min(100, Math.floor(percent))));
}
const ready =
status &&
(status.status === "READY" ||
percent >= 100 ||
(status.localChunkCount &&
status.totalChunkCount &&
status.localChunkCount >= status.totalChunkCount));
if (ready) {
return status;
}
} catch (_e) {
/* ignore transient */
}
if (Date.now() - start > timeoutMs) {
throw new Error("Resource not ready (timeout)");
}
await new Promise((r) => setTimeout(r, intervalMs));
}
}
async function loadPreviewInto(container, ctx) {
const isFullPreview = container && container.id === "preview-container";
const set = (el) => {
// Revoke previous blob URL if present
const prev = container.firstElementChild;
if (prev && prev.dataset && prev.dataset.bloburl) {
try {
URL.revokeObjectURL(prev.dataset.bloburl);
} catch {}
}
container.innerHTML = "";
container.appendChild(el);
try {
container.dataset.loading = "0";
} catch {}
try {
// Heuristic: mark whether preview is showing text content for inline editing eligibility
let isTextual = false;
if (el && el.classList && el.classList.contains("inline-text-editor")) {
isTextual = true;
} else if (el && el.tagName === "PRE") {
const ws =
el.style.whiteSpace || (window.getComputedStyle ? getComputedStyle(el).whiteSpace : "");
isTextual = String(ws).toLowerCase().includes("pre-wrap");
} else if (el && el.tagName === "IFRAME") {
isTextual = el.getAttribute("srcdoc") != null; // srcdoc implies HTML text, not PDF/file blob
} else if (el && el.tagName === "DIV") {
const pre = el.querySelector("pre");
if (pre) {
const ws =
pre.style.whiteSpace ||
(window.getComputedStyle ? getComputedStyle(pre).whiteSpace : "");
isTextual = String(ws).toLowerCase().includes("pre-wrap");
}
}
container.dataset.textual = isTextual ? "1" : "0";
} catch {}
try {
console.debug("[Q-Edit] set(): content rendered", { tag: el.tagName, id: container.id });
} catch {}
};
const looksHtml = (txt) =>
/<(?:!doctype|html|head|body|div|p|span|img|video|audio|iframe|section|article)/i.test(txt);
const tryParseJson = (txt) => {
try {
return JSON.parse(txt);
} catch {
return null;
}
};
try {
// Mark container as loading for progress updates; cleared when set() renders content
try {
container.dataset.loading = "1";
} catch {}
const service = ctx.service;
const identifier = ctx.identifier;
const name = ctx.name;
const lower = (service || "").toLowerCase();
const baseKind = getBaseServiceKind(service);
const isPriv = isPrivateService(service);
const filepath = (ctx && ctx.filepath) || "";
const textServices = ["blog", "blog_post", "blog_comment", "document", "game", "json", "code"];
const isText = textServices.some((t) => lower.includes(t)) || baseKind === "text";
// Start background status polling (no build), updates percent while loading
let stopProgress = false;
const statusWait = waitForResourceReady({
name,
service,
identifier,
initialBuild: false,
onProgress: (pct) => {
try {
if (stopProgress) {
return;
}
if (container.dataset && container.dataset.loading !== "1") {
return;
}
const p = Math.max(0, Math.min(100, Math.floor(pct)));
container.textContent = `${p}% Loaded`;
console.debug("[Q-Edit] progress", { p, service, identifier, container: container.id });
} catch {}
},
}).catch(() => {});
// Embedded WEBSITE/APP (public) - use qortal:// URL to leverage QDN routing
const sUp = (service || "").toUpperCase();
if (!isPriv && (sUp === "WEBSITE" || sUp === "APP")) {
const url = buildQortalEmbedUrl({ service: sUp, name, identifier, filepath });
const iframe = document.createElement("iframe");
iframe.src = url;
if (isFullPreview) {
iframe.style.width = "100%";
iframe.style.height = "70vh";
} else {
iframe.style.width = "100%";
iframe.style.height = "160px";
}
set(iframe);
stopProgress = true; // do not overwrite iframe with progress text
// While it loads, update percent in the preview title
if (isFullPreview) {
const titleEl = document.getElementById("preview-title");
const baseTitle = titleEl ? titleEl.textContent : "";
(async () => {
const start = Date.now();
while (Date.now() - start < 60000) {
try {
const status = await qortalRequest(
buildQdnParams({ action: "GET_QDN_RESOURCE_STATUS", name, service, identifier })
);
let percent = 0;
if (typeof status?.percentLoaded === "number") {
percent = status.percentLoaded;
} else if (status?.localChunkCount && status?.totalChunkCount) {
percent = Math.floor((status.localChunkCount / status.totalChunkCount) * 100);
}
const ready =
status &&
(status.status === "READY" ||
percent >= 100 ||
(status.localChunkCount &&
status.totalChunkCount &&
status.localChunkCount >= status.totalChunkCount));
if (titleEl && Number.isFinite(percent)) {
titleEl.textContent = `${baseTitle} - ${Math.max(0, Math.min(100, Math.floor(percent)))}% Loaded`;
}
if (ready) {
break;
}
} catch {}
await new Promise((r) => setTimeout(r, 800));
}
if (titleEl) {
titleEl.textContent = baseTitle;
}
})();
}
return;
}
if (isPriv) {
// Private: fetch base64 and decrypt
const encB64 = await qortalRequest(
buildQdnParams({
action: "FETCH_QDN_RESOURCE",
name,
service,
identifier,
encoding: "base64",
rebuild: false,
...(filepath ? { filepath } : {}),
})
);
const decB64 = await qortalRequest({ action: "DECRYPT_DATA", encryptedData: encB64 });
if (isText) {
const bytes = b64ToBytes(decB64);
const text = new TextDecoder("utf-8").decode(bytes);
const maybeJson = tryParseJson(text.trim());
if (maybeJson) {
const pre = document.createElement("pre");
if (isFullPreview) {
pre.style.maxWidth = "100%";
pre.style.maxHeight = "70vh";
pre.style.overflow = "auto";
pre.style.whiteSpace = "pre-wrap";
} else {
pre.style.maxWidth = "100%";
pre.style.maxHeight = "160px";
pre.style.overflow = "auto";
}
pre.textContent = JSON.stringify(maybeJson, null, 2);
stopProgress = true;
set(pre);
return;
}
if (looksHtml(text)) {
const iframe = document.createElement("iframe");
iframe.setAttribute("sandbox", "");
if (isFullPreview) {
iframe.style.width = "100%";
iframe.style.height = "70vh";
} else {
iframe.style.width = "100%";
iframe.style.height = "160px";
}
iframe.srcdoc = text;
stopProgress = true;
set(iframe);
return;
}
const pre = document.createElement("pre");
if (isFullPreview) {
pre.style.maxWidth = "100%";
pre.style.maxHeight = "70vh";
pre.style.overflow = "auto";
pre.style.whiteSpace = "pre-wrap";
} else {
pre.style.maxWidth = "100%";
pre.style.maxHeight = "160px";
pre.style.overflow = "auto";
}
pre.textContent = isFullPreview ? text : text.slice(0, 5000);
stopProgress = true;
set(pre);
} else {
// Media / file
const bytes = b64ToBytes(decB64);
// If content looks like text despite service label, render as text
if (isLikelyText(bytes)) {
const text = new TextDecoder("utf-8").decode(bytes);
const maybeJson = tryParseJson(text.trim());
if (maybeJson) {
const pre = document.createElement("pre");
if (isFullPreview) {
pre.style.maxWidth = "100%";
pre.style.maxHeight = "70vh";
pre.style.overflow = "auto";
pre.style.whiteSpace = "pre-wrap";
} else {
pre.style.maxWidth = "100%";
pre.style.maxHeight = "160px";
pre.style.overflow = "auto";
}
pre.textContent = JSON.stringify(maybeJson, null, 2);
stopProgress = true;
set(pre);
return;
}
if (looksHtml(text)) {
const iframe = document.createElement("iframe");
iframe.setAttribute("sandbox", "");
if (isFullPreview) {
iframe.style.width = "100%";
iframe.style.height = "70vh";
} else {
iframe.style.width = "100%";
iframe.style.height = "160px";
}
iframe.srcdoc = text;
stopProgress = true;
set(iframe);
return;
}
const pre = document.createElement("pre");
if (isFullPreview) {
pre.style.maxWidth = "100%";
pre.style.maxHeight = "70vh";
pre.style.overflow = "auto";
pre.style.whiteSpace = "pre-wrap";
} else {
pre.style.maxWidth = "100%";
pre.style.maxHeight = "160px";
pre.style.overflow = "auto";
}
pre.textContent = isFullPreview ? text : text.slice(0, 5000);
stopProgress = true;
set(pre);
return;
}
const mime =
detectMimeFromBytes(bytes) ||
(baseKind === "image"
? "image/png"
: baseKind === "video"
? "video/mp4"
: baseKind === "audio"
? "audio/mpeg"
: "application/octet-stream");
const blob = new Blob([bytes], { type: mime });
const url = URL.createObjectURL(blob);
if (mime === "application/pdf") {
const iframe = document.createElement("iframe");
iframe.type = "application/pdf";
iframe.src = url;
iframe.dataset.bloburl = url;
if (isFullPreview) {
iframe.style.width = "100%";
iframe.style.height = "70vh";
} else {
iframe.style.width = "100%";
iframe.style.height = "160px";
}
stopProgress = true;
set(iframe);
return;
}
if (baseKind === "image") {
const img = document.createElement("img");
img.src = url;
img.alt = identifier;
img.dataset.bloburl = url;
img.className = isFullPreview ? "preview-image full" : "preview-image";
img.setAttribute("data-service", service);
img.setAttribute("data-identifier", identifier);
img.setAttribute("data-name", name);
img.addEventListener("click", () => openImageOverlayFromDataUrl(url));
stopProgress = true;
set(img);
return;
}
if (baseKind === "video") {
const video = document.createElement("video");
video.dataset.bloburl = url;
video.controls = true;
video.className = isFullPreview ? "preview-video full" : "preview-video";
video.src = url;
video.setAttribute("data-service", service);
video.setAttribute("data-identifier", identifier);
video.setAttribute("data-name", name);
stopProgress = true;
set(video);
return;
}
if (baseKind === "audio") {
const audio = document.createElement("audio");
audio.dataset.bloburl = url;
audio.controls = true;
audio.className = isFullPreview ? "preview-audio full" : "preview-audio";
audio.src = url;
audio.setAttribute("data-service", service);
audio.setAttribute("data-identifier", identifier);
audio.setAttribute("data-name", name);
stopProgress = true;
set(audio);
return;
}
// Fallback file link
const a = document.createElement("a");
a.textContent = "Open";
a.href = url;
a.target = "_blank";
a.rel = "noopener";
a.dataset.bloburl = url;
stopProgress = true;
set(a);
}
} else {
// Public
if (isText) {
let resp;
try {
resp = await qortalRequest(
buildQdnParams({
action: "FETCH_QDN_RESOURCE",
name,
service,
identifier,
rebuild: false,
...(filepath ? { filepath } : {}),
})
);
} catch (_e) {
const b64 = await qortalRequest(
buildQdnParams({
action: "FETCH_QDN_RESOURCE",
name,
service,
identifier,
encoding: "base64",
rebuild: false,
...(filepath ? { filepath } : {}),
})
);
const bytes = b64ToBytes(b64);
const dec = new TextDecoder("utf-8").decode(bytes);
resp = dec || b64;
}
if (typeof resp === "string") {
const maybeJson = tryParseJson(resp.trim());
if (maybeJson) {
const pre = document.createElement("pre");
if (isFullPreview) {
pre.style.maxWidth = "100%";
pre.style.maxHeight = "70vh";
pre.style.overflow = "auto";
pre.style.whiteSpace = "pre-wrap";
} else {
pre.style.maxWidth = "100%";
pre.style.maxHeight = "160px";
pre.style.overflow = "auto";
}
pre.textContent = JSON.stringify(maybeJson, null, 2);
stopProgress = true;
set(pre);
return;
}
if (looksHtml(resp)) {
const iframe = document.createElement("iframe");
iframe.setAttribute("sandbox", "");
if (isFullPreview) {
iframe.style.width = "100%";
iframe.style.height = "70vh";
} else {
iframe.style.width = "100%";
iframe.style.height = "160px";
}
iframe.srcdoc = resp;
stopProgress = true;
set(iframe);
return;
}
const pre = document.createElement("pre");
if (isFullPreview) {
pre.style.maxWidth = "100%";
pre.style.maxHeight = "70vh";
pre.style.overflow = "auto";
pre.style.whiteSpace = "pre-wrap";
} else {
pre.style.maxWidth = "100%";
pre.style.maxHeight = "160px";
pre.style.overflow = "auto";
}
pre.textContent = isFullPreview ? resp : resp.slice(0, 5000);
stopProgress = true;
set(pre);
return;
} else if (resp && resp.type === "Buffer" && Array.isArray(resp.data)) {
const bytes = new Uint8Array(resp.data);
const text = new TextDecoder("utf-8").decode(bytes);
const maybeJson = tryParseJson(text.trim());
if (maybeJson) {
const pre = document.createElement("pre");
if (isFullPreview) {
pre.style.maxWidth = "100%";
pre.style.maxHeight = "70vh";
pre.style.overflow = "auto";
pre.style.whiteSpace = "pre-wrap";
} else {
pre.style.maxWidth = "100%";
pre.style.maxHeight = "160px";
pre.style.overflow = "auto";
}
pre.textContent = JSON.stringify(maybeJson, null, 2);
stopProgress = true;
set(pre);
return;
}
if (looksHtml(text)) {
const iframe = document.createElement("iframe");
iframe.setAttribute("sandbox", "");
if (isFullPreview) {
iframe.style.width = "100%";
iframe.style.height = "70vh";
} else {
iframe.style.width = "100%";
iframe.style.height = "160px";
}
iframe.srcdoc = text;
stopProgress = true;
set(iframe);
return;
}
const pre = document.createElement("pre");
if (isFullPreview) {
pre.style.maxWidth = "100%";
pre.style.maxHeight = "70vh";
pre.style.overflow = "auto";
pre.style.whiteSpace = "pre-wrap";
} else {
pre.style.maxWidth = "100%";
pre.style.maxHeight = "160px";
pre.style.overflow = "auto";
}
pre.textContent = isFullPreview ? text : text.slice(0, 5000);
set(pre);
return;
}
}
// Non-text: fetch as base64 and sniff
const b64 = await qortalRequest(
buildQdnParams({
action: "FETCH_QDN_RESOURCE",
name,
service,
identifier,
encoding: "base64",
rebuild: false,
...(filepath ? { filepath } : {}),
})
);
const bytes = b64ToBytes(b64);
// Text-like payload under non-text service? Render as text
if (isLikelyText(bytes)) {
const text = new TextDecoder("utf-8").decode(bytes);
const maybeJson = tryParseJson(text.trim());
if (maybeJson) {
const pre = document.createElement("pre");
if (isFullPreview) {
pre.style.maxWidth = "100%";
pre.style.maxHeight = "70vh";
pre.style.overflow = "auto";
pre.style.whiteSpace = "pre-wrap";
} else {
pre.style.maxWidth = "100%";
pre.style.maxHeight = "160px";
pre.style.overflow = "auto";
}
pre.textContent = JSON.stringify(maybeJson, null, 2);
set(pre);
return;
}
if (looksHtml(text)) {
const iframe = document.createElement("iframe");
iframe.setAttribute("sandbox", "");
if (isFullPreview) {
iframe.style.width = "100%";
iframe.style.height = "70vh";
} else {
iframe.style.width = "100%";
iframe.style.height = "160px";
}
iframe.srcdoc = text;
set(iframe);
return;
}
const pre = document.createElement("pre");
if (isFullPreview) {
pre.style.maxWidth = "100%";
pre.style.maxHeight = "70vh";
} else {
pre.style.maxWidth = "100%";
pre.style.maxHeight = "160px";
}
pre.style.overflow = "auto";
pre.style.whiteSpace = "pre-wrap";
pre.textContent = isFullPreview ? text : text.slice(0, 5000);
stopProgress = true;
set(pre);
return;
}
const mime =
detectMimeFromBytes(bytes) || guessMimeFromName(identifier, "application/octet-stream");
const blob = new Blob([bytes], { type: mime });
const url = URL.createObjectURL(blob);
if (mime === "application/pdf") {
const iframe = document.createElement("iframe");
iframe.type = "application/pdf";
iframe.src = url;
iframe.dataset.bloburl = url;
if (isFullPreview) {
iframe.style.width = "100%";
iframe.style.height = "70vh";
} else {
iframe.style.width = "100%";
iframe.style.height = "160px";
}
stopProgress = true;
set(iframe);
return;
}
if (mime.startsWith("image/")) {
const img = document.createElement("img");
img.src = url;
img.alt = identifier;
img.className = isFullPreview ? "preview-image full" : "preview-image";
img.dataset.bloburl = url;
img.setAttribute("data-service", service);
img.setAttribute("data-identifier", identifier);
img.setAttribute("data-name", name);
img.addEventListener("click", () => openImageOverlayFromDataUrl(url));
stopProgress = true;
set(img);
return;
}
if (mime.startsWith("video/")) {
const video = document.createElement("video");
video.dataset.bloburl = url;
video.controls = true;
video.className = isFullPreview ? "preview-video full" : "preview-video";
video.src = url;
video.setAttribute("data-service", service);
video.setAttribute("data-identifier", identifier);
video.setAttribute("data-name", name);
stopProgress = true;
set(video);
return;
}
if (mime.startsWith("audio/")) {
const audio = document.createElement("audio");
audio.dataset.bloburl = url;
audio.controls = true;
audio.className = isFullPreview ? "preview-audio full" : "preview-audio";
audio.src = url;
audio.setAttribute("data-service", service);
audio.setAttribute("data-identifier", identifier);
audio.setAttribute("data-name", name);
stopProgress = true;
set(audio);
return;
}
// Fallback: show a small hex/ASCII preview plus an Open link
const wrap = document.createElement("div");
const pre = document.createElement("pre");
const viewLen = Math.min(bytes.length, 2048);
let out = "";
for (let i = 0; i < viewLen; i += 16) {
let hex = "";
let ascii = "";
for (let j = 0; j < 16 && i + j < viewLen; j++) {
const b = bytes[i + j];
hex += b.toString(16).padStart(2, "0") + " ";
ascii += b >= 32 && b <= 126 ? String.fromCharCode(b) : ".";
}
out += hex.padEnd(16 * 3, " ") + " " + ascii + "\n";
}
pre.textContent = out + (bytes.length > viewLen ? "\n... (truncated)" : "");
if (isFullPreview) {
pre.style.maxWidth = "100%";
pre.style.maxHeight = "70vh";
} else {
pre.style.maxWidth = "100%";
pre.style.maxHeight = "160px";
}
pre.style.overflow = "auto";
pre.style.whiteSpace = "pre";
const a = document.createElement("a");
a.textContent = "Open";
a.href = url;
a.target = "_blank";
a.rel = "noopener";
a.dataset.bloburl = url;
a.style.display = "inline-block";
a.style.marginTop = "6px";
wrap.appendChild(pre);
wrap.appendChild(a);
stopProgress = true;
set(wrap);
}
} catch (e) {
container.textContent = "Failed to load preview: " + (e?.message || e);
console.error("Preview failed", e);
}
}
// ===== Delete/Edit/Metadata/Publish (from user's file, preserved) =====
async function deleteContent(service, identifier) {
try {
if (!userName || userName === "Name unavailable") {
return;
}
showPublishModal("Please wait...");
// Fetch existing metadata
let existingMetadata = {};
try {
const metadataResponse = await fetch(
`/arbitrary/resources/search?name=${userName}&service=${service}&identifier=${identifier}&includemetadata=true&exactmatchnames=true&mode=ALL`
);
if (metadataResponse.ok) {
const metadataResults = await metadataResponse.json();
if (metadataResults.length > 0 && metadataResults[0].metadata) {
existingMetadata = metadataResults[0].metadata;
}
}
} catch (err) {
console.error("Error fetching existing metadata:", err);
}
// Use a minimal 1-byte file to avoid hub check rejecting size=0
// A zero-byte Blob triggers the hub's `/arbitrary/check/tmp` to fail,
// which surfaces as a misleading "Not enough space" error.
const emptyFile = new Blob(["\n"], { type: "application/octet-stream" });
const deleteIdent = identifier === "default" ? "" : identifier;
// Prepare the publish parameters, including existing metadata if available
const publishParams = {
action: "PUBLISH_QDN_RESOURCE",
name: userName,
service: service,
identifier: deleteIdent,
file: emptyFile,
};
// List of metadata fields to delete
const metadataFields = ["filename", "title", "description"];
// Add existing metadata fields to publishParams if they exist
for (const field of metadataFields) {
if (existingMetadata[field]) {
publishParams[field] = "deleted";
}
}
if (existingMetadata["category"]) {
publishParams["category"] = "UNCATEGORIZED";
}
if (existingMetadata["tags"]) {
publishParams["tag1"] = "deleted";
}
// Proceed with publishing using publishWithFeedback
try {
await publishWithFeedback(publishParams);
console.log("Content deleted successfully");
} catch (error) {
console.error("Error deleting content:", error);
}
} catch (error) {
console.error("Error deleting content:", error);
}
}
async function editContent(service, identifier) {
try {
if (!userName || userName === "Name unavailable") {
return;
}
// Fetch existing metadata
let existingMetadata = {};
try {
const metadataResponse = await fetch(
`/arbitrary/resources/search?name=${userName}&service=${service}&identifier=${identifier}&includemetadata=true&exactmatchnames=true&mode=ALL`
);
if (metadataResponse.ok) {
const metadataResults = await metadataResponse.json();
if (metadataResults.length > 0 && metadataResults[0].metadata) {
existingMetadata = metadataResults[0].metadata;
}
}
} catch (err) {
console.error("Error fetching existing metadata:", err);
}
const editIdent = identifier === "default" ? "" : identifier;
// Prepare the publish parameters (for non-text flow; text flow handled inline on save)
const publishParams = {
action: "PUBLISH_QDN_RESOURCE",
name: userName,
service: service,
identifier: editIdent,
// 'file' will be added below after obtaining the edited or selected file
};
const textualInPreview = isTextDisplayedInPreview();
if (textualInPreview) {
// Fetch the current content and enable inline editing in the preview panel
let contentUrl = `/arbitrary/${service}/${userName}/${identifier}`;
let content = "";
try {
const contentResponse = await fetch(contentUrl);
if (contentResponse.ok) {
content = await contentResponse.text();
}
} catch (_err) {
// ignore; we'll try to read from current preview DOM below
}
if (!content) {
// Fallback: read currently displayed preview content (handles private resources)
try {
const container = document.getElementById("preview-container");
const el = container && container.firstElementChild;
if (el) {
if (el.tagName === "PRE") {
content = el.textContent || "";
} else if (el.tagName === "IFRAME" && el.getAttribute("srcdoc") != null) {
content = el.srcdoc || "";
} else if (el.tagName === "DIV") {
const pre = el.querySelector("pre");
if (pre) {
content = pre.textContent || "";
}
}
}
} catch {}
}
enableInlineTextEditInPreview({
content,
service,
identifier,
existingMetadata,
});
return; // Inline editor will manage the rest (metadata + publish or cancel)
} else {
// For other types, prompt the user to select a new file
const input = document.createElement("input");
input.type = "file";
input.click();
const selectedFilePromise = new Promise((resolve, reject) => {
input.onchange = (event) => {
const file = event.target.files[0];
resolve(file);
};
input.onerror = reject;
});
const selectedFile = await selectedFilePromise;
publishParams.file = selectedFile; // Add the selected file to publishParams
}
// Open metadata editor dialog
let updatedMetadata = await openMetadataEditorDialog(existingMetadata);
if (updatedMetadata === null) {
// User cancelled
return;
}
// Update 'publishParams' with 'updatedMetadata'
const metadataFields = ["filename", "title", "description", "category"];
for (const field of metadataFields) {
if (updatedMetadata[field]) {
publishParams[field] = updatedMetadata[field];
} else {
delete publishParams[field];
}
}
// Handle tags
if (updatedMetadata["tags"]) {
const tagsArray = updatedMetadata["tags"]
.split(",")
.map((tag) => tag.trim())
.filter((tag) => tag);
for (let i = 1; i <= 5; i++) {
if (tagsArray[i - 1]) {
publishParams[`tag${i}`] = tagsArray[i - 1];
} else {
delete publishParams[`tag${i}`];
}
}
} else {
// Remove tags if none provided
for (let i = 1; i <= 5; i++) {
delete publishParams[`tag${i}`];
}
}
// Proceed with publishing using publishWithFeedback
try {
await publishWithFeedback(publishParams);
console.log("Content edited successfully");
// Optionally, refresh the content display
// fetchContent();
} catch (error) {
console.error("Error editing content:", error);
}
} catch (error) {
console.error("Error editing content:", error);
}
}
// Enable inline text editing inside the main preview panel for text services
function enableInlineTextEditInPreview({ content, service, identifier, existingMetadata }) {
const container = document.getElementById("preview-container");
if (!container) {
return;
}
// If already in inline editing, just refresh content and focus
const existing = container.querySelector(".inline-text-editor");
if (existing) {
const ta = existing.querySelector("textarea");
if (ta) {
ta.value = content || "";
ta.focus();
}
return;
}
const wrapper = document.createElement("div");
wrapper.className = "inline-text-editor";
wrapper.style.display = "flex";
wrapper.style.flexDirection = "column";
wrapper.style.width = "100%";
wrapper.style.height = "70vh";
const toolbar = document.createElement("div");
toolbar.style.display = "flex";
toolbar.style.justifyContent = "flex-end";
toolbar.style.gap = "8px";
toolbar.style.marginBottom = "8px";
const cancelBtn = document.createElement("button");
cancelBtn.textContent = "Cancel";
const saveBtn = document.createElement("button");
saveBtn.textContent = "Save";
toolbar.appendChild(cancelBtn);
toolbar.appendChild(saveBtn);
const textarea = document.createElement("textarea");
textarea.style.flex = "1";
textarea.style.width = "100%";
textarea.style.resize = "vertical";
textarea.style.backgroundColor = "#3d4452";
textarea.style.color = "#c9d2d9";
textarea.style.border = "1px solid #445063";
textarea.style.borderRadius = "8px";
textarea.style.padding = "10px";
textarea.value = content || "";
wrapper.appendChild(toolbar);
wrapper.appendChild(textarea);
container.replaceChildren(wrapper);
try {
container.dataset.textual = "1";
} catch {}
cancelBtn.addEventListener("click", async () => {
// Restore the normal preview content
container.textContent = "Loading preview...";
try {
await loadPreviewInto(container, currentPreviewCtx);
} catch {}
});
saveBtn.addEventListener("click", async () => {
try {
const editedContent = textarea.value;
// Prepare publish params
const editIdent = identifier === "default" ? "" : identifier;
const publishParams = {
action: "PUBLISH_QDN_RESOURCE",
name: userName,
service,
identifier: editIdent,
file: new Blob([editedContent], { type: "text/plain" }),
};
// Let user edit metadata next
const updatedMetadata = await openMetadataEditorDialog(existingMetadata || {});
if (updatedMetadata === null) {
// Stay in edit mode
return;
}
const metadataFields = ["filename", "title", "description", "category"];
for (const field of metadataFields) {
if (updatedMetadata[field]) {
publishParams[field] = updatedMetadata[field];
} else {
delete publishParams[field];
}
}
if (updatedMetadata["tags"]) {
const tagsArray = updatedMetadata["tags"]
.split(",")
.map((tag) => tag.trim())
.filter((tag) => tag);
for (let i = 1; i <= 5; i++) {
if (tagsArray[i - 1]) {
publishParams[`tag${i}`] = tagsArray[i - 1];
} else {
delete publishParams[`tag${i}`];
}
}
} else {
for (let i = 1; i <= 5; i++) {
delete publishParams[`tag${i}`];
}
}
// Publish and then reload preview
await publishWithFeedback(publishParams);
container.textContent = "Loading preview...";
try {
await loadPreviewInto(container, currentPreviewCtx);
} catch {}
} catch (e) {
console.error("Inline edit save failed:", e);
}
});
}
// Replace: always use a file picker, even for text services
async function replaceContent(service, identifier) {
try {
if (!userName || userName === "Name unavailable") {
return;
}
showPublishModal("Please wait...");
// Fetch existing metadata
let existingMetadata = {};
try {
const metadataResponse = await fetch(
`/arbitrary/resources/search?name=${userName}&service=${service}&identifier=${identifier}&includemetadata=true&exactmatchnames=true&mode=ALL`
);
if (metadataResponse.ok) {
const metadataResults = await metadataResponse.json();
if (metadataResults.length > 0 && metadataResults[0].metadata) {
existingMetadata = metadataResults[0].metadata;
}
}
} catch (err) {
console.error("Error fetching existing metadata:", err);
}
const repIdent = identifier === "default" ? "" : identifier;
// Prepare the publish parameters
const publishParams = {
action: "PUBLISH_QDN_RESOURCE",
name: userName,
service: service,
identifier: repIdent,
// 'file' will be added below after user selects a file
};
// Always pick a file for replacement
closePublishModal();
const input = document.createElement("input");
input.type = "file";
input.click();
const selectedFilePromise = new Promise((resolve, reject) => {
input.onchange = (event) => {
const file = event.target.files[0];
resolve(file);
};
input.onerror = reject;
});
const selectedFile = await selectedFilePromise;
publishParams.file = selectedFile;
// Open metadata editor dialog
let updatedMetadata = await openMetadataEditorDialog(existingMetadata);
if (updatedMetadata === null) {
// User cancelled
return;
}
// Update 'publishParams' with 'updatedMetadata'
const metadataFields = ["filename", "title", "description", "category"];
for (const field of metadataFields) {
if (updatedMetadata[field]) {
publishParams[field] = updatedMetadata[field];
} else {
delete publishParams[field];
}
}
// Handle tags
if (updatedMetadata["tags"]) {
const tagsArray = updatedMetadata["tags"]
.split(",")
.map((tag) => tag.trim())
.filter((tag) => tag);
for (let i = 1; i <= 5; i++) {
if (tagsArray[i - 1]) {
publishParams[`tag${i}`] = tagsArray[i - 1];
} else {
delete publishParams[`tag${i}`];
}
}
} else {
// Remove tags if none provided
for (let i = 1; i <= 5; i++) {
delete publishParams[`tag${i}`];
}
}
// Proceed with publishing using publishWithFeedback
try {
await publishWithFeedback(publishParams);
console.log("Content replaced successfully");
} catch (error) {
console.error("Error replacing content:", error);
}
} catch (error) {
console.error("Error replacing content:", error);
}
}
function openTextEditorDialog(content) {
return new Promise((resolve) => {
// Create the modal overlay
const modalOverlay = document.createElement("div");
modalOverlay.style.position = "fixed";
modalOverlay.style.top = "0";
modalOverlay.style.left = "0";
modalOverlay.style.width = "100%";
modalOverlay.style.height = "100%";
modalOverlay.style.backgroundColor = "rgba(0, 0, 0, 0.5)";
modalOverlay.style.display = "flex";
modalOverlay.style.justifyContent = "center";
modalOverlay.style.alignItems = "center";
modalOverlay.style.zIndex = "1000";
// Create the modal content container
const modalContent = document.createElement("div");
modalContent.style.backgroundColor = "#2d3749"; // Use background color from main content
modalContent.style.color = "#c9d2d9"; // Use text color from your CSS
modalContent.style.padding = "20px";
modalContent.style.borderRadius = "25px"; // Match border radius from your CSS
modalContent.style.maxWidth = "600px";
modalContent.style.width = "90%";
modalContent.style.fontFamily = "'Lexend', sans-serif"; // Use the same font
modalContent.style.lineHeight = "1.6"; // Consistent line height
// Create the textarea for editing
const textarea = document.createElement("textarea");
textarea.style.width = "100%";
textarea.style.height = "300px";
textarea.style.backgroundColor = "#3d4452"; // Use background color from main content
textarea.style.color = "#c9d2d9"; // Use text color from your CSS
textarea.value = content;
// Create the button container
const buttonContainer = document.createElement("div");
buttonContainer.style.textAlign = "right";
buttonContainer.style.marginTop = "10px";
// Create the Save and Cancel buttons
const saveButton = document.createElement("button");
saveButton.textContent = "Save";
const cancelButton = document.createElement("button");
cancelButton.textContent = "Cancel";
cancelButton.style.marginRight = "10px";
buttonContainer.appendChild(cancelButton);
buttonContainer.appendChild(saveButton);
modalContent.appendChild(textarea);
modalContent.appendChild(buttonContainer);
modalOverlay.appendChild(modalContent);
document.body.appendChild(modalOverlay);
// Event listeners for the buttons
cancelButton.addEventListener("click", () => {
document.body.removeChild(modalOverlay);
closePublishModal();
resolve(null);
});
saveButton.addEventListener("click", () => {
const editedContent = textarea.value;
document.body.removeChild(modalOverlay);
resolve(editedContent);
});
});
}
function openMetadataDialog(metadata) {
// Create the modal overlay
const modalOverlay = document.createElement("div");
modalOverlay.style.position = "fixed";
modalOverlay.style.top = "0";
modalOverlay.style.left = "0";
modalOverlay.style.width = "100%";
modalOverlay.style.height = "100%";
modalOverlay.style.backgroundColor = "rgba(0, 0, 0, 0.5)";
modalOverlay.style.display = "flex";
modalOverlay.style.justifyContent = "center";
modalOverlay.style.alignItems = "center";
modalOverlay.style.zIndex = "1000";
// Create the modal content container
const modalContent = document.createElement("div");
modalContent.style.backgroundColor = "#2d3749"; // Use background color from main content
modalContent.style.color = "#c9d2d9"; // Use text color from your CSS
modalContent.style.padding = "20px";
modalContent.style.borderRadius = "25px"; // Match border radius from your CSS
modalContent.style.maxWidth = "600px";
modalContent.style.width = "90%";
modalContent.style.fontFamily = "'Lexend', sans-serif"; // Use the same font
modalContent.style.lineHeight = "1.6"; // Consistent line height
// Create the content display
const contentDiv = document.createElement("div");
contentDiv.style.maxHeight = "400px";
contentDiv.style.overflowY = "auto";
// Build the metadata display
for (let key in metadata) {
const keyElement = document.createElement("strong");
keyElement.textContent = key + ": ";
keyElement.style.color = "#ffffff"; // Make keys stand out
const valueElement = document.createElement("span");
valueElement.textContent = metadata[key];
const lineBreak = document.createElement("br");
contentDiv.appendChild(keyElement);
contentDiv.appendChild(valueElement);
contentDiv.appendChild(lineBreak);
}
// Create the Close button
const closeButton = document.createElement("button");
closeButton.textContent = "Close";
closeButton.style.marginTop = "10px";
modalContent.appendChild(contentDiv);
modalContent.appendChild(closeButton);
modalOverlay.appendChild(modalContent);
document.body.appendChild(modalOverlay);
// Event listener for the Close button
closeButton.addEventListener("click", () => {
document.body.removeChild(modalOverlay);
});
}
function openMetadataEditorDialog(existingMetadata) {
return new Promise((resolve) => {
// Create the modal overlay
const modalOverlay = document.createElement("div");
modalOverlay.style.position = "fixed";
modalOverlay.style.top = "0";
modalOverlay.style.left = "0";
modalOverlay.style.width = "100%";
modalOverlay.style.height = "100%";
modalOverlay.style.backgroundColor = "rgba(0, 0, 0, 0.5)";
modalOverlay.style.display = "flex";
modalOverlay.style.justifyContent = "center";
modalOverlay.style.alignItems = "center";
modalOverlay.style.zIndex = "1000";
// Create the modal content container
const modalContent = document.createElement("div");
modalContent.style.backgroundColor = "#2d3749";
modalContent.style.color = "#c9d2d9";
modalContent.style.padding = "20px";
modalContent.style.borderRadius = "25px";
modalContent.style.maxWidth = "600px";
modalContent.style.width = "90%";
modalContent.style.fontFamily = "'Lexend', sans-serif";
modalContent.style.lineHeight = "1.6";
// Create the form
const form = document.createElement("form");
const fields = ["filename", "title", "description", "category", "tags"];
// Define the categories
const categories = [
{ value: "", display: "" },
{ value: "ART", display: "Art and Design" },
{ value: "AUTOMOTIVE", display: "Automotive" },
{ value: "BEAUTY", display: "Beauty" },
{ value: "BOOKS", display: "Books and Reference" },
{ value: "BUSINESS", display: "Business" },
{ value: "COMMUNICATIONS", display: "Communications" },
{ value: "CRYPTOCURRENCY", display: "Cryptocurrency and Blockchain" },
{ value: "CULTURE", display: "Culture" },
{ value: "DATING", display: "Dating" },
{ value: "DESIGN", display: "Design" },
{ value: "ENTERTAINMENT", display: "Entertainment" },
{ value: "EVENTS", display: "Events" },
{ value: "FAITH", display: "Faith and Religion" },
{ value: "FASHION", display: "Fashion" },
{ value: "FINANCE", display: "Finance" },
{ value: "FOOD", display: "Food and Drink" },
{ value: "GAMING", display: "Gaming" },
{ value: "GEOGRAPHY", display: "Geography" },
{ value: "HEALTH", display: "Health" },
{ value: "HISTORY", display: "History" },
{ value: "HOME", display: "Home" },
{ value: "KNOWLEDGE", display: "Knowledge Share" },
{ value: "LANGUAGE", display: "Language" },
{ value: "LIFESTYLE", display: "Lifestyle" },
{ value: "MANUFACTURING", display: "Manufacturing" },
{ value: "MAPS", display: "Maps and Navigation" },
{ value: "MUSIC", display: "Music" },
{ value: "NEWS", display: "News" },
{ value: "OTHER", display: "Other" },
{ value: "PETS", display: "Pets" },
{ value: "PHILOSOPHY", display: "Philosophy" },
{ value: "PHOTOGRAPHY", display: "Photography" },
{ value: "POLITICS", display: "Politics" },
{ value: "PRODUCE", display: "Products and Services" },
{ value: "PRODUCTIVITY", display: "Productivity" },
{ value: "PSYCHOLOGY", display: "Psychology" },
{ value: "QORTAL", display: "Qortal" },
{ value: "SCIENCE", display: "Science" },
{ value: "SELF_CARE", display: "Self Care" },
{ value: "SELF_SUFFICIENCY", display: "Self-Sufficiency and Homesteading" },
{ value: "SHOPPING", display: "Shopping" },
{ value: "SOCIAL", display: "Social" },
{ value: "SOFTWARE", display: "Software" },
{ value: "SPIRITUALITY", display: "Spirituality" },
{ value: "SPORTS", display: "Sports" },
{ value: "STORYTELLING", display: "Storytelling" },
{ value: "TECHNOLOGY", display: "Technology" },
{ value: "TOOLS", display: "Tools" },
{ value: "TRAVEL", display: "Travel" },
{ value: "UNCATEGORIZED", display: "Uncategorized" },
{ value: "VIDEO", display: "Video" },
{ value: "WEATHER", display: "Weather" },
];
fields.forEach((field) => {
const label = document.createElement("label");
label.textContent = field.charAt(0).toUpperCase() + field.slice(1) + ":";
label.style.display = "block";
label.style.marginTop = "10px";
let input;
if (field === "category") {
// Create a select element for category
input = document.createElement("select");
input.name = field;
input.style.width = "100%";
input.style.padding = "5px";
input.style.marginTop = "5px";
// Add options to the select element
categories.forEach((category) => {
const option = document.createElement("option");
option.value = category.value;
option.textContent = category.display;
input.appendChild(option);
});
// Set the selected value if it exists in existingMetadata
if (existingMetadata[field]) {
input.value = existingMetadata[field];
} else {
input.value = ""; // Default to blank line
}
} else {
// Create an input element for other fields
input = document.createElement("input");
input.type = "text";
input.name = field;
input.style.width = "100%";
input.style.padding = "5px";
input.style.marginTop = "5px";
if (existingMetadata[field]) {
if (field === "tags" && Array.isArray(existingMetadata[field])) {
input.value = existingMetadata[field].join(", ");
} else {
input.value = existingMetadata[field];
}
} else {
input.placeholder = field.charAt(0).toUpperCase() + field.slice(1);
}
}
label.appendChild(input);
form.appendChild(label);
});
// Create the button container
const buttonContainer = document.createElement("div");
buttonContainer.style.textAlign = "right";
buttonContainer.style.marginTop = "20px";
// Create the Save and Cancel buttons
const saveButton = document.createElement("button");
saveButton.textContent = "Save";
saveButton.type = "submit";
saveButton.style.marginLeft = "10px";
const cancelButton = document.createElement("button");
cancelButton.textContent = "Cancel";
cancelButton.type = "button";
buttonContainer.appendChild(cancelButton);
buttonContainer.appendChild(saveButton);
form.appendChild(buttonContainer);
modalContent.appendChild(form);
modalOverlay.appendChild(modalContent);
document.body.appendChild(modalOverlay);
// Event listeners
cancelButton.addEventListener("click", (event) => {
event.preventDefault();
document.body.removeChild(modalOverlay);
closePublishModal();
resolve(null);
});
form.addEventListener("submit", (event) => {
event.preventDefault();
// Collect the metadata
const formData = new FormData(form);
const updatedMetadata = {};
fields.forEach((field) => {
const value = formData.get(field);
if (value) {
updatedMetadata[field] = value;
}
});
document.body.removeChild(modalOverlay);
resolve(updatedMetadata);
});
});
}
let publishModal = null;
function showPublishModal(message) {
if (!publishModal) {
// Create the modal
publishModal = document.createElement("div");
publishModal.style.position = "fixed";
publishModal.style.top = "0";
publishModal.style.left = "0";
publishModal.style.width = "100%";
publishModal.style.height = "100%";
publishModal.style.backgroundColor = "rgba(0, 0, 0, 0.5)";
publishModal.style.display = "flex";
publishModal.style.justifyContent = "center";
publishModal.style.alignItems = "center";
publishModal.style.zIndex = "1000";
// Create the modal content container
const modalContent = document.createElement("div");
modalContent.style.backgroundColor = "#2d3749"; // Use background color from main content
modalContent.style.padding = "20px";
modalContent.style.borderRadius = "5px";
modalContent.style.maxWidth = "400px";
modalContent.style.width = "90%";
modalContent.style.textAlign = "center";
// Create the message element
const messageElement = document.createElement("p");
messageElement.id = "publish-modal-message";
messageElement.textContent = message;
modalContent.appendChild(messageElement);
publishModal.appendChild(modalContent);
document.body.appendChild(publishModal);
} else {
// Update the message
const messageElement = publishModal.querySelector("#publish-modal-message");
messageElement.textContent = message;
// Remove any buttons (Retry/Cancel) if they exist
const buttons = publishModal.querySelector("#publish-modal-buttons");
if (buttons) {
buttons.remove();
}
}
if (message === "Publish TX submitted! Confirmation needed.") {
// Create buttons container
const buttonsContainer = document.createElement("div");
buttonsContainer.id = "publish-modal-buttons";
buttonsContainer.style.marginTop = "20px";
// Create Close button
const closeButton = document.createElement("button");
closeButton.textContent = "Close";
closeButton.addEventListener("click", () => {
closePublishModal();
});
buttonsContainer.appendChild(closeButton);
// Append button to modal content
const modalContent = publishModal.firstChild;
modalContent.appendChild(buttonsContainer);
}
}
function closePublishModal() {
if (publishModal) {
document.body.removeChild(publishModal);
publishModal = null;
}
}
function showPublishErrorModal(errorMessage, onRetry, onCancel) {
if (publishModal) {
// Update the message
const messageElement = publishModal.querySelector("#publish-modal-message");
messageElement.textContent = errorMessage;
// Remove any existing buttons
const existingButtons = publishModal.querySelector("#publish-modal-buttons");
if (existingButtons) {
existingButtons.remove();
}
// Create buttons container
const buttonsContainer = document.createElement("div");
buttonsContainer.id = "publish-modal-buttons";
buttonsContainer.style.marginTop = "20px";
// Create Retry button
const retryButton = document.createElement("button");
retryButton.textContent = "Retry";
retryButton.style.marginRight = "10px";
retryButton.addEventListener("click", () => {
onRetry();
});
// Create Cancel button
const cancelButton = document.createElement("button");
cancelButton.textContent = "Cancel";
cancelButton.addEventListener("click", () => {
onCancel();
});
buttonsContainer.appendChild(retryButton);
buttonsContainer.appendChild(cancelButton);
// Append buttons to modal content
const modalContent = publishModal.firstChild;
modalContent.appendChild(buttonsContainer);
}
}
async function publishWithFeedback(publishParams) {
return new Promise(async (resolve, reject) => {
async function attemptPublish() {
try {
// Enforce encryption automatically for private services
await ensureEncryptionForPublishParams(publishParams);
// Message hint if private service
const svc =
publishParams.service ||
(publishParams.resources && publishParams.resources[0]?.service) ||
"";
const isPriv = isPrivateService(svc);
const note = isPriv ? "Private service detected — encrypting to your account only. " : "";
// Show modal with hint (if any)
showPublishModal(`${note}Attempting to publish, please wait...`);
const response = await qortalRequest(publishParams);
// Close modal
resolve(response);
showPublishModal("Publish TX submitted! Confirmation needed.");
} catch (error) {
// Update modal to show error message and Retry/Cancel buttons
showPublishErrorModal(
`Publishing failed: ${error.message}`,
() => {
// On Retry
attemptPublish();
},
() => {
// On Cancel
closePublishModal();
reject(error);
}
);
}
}
await attemptPublish();
});
}
// ===== Publish helpers: new actions (Add file/folder/New text) =====
function guessServiceFromFilename(filename) {
const lower = String(filename || "").toLowerCase();
if (/(\.jpg|\.jpeg|\.png|\.gif|\.webp)$/.test(lower)) {
return "IMAGE";
}
if (/(\.mp4|\.m4v|\.webm|\.ogv)$/.test(lower)) {
return "VIDEO";
}
if (/(\.mp3|\.wav|\.ogg|\.m4a|\.aac)$/.test(lower)) {
return "AUDIO";
}
if (/\.json$/.test(lower)) {
return "JSON";
}
if (/(\.txt|\.md|\.csv|\.log)$/.test(lower)) {
return "DOCUMENT";
}
if (/\.pdf$/.test(lower)) {
return "DOCUMENT";
}
return "FILE";
}
function defaultIdentifierFromFileName(filename) {
const base =
String(filename || "")
.split("/")
.pop() || "";
const i = base.lastIndexOf(".");
return i > 0 ? base.slice(0, i) : base;
}
function safeIdentifierFromPath(path) {
const p = String(path || "").replace(/^\.+\/?/, "");
return p.replace(/[\\/]+/g, "_");
}
function applyMetadataToParams(target, md) {
if (!md) {
return;
}
const fields = ["filename", "title", "description", "category"];
for (const k of fields) {
if (md[k]) {
target[k] = md[k];
} else {
delete target[k];
}
}
if (md["tags"]) {
const tagsArray = String(md["tags"])
.split(",")
.map((t) => t.trim())
.filter(Boolean);
for (let i = 1; i <= 5; i++) {
if (tagsArray[i - 1]) {
target[`tag${i}`] = tagsArray[i - 1];
} else {
delete target[`tag${i}`];
}
}
} else {
for (let i = 1; i <= 5; i++) {
delete target[`tag${i}`];
}
}
}
function openPublishDetailsDialog(defaults) {
return new Promise((resolve) => {
const modalOverlay = document.createElement("div");
Object.assign(modalOverlay.style, {
position: "fixed",
top: "0",
left: "0",
width: "100%",
height: "100%",
backgroundColor: "rgba(0, 0, 0, 0.5)",
display: "flex",
justifyContent: "center",
alignItems: "center",
zIndex: "1000",
});
const modalContent = document.createElement("div");
Object.assign(modalContent.style, {
backgroundColor: "#2d3749",
color: "#c9d2d9",
padding: "20px",
borderRadius: "12px",
width: "90%",
maxWidth: "520px",
fontFamily: "'Lexend', sans-serif",
lineHeight: "1.6",
});
const title = document.createElement("div");
title.textContent = "Publish details";
title.style.fontWeight = "600";
title.style.marginBottom = "10px";
const form = document.createElement("form");
form.innerHTML = `
Note: Private service detected - your content will be encrypted to your
account only. No other account can decrypt it.
`;
const svcInput = form.querySelector('input[name="service"]');
const idInput = form.querySelector('input[name="identifier"]');
const noteDiv = form.querySelector("#publish-private-note");
if (svcInput) {
svcInput.value = defaults?.service || "";
// Enhance with combobox behavior
try {
createServiceCombobox(/** @type {HTMLInputElement} */ (svcInput));
} catch {}
const updateNote = () => {
const s = String(svcInput.value || "")
.trim()
.toUpperCase();
if (noteDiv) {
noteDiv.style.display = isPrivateService(s) ? "block" : "none";
}
};
svcInput.addEventListener("input", updateNote);
updateNote();
}
if (idInput) {
idInput.value = defaults?.identifier || "";
}
form.querySelector('[data-act="cancel"]').addEventListener("click", () => {
document.body.removeChild(modalOverlay);
resolve(null);
});
form.addEventListener("submit", (e) => {
e.preventDefault();
const svc = String(svcInput.value || "")
.trim()
.toUpperCase();
const ident = String(idInput.value || "").trim();
document.body.removeChild(modalOverlay);
resolve({ service: svc, identifier: ident });
});
modalContent.appendChild(title);
modalContent.appendChild(form);
modalOverlay.appendChild(modalContent);
document.body.appendChild(modalOverlay);
});
}
async function handlePublishAddFile() {
try {
if (!isAuthenticated || !userName || userName === "Name unavailable") {
alert("Authenticate and select a name first.");
return;
}
// Pick a file
const inp = document.createElement("input");
inp.type = "file";
const file = await new Promise((resolve, reject) => {
inp.onchange = (e) => {
const f = /** @type {HTMLInputElement} */ (e.target).files?.[0] || null;
resolve(f);
};
inp.onerror = reject;
inp.click();
});
if (!file) {
return;
}
const svcGuess = guessServiceFromFilename(file.name);
const identGuess = defaultIdentifierFromFileName(file.name);
const details = await openPublishDetailsDialog({ service: svcGuess, identifier: identGuess });
if (!details) {
return;
}
const md = await openMetadataEditorDialog({ filename: file.name });
if (md === null) {
return;
}
const params = {
action: "PUBLISH_QDN_RESOURCE",
name: userName,
service: details.service,
identifier: details.identifier || "",
file,
};
applyMetadataToParams(params, md);
await publishWithFeedback(params);
// Refresh data
showSpinner();
try {
await loadAllResults();
buildSidebarTree();
applyServiceFilter();
await fetchPage();
} finally {
hideSpinner();
}
} catch (e) {
alert("Publish failed: " + (e?.message || e));
}
}
async function handlePublishAddFolder() {
try {
if (!isAuthenticated || !userName || userName === "Name unavailable") {
alert("Authenticate and select a name first.");
return;
}
const inp = document.createElement("input");
inp.type = "file";
// @ts-ignore - webkitdirectory is widely supported in Chromium-based browsers
inp.webkitdirectory = true;
inp.multiple = true;
const files = await new Promise((resolve, reject) => {
inp.onchange = (e) => {
const list = /** @type {HTMLInputElement} */ (e.target).files;
resolve(list ? Array.from(list) : []);
};
inp.onerror = reject;
inp.click();
});
if (!files || files.length === 0) {
return;
}
// Suggest service FILE for mixed folders
const details = await openPublishDetailsDialog({ service: "FILE", identifier: "" });
if (!details) {
return;
}
const md = await openMetadataEditorDialog({});
if (md === null) {
return;
}
showPublishModal(`Publishing ${files.length} files...`);
try {
const resources = files.map((f) => {
const rel = /** @type {any} */ (f).webkitRelativePath || f.name;
const ident = safeIdentifierFromPath(rel);
const r = {
name: userName,
service: details.service,
identifier: ident || "default",
file: f,
filename: f.name,
};
applyMetadataToParams(r, md);
return r;
});
// Auto-attach encryption for private services
const multiParams = { action: "PUBLISH_MULTIPLE_QDN_RESOURCES", resources };
await ensureEncryptionForPublishParams(multiParams);
// Add brief note for private
if (isPrivateService(details.service)) {
showPublishModal(
`Private service detected - encrypting to your account only. Publishing ${files.length} files...`
);
}
const response = await qortalRequest(multiParams);
console.log("Folder publish response:", response);
// Reload data
await loadAllResults();
buildSidebarTree();
applyServiceFilter();
await fetchPage();
} finally {
closePublishModal();
}
} catch (e) {
closePublishModal();
alert("Folder publish failed: " + (e?.message || e));
}
}
// ===== Compose (New text) page =====
let composeMetadata = {};
async function openComposePage({ service, identifier, content } = {}) {
composeMetadata = {};
const svcEl = document.getElementById("compose-service");
const idEl = document.getElementById("compose-identifier");
const txt = document.getElementById("compose-text");
const sum = document.getElementById("compose-metadata-summary");
const privNote = document.getElementById("compose-private-note");
if (svcEl) {
/** @type {HTMLInputElement} */ (svcEl).value = (service || "DOCUMENT").toString();
// Toggle private service note
const updateNote = () => {
const s = String(/** @type {HTMLInputElement} */ (svcEl).value || "")
.trim()
.toUpperCase();
if (privNote) {
privNote.style.display = isPrivateService(s) ? "block" : "none";
}
};
try {
// hook up service combobox to reuse suggestions if available
createServiceCombobox(/** @type {HTMLInputElement} */ (svcEl));
} catch {}
svcEl.addEventListener("input", updateNote);
updateNote();
}
if (idEl) {
/** @type {HTMLInputElement} */ (idEl).value = (identifier || "").toString();
}
if (txt) {
/** @type {HTMLTextAreaElement} */ (txt).value = (content || "").toString();
}
if (sum) {
sum.textContent = "";
}
showSection("compose");
// Wire one-time handlers
const editBtn = document.getElementById("compose-edit-metadata");
if (editBtn && editBtn.dataset.bound !== "1") {
editBtn.dataset.bound = "1";
editBtn.addEventListener("click", async () => {
const updated = await openMetadataEditorDialog(composeMetadata || {});
if (updated) {
composeMetadata = updated;
const keys = Object.keys(updated).filter(
(k) => updated[k] && String(updated[k]).trim() !== ""
);
const sumEl = document.getElementById("compose-metadata-summary");
if (sumEl) {
sumEl.textContent = keys.length > 0 ? `${keys.length} field(s) set` : "";
}
}
});
}
const backBtn = document.getElementById("compose-back-btn");
if (backBtn && backBtn.dataset.bound !== "1") {
backBtn.dataset.bound = "1";
backBtn.addEventListener("click", async () => {
showSection(searchModeActive ? "search" : "content");
});
}
const pubBtn = document.getElementById("compose-publish");
if (pubBtn && pubBtn.dataset.bound !== "1") {
pubBtn.dataset.bound = "1";
pubBtn.addEventListener("click", async () => {
try {
if (!isAuthenticated || !userName || userName === "Name unavailable") {
alert("Authenticate and select a name first.");
return;
}
const svc = /** @type {HTMLInputElement} */ (
document.getElementById("compose-service")
).value
.trim()
.toUpperCase();
const ident = /** @type {HTMLInputElement} */ (
document.getElementById("compose-identifier")
).value.trim();
const text = /** @type {HTMLTextAreaElement} */ (document.getElementById("compose-text"))
.value;
if (!svc) {
alert("Enter a service (e.g. DOCUMENT, JSON)");
return;
}
const params = {
action: "PUBLISH_QDN_RESOURCE",
name: userName,
service: svc,
identifier: ident || "",
file: new Blob([text || ""], {
type: svc === "JSON" ? "application/json" : "text/plain",
}),
};
applyMetadataToParams(params, composeMetadata);
await publishWithFeedback(params);
// Refresh view
showSpinner();
try {
await loadAllResults();
buildSidebarTree();
applyServiceFilter();
await fetchPage();
} finally {
hideSpinner();
}
showSection("content");
} catch (e) {
alert("Publish failed: " + (e?.message || e));
}
});
}
}
window.addEventListener("beforeunload", () => {
const el = document.querySelector("#inline-viewer > *[data-bloburl]");
if (el && el.dataset.bloburl) {
try {
URL.revokeObjectURL(el.dataset.bloburl);
} catch {}
}
});
const searchBtn = document.getElementById("search-button");
if (searchBtn) {
searchBtn.addEventListener("click", async () => {
if (searchModeActive) {
// Toggle off: same behavior as sidebar "Back to My Files"
setSearchMode(false);
showSpinner();
try {
buildSidebarTree();
await fetchPage();
} finally {
hideSpinner();
}
} else {
setSearchMode(true);
const sp = document.getElementById("search-page");
const ph = sp ? sp.querySelector(".search-placeholder") : null;
if (ph) {
ph.textContent = "Search is coming soon...";
}
}
});
}
function updateSidebarBanner() {
const nm = document.getElementById("sidebar-name");
const ctxBtn = document.getElementById("sidebar-context");
const exitBtn = document.getElementById("sidebar-exit-search");
const banner = document.getElementById("sidebar-banner");
let sortBtn = document.getElementById("sidebar-sort-by-name");
if (searchModeActive) {
if (ctxBtn) {
ctxBtn.disabled = false;
ctxBtn.textContent = "Search Results";
}
if (exitBtn) {
exitBtn.style.display = "inline-block";
}
// Highlight "Search Results" when full, unfiltered results are shown on Search page
try {
if (ctxBtn) {
const searchVisible = Sections.search && Sections.search.style.display !== "none";
const filtersCleared =
(!searchSelectedServices || searchSelectedServices.size === 0) &&
!searchCurrentPrefixFilter &&
!searchCurrentIdentifierFilter &&
!searchCurrentNameFilter;
const active = !!(searchVisible && filtersCleared);
ctxBtn.setAttribute("data-active", String(active));
}
} catch {}
// Ensure sort-by-name toggle is present
if (banner) {
if (!sortBtn) {
sortBtn = document.createElement("button");
sortBtn.id = "sidebar-sort-by-name";
sortBtn.className = "icon-button";
sortBtn.style.marginLeft = "6px";
banner.appendChild(sortBtn);
sortBtn.addEventListener("click", () => {
searchGroupByName = !searchGroupByName;
try {
window.localStorage.setItem("qedit:searchGroupByName", String(!!searchGroupByName));
} catch {}
updateSidebarBanner();
buildSidebarTree();
});
}
sortBtn.style.display = "inline-block";
sortBtn.setAttribute("aria-pressed", String(!!searchGroupByName));
sortBtn.textContent = searchGroupByName ? "Group by Name: On" : "Group by Name: Off";
}
} else {
if (ctxBtn) {
ctxBtn.disabled = true;
ctxBtn.innerHTML =
'My Files - ";
}
if (exitBtn) {
exitBtn.style.display = "none";
}
if (sortBtn) {
sortBtn.style.display = "none";
}
}
if (nm) {
nm.textContent = userName || "(not authenticated)";
}
}
const previewBackBtn = document.getElementById("preview-back-btn");
if (previewBackBtn) {
previewBackBtn.addEventListener("click", () => {
// Return to Search if we came from Search mode; otherwise back to My Files
showSection(searchModeActive ? "search" : "content");
});
}
// Sidebar banner button wiring
const sidebarContextBtn = document.getElementById("sidebar-context");
if (sidebarContextBtn) {
sidebarContextBtn.addEventListener("click", () => {
if (searchModeActive) {
// Reset search-mode filters and show full results
clearSearchTreeFilters();
applySearchFilter();
renderSearchResults();
showSection("search");
}
});
}
const sidebarExitBtn = document.getElementById("sidebar-exit-search");
if (sidebarExitBtn) {
sidebarExitBtn.addEventListener("click", async () => {
setSearchMode(false);
showSpinner();
try {
// Rebuild My Files sidebar immediately
buildSidebarTree();
await fetchPage();
} finally {
hideSpinner();
}
});
}
// ===== Search UI (form + results) =====
function initSearchUI() {
const form = document.getElementById("search-form");
const resultsHost = document.getElementById("search-results");
const moreWrap = document.getElementById("search-more");
const moreBtn = document.getElementById("search-load-more");
const resetBtn = document.getElementById("search-reset");
const summary = document.getElementById("search-summary");
if (!form || !resultsHost || !moreBtn || !summary) {
return;
}
if (form.dataset.bound === "1") {
// Ensure summary reflects current state when toggling back
renderSearchResults();
return;
}
form.dataset.bound = "1";
// Restore last query
try {
const raw = localStorage.getItem(LS_LAST_SEARCH_KEY);
if (raw) {
const p = JSON.parse(raw);
applySearchParamsToForm(form, p);
searchState.params = p;
searchState.limit = Number(p.limit) || 100;
}
} catch {}
form.addEventListener("submit", async (e) => {
e.preventDefault();
await performSearch({ reset: true });
});
if (resetBtn) {
resetBtn.addEventListener("click", () => {
// Preserve current limit selection across reset
const limitEl = /** @type {HTMLSelectElement|null} */ (
document.getElementById("search-limit")
);
const currentLimitVal = limitEl ? limitEl.value : null;
form.reset();
if (limitEl && currentLimitVal !== null) {
limitEl.value = currentLimitVal;
}
searchState = {
params: null,
results: [],
offset: 0,
limit: Number(currentLimitVal) || searchState.limit || 100,
hasMore: false,
inFlight: false,
};
// Clear search tree filters
clearSearchTreeFilters();
resultsHost.innerHTML = "";
summary.textContent = "";
if (moreWrap) {
moreWrap.style.display = "none";
}
try {
localStorage.removeItem(LS_LAST_SEARCH_KEY);
} catch {}
try {
buildSidebarTree();
} catch {}
});
}
moreBtn.addEventListener("click", async () => {
await performSearch({ reset: false });
});
}
function readSearchParamsFromForm() {
const form = document.getElementById("search-form");
if (!form) {
return null;
}
const get = (id) => {
const el = document.getElementById(id);
return el ? el.value.trim() : "";
};
const getBool = (id) => {
const el = document.getElementById(id);
return !!(el && /** @type {HTMLInputElement} */ (el).checked);
};
const getNum = (id, d) => {
const el = document.getElementById(id);
const v = el ? parseInt(/** @type {HTMLSelectElement} */ (el).value, 10) : d;
return Number.isFinite(v) && v > 0 ? v : d;
};
// Normalize service to uppercase as QDN expects exact case (e.g., APP)
let service = get("search-service");
if (service) {
service = service.toUpperCase();
}
const p = {
query: get("search-query"),
name: get("search-name"),
identifier: get("search-identifier"),
service,
prefix: getBool("search-prefix"),
includeMetadata: getBool("search-include-metadata"),
exactMatchNames: getBool("search-exact-names"),
reverse: getBool("search-reverse"),
limit: getNum("search-limit", 100),
};
return p;
}
function applySearchParamsToForm(form, p) {
const setVal = (id, v) => {
const el = /** @type {HTMLInputElement|HTMLSelectElement|null} */ (document.getElementById(id));
if (el != null && typeof v !== "undefined") {
el.value = String(v);
}
};
const setChk = (id, v) => {
const el = /** @type {HTMLInputElement|null} */ (document.getElementById(id));
if (el != null) {
el.checked = !!v;
}
};
setVal("search-query", p.query || "");
setVal("search-name", p.name || "");
setVal("search-identifier", p.identifier || "");
setVal("search-service", p.service || "");
setChk("search-prefix", !!p.prefix);
setChk("search-include-metadata", !!p.includeMetadata);
setChk("search-exact-names", !!p.exactMatchNames);
setChk("search-reverse", p.reverse !== false);
setVal("search-limit", String(p.limit || 100));
}
function buildSearchUrl(params, offset) {
const u = new URL(location.origin + "/arbitrary/resources/search");
const add = (k, v) => {
if (v === undefined || v === null) {
return;
}
const s = String(v).trim();
if (s.length === 0) {
return;
}
u.searchParams.append(k, s);
};
add("query", params.query);
add("identifier", params.identifier);
add("name", params.name);
add("service", params.service);
if (params.prefix) {
add("prefix", true);
}
if (params.includeMetadata) {
add("includemetadata", true);
}
if (params.exactMatchNames) {
add("exactmatchnames", true);
}
add("reverse", params.reverse !== false);
add("limit", params.limit || 100);
add("offset", offset || 0);
// Return newest-first results by default
add("mode", "ALL");
return u.toString();
}
async function performSearch({ reset }) {
if (searchState.inFlight) {
return;
}
const form = document.getElementById("search-form");
const resultsHost = document.getElementById("search-results");
const summary = document.getElementById("search-summary");
const moreWrap = document.getElementById("search-more");
if (!form || !resultsHost || !summary) {
return;
}
const params = readSearchParamsFromForm();
if (!params || (!params.query && !params.identifier && !params.name && !params.service)) {
summary.textContent = "Enter at least one field to search.";
return;
}
searchState.params = params;
searchState.limit = Number(params.limit) || 100;
if (reset) {
searchState.results = [];
searchState.offset = 0;
searchState.hasMore = false;
resultsHost.innerHTML = "";
// Clear any prior tree filters when starting a new search
clearSearchTreeFilters();
}
const url = buildSearchUrl(params, searchState.offset);
try {
searchState.inFlight = true;
summary.textContent = "Searching...";
const resp = await fetch(url);
if (!resp.ok) {
throw new Error("Search failed");
}
const items = await resp.json();
// Normalize/sort by updated if reverse not handled
if (!params.reverse) {
items.sort((a, b) => (a.updated || a.created || 0) - (b.updated || b.created || 0));
}
// Mark deletion flags to respect Hide Deleted in the tree
try {
await markDeletedEntries(items);
} catch (e) {
console.warn("Mark deleted for search items failed:", e);
}
searchState.results.push(...items);
searchState.offset += items.length;
searchState.hasMore = items.length >= searchState.limit;
try {
localStorage.setItem(LS_LAST_SEARCH_KEY, JSON.stringify(params));
} catch {}
applySearchFilter();
renderSearchResults();
// Update sidebar to reflect new results
try {
buildSidebarTree();
} catch {}
if (moreWrap) {
moreWrap.style.display = searchState.hasMore ? "block" : "none";
}
} catch (e) {
summary.textContent = "Search error: " + (e?.message || e);
} finally {
searchState.inFlight = false;
}
}
function renderSearchResults() {
const resultsHost = document.getElementById("search-results");
const summary = document.getElementById("search-summary");
const moreWrap = document.getElementById("search-more");
if (!resultsHost || !summary) {
return;
}
let items =
searchFilteredResults && Array.isArray(searchFilteredResults)
? searchFilteredResults
: searchState.results || [];
// Sort for display according to search sort state
const itemsToRender = items.slice().sort(compareResultsSearch);
const total = items.length;
if (total === 0) {
resultsHost.innerHTML = "";
summary.textContent = searchState.inFlight ? "Searching..." : "No results yet.";
if (moreWrap) {
moreWrap.style.display = "none";
}
return;
}
summary.textContent = `${total} result${total === 1 ? "" : "s"}${searchState.hasMore ? " (more available)" : ""}`;
// Promote any playing media from the current results host before re-rendering
const existingAv = resultsHost.querySelector("video, audio");
if (existingAv) {
const shouldPromote =
existingAv.paused === false ||
((existingAv.currentTime || 0) > 0 && existingAv.ended === false);
if (shouldPromote) {
promoteMedia(existingAv, {
service: existingAv.getAttribute("data-service") || "",
identifier: existingAv.getAttribute("data-identifier") || "default",
name: existingAv.getAttribute("data-name") || userName,
});
}
}
// Build table with inline previews
let html =
'";
resultsHost.innerHTML = html;
// Initialize inline preview holders within search results only
initPreviews(resultsHost);
try {
updateSidebarBanner();
} catch {}
// Upgrade avatars in the Name column
resultsHost.querySelectorAll(".name-with-avatar").forEach((wrap) => {
const nm = wrap.getAttribute("data-name") || "";
// Replace the placeholder span with a real avatar image if available
const place = wrap.querySelector(".avatar-img");
if (place) {
getAvatarForName(nm)
.then((res) => {
if (res && res.url) {
const img = document.createElement("img");
img.className = "avatar-img";
img.src = res.url;
img.alt = nm;
place.replaceWith(img);
} else {
// Keep initial already rendered
}
})
.catch(() => {});
}
});
// Wire up identifier click to open preview
resultsHost.querySelectorAll(".identifier-text").forEach((el) => {
el.addEventListener("click", () => {
const nm = el.getAttribute("data-name") || "";
const svc = el.getAttribute("data-service") || "";
const ident = el.getAttribute("data-identifier") || "default";
openPreviewPage({ name: nm, service: svc, identifier: ident });
});
});
// Wire up embed copy buttons
resultsHost.querySelectorAll(".copy-embed-icon").forEach((el) => {
el.addEventListener("click", async function () {
const nm = this.getAttribute("data-name") || "";
const svc = this.getAttribute("data-service") || "";
const ident = this.getAttribute("data-identifier") || "default";
const url = `qortal://use-embed/IMAGE?name=${encodeURIComponent(nm)}&identifier=${encodeURIComponent(ident)}&service=${encodeURIComponent(svc)}`;
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(url);
} else {
const ta = document.createElement("textarea");
ta.value = url;
document.body.appendChild(ta);
ta.select();
document.execCommand("copy");
document.body.removeChild(ta);
}
const prev = this.parentElement.querySelector(".copy-embed-toast");
if (prev) {
prev.remove();
}
const msg = document.createElement("span");
msg.className = "copy-embed-toast";
msg.textContent = "Embed link copied!";
msg.style.marginLeft = "6px";
msg.style.fontSize = "12px";
msg.style.color = "#1f6feb";
this.parentElement.appendChild(msg);
setTimeout(() => {
if (msg && msg.parentElement) {
msg.parentElement.removeChild(msg);
}
}, 1800);
} catch (e) {
alert("Could not copy link: " + (e?.message || e));
}
});
});
// Wire up metadata dialog
resultsHost.querySelectorAll(".clickable-metadata").forEach((el) => {
el.addEventListener("click", function () {
const idx = parseInt(this.getAttribute("data-metadata-index"), 10);
if (!isNaN(idx) && idx >= 0 && idx < metadataArray.length) {
openMetadataDialog(metadataArray[idx]);
}
});
});
}
function escapeHtml(s) {
return String(s || "").replace(
/[&<>\"]/g,
(ch) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[ch]
);
}
function escapeAttr(s) {
return escapeHtml(s).replace(/"/g, """);
}
// ===== Global media controller =====
const mediaControls = document.getElementById("media-controls");
const mediaPlayPause = document.getElementById("media-play-pause");
const mediaStop = document.getElementById("media-stop");
const mediaTitle = document.getElementById("media-title");
const globalMediaHost = document.createElement("div");
globalMediaHost.id = "global-media-host";
globalMediaHost.style.display = "none";
document.body.appendChild(globalMediaHost);
let globalMedia = { el: null, ctx: null, blobUrl: null };
function setMediaTitle(ctx) {
if (!mediaTitle) {
return;
}
if (!ctx) {
mediaTitle.textContent = "";
mediaTitle.title = "";
return;
}
const t = `${ctx.service || ""} - ${ctx.identifier || "default"} - ${ctx.name || ""}`.trim();
mediaTitle.textContent = t;
mediaTitle.title = t;
}
function showMediaControls(show) {
if (!mediaControls) {
return;
}
mediaControls.style.display = show ? "inline-flex" : "none";
if (!show) {
setMediaTitle(null);
}
}
function promoteMedia(el, ctx) {
if (!el) {
return;
}
try {
globalMediaHost.appendChild(el);
} catch {}
globalMedia.el = el;
globalMedia.ctx = ctx || null;
globalMedia.blobUrl = el.dataset && el.dataset.bloburl ? el.dataset.bloburl : null;
setMediaTitle(ctx);
showMediaControls(true);
}
function releaseGlobalMedia() {
if (globalMedia.el && globalMedia.el.parentElement === globalMediaHost) {
try {
globalMediaHost.removeChild(globalMedia.el);
} catch {}
}
globalMedia.el = null;
globalMedia.ctx = null;
globalMedia.blobUrl = null;
showMediaControls(false);
}
if (mediaPlayPause) {
mediaPlayPause.addEventListener("click", () => {
const el = globalMedia.el;
if (!el) {
return;
}
if (el.paused) {
el.play().catch(() => {});
} else {
el.pause();
}
});
}
if (mediaStop) {
mediaStop.addEventListener("click", () => {
const el = globalMedia.el;
if (!el) {
return;
}
try {
el.pause();
el.currentTime = 0;
} catch {}
if (globalMedia.blobUrl) {
try {
URL.revokeObjectURL(globalMedia.blobUrl);
} catch {}
}
releaseGlobalMedia();
});
}
let currentPreviewCtx = null;
function isTextDisplayedInPreview() {
const container = document.getElementById("preview-container");
if (!container) {
return false;
}
if (container.dataset && container.dataset.textual != null) {
return container.dataset.textual === "1";
}
const el = container.firstElementChild;
if (!el) {
return false;
}
if (el.classList && el.classList.contains("inline-text-editor")) {
return true;
}
if (el.tagName === "IFRAME" && el.getAttribute("srcdoc") != null) {
return true;
}
if (el.tagName === "PRE") {
const ws =
el.style.whiteSpace || (window.getComputedStyle ? getComputedStyle(el).whiteSpace : "");
return String(ws).toLowerCase().includes("pre-wrap");
}
const pre = container.querySelector(":scope > div pre");
if (pre) {
const ws =
pre.style.whiteSpace || (window.getComputedStyle ? getComputedStyle(pre).whiteSpace : "");
return String(ws).toLowerCase().includes("pre-wrap");
}
return false;
}
function updatePreviewActionsState() {
const editBtn = document.getElementById("preview-edit");
const replaceBtn = document.getElementById("preview-replace");
const deleteBtn = document.getElementById("preview-delete");
if (!editBtn || !replaceBtn || !deleteBtn) {
return;
}
const textual = isTextDisplayedInPreview();
// Update button tooltips to reflect behavior; labels remain stable
editBtn.title = textual ? "Edit inline" : "Edit (choose file)";
replaceBtn.title = "Replace (choose file)";
// Ownership: show actions only if previewing an item published by the current selected name
const isOwner = !!(
currentPreviewCtx &&
currentPreviewCtx.name &&
userName &&
currentPreviewCtx.name === userName
);
[editBtn, replaceBtn, deleteBtn].forEach((btn) => {
btn.style.display = isOwner ? "inline-block" : "none";
btn.disabled = !isOwner;
});
}