4543 lines
146 KiB
JavaScript
4543 lines
146 KiB
JavaScript
// ===== Globals =====
|
|
let bulkDeleteMode = false;
|
|
|
|
// ===== Bulk Delete State =====
|
|
setBulkMode(false);
|
|
const selectedForDeletion = new Set(); // keys as `${service}||${identifier||'default'}`
|
|
function selKey(service, identifier) {
|
|
return `${service}||${identifier || "default"}`;
|
|
}
|
|
function getSelectedCount() {
|
|
return selectedForDeletion.size;
|
|
}
|
|
function clearSelected() {
|
|
selectedForDeletion.clear();
|
|
updateBulkControlsUI();
|
|
}
|
|
let _userPublicKey = ""; // reserved for future publish/auth flows
|
|
let userAddress = "";
|
|
let userName = "";
|
|
let isAuthenticated = false;
|
|
let allNames = [];
|
|
let authStatus = "idle";
|
|
let _namesStatus = "idle"; // future UI state for name loading
|
|
let _allResults = []; // planned for search results caching
|
|
let metadataArray = [];
|
|
|
|
// ===== Search State =====
|
|
const LS_LAST_SEARCH_KEY = "qedit:lastSearch";
|
|
let searchState = {
|
|
params: null,
|
|
results: [],
|
|
offset: 0,
|
|
limit: 100,
|
|
hasMore: false,
|
|
inFlight: false,
|
|
};
|
|
|
|
// Pagination
|
|
let currentPage = 1;
|
|
let itemsPerPage = 25;
|
|
let totalResults = 0;
|
|
|
|
// Render helper bridge (safe fallback if render helper isn't loaded yet)
|
|
function renderIntoCompat(el, htmlString, mode) {
|
|
if (!el) {
|
|
return;
|
|
}
|
|
try {
|
|
if (window.QEditRender && typeof window.QEditRender.renderInto === "function") {
|
|
window.QEditRender.renderInto(el, htmlString, mode || "replace");
|
|
return;
|
|
}
|
|
} catch {}
|
|
if (mode === "append") {
|
|
el.insertAdjacentHTML("beforeend", htmlString);
|
|
} else if (mode === "prepend") {
|
|
el.insertAdjacentHTML("afterbegin", htmlString);
|
|
} else {
|
|
el.innerHTML = htmlString;
|
|
}
|
|
}
|
|
|
|
// Pagination preferences (storage + hash)
|
|
const LS_ITEMS_PER_PAGE_KEY = "qedit_itemsPerPage";
|
|
|
|
function getPageFromHash() {
|
|
const h = (location.hash || "").toLowerCase();
|
|
const m = h.match(/page=(\d+)/);
|
|
if (m) {
|
|
const p = parseInt(m[1], 10);
|
|
if (!isNaN(p) && p >= 1) {
|
|
return p;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function setPageHash(p) {
|
|
try {
|
|
const newHash = `#page=${p}`;
|
|
if (location.hash !== newHash) {
|
|
location.hash = newHash;
|
|
}
|
|
} catch {}
|
|
}
|
|
|
|
function initPaginationPrefs() {
|
|
// itemsPerPage from localStorage
|
|
try {
|
|
const stored = localStorage.getItem(LS_ITEMS_PER_PAGE_KEY);
|
|
const sel = document.getElementById("items-per-page-dropdown");
|
|
if (stored) {
|
|
const v = parseInt(stored, 10);
|
|
if (!isNaN(v) && v > 0) {
|
|
itemsPerPage = v;
|
|
if (sel) {
|
|
sel.value = String(v);
|
|
}
|
|
}
|
|
} else {
|
|
if (sel) {
|
|
sel.value = String(itemsPerPage);
|
|
}
|
|
}
|
|
} catch {}
|
|
|
|
// page from hash
|
|
const hp = getPageFromHash();
|
|
if (hp) {
|
|
currentPage = hp;
|
|
}
|
|
|
|
// react to browser hash navigation
|
|
window.addEventListener("hashchange", async () => {
|
|
const hp2 = getPageFromHash();
|
|
if (hp2 && hp2 !== currentPage) {
|
|
currentPage = hp2;
|
|
await fetchPage();
|
|
}
|
|
});
|
|
}
|
|
|
|
let totalSize = 0;
|
|
let _currentServiceFilter = "ALL"; // reserved: legacy filter placeholder
|
|
|
|
const infoDetails = `<img src="red-x.svg" style="width:15px;height:15px;"> Click the identifier to "delete" content.<br>(This will replace it with a blank file.)<br><br>
|
|
<img src="file-up.png" style="width:15px;height:15px;"> Click the file select icon to "edit" content.<br>(This will replace it with a selected file.)`;
|
|
|
|
// Data sets for chips-based filtering
|
|
let masterResults = []; // full unfiltered list for current name
|
|
let filteredResults = []; // results after chips selection
|
|
let selectedServices = new Set(); // inclusion set; empty => show all
|
|
let serviceCounts = {}; // { service: count }
|
|
|
|
// === Tree-driven filters ===
|
|
let currentPrefixFilter = null; // e.g., "qtube"
|
|
let currentIdentifierFilter = null; // exact identifier; if set, we will show inline preview
|
|
|
|
function clearTreeFilters() {
|
|
currentPrefixFilter = null;
|
|
currentIdentifierFilter = null;
|
|
}
|
|
|
|
// === Deleted-content filter (default ON) ===
|
|
const TINY_SIZE_THRESHOLD = 128; // bytes; QDN may report ~32B for 1B newline
|
|
let hideDeleted = localStorage.getItem("hideDeleted") !== "false"; // default true
|
|
function getBaselineResults() {
|
|
// When hiding deletions, exclude items flagged as deleted
|
|
if (!hideDeleted) {
|
|
return masterResults;
|
|
}
|
|
return masterResults.filter((r) => !r.__isDeleted);
|
|
}
|
|
function recomputeServiceCounts() {
|
|
serviceCounts = {};
|
|
const base = getBaselineResults();
|
|
for (const r of base) {
|
|
const svc = r.service || "UNKNOWN";
|
|
serviceCounts[svc] = (serviceCounts[svc] || 0) + 1;
|
|
}
|
|
}
|
|
// ===== DOM & UI helpers =====
|
|
const contentPage = document.getElementById("content-page");
|
|
const authButton = document.getElementById("auth-button");
|
|
const nameSwitcherEl = document.getElementById("name-switcher");
|
|
const nameSelectEl = document.getElementById("name-select");
|
|
const loadingOverlay = document.getElementById("loading-overlay");
|
|
|
|
function showSpinner() {
|
|
if (loadingOverlay) {
|
|
loadingOverlay.style.display = "flex";
|
|
}
|
|
}
|
|
function hideSpinner() {
|
|
if (loadingOverlay) {
|
|
loadingOverlay.style.display = "none";
|
|
}
|
|
}
|
|
|
|
// ===== Sidebar toggle fallback (in case module init fails) =====
|
|
(function initSidebarToggleFallback() {
|
|
// Expose a very simple global fallback toggler for inline onclick usage
|
|
// This ensures the sidebar can always be toggled even if event wiring changes.
|
|
window.QEditToggleSidebar = function (action) {
|
|
try {
|
|
const shell = document.getElementById("app-shell");
|
|
const sidebar = document.getElementById("sidebar");
|
|
const collapseBtn = document.getElementById("sidebar-collapse");
|
|
const revealBtn = document.getElementById("sidebar-reveal");
|
|
if (!shell || !sidebar) {
|
|
return false;
|
|
}
|
|
const currentlyHidden =
|
|
sidebar.style.display === "none" ||
|
|
sidebar.getAttribute("aria-hidden") === "true" ||
|
|
shell.classList.contains("is-collapsed");
|
|
let collapse;
|
|
if (action === "show") {
|
|
collapse = false;
|
|
} else if (action === "hide") {
|
|
collapse = true;
|
|
} else {
|
|
collapse = !currentlyHidden;
|
|
}
|
|
function apply(collapsed) {
|
|
if (collapsed) {
|
|
shell.classList.add("is-collapsed");
|
|
sidebar.style.display = "none";
|
|
sidebar.setAttribute("aria-hidden", "true");
|
|
if (collapseBtn) {
|
|
collapseBtn.setAttribute("aria-pressed", "true");
|
|
collapseBtn.setAttribute("aria-expanded", "false");
|
|
}
|
|
if (revealBtn) {
|
|
revealBtn.style.display = "inline-block";
|
|
}
|
|
} else {
|
|
shell.classList.remove("is-collapsed");
|
|
sidebar.style.display = "";
|
|
sidebar.removeAttribute("aria-hidden");
|
|
if (collapseBtn) {
|
|
collapseBtn.setAttribute("aria-pressed", "false");
|
|
collapseBtn.setAttribute("aria-expanded", "true");
|
|
}
|
|
if (revealBtn) {
|
|
revealBtn.style.display = "none";
|
|
}
|
|
}
|
|
try {
|
|
window.localStorage.setItem("qedit:sidebarCollapsed", String(!!collapsed));
|
|
} catch {}
|
|
}
|
|
apply(collapse);
|
|
} catch {}
|
|
return false;
|
|
};
|
|
function getStoredCollapsed() {
|
|
try {
|
|
const v = window.localStorage.getItem("qedit:sidebarCollapsed");
|
|
return v === "true";
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
function setStoredCollapsed(val) {
|
|
try {
|
|
window.localStorage.setItem("qedit:sidebarCollapsed", String(!!val));
|
|
} catch {}
|
|
}
|
|
function applyCollapsed(collapsed) {
|
|
const shell = document.getElementById("app-shell");
|
|
const sidebar = document.getElementById("sidebar");
|
|
const collapseBtn = document.getElementById("sidebar-collapse");
|
|
const revealBtn = document.getElementById("sidebar-reveal");
|
|
if (!shell || !sidebar) {
|
|
return;
|
|
}
|
|
if (collapsed) {
|
|
// Simple, robust: hide the sidebar directly
|
|
shell.classList.add("is-collapsed");
|
|
sidebar.style.display = "none";
|
|
sidebar.setAttribute("aria-hidden", "true");
|
|
if (collapseBtn) {
|
|
collapseBtn.setAttribute("aria-pressed", "true");
|
|
collapseBtn.setAttribute("aria-expanded", "false");
|
|
}
|
|
if (revealBtn) {
|
|
revealBtn.style.display = "inline-block";
|
|
}
|
|
} else {
|
|
shell.classList.remove("is-collapsed");
|
|
sidebar.style.display = "";
|
|
sidebar.removeAttribute("aria-hidden");
|
|
if (collapseBtn) {
|
|
collapseBtn.setAttribute("aria-pressed", "false");
|
|
collapseBtn.setAttribute("aria-expanded", "true");
|
|
}
|
|
if (revealBtn) {
|
|
revealBtn.style.display = "none";
|
|
}
|
|
}
|
|
}
|
|
function bind() {
|
|
const shell = document.getElementById("app-shell");
|
|
if (!shell || shell.dataset.sidebarInitialized === "1") {
|
|
return; // module already bound
|
|
}
|
|
shell.dataset.sidebarInitialized = "1";
|
|
applyCollapsed(getStoredCollapsed());
|
|
const collapseBtn = document.getElementById("sidebar-collapse");
|
|
const revealBtn = document.getElementById("sidebar-reveal");
|
|
if (collapseBtn) {
|
|
collapseBtn.addEventListener("click", () => {
|
|
const nowCollapsed = !shell.classList.contains("is-collapsed");
|
|
applyCollapsed(nowCollapsed);
|
|
setStoredCollapsed(nowCollapsed);
|
|
});
|
|
}
|
|
if (revealBtn) {
|
|
revealBtn.addEventListener("click", () => {
|
|
applyCollapsed(false);
|
|
setStoredCollapsed(false);
|
|
});
|
|
}
|
|
document.addEventListener("click", (e) => {
|
|
// Robustly handle clicks that originate on Text/SVG nodes by finding a nearby Element
|
|
const targetEl =
|
|
e.target && e.target instanceof Element
|
|
? e.target
|
|
: e.target && /** @type {any} */ (e.target).parentElement
|
|
? /** @type {HTMLElement} */ (/** @type {any} */ (e.target).parentElement)
|
|
: null;
|
|
const el = targetEl ? targetEl.closest("#sidebar-collapse, #sidebar-reveal") : null;
|
|
if (!el) {
|
|
return;
|
|
}
|
|
e.preventDefault();
|
|
if (el.id === "sidebar-collapse") {
|
|
const nowCollapsed = !shell.classList.contains("is-collapsed");
|
|
applyCollapsed(nowCollapsed);
|
|
setStoredCollapsed(nowCollapsed);
|
|
} else if (el.id === "sidebar-reveal") {
|
|
applyCollapsed(false);
|
|
setStoredCollapsed(false);
|
|
}
|
|
});
|
|
}
|
|
if (document.readyState === "loading") {
|
|
document.addEventListener("DOMContentLoaded", bind, { once: true });
|
|
} else {
|
|
bind();
|
|
}
|
|
})();
|
|
|
|
// ===== View Controller: show only one main section at a time =====
|
|
const Sections = {
|
|
search: document.getElementById("search-page"),
|
|
info: document.getElementById("info-page"),
|
|
content: document.getElementById("content-page"),
|
|
preview: document.getElementById("preview-page"),
|
|
compose: document.getElementById("compose-page"),
|
|
};
|
|
function showSection(which) {
|
|
// Promote any currently playing media before switching away
|
|
try {
|
|
const prev = Object.entries(Sections).find(([_k, v]) => v && v.style.display !== "none");
|
|
const prevKey = prev ? prev[0] : null;
|
|
if (prevKey && prevKey !== which) {
|
|
const from = Sections[prevKey];
|
|
if (from) {
|
|
const av = from.querySelector("video, audio");
|
|
const shouldPromote =
|
|
av && (av.paused === false || ((av.currentTime || 0) > 0 && av.ended === false));
|
|
if (shouldPromote) {
|
|
promoteMedia(av, {
|
|
service: av.getAttribute("data-service") || "",
|
|
identifier: av.getAttribute("data-identifier") || "default",
|
|
name: av.getAttribute("data-name") || userName,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
} catch {}
|
|
|
|
for (const [key, el] of Object.entries(Sections)) {
|
|
if (!el) {
|
|
continue;
|
|
}
|
|
el.style.display = key === which ? "block" : "none";
|
|
}
|
|
}
|
|
|
|
// ===== Search mode state =====
|
|
let searchModeActive = false;
|
|
function updateSearchButtonUI() {
|
|
const btn = document.getElementById("search-button");
|
|
if (!btn) {
|
|
return;
|
|
}
|
|
btn.setAttribute("aria-pressed", String(!!searchModeActive));
|
|
const isOn = !!searchModeActive;
|
|
btn.setAttribute("title", isOn ? "Search (open)" : "Search");
|
|
btn.setAttribute("aria-label", isOn ? "Search is open" : "Search");
|
|
}
|
|
function setSearchMode(on) {
|
|
searchModeActive = !!on;
|
|
updateSidebarBanner();
|
|
updateSearchButtonUI();
|
|
showSection(on ? "search" : "content");
|
|
if (searchModeActive) {
|
|
try {
|
|
initSearchUI();
|
|
} catch {}
|
|
}
|
|
}
|
|
|
|
// ===== Sidebar tree =====
|
|
function buildSidebarTree() {
|
|
const tree = document.getElementById("file-tree");
|
|
if (!tree) {
|
|
return;
|
|
}
|
|
tree.innerHTML = "";
|
|
const base = getBaselineResults();
|
|
|
|
// 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.textContent = "▶";
|
|
toggle.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
const open = children.classList.toggle("expanded");
|
|
line.setAttribute("aria-expanded", String(open));
|
|
toggle.textContent = open ? "▼" : "▶";
|
|
});
|
|
|
|
const label = document.createElement("span");
|
|
label.className = "tree-label";
|
|
label.textContent = svc;
|
|
label.addEventListener("click", async () => {
|
|
// 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 = {};
|
|
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);
|
|
}
|
|
// No second separator: treat as single-item group (will render as leaf)
|
|
return id;
|
|
}
|
|
return pfx;
|
|
}
|
|
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) => {
|
|
const leaf = document.createElement("div");
|
|
leaf.className = "tree-node";
|
|
const lline = document.createElement("div");
|
|
lline.className = "tree-line";
|
|
lline.setAttribute("role", "treeitem");
|
|
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(document.createElement("span"));
|
|
lline.appendChild(llabel);
|
|
children.appendChild(lline);
|
|
});
|
|
} 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) => {
|
|
const leaf = document.createElement("div");
|
|
leaf.className = "tree-node";
|
|
const lline = document.createElement("div");
|
|
lline.className = "tree-line";
|
|
lline.setAttribute("role", "treeitem");
|
|
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(document.createElement("span"));
|
|
lline.appendChild(llabel);
|
|
children.appendChild(lline);
|
|
});
|
|
|
|
// 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.textContent = "▶";
|
|
ptoggle.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
const open = pchildren.classList.toggle("expanded");
|
|
pline.setAttribute("aria-expanded", String(open));
|
|
ptoggle.textContent = open ? "▼" : "▶";
|
|
});
|
|
|
|
const plabel = document.createElement("span");
|
|
plabel.className = "tree-label";
|
|
plabel.textContent = pfx;
|
|
plabel.addEventListener("click", async () => {
|
|
// 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) => {
|
|
const leaf = document.createElement("div");
|
|
leaf.className = "tree-node";
|
|
const lline = document.createElement("div");
|
|
lline.className = "tree-line";
|
|
lline.setAttribute("role", "treeitem");
|
|
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(document.createElement("span"));
|
|
lline.appendChild(llabel);
|
|
pchildren.appendChild(lline);
|
|
});
|
|
|
|
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 {
|
|
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";
|
|
titleEl.textContent = `${ctx.service} — ${ident} — ${ctx.name || ""}`;
|
|
}
|
|
|
|
// 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}<br>${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}<br>${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 = "<p>Loading...</p>";
|
|
const start = (currentPage - 1) * itemsPerPage;
|
|
const pageItems = filteredResults.slice(start, start + itemsPerPage);
|
|
buildContentTable(pageItems);
|
|
} catch (error) {
|
|
console.error("Error fetching page:", error);
|
|
document.getElementById("content-details").innerHTML = `<p>Error: ${error.message}</p>`;
|
|
}
|
|
}
|
|
|
|
// ===== Table & pagination =====
|
|
function buildIdentifierCellHTML(result, identifier) {
|
|
const svc = result.service;
|
|
const key = selKey(svc, identifier);
|
|
const checkbox = `<input type="checkbox" class="bulk-select" data-service="${svc}" data-identifier="${identifier}" ${selectedForDeletion.has(key) ? "checked" : ""} />`;
|
|
const deleteIcon = `<img src="red-x.svg" class="clickable-delete action-icon" title="Delete" alt="Delete" data-service="${svc}" data-identifier="${identifier}"/>`;
|
|
const editIcon = `<img src="file-up.png" class="clickable-edit action-icon" title="Edit" alt="Edit" data-service="${svc}" data-identifier="${identifier}"/>`;
|
|
const embedIcon = isEmbedService(svc)
|
|
? `<svg class="action-icon copy-embed-icon" data-name="${userName}" data-service="${svc}" data-identifier="${identifier}" width="15" height="15" viewBox="0 0 24 24" style="margin-left:6px;">
|
|
<circle cx="12" cy="12" r="11" fill="#ffffff"></circle>
|
|
<path fill="#0f1a2e" d="M10.59 13.41a1 1 0 0 0 1.41 1.41l4.95-4.95a3 3 0 1 0-4.24-4.24l-2.12 2.12a1 1 0 1 0 1.41 1.41l2.12-2.12a1 1 0 1 1 1.41 1.41l-4.95 4.95zM13.41 10.59a1 1 0 0 0-1.41-1.41L7.05 14.13a3 3 0 1 0 4.24 4.24l2.12-2.12a1 1 0 1 0-1.41-1.41l-2.12 2.12a1 1 0 1 1-1.41-1.41l4.95-4.95z"/>
|
|
</svg>`
|
|
: "";
|
|
return `${checkbox}${deleteIcon}${editIcon}<span class="identifier-text" data-service="${svc}" data-identifier="${identifier}" data-name="${userName}" title="Open preview">${identifier}</span>${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 = "<p>No results found.</p>";
|
|
contentSummaryDiv.innerHTML = "";
|
|
document.getElementById("pagination-top").innerHTML = "";
|
|
document.getElementById("pagination-bottom").innerHTML = "";
|
|
return;
|
|
}
|
|
results.sort((a, b) => (b.updated || b.created) - (a.updated || a.created));
|
|
let tableHtml = "<table>";
|
|
tableHtml += `<tr>
|
|
<th>Service</th>
|
|
<th>Identifier ${bulkDeleteMode ? '<input type="checkbox" id="select-all-checkbox" title="Select all on this page"><label class="select-all-label" for="select-all-checkbox">Select All</label>' : ""}</th>
|
|
<th>Metadata</th>
|
|
<th>Preview</th>
|
|
<th>Size</th>
|
|
<th>Created / Updated</th>
|
|
</tr>`;
|
|
metadataArray = [];
|
|
for (const result of results) {
|
|
const identifier = result.identifier === undefined ? "default" : result.identifier;
|
|
let createdString = new Date(result.created).toLocaleString();
|
|
if (isNaN(new Date(result.created))) {
|
|
createdString = "Unknown";
|
|
}
|
|
let updatedString = new Date(result.updated).toLocaleString();
|
|
if (isNaN(new Date(result.updated))) {
|
|
updatedString = "Never";
|
|
}
|
|
const sizeString = formatSize(result.size || 0);
|
|
let metadataKeys = "";
|
|
let metadataIndex = -1;
|
|
if (result.metadata) {
|
|
metadataIndex = metadataArray.length;
|
|
metadataArray.push(result.metadata);
|
|
metadataKeys = Object.keys(result.metadata).join(", ");
|
|
}
|
|
|
|
tableHtml += `<tr>
|
|
<td>${result.service}</td>
|
|
<td>${buildIdentifierCellHTML(result, identifier)}</td>
|
|
<td><span class="clickable-metadata" data-metadata-index='${metadataIndex}'>${metadataKeys}</span></td>
|
|
<td>${generatePreviewHTML(result, userName, identifier)}</td>
|
|
<td>${sizeString}</td>
|
|
<td>${createdString}<br>${updatedString}</td>
|
|
</tr>`;
|
|
}
|
|
tableHtml += `</table>`;
|
|
const startItem = (currentPage - 1) * itemsPerPage + 1;
|
|
const endItem = Math.min(startItem + itemsPerPage - 1, totalResults);
|
|
const selectedCount = getSelectedCount();
|
|
contentSummaryDiv.innerHTML = `<div class="summary-bar"><span>${startItem}-${endItem} of ${totalResults} results</span><span style=\"margin-left:12px;\">Total Size: ${formatSize(totalSize)}</span><span style=\"margin-left:18px;\"><button id=\"bulk-delete-toggle\">${!bulkDeleteMode ? "Delete Files" : selectedCount > 0 ? `Delete ${selectedCount} Files` : "Deleting Files"}</button>${selectedCount > 0 ? '<button id=\"clear-selected-btn\" style=\"margin-left:8px;\">Clear Selected</button>' : ""}</span></div>`;
|
|
|
|
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 && !playing.paused) {
|
|
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 = '<nav class="pagination-controls" aria-label="Pagination">';
|
|
// First/Prev
|
|
if (currentPage > 1) {
|
|
html += `<span class="pagination-link" data-page="1" aria-label="First">««</span>`;
|
|
html += `<span class="pagination-link" data-page="${currentPage - 1}" aria-label="Previous">«</span>`;
|
|
} else {
|
|
html += `<span class="disabled" aria-disabled="true">««</span><span class="disabled" aria-disabled="true">«</span>`;
|
|
}
|
|
|
|
// Windowed pages
|
|
const windowSize = 2;
|
|
const pages = new Set([1, 2, totalPages - 1, totalPages]);
|
|
for (let p = currentPage - windowSize; p <= currentPage + windowSize; p++) {
|
|
if (p >= 1 && p <= totalPages) {
|
|
pages.add(p);
|
|
}
|
|
}
|
|
const sorted = Array.from(pages).sort((a, b) => a - b);
|
|
let last = 0;
|
|
for (const p of sorted) {
|
|
if (p - last > 1) {
|
|
html += `<span class="ellipsis" aria-hidden="true">…</span>`;
|
|
}
|
|
if (p === currentPage) {
|
|
html += `<span class="current-page" aria-current="page">${p}</span>`;
|
|
} else {
|
|
html += `<span class="pagination-link" data-page="${p}">${p}</span>`;
|
|
}
|
|
last = p;
|
|
}
|
|
|
|
// Next/Last
|
|
if (currentPage < totalPages) {
|
|
html += `<span class="pagination-link" data-page="${currentPage + 1}" aria-label="Next">»</span>`;
|
|
html += `<span class="pagination-link" data-page="${totalPages}" aria-label="Last">»»</span>`;
|
|
} else {
|
|
html += `<span class="disabled" aria-disabled="true">»</span><span class="disabled" aria-disabled="true">»»</span>`;
|
|
}
|
|
|
|
// Jump control
|
|
html += `<span class="jump-to-page">
|
|
<label style="margin-right:4px;">Jump:</label>
|
|
<input type="number" class="jump-input" min="1" max="${totalPages}" value="${currentPage}" aria-label="Jump to page">
|
|
<button type="button" class="jump-btn">Go</button>
|
|
</span>`;
|
|
|
|
html += "</nav>";
|
|
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";
|
|
}
|
|
|
|
// ===== 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 `<div class="preview-holder" data-name="${safeName}" data-service="${safeService}" data-identifier="${safeIdent}">0% Loaded</div>`;
|
|
}
|
|
|
|
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)
|
|
);
|
|
}
|
|
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";
|
|
}
|
|
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 = "240px";
|
|
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 = "240px";
|
|
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 = "240px";
|
|
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 = "240px";
|
|
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 = "240px";
|
|
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 = "240px";
|
|
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 = "240px";
|
|
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 = "240px";
|
|
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 = "240px";
|
|
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 = "240px";
|
|
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 = "240px";
|
|
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 = "240px";
|
|
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 = "240px";
|
|
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 = "240px";
|
|
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 = "240px";
|
|
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 = "240px";
|
|
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 = "240px";
|
|
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 = "240px";
|
|
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 = "240px";
|
|
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 {
|
|
// Show modal with "Attempting to publish, please wait..."
|
|
showPublishModal("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 = `
|
|
<label style="display:block;margin-top:8px;">Service:
|
|
<input name="service" type="text" style="width:100%;padding:6px;margin-top:4px;" placeholder="e.g. IMAGE, DOCUMENT" />
|
|
</label>
|
|
<label style="display:block;margin-top:8px;">Identifier (optional):
|
|
<input name="identifier" type="text" style="width:100%;padding:6px;margin-top:4px;" placeholder="identifier or blank for default" />
|
|
</label>
|
|
<div style="display:flex;justify-content:flex-end;gap:8px;margin-top:12px;">
|
|
<button type="button" data-act="cancel">Cancel</button>
|
|
<button type="submit">Next</button>
|
|
</div>
|
|
`;
|
|
const svcInput = form.querySelector('input[name="service"]');
|
|
const idInput = form.querySelector('input[name="identifier"]');
|
|
if (svcInput) {
|
|
svcInput.value = defaults?.service || "";
|
|
}
|
|
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;
|
|
});
|
|
const response = await qortalRequest({
|
|
action: "PUBLISH_MULTIPLE_QDN_RESOURCES",
|
|
resources,
|
|
});
|
|
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");
|
|
if (svcEl) {
|
|
/** @type {HTMLInputElement} */ (svcEl).value = (service || "DOCUMENT").toString();
|
|
}
|
|
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 {
|
|
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");
|
|
if (searchModeActive) {
|
|
if (ctxBtn) {
|
|
ctxBtn.disabled = false;
|
|
ctxBtn.textContent = "Search Results";
|
|
}
|
|
if (exitBtn) {
|
|
exitBtn.style.display = "inline-block";
|
|
}
|
|
} else {
|
|
if (ctxBtn) {
|
|
ctxBtn.disabled = true;
|
|
ctxBtn.innerHTML =
|
|
'My Files — <span id="sidebar-name">' + (userName || "(not authenticated)") + "</span>";
|
|
}
|
|
if (exitBtn) {
|
|
exitBtn.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) {
|
|
showSection("search");
|
|
}
|
|
});
|
|
}
|
|
const sidebarExitBtn = document.getElementById("sidebar-exit-search");
|
|
if (sidebarExitBtn) {
|
|
sidebarExitBtn.addEventListener("click", async () => {
|
|
setSearchMode(false);
|
|
showSpinner();
|
|
try {
|
|
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", () => {
|
|
form.reset();
|
|
searchState = {
|
|
params: null,
|
|
results: [],
|
|
offset: 0,
|
|
limit: 100,
|
|
hasMore: false,
|
|
inFlight: false,
|
|
};
|
|
resultsHost.innerHTML = "";
|
|
summary.textContent = "";
|
|
if (moreWrap) {
|
|
moreWrap.style.display = "none";
|
|
}
|
|
try {
|
|
localStorage.removeItem(LS_LAST_SEARCH_KEY);
|
|
} 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 = "";
|
|
}
|
|
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));
|
|
}
|
|
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 {}
|
|
renderSearchResults();
|
|
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;
|
|
}
|
|
const items = searchState.results || [];
|
|
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)" : ""}`;
|
|
// Build table with inline previews
|
|
let html =
|
|
"<table><tr><th>Name</th><th>Service</th><th>Identifier</th><th>Title</th><th>Preview</th><th>Size</th><th>Updated</th><th>Open</th></tr>";
|
|
for (const r of items) {
|
|
const name = r.name || "";
|
|
const svc = r.service || "";
|
|
const ident =
|
|
r.identifier === undefined || r.identifier === null || r.identifier === ""
|
|
? "default"
|
|
: r.identifier;
|
|
const title = r.metadata && r.metadata.title ? r.metadata.title : "";
|
|
const size = Number(r.size || 0).toLocaleString();
|
|
const upd = new Date(r.updated || r.created || 0);
|
|
const updated = isNaN(upd) ? "" : upd.toLocaleString();
|
|
const previewCell = generatePreviewHTML(r, name, ident);
|
|
const row = `
|
|
<tr>
|
|
<td>${escapeHtml(name)}</td>
|
|
<td>${escapeHtml(svc)}</td>
|
|
<td title="${escapeHtml(ident)}">${escapeHtml(ident)}</td>
|
|
<td title="${escapeHtml(title)}">${escapeHtml(title)}</td>
|
|
<td>${previewCell}</td>
|
|
<td style="text-align:right;">${size}</td>
|
|
<td>${escapeHtml(updated)}</td>
|
|
<td><span class="open-preview" data-name="${escapeAttr(name)}" data-service="${escapeAttr(svc)}" data-identifier="${escapeAttr(ident)}">Open</span></td>
|
|
</tr>`;
|
|
html += row;
|
|
}
|
|
html += "</table>";
|
|
resultsHost.innerHTML = html;
|
|
// Initialize inline preview holders within search results only
|
|
initPreviews(resultsHost);
|
|
// Wire up preview links
|
|
resultsHost.querySelectorAll(".open-preview").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 });
|
|
});
|
|
});
|
|
}
|
|
|
|
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;
|
|
});
|
|
}
|