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