Files
q-edit/script.js
T
2025-08-24 14:17:14 -04:00

1643 lines
68 KiB
JavaScript

// ===== Globals =====
// ===== 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 = '';
let userAddress = '';
let userName = '';
let isAuthenticated = false;
let allNames = [];
let authStatus = 'idle';
let namesStatus = 'idle';
let allResults = [];
let metadataArray = [];
// Pagination
let currentPage = 1;
let itemsPerPage = 25;
let totalResults = 0;
// 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'; // 'ALL' means no service filter
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 }
// === Deleted-content filter (default ON) ===
const TINY_SIZE_THRESHOLD = 128; // bytes; QDN may report ~32B for 1B newline
let hideDeleted = (localStorage.getItem('hideDeleted') !== 'false'); // default true
function getBaselineResults() {
// When hiding deletions, exclude items flagged as deleted
if (!hideDeleted) return masterResults;
return masterResults.filter(r => !r.__isDeleted);
}
function recomputeServiceCounts() {
serviceCounts = {};
const base = getBaselineResults();
for (const r of base) {
const svc = r.service || 'UNKNOWN';
serviceCounts[svc] = (serviceCounts[svc] || 0) + 1;
}
}
// ===== DOM & UI helpers =====
const contentPage = document.getElementById('content-page');
const authButton = document.getElementById('auth-button');
const nameSwitcherEl = document.getElementById('name-switcher');
const nameSelectEl = document.getElementById('name-select');
const loadingOverlay = document.getElementById('loading-overlay');
function showSpinner(){ if (loadingOverlay) loadingOverlay.style.display = 'flex'; }
function hideSpinner(){ if (loadingOverlay) loadingOverlay.style.display = 'none'; }
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';
}
// 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 { contentPage.style.display = "block"; 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();
initHideDeletedUI();
function initHideDeletedUI() {
const filterDiv = document.getElementById('filter-options');
if (!filterDiv) return;
// Avoid duplicate
if (document.getElementById('hide-deleted-checkbox')) return;
const label = document.createElement('label');
label.style.marginRight = '10px';
label.style.cursor = 'pointer';
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.id = 'hide-deleted-checkbox';
cb.checked = hideDeleted;
cb.style.marginRight = '6px';
label.appendChild(cb);
label.appendChild(document.createTextNode('Hide Deleted Content'));
// Prefer placing it before the service chips
filterDiv.insertBefore(label, filterDiv.firstChild);
cb.addEventListener('change', async () => {
hideDeleted = cb.checked;
localStorage.setItem('hideDeleted', String(hideDeleted));
showSpinner(); contentPage.style.display = "none";
try {
recomputeServiceCounts();
buildServiceChips();
applyServiceFilter();
await fetchPage();
} finally {
contentPage.style.display = "block"; hideSpinner();
}
});
}
// ===== QDN Preview helpers =====
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';
// 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';
return fallback || 'application/octet-stream';
}
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();
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();
initHideDeletedUI();
currentServiceFilter = 'ALL';
await loadAllResults(); await fetchPage();
contentPage.style.display = "block";
authStatus = 'succeeded'; updateAuthUI();
initHideDeletedUI(); hideSpinner();
} catch (error) {
console.error('Error fetching account details:', error);
authStatus = 'failed'; updateAuthUI();
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();
initHideDeletedUI();
try {
await loadAllResults(); await fetchPage();
} finally { contentPage.style.display = "block"; 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;
try { await waitForResourceReady({ name, service, identifier, initialBuild: true, timeoutMs: 20000, intervalMs: 500 }); } catch (e) { /* ignore */ }
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();
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();
if (selectedServices.size === 0) {
filteredResults = base.slice();
} else {
filteredResults = base.filter(r => selectedServices.has(r.service));
}
totalResults = filteredResults.length;
totalSize = filteredResults.reduce((acc, r) => acc + (r.size || 0), 0);
}
// ===== Data fetchers =====
async function fetchPage() {
try {
if (!userName || userName === 'Name unavailable') return;
// Client-side pagination using filteredResults
const contentDetails = document.getElementById('content-details');
contentDetails.innerHTML = '<p>Loading...</p>';
const start = (currentPage - 1) * itemsPerPage;
const pageItems = filteredResults.slice(start, start + itemsPerPage);
buildContentTable(pageItems);
} catch (error) {
console.error('Error fetching page:', error);
document.getElementById('content-details').innerHTML = `<p>Error: ${error.message}</p>`;
}
}
// ===== Table & pagination =====
function buildIdentifierCellHTML(result, identifier) {
const svc = result.service;
const key = selKey(svc, identifier);
const checkbox = `<input type="checkbox" class="bulk-select" data-service="${svc}" data-identifier="${identifier}" ${selectedForDeletion.has(key) ? 'checked' : ''} />`;
const deleteIcon = `<img src="red-x.svg" class="clickable-delete action-icon" title="Delete" alt="Delete" data-service="${svc}" data-identifier="${identifier}"/>`;
const editIcon = `<img src="file-up.png" class="clickable-edit action-icon" title="Edit" alt="Edit" data-service="${svc}" data-identifier="${identifier}"/>`;
const embedIcon = isEmbedService(svc) ? (
`<svg class="action-icon copy-embed-icon" data-name="${userName}" data-service="${svc}" data-identifier="${identifier}" width="15" height="15" viewBox="0 0 24 24" style="margin-left:6px;">
<circle cx="12" cy="12" r="11" fill="#ffffff"></circle>
<path fill="#0f1a2e" d="M10.59 13.41a1 1 0 0 0 1.41 1.41l4.95-4.95a3 3 0 1 0-4.24-4.24l-2.12 2.12a1 1 0 1 0 1.41 1.41l2.12-2.12a1 1 0 1 1 1.41 1.41l-4.95 4.95zM13.41 10.59a1 1 0 0 0-1.41-1.41L7.05 14.13a3 3 0 1 0 4.24 4.24l2.12-2.12a1 1 0 1 0-1.41-1.41l-2.12 2.12a1 1 0 1 1-1.41-1.41l4.95-4.95z"/>
</svg>`
) : '';
return `${checkbox}${deleteIcon}${editIcon}<span class="identifier-text">${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();
applyServiceFilter();
selectedForDeletion.clear();
setBulkMode(false);
await fetchPage();
} catch (err) {
alert('Bulk delete failed: ' + (err?.message || err));
} finally {
closePublishModal();
}
}
function buildContentTable(results) {
const contentDetailsDiv = document.getElementById('content-details');
const contentSummaryDiv = document.getElementById('content-summary');
if (results.length === 0) {
contentDetailsDiv.innerHTML = '<p>No results found.</p>';
contentSummaryDiv.innerHTML = '';
document.getElementById('pagination-top').innerHTML = '';
document.getElementById('pagination-bottom').innerHTML = '';
return;
}
results.sort((a, b) => (b.updated || b.created) - (a.updated || a.created));
let tableHtml = '<table>';
tableHtml += `<tr>
<th>Service</th>
<th>Identifier ${ bulkDeleteMode ? '<input type="checkbox" id="select-all-checkbox" title="Select all on this page"><label class="select-all-label" for="select-all-checkbox">Select All</label>' : '' }</th>
<th>Metadata</th>
<th>Preview</th>
<th>Size</th>
<th>Created / Updated</th>
</tr>`;
metadataArray = [];
for (const result of results) {
const identifier = (result.identifier === undefined) ? 'default' : result.identifier;
const isImageLike = (getBaseServiceKind(result.service) === 'image');
let createdString = new Date(result.created).toLocaleString(); if (isNaN(new Date(result.created))) createdString = 'Unknown';
let updatedString = new Date(result.updated).toLocaleString(); if (isNaN(new Date(result.updated))) updatedString = 'Never';
const sizeString = formatSize(result.size || 0);
let metadataKeys = ''; let metadataIndex = -1;
if (result.metadata) { metadataIndex = metadataArray.length; metadataArray.push(result.metadata); metadataKeys = Object.keys(result.metadata).join(', '); }
tableHtml += `<tr>
<td>${result.service}</td>
<td>${buildIdentifierCellHTML(result, identifier)}</td>
<td><span class="clickable-metadata" data-metadata-index='${metadataIndex}'>${metadataKeys}</span></td>
<td>${generatePreviewHTML(result, userName, identifier)}</td>
<td>${sizeString}</td>
<td>${createdString}<br>${updatedString}</td>
</tr>`;
}
tableHtml += `</table>`;
const startItem = (currentPage - 1) * itemsPerPage + 1;
const endItem = Math.min(startItem + itemsPerPage - 1, totalResults);
const selectedCount = getSelectedCount();
contentSummaryDiv.innerHTML = `<div class="summary-bar"><span>${startItem}-${endItem} of ${totalResults} results</span><span style=\"margin-left:12px;\">Total Size: ${formatSize(totalSize)}</span><span style=\"margin-left:18px;\"><button id=\"bulk-delete-toggle\">${ !bulkDeleteMode ? 'Delete Files' : (selectedCount > 0 ? `Delete ${selectedCount} Files` : 'Deleting Files') }</button>${ selectedCount > 0 ? '<button id=\"clear-selected-btn\" style=\"margin-left:8px;\">Clear Selected</button>' : '' }</span></div>`;
const paginationHTML = buildPaginationControls();
document.getElementById('pagination-top').innerHTML = paginationHTML;
document.getElementById('pagination-bottom').innerHTML = paginationHTML;
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 { contentPage.style.display = "block"; 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 { contentPage.style.display = "block"; hideSpinner(); }
}
}
});
});
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();
}
function buildPaginationControls() {
const totalPages = Math.ceil(totalResults / itemsPerPage);
if (totalPages <= 1) return '';
let html = '<nav class="pagination-controls" aria-label="Pagination">';
// First/Prev
if (currentPage > 1) {
html += `<span class="pagination-link" data-page="1" aria-label="First">««</span>`;
html += `<span class="pagination-link" data-page="${currentPage - 1}" aria-label="Previous">«</span>`;
} else {
html += `<span class="disabled" aria-disabled="true">««</span><span class="disabled" aria-disabled="true">«</span>`;
}
// Windowed pages
const windowSize = 2;
const pages = new Set([1, 2, totalPages - 1, totalPages]);
for (let p = currentPage - windowSize; p <= currentPage + windowSize; p++) {
if (p >= 1 && p <= totalPages) pages.add(p);
}
const sorted = Array.from(pages).sort((a,b)=>a-b);
let last = 0;
for (const p of sorted) {
if (p - last > 1) html += `<span class="ellipsis" aria-hidden="true">…</span>`;
if (p === currentPage) html += `<span class="current-page" aria-current="page">${p}</span>`;
else html += `<span class="pagination-link" data-page="${p}">${p}</span>`;
last = p;
}
// Next/Last
if (currentPage < totalPages) {
html += `<span class="pagination-link" data-page="${currentPage + 1}" aria-label="Next">»</span>`;
html += `<span class="pagination-link" data-page="${totalPages}" aria-label="Last">»»</span>`;
} else {
html += `<span class="disabled" aria-disabled="true">»</span><span class="disabled" aria-disabled="true">»»</span>`;
}
// Jump control
html += `<span class="jump-to-page">
<label style="margin-right:4px;">Jump:</label>
<input type="number" class="jump-input" min="1" max="${totalPages}" value="${currentPage}" aria-label="Jump to page">
<button type="button" class="jump-btn">Go</button>
</span>`;
html += '</nav>';
return html;
}
function addPaginationEventHandlers() {
document.querySelectorAll('.pagination-link').forEach(link => {
link.addEventListener('click', async function() {
const newPage = parseInt(this.getAttribute('data-page'), 10);
if (!isNaN(newPage)) {
showSpinner(); contentPage.style.display = "none";
try { currentPage = newPage; setPageHash(currentPage); await fetchPage(); }
finally { contentPage.style.display = "block"; 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 { contentPage.style.display = "block"; 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 { contentPage.style.display = "block"; hideSpinner(); }
}
}
});
});
}
function formatSize(size) {
if (size > (1024*1024*1024*1024)) return (size / (1024*1024*1024*1024)).toFixed(2) + ' TB';
if (size > (1024*1024*1024)) return (size / (1024*1024*1024)).toFixed(2) + ' GB';
if (size > (1024*1024)) return (size / (1024*1024)).toFixed(2) + ' MB';
if (size > 1024) return (size / 1024).toFixed(2) + ' KB';
return (size || 0) + ' B';
}
// ===== Preview rendering =====
function generatePreviewHTML(result, userName, identifier) {
const safeName = (result.name || userName || '').replace(/"/g, '&quot;');
const safeService = (result.service || '').replace(/"/g, '&quot;');
const safeIdent = (identifier || 'default').replace(/"/g, '&quot;');
return `<div class="preview-holder" data-name="${safeName}" data-service="${safeService}" data-identifier="${safeIdent}">0% Loaded</div>`;
}
function initPreviews() {
const holders = document.querySelectorAll('.preview-holder');
holders.forEach((el) => {
const svc = (el.getAttribute('data-service') || '').toUpperCase();
const ident = el.getAttribute('data-identifier') || 'default';
const nm = el.getAttribute('data-name') || userName;
loadPreviewInto(el, { service: svc, identifier: ident, name: nm });
});
}
function isPrivateService(service) {
const s = (service || '').toUpperCase();
return s.endsWith('_PRIVATE') || ['QCHAT_ATTACHMENT_PRIVATE','ATTACHMENT_PRIVATE','FILE_PRIVATE','IMAGE_PRIVATE','VIDEO_PRIVATE','AUDIO_PRIVATE','VOICE_PRIVATE','DOCUMENT_PRIVATE','MAIL_PRIVATE','MESSAGE_PRIVATE'].includes(s);
}
function isExcludedFromDeletionCheck(service){
const s = (service||'').toUpperCase();
return s === 'CHAIN_DATA' || s === 'CHAIN_COMMENT';
}
function isEmbedService(service){
const s = (service||'').toUpperCase();
return s === 'IMAGE' || s === 'QCHAT_IMAGE' || s.includes('THUMBNAIL');
}
function getBaseServiceKind(service) {
const s = (service || '').toUpperCase();
if (s.includes('IMAGE') || s.includes('THUMBNAIL')) return 'image';
if (s.includes('VIDEO')) return 'video';
if (s.includes('AUDIO') || s.includes('VOICE') || s.includes('PODCAST')) return 'audio';
if (s.includes('DOCUMENT') || s.includes('BLOG') || s.includes('COMMENT') || s.includes('JSON') || s.includes('CODE')) return 'text';
if (s.includes('FILE') || s.includes('ATTACHMENT')) return 'file';
return 'file';
}
async function waitForResourceReady({ name, service, identifier, initialBuild = true, timeoutMs = 60000, intervalMs = 800, onProgress }) {
const start = Date.now();
if (initialBuild) {
try { await qortalRequest(buildQdnParams({ action: 'GET_QDN_RESOURCE_STATUS', name, service, identifier, build: true })); }
catch (e) { /* ignore */ }
}
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 set = (el) => { container.innerHTML=''; container.appendChild(el); };
const looksHtml = (txt) => /<(?:!doctype|html|head|body|div|p|span|img|video|audio|iframe|section|article)/i.test(txt);
try {
// Wait until built; update percent text while polling
await waitForResourceReady({
name: ctx.name, service: ctx.service, identifier: ctx.identifier, initialBuild: true,
onProgress: (pct) => { container.textContent = `${pct}% Loaded`; }
});
const service = ctx.service;
const identifier = ctx.identifier;
const name = ctx.name;
const lower = service.toLowerCase();
const baseKind = getBaseServiceKind(service);
const isPriv = isPrivateService(service);
// Text-like services (public and private)
const textServices = ['blog','blog_post','blog_comment','document','game','json','code'];
const isText = textServices.some(t => lower.includes(t)) || baseKind === 'text';
if (isPriv) {
// Private path: always fetch base64, then decrypt via DECRYPT_DATA
const encB64 = await qortalRequest(buildQdnParams({ action:'FETCH_QDN_RESOURCE', name, service, identifier, encoding:'base64', rebuild:false }));
const decB64 = await qortalRequest({ action:'DECRYPT_DATA', encryptedData: encB64 });
if (isText) {
// Decode to UTF-8 string
const bin = atob(decB64); const bytes = new Uint8Array(bin.length); for (let i=0;i<bin.length;i++) bytes[i]=bin.charCodeAt(i);
const dec = new TextDecoder('utf-8'); const text = dec.decode(bytes);
if (looksHtml(text)) {
const iframe = document.createElement('iframe');
iframe.setAttribute('sandbox','');
iframe.style.width = '240px'; iframe.style.height = '160px';
iframe.srcdoc = text; set(iframe);
} else {
const pre = document.createElement('pre');
pre.style.maxWidth = '240px'; pre.style.maxHeight = '160px'; pre.style.overflow = 'auto';
pre.textContent = text.slice(0, 5000); set(pre);
}
return;
} else {
// Media / file: make Blob URL from decrypted bytes
const bytes = b64ToBytes(decB64);
const mime = detectMimeFromBytes(bytes) || (baseKind==='image' ? 'image/*' : baseKind==='video' ? 'video/mp4' : baseKind==='audio' ? 'audio/mpeg' : 'application/octet-stream');
const blob = new Blob([bytes], { type: mime });
const url = URL.createObjectURL(blob);
if (baseKind === 'image') {
const img = document.createElement('img');
img.src = url; img.alt = identifier; img.className = 'preview-image';
img.addEventListener('click', () => openImageOverlayFromDataUrl(url));
set(img); return;
}
if (baseKind === 'video') {
const video = document.createElement('video');
video.controls = true; video.className = 'preview-video'; video.src = url; set(video); return;
}
if (baseKind === 'audio') {
const audio = document.createElement('audio');
audio.controls = true; audio.className = 'preview-audio'; audio.src = url; set(audio); return;
}
const a = document.createElement('a'); a.textContent = 'Open'; a.href = url; a.target = '_blank'; a.rel = 'noopener';
set(a); return;
}
} else {
// Public path (existing behavior)
if (isText) {
let resp;
try {
resp = await qortalRequest(buildQdnParams({ action:'FETCH_QDN_RESOURCE', name, service, identifier, rebuild: false }));
} catch (e) {
const b64 = await qortalRequest(buildQdnParams({ action:'FETCH_QDN_RESOURCE', name, service, identifier, encoding:'base64', rebuild:false }));
const bin = atob(b64); const bytes = new Uint8Array(bin.length); for (let i=0;i<bin.length;i++) bytes[i]=bin.charCodeAt(i);
const dec = new TextDecoder('utf-8'); const text = dec.decode(bytes);
resp = text || b64;
}
if (typeof resp === 'string') {
if (looksHtml(resp)) {
const iframe = document.createElement('iframe');
iframe.setAttribute('sandbox','');
iframe.style.width = '240px'; iframe.style.height = '160px';
iframe.srcdoc = resp; set(iframe);
} else {
const pre = document.createElement('pre');
pre.style.maxWidth = '240px'; pre.style.maxHeight = '160px'; pre.style.overflow = 'auto';
pre.textContent = resp.slice(0, 5000); set(pre);
}
} else if (resp && typeof resp === 'object') {
const pre = document.createElement('pre');
pre.style.maxWidth = '240px'; pre.style.maxHeight = '160px'; pre.style.overflow = 'auto';
pre.textContent = JSON.stringify(resp, null, 2).slice(0, 5000); set(pre);
} else { container.textContent = '(unavailable)'; }
return;
}
// Media/file via base64 -> Blob URL
const b64 = await qortalRequest(buildQdnParams({ action:'FETCH_QDN_RESOURCE', name, service, identifier, encoding:'base64', rebuild:false }));
const bytes = b64ToBytes(b64);
const mime = detectMimeFromBytes(bytes) || (baseKind==='image' ? 'image/*' : baseKind==='video' ? 'video/mp4' : baseKind==='audio' ? 'audio/mpeg' : 'application/octet-stream');
const blob = new Blob([bytes], { type: mime });
const url = URL.createObjectURL(blob);
if (baseKind === 'image') {
const img = document.createElement('img');
img.src = url; img.alt = identifier; img.className = 'preview-image';
img.addEventListener('click', () => openImageOverlayFromDataUrl(url));
set(img); return;
}
if (baseKind === 'video') {
const video = document.createElement('video');
video.controls = true; video.className = 'preview-video'; video.src = url; set(video); return;
}
if (baseKind === 'audio') {
const audio = document.createElement('audio');
audio.controls = true; audio.className = 'preview-audio'; audio.src = url; set(audio); return;
}
const a = document.createElement('a'); a.textContent = 'Open'; a.href = url; a.target = '_blank'; a.rel = 'noopener';
set(a); return;
}
} catch (err) {
console.error('Preview load error for', ctx, err);
container.textContent = 'Preview unavailable';
}
}
// ===== 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 {
const response = 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;
}
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 editIdent = (identifier === 'default') ? '' : identifier;
// Prepare the publish parameters
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 textServices = ['BLOG', 'BLOG_POST', 'BLOG_COMMENT', 'DOCUMENT'];
if (textServices.includes(service)) {
// For text types, fetch the current content
let contentUrl = `/arbitrary/${service}/${userName}/${identifier}`;
let content = '';
try {
const contentResponse = await fetch(contentUrl);
if (contentResponse.ok) {
content = await contentResponse.text();
} else {
content = 'Error fetching content';
}
} catch (err) {
content = 'Error fetching content';
}
// Open a modal dialog to edit the content
let editedContent = await openTextEditorDialog(content);
if (editedContent === null) {
// User cancelled
return;
}
// Create a new Blob with the edited content
const editedFile = new Blob([editedContent], { type: 'text/plain' });
publishParams.file = editedFile; // Add the edited file to publishParams
} else {
closePublishModal();
// 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 {
const response = 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);
}
}
function openTextEditorDialog(content) {
return new Promise((resolve, reject) => {
// 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, reject) => {
// Create the modal overlay
const modalOverlay = document.createElement('div');
modalOverlay.style.position = 'fixed';
modalOverlay.style.top = '0';
modalOverlay.style.left = '0';
modalOverlay.style.width = '100%';
modalOverlay.style.height = '100%';
modalOverlay.style.backgroundColor = 'rgba(0, 0, 0, 0.5)';
modalOverlay.style.display = 'flex';
modalOverlay.style.justifyContent = 'center';
modalOverlay.style.alignItems = 'center';
modalOverlay.style.zIndex = '1000';
// Create the modal content container
const modalContent = document.createElement('div');
modalContent.style.backgroundColor = '#2d3749';
modalContent.style.color = '#c9d2d9';
modalContent.style.padding = '20px';
modalContent.style.borderRadius = '25px';
modalContent.style.maxWidth = '600px';
modalContent.style.width = '90%';
modalContent.style.fontFamily = "'Lexend', sans-serif";
modalContent.style.lineHeight = '1.6';
// Create the form
const form = document.createElement('form');
const fields = ['filename', 'title', 'description', 'category', 'tags'];
// Define the categories
const categories = [
{ value: '', display: '' },
{ value: 'ART', display: 'Art and Design' },
{ value: 'AUTOMOTIVE', display: 'Automotive' },
{ value: 'BEAUTY', display: 'Beauty' },
{ value: 'BOOKS', display: 'Books and Reference' },
{ value: 'BUSINESS', display: 'Business' },
{ value: 'COMMUNICATIONS', display: 'Communications' },
{ value: 'CRYPTOCURRENCY', display: 'Cryptocurrency and Blockchain' },
{ value: 'CULTURE', display: 'Culture' },
{ value: 'DATING', display: 'Dating' },
{ value: 'DESIGN', display: 'Design' },
{ value: 'ENTERTAINMENT', display: 'Entertainment' },
{ value: 'EVENTS', display: 'Events' },
{ value: 'FAITH', display: 'Faith and Religion' },
{ value: 'FASHION', display: 'Fashion' },
{ value: 'FINANCE', display: 'Finance' },
{ value: 'FOOD', display: 'Food and Drink' },
{ value: 'GAMING', display: 'Gaming' },
{ value: 'GEOGRAPHY', display: 'Geography' },
{ value: 'HEALTH', display: 'Health' },
{ value: 'HISTORY', display: 'History' },
{ value: 'HOME', display: 'Home' },
{ value: 'KNOWLEDGE', display: 'Knowledge Share' },
{ value: 'LANGUAGE', display: 'Language' },
{ value: 'LIFESTYLE', display: 'Lifestyle' },
{ value: 'MANUFACTURING', display: 'Manufacturing' },
{ value: 'MAPS', display: 'Maps and Navigation' },
{ value: 'MUSIC', display: 'Music' },
{ value: 'NEWS', display: 'News' },
{ value: 'OTHER', display: 'Other' },
{ value: 'PETS', display: 'Pets' },
{ value: 'PHILOSOPHY', display: 'Philosophy' },
{ value: 'PHOTOGRAPHY', display: 'Photography' },
{ value: 'POLITICS', display: 'Politics' },
{ value: 'PRODUCE', display: 'Products and Services' },
{ value: 'PRODUCTIVITY', display: 'Productivity' },
{ value: 'PSYCHOLOGY', display: 'Psychology' },
{ value: 'QORTAL', display: 'Qortal' },
{ value: 'SCIENCE', display: 'Science' },
{ value: 'SELF_CARE', display: 'Self Care' },
{ value: 'SELF_SUFFICIENCY', display: 'Self-Sufficiency and Homesteading' },
{ value: 'SHOPPING', display: 'Shopping' },
{ value: 'SOCIAL', display: 'Social' },
{ value: 'SOFTWARE', display: 'Software' },
{ value: 'SPIRITUALITY', display: 'Spirituality' },
{ value: 'SPORTS', display: 'Sports' },
{ value: 'STORYTELLING', display: 'Storytelling' },
{ value: 'TECHNOLOGY', display: 'Technology' },
{ value: 'TOOLS', display: 'Tools' },
{ value: 'TRAVEL', display: 'Travel' },
{ value: 'UNCATEGORIZED', display: 'Uncategorized' },
{ value: 'VIDEO', display: 'Video' },
{ value: 'WEATHER', display: 'Weather' },
];
fields.forEach(field => {
const label = document.createElement('label');
label.textContent = field.charAt(0).toUpperCase() + field.slice(1) + ':';
label.style.display = 'block';
label.style.marginTop = '10px';
let input;
if (field === 'category') {
// Create a select element for category
input = document.createElement('select');
input.name = field;
input.style.width = '100%';
input.style.padding = '5px';
input.style.marginTop = '5px';
// Add options to the select element
categories.forEach(category => {
const option = document.createElement('option');
option.value = category.value;
option.textContent = category.display;
input.appendChild(option);
});
// Set the selected value if it exists in existingMetadata
if (existingMetadata[field]) {
input.value = existingMetadata[field];
} else {
input.value = ''; // Default to blank line
}
} else {
// Create an input element for other fields
input = document.createElement('input');
input.type = 'text';
input.name = field;
input.style.width = '100%';
input.style.padding = '5px';
input.style.marginTop = '5px';
if (existingMetadata[field]) {
if (field === 'tags' && Array.isArray(existingMetadata[field])) {
input.value = existingMetadata[field].join(', ');
} else {
input.value = existingMetadata[field];
}
} else {
input.placeholder = field.charAt(0).toUpperCase() + field.slice(1);
}
}
label.appendChild(input);
form.appendChild(label);
});
// Create the button container
const buttonContainer = document.createElement('div');
buttonContainer.style.textAlign = 'right';
buttonContainer.style.marginTop = '20px';
// Create the Save and Cancel buttons
const saveButton = document.createElement('button');
saveButton.textContent = 'Save';
saveButton.type = 'submit';
saveButton.style.marginLeft = '10px';
const cancelButton = document.createElement('button');
cancelButton.textContent = 'Cancel';
cancelButton.type = 'button';
buttonContainer.appendChild(cancelButton);
buttonContainer.appendChild(saveButton);
form.appendChild(buttonContainer);
modalContent.appendChild(form);
modalOverlay.appendChild(modalContent);
document.body.appendChild(modalOverlay);
// Event listeners
cancelButton.addEventListener('click', (event) => {
event.preventDefault();
document.body.removeChild(modalOverlay);
closePublishModal();
resolve(null);
});
form.addEventListener('submit', (event) => {
event.preventDefault();
// Collect the metadata
const formData = new FormData(form);
const updatedMetadata = {};
fields.forEach(field => {
const value = formData.get(field);
if (value) {
updatedMetadata[field] = value;
}
});
document.body.removeChild(modalOverlay);
resolve(updatedMetadata);
});
});
}
let publishModal = null;
function showPublishModal(message) {
if (!publishModal) {
// Create the modal
publishModal = document.createElement('div');
publishModal.style.position = 'fixed';
publishModal.style.top = '0';
publishModal.style.left = '0';
publishModal.style.width = '100%';
publishModal.style.height = '100%';
publishModal.style.backgroundColor = 'rgba(0, 0, 0, 0.5)';
publishModal.style.display = 'flex';
publishModal.style.justifyContent = 'center';
publishModal.style.alignItems = 'center';
publishModal.style.zIndex = '1000';
// Create the modal content container
const modalContent = document.createElement('div');
modalContent.style.backgroundColor = '#2d3749'; // Use background color from main content
modalContent.style.padding = '20px';
modalContent.style.borderRadius = '5px';
modalContent.style.maxWidth = '400px';
modalContent.style.width = '90%';
modalContent.style.textAlign = 'center';
// Create the message element
const messageElement = document.createElement('p');
messageElement.id = 'publish-modal-message';
messageElement.textContent = message;
modalContent.appendChild(messageElement);
publishModal.appendChild(modalContent);
document.body.appendChild(publishModal);
} else {
// Update the message
const messageElement = publishModal.querySelector('#publish-modal-message');
messageElement.textContent = message;
// Remove any buttons (Retry/Cancel) if they exist
const buttons = publishModal.querySelector('#publish-modal-buttons');
if (buttons) {
buttons.remove();
}
}
if (message == "Publish TX submitted! Confirmation needed.") {
// Create buttons container
const buttonsContainer = document.createElement('div');
buttonsContainer.id = 'publish-modal-buttons';
buttonsContainer.style.marginTop = '20px';
// Create Close button
const closeButton = document.createElement('button');
closeButton.textContent = 'Close';
closeButton.addEventListener('click', () => {
closePublishModal();
});
buttonsContainer.appendChild(closeButton);
// Append button to modal content
const modalContent = publishModal.firstChild;
modalContent.appendChild(buttonsContainer);
}
}
function closePublishModal() {
if (publishModal) {
document.body.removeChild(publishModal);
publishModal = null;
}
}
function showPublishErrorModal(errorMessage, onRetry, onCancel) {
if (publishModal) {
// Update the message
const messageElement = publishModal.querySelector('#publish-modal-message');
messageElement.textContent = errorMessage;
// Remove any existing buttons
const existingButtons = publishModal.querySelector('#publish-modal-buttons');
if (existingButtons) {
existingButtons.remove();
}
// Create buttons container
const buttonsContainer = document.createElement('div');
buttonsContainer.id = 'publish-modal-buttons';
buttonsContainer.style.marginTop = '20px';
// Create Retry button
const retryButton = document.createElement('button');
retryButton.textContent = 'Retry';
retryButton.style.marginRight = '10px';
retryButton.addEventListener('click', () => {
onRetry();
});
// Create Cancel button
const cancelButton = document.createElement('button');
cancelButton.textContent = 'Cancel';
cancelButton.addEventListener('click', () => {
onCancel();
});
buttonsContainer.appendChild(retryButton);
buttonsContainer.appendChild(cancelButton);
// Append buttons to modal content
const modalContent = publishModal.firstChild;
modalContent.appendChild(buttonsContainer);
}
}
async function publishWithFeedback(publishParams) {
return new Promise(async (resolve, reject) => {
async function attemptPublish() {
try {
// Show modal with "Attempting to publish, please wait..."
showPublishModal("Attempting to publish, please wait...");
const response = await qortalRequest(publishParams);
// Close modal
resolve(response);
showPublishModal("Publish TX submitted! Confirmation needed.");
} catch (error) {
// Update modal to show error message and Retry/Cancel buttons
showPublishErrorModal(`Publishing failed: ${error.message}`, () => {
// On Retry
attemptPublish();
}, () => {
// On Cancel
closePublishModal();
reject(error);
});
}
}
await attemptPublish();
});
}