Files
Simon James b54a3139c7 Initial commit: Qortal Web Builder monorepo.
Includes QWB, Qortal Web, and Q-Shops Q-Apps with shared packages and build scripts.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-09 12:17:29 +00:00

3481 lines
124 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =========================================================================
* Personal portfolio Q-App — create your site, publish WEBSITE from here
*
* For anyone: on-chain portfolio, Qortal ecosystem contribution, and
* real-life achievements. Config: localStorage + DOCUMENT + WEBSITE ZIP.
* ======================================================================= */
(function () {
'use strict';
const DEFAULT_PUBLISHER = 'Your Name';
const SITE_STORAGE_KEY = 'portfolio_sovereign_site_v3';
const SITE_DOCUMENT_ID = 'portfolio_sovereign_site_config_v3';
/** v2 localStorage + DOCUMENT id (legacy prefix; kept so older saves still migrate) */
const LEGACY_SITE_STORAGE_KEY = 'sj_sovereign_site_v2';
const LEGACY_SITE_DOCUMENT_ID = 'sj_sovereign_site_config_v2';
/**
* Lowercase registered names that skip on-chain DOCUMENT (WEBSITE / “Publish my website” only).
* Add only if you need that behaviour; leave empty in the template.
*/
const NO_DOCUMENT_QDN_NAME_KEYS = new Set([]);
/** Reorderable page regions under #sn-app (header stays fixed at top). */
const LAYOUT_DEFAULT_ORDER = ['site', 'hero', 'apps', 'feed', 'pages', 'community', 'about', 'poi', 'editor', 'foot'];
const LAYOUT_BLOCK_ELEMENTS = {
site: 'sn-lane-site',
hero: 'sn-lane-hero',
apps: 'sn-lane-apps',
feed: 'sn-lane-feed',
pages: 'sn-lane-pages',
community: 'sn-lane-community',
about: 'sn-about-section',
poi: 'sn-lane-poi',
editor: 'sn-site-editor',
foot: 'sn-lane-foot',
};
const LAYOUT_LABELS = {
site: 'Kicker line',
hero: 'Hero and identity',
apps: 'Featured Q-App',
feed: 'Published Q-Apps embeds',
pages: 'More pages',
community: 'Community',
about: 'Story and links',
poi: 'World Map photos',
editor: 'Site builder',
foot: 'Footer',
};
const LAYOUT_KEYS_NO_DELETE = new Set(['editor']);
function isDocumentQdnEnabledForPublisher() {
const k = String(publisherName() || '')
.trim()
.toLowerCase();
if (!k) return true;
return !NO_DOCUMENT_QDN_NAME_KEYS.has(k);
}
function defaultUiTemplate() {
return {
docTitle: 'My Personal Website — Qortal',
heroEyebrow: 'PORTFOLIO · QORTAL & REAL LIFE',
nameFirst: 'Your',
nameLast: 'Name',
tagline:
'Your corner of the chain — <em>apps you ship</em>, <strong>how you help the network</strong>, and the achievements that matter offline. Edit everything in Site settings, then publish your own WEBSITE.',
heroBtnWorkshop: 'Open featured Q-App →',
heroBtnQapps: 'My Q-Apps on Qortal',
heroBtnCommunity: 'Groups & life',
identityCardTitle: 'Your Name',
ownerLinePrefix: 'Name owner ·',
visitorEyebrow: 'Your wallet',
statQdn: 'QDN items',
statApps: 'Q-Apps',
statArb: 'Arb. txs',
statMinted: 'Minted',
statQort: 'QORT',
statLinks: 'Links',
sec01Eyebrow: 'SECTION · 01',
sec01Title: 'Featured Q-App',
sec01Sub:
'Pick one app you are proud of — live preview here, full screen in Qortal. A single flagship for your portfolio.',
sec02Eyebrow: 'ON THE CHAIN',
sec02Title: 'Published Q-Apps',
sec02Sub:
'Tabs for Q-Apps you publish — your visible contribution to the Qortal ecosystem. Replace URLs in Site settings with your channels and apps.',
embedOpenLabel: 'open in Qortal ↗',
embedFallbackText: '$ connect in Qortal to load embedded Q-Apps',
embedTabsAria: 'Your published Q-Apps',
sec03Eyebrow: 'SECTION · 03',
sec03Title: 'More pages & websites',
sec03Sub:
'Optional extra pages (long stories, timelines, or nested sites) published from Site settings.',
sec04Eyebrow: 'SECTION · 04',
sec04Title: 'Story & achievements',
linksAsideHead: '// quick links',
linksAsideIntro:
'Saved <span class="sn-mono">qortal://</span> links with live previews via <span class="sn-mono">/render/</span> inside the Hub.',
linkPreviewsAria: 'Portfolio links with live previews',
footerLeft: '// your portfolio · sovereign',
footerBuild: 'portfolio · qortal template',
siteSettingsH3: 'My Personal Website',
siteEditorLeadHtml:
'<strong>Build your own website here</strong> — the form below changes this page in real time. Set your <span class="sn-mono">registered name</span> and fill in each section. <strong>Connect</strong> in the header, then <span class="sn-mono">Publish my website</span> to put your public site on <span class="sn-mono">qortal://WEBSITE/</span><em>yourName</em>. Use <span class="sn-mono">Publish to QDN</span> to save this Q-Apps config (DOCUMENT) on-chain; that button appears in the corner when you have unsaved config changes.',
communityEyebrow: 'COMMUNITY',
communityTitle: 'Groups & life counter',
communitySub:
'Qortal <strong>group</strong> identifiers and a <strong>life</strong> stat, plus <strong>private</strong> listings you name yourself — the same class of data Crowetics Q-Community / importer tools use (e.g. <span class="sn-mono">publicGroupId</span> / <span class="sn-mono">adminGroupId</span> in a baked WEBSITE). Edit the JSON-like fields in the site builder; they ship inside your WEBSITE ZIP.',
poiPhotosEyebrow: 'WORLD MAP',
poiPhotosTitle: 'Photos from my public POIs',
poiPhotosSub:
'Pictures already on your POI “wall” in <span class="sn-mono">World Map</span> live on QDN as IMAGE resources — list them here for your portfolio.',
};
}
/** Default HTML for Section 04 — edit under Site settings → Story & achievements */
const DEFAULT_ABOUT_HTML =
'<p><strong>On Qortal</strong> — describe the apps you publish, channels you run, and how you contribute to the network: minting, tools, content, or community building. Link to your <span class="sn-mono">qortal://APP/…</span> resources so visitors can open them in-app.</p>' +
'<p><strong>World Map photos</strong> — add pictures from your public POI “wall” in <strong>Site settings → World Map photos</strong> (paste each <span class="sn-mono">qortal://IMAGE/…</span> link — same files World Map already published).</p>' +
'<p><strong>In real life</strong> — work, study, crafts, or causes you care about. Milestones or a short bio that does not have to mirror your on-chain identity.</p>' +
'<p>This template is customized in <strong>Site settings</strong>, then <strong>Publish my website</strong> in the header on a name you own.</p>';
function defaultSiteConfig() {
return {
version: 3,
/** Registered Qortal name this site tracks (QDN stats, DOCUMENT config, avatar). */
publisherName: DEFAULT_PUBLISHER,
/** Flat copy map — every visible title / blurb (see applyChromeUi). */
ui: defaultUiTemplate(),
aboutHtml: DEFAULT_ABOUT_HTML,
workshop: {
label: 'Open featured Q-App',
qortalHref: 'qortal://APP/The People of Qortal',
},
links: [
{ id: uid(), label: 'The People of Qortal — hub', href: 'qortal://APP/The People of Qortal' },
{ id: uid(), label: 'Q-Mail', href: 'qortal://APP/Q-Mail' },
{ id: uid(), label: 'Q-Minter — governance', href: 'qortal://APP/Q-Minter' },
{ id: uid(), label: 'ThankQ', href: 'qortal://APP/ThankQ' },
{ id: uid(), label: 'World Map', href: 'qortal://APP/World Map' },
{ id: uid(), label: 'Quitter', href: 'qortal://APP/Quitter' },
],
pages: [],
embeds: [
{ id: uid(), label: 'Q-Tube', qortalHref: 'qortal://APP/Q-Tube' },
{ id: uid(), label: 'Quitter', qortalHref: 'qortal://APP/Quitter' },
{ id: uid(), label: 'ThankQ', qortalHref: 'qortal://APP/ThankQ' },
],
/** Public POI wall photos: qortal://IMAGE/Name/worldmap_marker_*_photo_0 … (same identifiers World Map publishes) */
poiPhotos: [],
/**
* Qommunity / Crowetic-style: public & admin group IDs, life stat, private group rows.
* Shipped in DOCUMENT + in WEBSITE <script id="sn-embed-config"> for importers.
*/
community: {
sectionHidden: false,
lifeLabel: 'Life on chain',
lifeValue: '',
publicGroupId: '',
publicGroupName: '',
adminGroupId: '',
adminGroupName: '',
/** Curated “private” groups (display only; not on-chain access control). { name, groupId, note } */
privateGroups: [],
},
/**
* Main page order (each key maps to a block in index.html) and per-block hidden state (× in the builder).
*/
layout: {
order: LAYOUT_DEFAULT_ORDER.slice(),
hidden: {},
},
};
}
function uid() {
return 'x' + Math.random().toString(36).slice(2, 10) + Date.now().toString(36);
}
const STATE = {
ready: false,
inQortal: typeof qortalRequest === 'function',
userAccount: null,
ownerAddress: null,
/** True when wallet may publish DOCUMENT config to the tracked registered name */
canPublishDocument: false,
site: defaultSiteConfig(),
activeEmbed: 0,
embedSrcCache: {},
/** Bumped after Connect so /render/ iframes reload with wallet context */
sessionTag: null,
/** QDN + wallet stats (see Account Explorer /addresses/mintership, arbitrary resource search) */
counts: { qdn: 0, apps: 0, arb: 0, blocks: 0, balance: null },
/** Filled after Connect — visitors chain stats (mintership, names, payments) */
visitorStats: null,
/** Cached resolved URLs for qortal://IMAGE/… POI photos */
poiImgSrcCache: {},
/** true = visual `data-vi-key` form is split into side panels next to each page segment */
segmentEditMode: false,
/**
* JSON snapshot of `STATE.site` when config was last in sync (loaded from or published to QDN).
* Used to show the floating "Publish to QDN" only when the DOCUMENT config has unpublished edits.
*/
qdnConfigBaseline: null,
/** In Hub + Connect: click-to-edit the live page; uses contenteditable (WYSIWYG) + form sync. */
wysiwygMode: true,
};
function publisherName() {
try {
const n = STATE.site && STATE.site.publisherName;
return String(n == null ? '' : n).trim() || DEFAULT_PUBLISHER;
} catch (_) {
return DEFAULT_PUBLISHER;
}
}
/** Shown in header; matches how this Q-App is addressed on a registered name. */
function qortalAppLinkDisplay() {
const n = String(publisherName() || DEFAULT_PUBLISHER).trim() || DEFAULT_PUBLISHER;
return 'qortal://APP/' + n;
}
function qortalAppLinkHref() {
const n = String(publisherName() || DEFAULT_PUBLISHER).trim() || DEFAULT_PUBLISHER;
return 'qortal://APP/' + encodeURIComponent(n);
}
/** Public static site address after Publish my website (WEBSITE service on the registered name). */
function qortalWebsiteLinkDisplay() {
const n = String(publisherName() || DEFAULT_PUBLISHER).trim() || DEFAULT_PUBLISHER;
return 'qortal://WEBSITE/' + n;
}
function qortalWebsiteLinkHref() {
const n = String(publisherName() || DEFAULT_PUBLISHER).trim() || DEFAULT_PUBLISHER;
return 'qortal://WEBSITE/' + encodeURIComponent(n);
}
function siteConfigSnapshot() {
try {
return JSON.stringify(STATE.site);
} catch (_) {
return null;
}
}
function setQdnBaselineFromCurrent() {
STATE.qdnConfigBaseline = siteConfigSnapshot();
}
function isQdnConfigDirty() {
if (!STATE.inQortal || STATE.qdnConfigBaseline == null) return false;
if (!isDocumentQdnEnabledForPublisher()) return false;
const cur = siteConfigSnapshot();
return cur != null && cur !== STATE.qdnConfigBaseline;
}
function updateBrandLine() {
const a = document.getElementById('sn-brand-line');
if (!a) return;
a.textContent = qortalAppLinkDisplay();
a.setAttribute('href', qortalAppLinkHref());
a.title = 'Open this Q-App: ' + qortalAppLinkDisplay();
const h = document.getElementById('sn-vi-app-link-hint');
if (h) {
h.textContent =
'Q-App: ' + qortalAppLinkDisplay() + ' · Public site: ' + qortalWebsiteLinkDisplay() + ' (name updates with the field above)';
}
const www = document.getElementById('sn-floating-qdn-website-link');
if (www) {
www.textContent = qortalWebsiteLinkDisplay();
www.setAttribute('href', qortalWebsiteLinkHref());
www.title = 'Open your published public website in Qortal: ' + qortalWebsiteLinkDisplay();
}
const fl = document.getElementById('sn-floating-qdn-app-link');
if (fl) {
fl.textContent = qortalAppLinkDisplay();
fl.setAttribute('href', qortalAppLinkHref());
fl.title = 'Open this Q-App (builder) in Qortal: ' + qortalAppLinkDisplay();
}
}
function updateFloatingQdnBar() {
const wrap = document.getElementById('sn-floating-qdn');
if (!wrap) return;
const show = !!STATE.inQortal && isQdnConfigDirty();
wrap.hidden = !show;
wrap.setAttribute('aria-hidden', show ? 'false' : 'true');
}
/** Wix-style grouped fields: { title, fields: { k, label?, multiline?, html? }[] } */
const VISUAL_UI_GROUPS = [
{ title: 'Site title', fields: [
{ k: 'docTitle', label: 'Browser tab title' },
{ k: 'siteSettingsH3', label: 'Site settings panel heading' },
] },
{ title: 'Hero', fields: [
{ k: 'heroEyebrow', label: 'Eyebrow line' },
{ k: 'nameFirst', label: 'Name — first word' },
{ k: 'nameLast', label: 'Name — second word' },
{ k: 'tagline', label: 'Tagline (HTML allowed)', multiline: true, html: true },
{ k: 'heroBtnWorkshop', label: 'Primary hero button' },
{ k: 'heroBtnQapps', label: 'Secondary hero button' },
{ k: 'heroBtnCommunity', label: 'Third hero button' },
] },
{ title: 'Identity card', fields: [
{ k: 'identityCardTitle', label: 'Card title' },
{ k: 'ownerLinePrefix', label: '“Name owner” prefix' },
{ k: 'visitorEyebrow', label: 'Visitor stats eyebrow' },
] },
{ title: 'Stat labels', fields: [
{ k: 'statQdn', label: 'QDN stat' },
{ k: 'statApps', label: 'Q-Apps stat' },
{ k: 'statArb', label: 'Arb. txs stat' },
{ k: 'statMinted', label: 'Minted stat' },
{ k: 'statQort', label: 'QORT stat' },
{ k: 'statLinks', label: 'Links stat' },
] },
{ title: 'Section 01 — Featured Q-App', fields: [
{ k: 'sec01Eyebrow', label: 'Eyebrow' },
{ k: 'sec01Title', label: 'Title' },
{ k: 'sec01Sub', label: 'Subtitle (HTML allowed)', multiline: true, html: true },
] },
{ title: 'Section 02 — Embedded Q-Apps', fields: [
{ k: 'sec02Eyebrow', label: 'Eyebrow' },
{ k: 'sec02Title', label: 'Title' },
{ k: 'sec02Sub', label: 'Subtitle (HTML allowed)', multiline: true, html: true },
{ k: 'embedOpenLabel', label: '“Open in Qortal” label' },
{ k: 'embedFallbackText', label: 'Offline embed hint' },
{ k: 'embedTabsAria', label: 'Tabs accessibility label' },
] },
{ title: 'Section 03 — More pages', fields: [
{ k: 'sec03Eyebrow', label: 'Eyebrow' },
{ k: 'sec03Title', label: 'Title' },
{ k: 'sec03Sub', label: 'Subtitle (HTML allowed)', multiline: true, html: true },
] },
{ title: 'Section 04 — Story', fields: [
{ k: 'sec04Eyebrow', label: 'Eyebrow' },
{ k: 'sec04Title', label: 'Title' },
] },
{ title: 'World Map photos block', fields: [
{ k: 'poiPhotosEyebrow', label: 'Eyebrow' },
{ k: 'poiPhotosTitle', label: 'Title' },
{ k: 'poiPhotosSub', label: 'Subtitle (HTML allowed)', multiline: true, html: true },
] },
{ title: 'Links column', fields: [
{ k: 'linksAsideHead', label: 'Small heading' },
{ k: 'linksAsideIntro', label: 'Intro (HTML allowed)', multiline: true, html: true },
{ k: 'linkPreviewsAria', label: 'Grid aria-label' },
] },
{ title: 'Footer', fields: [
{ k: 'footerLeft', label: 'Left line' },
{ k: 'footerBuild', label: 'Right build tag' },
] },
{ title: 'Builder intro (HTML)', fields: [
{ k: 'siteEditorLeadHtml', label: 'Text above the form (HTML)', multiline: true, html: true },
] },
{ title: 'Community (groups, life, private)', fields: [
{ k: 'communityEyebrow', label: 'Eyebrow' },
{ k: 'communityTitle', label: 'Title' },
{ k: 'communitySub', label: 'Subtitle (HTML allowed)', multiline: true, html: true },
] },
];
let visualDesignerBuilt = false;
let visualSaveTimer = null;
let visualFormDelegateBound = false;
function ensureVisualFormDelegate() {
if (visualFormDelegateBound) return;
const app = document.getElementById('sn-app');
if (!app) return;
app.addEventListener('input', onVisualFieldInput, true);
app.addEventListener('change', onVisualFieldInput, true);
visualFormDelegateBound = true;
}
/**
* Side panels: VISUAL_UI_GROUPS indices next to the matching page block (see index.html).
* sn-panel-site uses #sn-panel-site-core so the registered-name block can sit in the same aside.
*/
const SEGMENT_EDIT_PANELS = [
{ el: 'sn-panel-site', groupIndexes: [0, 11], coreId: 'sn-panel-site-core' },
{ el: 'sn-panel-hero', groupIndexes: [1, 2, 3] },
{ el: 'sn-panel-s01', groupIndexes: [4] },
{ el: 'sn-panel-s02', groupIndexes: [5] },
{ el: 'sn-panel-s03', groupIndexes: [6] },
{ el: 'sn-panel-community', groupIndexes: [12] },
{ el: 'sn-panel-about', groupIndexes: [7, 9] },
{ el: 'sn-panel-poi', groupIndexes: [8] },
{ el: 'sn-panel-foot', groupIndexes: [10] },
];
function scheduleVisualSave() {
if (visualSaveTimer) clearTimeout(visualSaveTimer);
visualSaveTimer = setTimeout(() => {
try {
saveLocalSite();
} catch (_) {}
}, 400);
}
function appendVisualGroupTo(host, gIndex) {
const g = VISUAL_UI_GROUPS[gIndex];
if (!g || !host) return;
const section = el('div', 'sn-vi-group');
const h = el('h4', 'sn-vi-group-title', g.title);
section.appendChild(h);
const grid = el('div', 'sn-vi-grid');
g.fields.forEach((f) => {
const lab = el('label', 'sn-field');
const span = el('span', 'sn-field-l', f.label || f.k);
if (f.html) {
const hint = el('span', 'sn-vi-field-hint', ' HTML');
span.appendChild(hint);
}
const ta = f.multiline
? (() => {
const t = el('textarea', 'sn-input sn-textarea' + (f.html ? ' sn-textarea-code' : ''));
t.setAttribute('data-vi-key', f.k);
t.rows = f.html ? 3 : 2;
t.setAttribute('spellcheck', 'false');
return t;
})()
: (() => {
const i = el('input', 'sn-input');
i.setAttribute('type', 'text');
i.setAttribute('data-vi-key', f.k);
i.setAttribute('autocomplete', 'off');
return i;
})();
lab.appendChild(span);
lab.appendChild(ta);
grid.appendChild(lab);
});
section.appendChild(grid);
host.appendChild(section);
}
function relocateIdentityForSegmentMode() {
const idBlock = document.getElementById('sn-vi-identity');
if (!idBlock) return;
const design = document.getElementById('sn-visual-designer');
const sitePanel = document.getElementById('sn-panel-site');
const siteCore = document.getElementById('sn-panel-site-core');
const fields = document.getElementById('sn-vi-fields');
if (STATE.segmentEditMode && sitePanel && siteCore) {
sitePanel.insertBefore(idBlock, siteCore);
} else if (design && fields) {
design.insertBefore(idBlock, fields);
}
}
function rebuildVisualFormLayout() {
const host = document.getElementById('sn-vi-fields');
const app = document.getElementById('sn-app');
if (!host) return;
ensureVisualFormDelegate();
const seg = !!STATE.segmentEditMode;
if (app) app.classList.toggle('sn-app--segment-edit', seg);
if (seg) {
host.innerHTML = '';
host.setAttribute('hidden', '');
host.setAttribute('aria-hidden', 'true');
} else {
host.removeAttribute('hidden');
host.setAttribute('aria-hidden', 'false');
}
SEGMENT_EDIT_PANELS.forEach((def) => {
const panel = document.getElementById(def.el);
if (!panel) return;
const target = def.coreId ? document.getElementById(def.coreId) : panel;
if (!target) return;
if (def.coreId) {
target.innerHTML = '';
} else {
panel.innerHTML = '';
}
if (seg) {
def.groupIndexes.forEach((gi) => appendVisualGroupTo(target, gi));
panel.removeAttribute('hidden');
panel.setAttribute('aria-hidden', 'false');
} else {
panel.setAttribute('hidden', '');
panel.setAttribute('aria-hidden', 'true');
}
});
if (!seg) {
host.innerHTML = '';
VISUAL_UI_GROUPS.forEach((g, i) => appendVisualGroupTo(host, i));
}
relocateIdentityForSegmentMode();
visualDesignerBuilt = true;
syncVisualDesignerFields();
}
function onVisualFieldInput(ev) {
const t = ev.target;
if (!t || !t.getAttribute('data-vi-key')) return;
const k = t.getAttribute('data-vi-key');
if (!STATE.site.ui) STATE.site.ui = defaultUiTemplate();
STATE.site.ui[k] = t.value;
applyChromeUi();
scheduleVisualSave();
}
function ensureVisualDesigner() {
if (visualDesignerBuilt) return;
rebuildVisualFormLayout();
}
function syncVisualDesignerFields() {
if (!visualDesignerBuilt) return;
const root = document.getElementById('sn-app');
if (!root) return;
root.querySelectorAll('[data-vi-key]').forEach((inp) => {
if (document.activeElement === inp) return;
const k = inp.getAttribute('data-vi-key');
if (!k || !STATE.site.ui) return;
const v = STATE.site.ui[k];
inp.value = v != null ? String(v) : '';
});
updateBrandLine();
}
const $ = (s, r) => (r || document).querySelector(s);
const $$ = (s, r) => Array.from((r || document).querySelectorAll(s));
const el = (tag, cls, txt) => {
const e = document.createElement(tag);
if (cls) e.className = cls;
if (txt != null) e.textContent = txt;
return e;
};
const esc = (s) => String(s == null ? '' : s)
.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
function objectToBase64(obj) {
const json = JSON.stringify(obj);
const bytes = new TextEncoder().encode(json);
let bin = '';
for (const b of bytes) bin += String.fromCharCode(b);
return btoa(bin);
}
/** Binary ZIP / arbitrary bytes → base64 for PUBLISH_QDN_RESOURCE.data64 */
function uint8ToBase64(bytes) {
const u = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
let bin = '';
for (let i = 0; i < u.length; i++) bin += String.fromCharCode(u[i]);
return btoa(bin);
}
function accountNamesList(raw) {
let list = [];
if (Array.isArray(raw)) list = raw;
else if (raw && Array.isArray(raw.names)) list = raw.names;
else list = normList(raw);
return list
.map((n) => (typeof n === 'string' ? n : n && (n.name || n.label || n)))
.filter((s) => s && String(s).trim());
}
/** If user never set a tracked name, use their only registered name so stats match their wallet. */
async function maybeSetPublisherNameFromWallet() {
if (!STATE.userAccount || !STATE.inQortal) return;
const cur = String(STATE.site.publisherName || '').trim();
if (cur && cur !== DEFAULT_PUBLISHER) return;
try {
const raw = await qReq({
action: 'GET_ACCOUNT_NAMES',
address: STATE.userAccount.address,
limit: 200,
offset: 0,
});
const list = accountNamesList(raw);
if (list.length === 1) {
STATE.site.publisherName = list[0];
saveLocalSite();
applyChromeUi();
}
} catch (_) {}
}
/** Embed baked site config for standalone WEBSITE or APP ZIP (escape < for script safety). */
function jsonForScriptTag(obj) {
return JSON.stringify(obj).replace(/</g, '\\u003c');
}
function injectEmbedConfig(html, jsonText) {
const re = /(<script[^>]*\bid\s*=\s*["']sn-embed-config["'][^>]*>)([\s\S]*?)(<\/script>)/i;
if (re.test(html)) return html.replace(re, '$1' + jsonText + '$3');
return html.replace(
/<\/head>/i,
' <script id="sn-embed-config" type="application/json">' + jsonText + '</script>\n</head>'
);
}
/**
* Pack static assets + embedded STATE.site (with chosen publisherName) into a STORE ZIP
* for qortalRequest({ service: 'WEBSITE', data64 }).
*/
async function buildWebsiteZipBytes(siteSnapshot) {
const base = '';
const fetchText = (path) => fetch(base + path, { cache: 'no-store' }).then((r) => {
if (!r.ok) throw new Error('Missing ' + path + ' (' + r.status + ')');
return r.text();
});
const [htmlRaw, appPart, stylePart, manifestRaw, zipPart] = await Promise.all([
fetchText('index.html'),
fetchText('app.js'),
fetchText('style.css'),
fetchText('manifest.json'),
fetchText('zip-store.js'),
]);
let favBytes;
try {
const fr = await fetch(base + 'favicon.svg', { cache: 'no-store' });
if (!fr.ok) throw new Error('no favicon');
favBytes = new Uint8Array(await fr.arrayBuffer());
} catch (_) {
favBytes = new TextEncoder().encode('');
}
const cfg = jsonForScriptTag(siteSnapshot);
const htmlOut = injectEmbedConfig(htmlRaw, cfg);
let manifestStr = manifestRaw;
try {
const m = JSON.parse(manifestRaw);
const t = siteSnapshot && siteSnapshot.ui && siteSnapshot.ui.docTitle;
if (t) m.title = String(t);
m.description = (m.description || '') + ' Published as WEBSITE from the portfolio template.';
manifestStr = JSON.stringify(m, null, 2);
} catch (_) {}
if (typeof buildZipStore !== 'function') {
throw new Error('zip-store.js did not define buildZipStore');
}
return buildZipStore({
'index.html': htmlOut,
'app.js': appPart,
'style.css': stylePart,
'manifest.json': manifestStr,
'favicon.svg': favBytes,
'zip-store.js': zipPart,
});
}
async function pickOwnedRegisteredName() {
if (!STATE.inQortal) return null;
let acc = STATE.userAccount;
if (!acc || !acc.address) {
try {
acc = await qReq({ action: 'GET_USER_ACCOUNT' });
STATE.userAccount = acc;
} catch (_) {
toast('Connect your wallet first', 'warn');
return null;
}
}
const raw = await qReq({
action: 'GET_ACCOUNT_NAMES',
address: acc.address,
limit: 200,
offset: 0,
});
const names = accountNamesList(raw);
if (!names.length) {
toast('You need at least one registered Qortal name to publish a WEBSITE', 'warn');
return null;
}
if (names.length === 1) return String(names[0]);
const lines = names.slice(0, 40).join(', ');
const chosen = window.prompt('Enter the registered name to publish this WEBSITE under:\n\n' + lines, names[0] || '');
if (chosen == null || !String(chosen).trim()) return null;
const t = String(chosen).trim();
if (!names.includes(t)) {
toast('That name is not registered to your wallet', 'warn');
return null;
}
return t;
}
async function onPublishMyWebsite() {
if (!STATE.inQortal) {
toast('Open inside Qortal to publish', 'warn');
return;
}
if (typeof buildZipStore !== 'function') {
toast('ZIP builder missing — ensure zip-store.js is loaded', 'err');
return;
}
const publishName = await pickOwnedRegisteredName();
if (!publishName) return;
try {
toast('Building ZIP…');
const snap = mergeSite(JSON.parse(JSON.stringify(STATE.site)));
snap.publisherName = publishName;
const zipBytes = await buildWebsiteZipBytes(snap);
const title =
(snap.ui && snap.ui.docTitle && String(snap.ui.docTitle).trim()) ||
publishName + ' — website';
const websiteFile = new File([zipBytes], 'website.zip', { type: 'application/zip' });
await qReq({
action: 'PUBLISH_QDN_RESOURCE',
name: publishName,
service: 'WEBSITE',
file: websiteFile,
filename: 'website.zip',
isMultiFileZip: true,
title,
description: 'My Personal Website — portfolio + Qommunity-style community (groups, life counter, private rows) in embedded site config',
});
STATE.site.publisherName = publishName;
await loadEverything();
saveLocalSite();
toast('Published WEBSITE on «' + publishName + '» — public site: ' + 'qortal://WEBSITE/' + publishName);
} catch (e) {
toast('Website publish failed: ' + (e && e.message ? e.message : e), 'err');
}
}
function base64ToObject(b64) {
const bin = atob(b64);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
const json = new TextDecoder('utf-8').decode(bytes);
return JSON.parse(json);
}
async function qReq(payload) {
if (typeof qortalRequest !== 'function') {
throw new Error('Qortal API unavailable (preview mode)');
}
return qortalRequest(payload);
}
function normList(batch) {
if (Array.isArray(batch)) return batch;
if (batch && Array.isArray(batch.resources)) return batch.resources;
return [];
}
/** qortal://APP/AppName/path/with/segments */
function parseQortalAppHref(href) {
const s = String(href || '').trim();
const m = /^qortal:\/\/APP\/([^/]+)\/(.+)$/i.exec(s);
if (m) {
return {
name: decodeURIComponentSafe(m[1]),
path: decodeURIComponentSafe(m[2].replace(/\+/g, '%20')),
};
}
const simple = /^qortal:\/\/APP\/([^/?#]+)\/?$/i.exec(s);
if (simple) return { name: decodeURIComponentSafe(simple[1]), path: null };
return null;
}
function decodeURIComponentSafe(s) {
try {
return decodeURIComponent(s);
} catch (_) {
return s;
}
}
/** World Map POI wall photos: qortal://IMAGE/PublisherName/worldmap_marker_…_photo_0 */
function parseQortalImageHref(href) {
const s = String(href || '').trim();
const m = /^qortal:\/\/IMAGE\/([^/]+)\/([^?#]+)$/i.exec(s);
if (!m) return null;
return {
name: decodeURIComponentSafe(m[1].replace(/\+/g, '%20')),
identifier: decodeURIComponentSafe(m[2].replace(/\+/g, '%20')),
};
}
async function resolvePoiImageSrc(parsed) {
if (!parsed || !parsed.name || !parsed.identifier) return null;
if (STATE.inQortal) {
try {
const u = await qReq({
action: 'GET_QDN_RESOURCE_URL',
service: 'IMAGE',
name: parsed.name,
identifier: parsed.identifier,
});
if (u && typeof u === 'string') return u;
} catch (_) {}
}
return qdnUrl('IMAGE', parsed.name, parsed.identifier, null);
}
function mergeSite(raw) {
const d = defaultSiteConfig();
if (!raw || typeof raw !== 'object') return d;
const out = Object.assign({}, d, raw);
if (!Array.isArray(out.links)) out.links = d.links;
if (!Array.isArray(out.pages)) out.pages = d.pages;
if (!Array.isArray(out.embeds) || !out.embeds.length) out.embeds = d.embeds;
if (!Array.isArray(out.poiPhotos)) out.poiPhotos = [];
if (typeof out.aboutHtml !== 'string' || !String(out.aboutHtml).trim()) out.aboutHtml = d.aboutHtml;
if (typeof out.publisherName !== 'string' || !String(out.publisherName).trim()) {
out.publisherName = d.publisherName;
}
const dui = d.ui || defaultUiTemplate();
out.ui = Object.assign({}, dui, typeof out.ui === 'object' && out.ui ? out.ui : {});
Object.keys(dui).forEach((k) => {
if (out.ui[k] == null || out.ui[k] === '') out.ui[k] = dui[k];
});
if (out.ui.siteSettingsH3 === 'Visual site builder' || out.ui.siteSettingsH3 === 'My Website Builder') {
out.ui.siteSettingsH3 = dui.siteSettingsH3;
}
const dcomm = d.community;
if (!out.community || typeof out.community !== 'object') {
out.community = Object.assign({}, dcomm);
} else {
out.community = Object.assign({}, dcomm, out.community);
}
if (!Array.isArray(out.community.privateGroups)) out.community.privateGroups = [];
const dw = d.workshop;
if (!out.workshop || typeof out.workshop !== 'object') {
out.workshop = { label: dw.label, qortalHref: dw.qortalHref };
} else {
if (typeof out.workshop.label !== 'string' || !out.workshop.label.trim()) out.workshop.label = dw.label;
if (typeof out.workshop.qortalHref !== 'string' || !out.workshop.qortalHref.trim()) {
out.workshop.qortalHref = dw.qortalHref;
}
}
const dLay = d.layout;
if (!out.layout || typeof out.layout !== 'object') {
out.layout = { order: dLay.order.slice(), hidden: { ...dLay.hidden } };
} else {
if (!Array.isArray(out.layout.order)) {
out.layout.order = dLay.order.slice();
} else {
out.layout.order = normalizeLayoutOrder(out.layout.order);
}
if (!out.layout.hidden || typeof out.layout.hidden !== 'object') {
out.layout.hidden = {};
} else {
const h = {};
LAYOUT_DEFAULT_ORDER.forEach((k) => {
if (out.layout.hidden[k] === true) h[k] = true;
});
out.layout.hidden = h;
}
}
out.version = 3;
return out;
}
function normalizeLayoutOrder(userOrder) {
const d = LAYOUT_DEFAULT_ORDER;
const seen = new Set();
const out = [];
(Array.isArray(userOrder) ? userOrder : []).forEach((k) => {
if (d.indexOf(k) === -1) return;
if (seen.has(k)) return;
seen.add(k);
out.push(k);
});
d.forEach((k) => {
if (!seen.has(k)) out.push(k);
});
return out;
}
function getLayoutNodeByKey(key) {
const elId = LAYOUT_BLOCK_ELEMENTS[key];
if (!elId) return null;
return document.getElementById(elId);
}
function ensureLayoutState() {
if (!STATE.site.layout || typeof STATE.site.layout !== 'object') {
STATE.site.layout = { order: LAYOUT_DEFAULT_ORDER.slice(), hidden: {} };
}
if (!Array.isArray(STATE.site.layout.order)) {
STATE.site.layout.order = LAYOUT_DEFAULT_ORDER.slice();
}
STATE.site.layout.order = normalizeLayoutOrder(STATE.site.layout.order);
if (!STATE.site.layout.hidden || typeof STATE.site.layout.hidden !== 'object') {
STATE.site.layout.hidden = {};
}
}
function applyPageLayout() {
const app = document.getElementById('sn-app');
if (!app) return;
ensureLayoutState();
const order = STATE.site.layout.order;
const hidden = STATE.site.layout.hidden;
const header = app.querySelector('header.sn-top');
if (!header) return;
let prev = header;
order.forEach((key) => {
const el = getLayoutNodeByKey(key);
if (!el || el.parentNode !== app) return;
if (hidden[key] === true) {
el.hidden = true;
el.setAttribute('aria-hidden', 'true');
el.setAttribute('data-sn-layout-off', '1');
} else {
el.hidden = false;
el.setAttribute('aria-hidden', 'false');
el.removeAttribute('data-sn-layout-off');
}
app.insertBefore(el, prev.nextSibling);
prev = el;
});
syncLayoutBlockChrome();
renderLayoutRestoreList();
}
function renderLayoutRestoreList() {
const ul = document.getElementById('sn-layout-restore-list');
const details = document.getElementById('sn-layout-restore-wrap');
if (!ul) return;
ensureLayoutState();
const h = STATE.site.layout.hidden || {};
const keys = LAYOUT_DEFAULT_ORDER.filter((k) => h[k] === true);
ul.innerHTML = '';
if (!keys.length) {
const li = document.createElement('li');
li.className = 'sn-mono sn-dim';
li.textContent = 'No sections are hidden. Use the × on a block in the page (Hub) to remove one from the public view.';
ul.appendChild(li);
if (details) details.open = false;
return;
}
if (details) details.open = true;
keys.forEach((k) => {
const li = document.createElement('li');
li.className = 'sn-layout-restore-item';
const label = LAYOUT_LABELS[k] || k;
li.appendChild(document.createTextNode(label + ' '));
const b = document.createElement('button');
b.type = 'button';
b.className = 'sn-btn sn-btn-ghost sn-btn-sm';
b.textContent = 'Show again';
b.setAttribute('data-sn-lc-restore', k);
b.title = 'Show this block on the page again';
li.appendChild(b);
ul.appendChild(li);
});
}
function syncLayoutBlockChrome() {
const app = document.getElementById('sn-app');
if (!app) return;
ensureLayoutState();
const order = STATE.site.layout.order;
LAYOUT_DEFAULT_ORDER.forEach((key) => {
const node = getLayoutNodeByKey(key);
if (!node) return;
let chrome = node.querySelector(':scope > .sn-layout-chrome');
if (chrome) chrome.remove();
if (!STATE.inQortal) {
return;
}
chrome = document.createElement('div');
chrome.className = 'sn-layout-chrome';
chrome.setAttribute('data-sn-lc', key);
const drag = document.createElement('span');
drag.className = 'sn-layout-chrome__drag';
drag.textContent = '⋮⋮';
drag.title = 'Drag to reorder with another block';
drag.setAttribute('draggable', 'true');
const up = document.createElement('button');
up.type = 'button';
up.className = 'sn-btn sn-btn-ghost sn-btn-sm sn-layout-chrome__btn';
up.textContent = '↑';
up.setAttribute('data-sn-lc-act', 'up');
up.title = 'Move this block up';
const down = document.createElement('button');
down.type = 'button';
down.className = 'sn-btn sn-btn-ghost sn-btn-sm sn-layout-chrome__btn';
down.textContent = '↓';
down.setAttribute('data-sn-lc-act', 'down');
down.title = 'Move this block down';
chrome.appendChild(drag);
chrome.appendChild(up);
chrome.appendChild(down);
if (!LAYOUT_KEYS_NO_DELETE.has(key)) {
const rm = document.createElement('button');
rm.type = 'button';
rm.className = 'sn-btn sn-btn-ghost sn-btn-sm sn-layout-chrome__btn';
rm.textContent = '×';
rm.setAttribute('data-sn-lc-act', 'rm');
rm.title = 'Remove this block from the public page (restore below)';
chrome.appendChild(rm);
}
const idx = order.indexOf(key);
if (idx <= 0) up.disabled = true;
if (idx < 0 || idx >= order.length - 1) down.disabled = true;
if (node.firstChild) {
node.insertBefore(chrome, node.firstChild);
} else {
node.appendChild(chrome);
}
node.classList.add('sn-layout-block');
});
}
function layoutActionMove(key, dir) {
ensureLayoutState();
const o = STATE.site.layout.order.slice();
const i = o.indexOf(key);
if (i < 0) return;
if (dir === 'up' && i > 0) {
const t = o[i - 1];
o[i - 1] = o[i];
o[i] = t;
} else if (dir === 'down' && i < o.length - 1) {
const t = o[i + 1];
o[i + 1] = o[i];
o[i] = t;
} else {
return;
}
STATE.site.layout.order = o;
saveLocalSite();
applyPageLayout();
}
function layoutActionHide(key) {
if (LAYOUT_KEYS_NO_DELETE.has(key)) {
toast('The builder panel always stays on the page so you can edit and restore other sections.', 'warn');
return;
}
ensureLayoutState();
STATE.site.layout.hidden[key] = true;
saveLocalSite();
applyPageLayout();
}
function layoutMoveKeyBefore(from, to) {
ensureLayoutState();
if (from === to) return;
const o = STATE.site.layout.order.slice();
const iFrom = o.indexOf(from);
const iTo = o.indexOf(to);
if (iFrom < 0 || iTo < 0) return;
o.splice(iFrom, 1);
const j = o.indexOf(to);
o.splice(j, 0, from);
STATE.site.layout.order = o;
saveLocalSite();
applyPageLayout();
}
function layoutRestoreKey(key) {
ensureLayoutState();
if (STATE.site.layout.hidden[key] === true) {
delete STATE.site.layout.hidden[key];
}
saveLocalSite();
applyPageLayout();
}
function wirePageLayoutOnce() {
const app = document.getElementById('sn-app');
if (!app || app.dataset.snLayoutControls) return;
app.dataset.snLayoutControls = '1';
app.addEventListener('click', (e) => {
const r = e.target.closest('[data-sn-lc-restore]');
if (r && app.contains(r)) {
e.preventDefault();
e.stopPropagation();
layoutRestoreKey(r.getAttribute('data-sn-lc-restore') || '');
return;
}
const btn = e.target.closest('[data-sn-lc-act]');
if (!btn || !app.contains(btn)) return;
e.preventDefault();
e.stopPropagation();
const wrap = btn.closest('.sn-layout-chrome');
const key = wrap && wrap.getAttribute('data-sn-lc');
if (!key) return;
const act = btn.getAttribute('data-sn-lc-act');
if (act === 'up') layoutActionMove(key, 'up');
else if (act === 'down') layoutActionMove(key, 'down');
else if (act === 'rm') layoutActionHide(key);
});
app.addEventListener('dragstart', (e) => {
const h = e.target.closest('.sn-layout-chrome__drag');
if (!h || !app.contains(h)) return;
const block = h.closest('[data-sn-layout-block]');
if (!block) return;
const k = block.getAttribute('data-sn-layout-block');
if (!k) return;
e.dataTransfer.setData('text/plain', k);
e.dataTransfer.effectAllowed = 'move';
});
app.addEventListener('dragover', (e) => {
if (!e.target.closest('[data-sn-layout-block]')) return;
e.preventDefault();
});
app.addEventListener('drop', (e) => {
const block = e.target.closest('[data-sn-layout-block]');
if (!block || !app.contains(block)) return;
e.preventDefault();
const from = e.dataTransfer.getData('text/plain');
const to = block.getAttribute('data-sn-layout-block');
if (!from || !to || from === to) return;
layoutMoveKeyBefore(from, to);
});
}
function loadLocalSite() {
try {
let t = localStorage.getItem(SITE_STORAGE_KEY);
if (!t) {
t = localStorage.getItem(LEGACY_SITE_STORAGE_KEY);
if (t) {
try {
localStorage.setItem(SITE_STORAGE_KEY, t);
localStorage.removeItem(LEGACY_SITE_STORAGE_KEY);
} catch (_) {}
}
}
if (!t) return null;
return mergeSite(JSON.parse(t));
} catch (_) {
return null;
}
}
function saveLocalSite() {
try {
localStorage.setItem(SITE_STORAGE_KEY, JSON.stringify(STATE.site));
} catch (e) {
toast('Could not save locally: ' + (e.message || e), 'err');
}
updateFloatingQdnBar();
}
async function fetchSiteFromQDN() {
if (!isDocumentQdnEnabledForPublisher()) return null;
const ids = [SITE_DOCUMENT_ID, LEGACY_SITE_DOCUMENT_ID];
for (let i = 0; i < ids.length; i++) {
try {
const raw = await qReq({
action: 'FETCH_QDN_RESOURCE',
name: publisherName(),
service: 'DOCUMENT',
identifier: ids[i],
});
if (raw == null || raw === '') continue;
if (typeof raw === 'object' && raw !== null && !ArrayBuffer.isView(raw) && !(raw instanceof ArrayBuffer)) {
return mergeSite(raw);
}
const str = typeof raw === 'string' ? raw : new TextDecoder().decode(new Uint8Array(raw));
if (str && str.trim()) return mergeSite(JSON.parse(str));
} catch (_) {}
}
return null;
}
async function tryLoadSiteConfig() {
const embedEl = document.getElementById('sn-embed-config');
if (embedEl && embedEl.textContent && embedEl.textContent.trim()) {
try {
STATE.site = mergeSite(JSON.parse(embedEl.textContent.trim()));
return;
} catch (e) {
console.warn('Embedded site config', e);
}
}
const local = loadLocalSite();
if (local) {
STATE.site = local;
return;
}
let merged = mergeSite(null);
if (STATE.inQortal) {
try {
const remote = await fetchSiteFromQDN();
if (remote) merged = remote;
} catch (_) {
/* no published config yet */
}
}
STATE.site = merged;
}
function uiStr(key, fallback) {
const u = STATE.site && STATE.site.ui;
const v = u && u[key];
return v != null && String(v).length ? String(v) : fallback || '';
}
function ensureCommunity() {
const s = STATE.site;
if (!s.community || typeof s.community !== 'object') {
s.community = Object.assign({}, defaultSiteConfig().community);
}
if (!Array.isArray(s.community.privateGroups)) s.community.privateGroups = [];
return s.community;
}
function applyCommunityDataToDom() {
const c = ensureCommunity();
const sec = document.getElementById('sn-community-section');
if (sec) sec.hidden = c.sectionHidden === true;
const setN = (id, v) => {
const n = document.getElementById(id);
if (!n) return;
if (wysiIsEditingNode(n)) return;
const t = v == null ? '' : String(v).trim();
n.textContent = t === '' ? '—' : t;
};
setN('sn-life-label', c.lifeLabel);
setN('sn-life-value', c.lifeValue);
setN('sn-pub-gid', c.publicGroupId);
setN('sn-pub-gname', c.publicGroupName);
setN('sn-adm-gid', c.adminGroupId);
setN('sn-adm-gname', c.adminGroupName);
const host = document.getElementById('sn-private-groups');
if (!host) return;
host.innerHTML = '';
const list = c.privateGroups || [];
if (!list.length) {
const p = el('p', 'sn-mono sn-dim sn-community-empty');
p.textContent = 'No private groups listed — add rows in the site builder.';
host.appendChild(p);
return;
}
list.forEach((row) => {
const card = el('div', 'sn-private-group-card');
const name = el('div', 'sn-private-group-name', row.name || 'Unnamed');
const gid = row.groupId != null ? String(row.groupId).trim() : '';
const idl = el('div', 'sn-private-group-id sn-mono sn-dim', gid ? 'Group ID: ' + gid : '');
const badge = el('div', 'sn-private-group-badge sn-mono', 'private');
const top = el('div', 'sn-private-group-top');
top.appendChild(badge);
top.appendChild(name);
card.appendChild(top);
if (gid) card.appendChild(idl);
if (row.note && String(row.note).trim()) {
const note = el('p', 'sn-private-group-note sn-dim', String(row.note).trim());
card.appendChild(note);
}
host.appendChild(card);
});
}
function isWysiwygContextActive() {
return !!(
STATE.wysiwygMode !== false && STATE.inQortal && STATE.userAccount
);
}
function wysiIsEditingNode(n) {
if (!n || !isWysiwygContextActive()) return false;
const ae = document.activeElement;
if (!ae) return false;
return n === ae || n.contains(ae);
}
const WYSIWYG_PAGE_CONFIG = [
{ id: 'sn-hero-eyebrow', key: 'heroEyebrow', html: false, kind: 'ui' },
{ id: 'sn-name-first', key: 'nameFirst', html: false, kind: 'ui' },
{ id: 'sn-name-last', key: 'nameLast', html: false, kind: 'ui' },
{ id: 'sn-tagline', key: 'tagline', html: true, kind: 'ui' },
{ id: 'sn-hero-lbl-apps', key: 'heroBtnWorkshop', html: false, kind: 'ui' },
{ id: 'sn-hero-lbl-feed', key: 'heroBtnQapps', html: false, kind: 'ui' },
{ id: 'sn-hero-lbl-community', key: 'heroBtnCommunity', html: false, kind: 'ui' },
{ id: 'sn-id-name', key: 'identityCardTitle', html: false, kind: 'ui' },
{ id: 'sn-owner-line-prefix', key: 'ownerLinePrefix', html: false, kind: 'ui' },
{ id: 'sn-visitor-eyebrow', key: 'visitorEyebrow', html: false, kind: 'ui' },
{ id: 'sn-stat-l-qdn', key: 'statQdn', html: false, kind: 'ui' },
{ id: 'sn-stat-l-apps', key: 'statApps', html: false, kind: 'ui' },
{ id: 'sn-stat-l-arb', key: 'statArb', html: false, kind: 'ui' },
{ id: 'sn-stat-l-blocks', key: 'statMinted', html: false, kind: 'ui' },
{ id: 'sn-stat-l-balance', key: 'statQort', html: false, kind: 'ui' },
{ id: 'sn-stat-l-links', key: 'statLinks', html: false, kind: 'ui' },
{ id: 'sn-sec01-eyebrow', key: 'sec01Eyebrow', html: false, kind: 'ui' },
{ id: 'sn-sec01-title', key: 'sec01Title', html: false, kind: 'ui' },
{ id: 'sn-sec01-sub', key: 'sec01Sub', html: true, kind: 'ui' },
{ id: 'sn-sec02-eyebrow', key: 'sec02Eyebrow', html: false, kind: 'ui' },
{ id: 'sn-sec02-title', key: 'sec02Title', html: false, kind: 'ui' },
{ id: 'sn-sec02-sub', key: 'sec02Sub', html: true, kind: 'ui' },
{ id: 'sn-embed-open-label', key: 'embedOpenLabel', html: false, kind: 'ui' },
{ id: 'sn-embed-fallback-text', key: 'embedFallbackText', html: false, kind: 'ui' },
{ id: 'sn-sec03-eyebrow', key: 'sec03Eyebrow', html: false, kind: 'ui' },
{ id: 'sn-sec03-title', key: 'sec03Title', html: false, kind: 'ui' },
{ id: 'sn-sec03-sub', key: 'sec03Sub', html: true, kind: 'ui' },
{ id: 'sn-comm-eyebrow', key: 'communityEyebrow', html: false, kind: 'ui' },
{ id: 'sn-comm-title', key: 'communityTitle', html: false, kind: 'ui' },
{ id: 'sn-comm-sub', key: 'communitySub', html: true, kind: 'ui' },
{ id: 'sn-life-label', key: 'lifeLabel', html: false, kind: 'community' },
{ id: 'sn-life-value', key: 'lifeValue', html: false, kind: 'community' },
{ id: 'sn-pub-gname', key: 'publicGroupName', html: false, kind: 'community' },
{ id: 'sn-pub-gid', key: 'publicGroupId', html: false, kind: 'community' },
{ id: 'sn-adm-gname', key: 'adminGroupName', html: false, kind: 'community' },
{ id: 'sn-adm-gid', key: 'adminGroupId', html: false, kind: 'community' },
{ id: 'sn-sec04-eyebrow', key: 'sec04Eyebrow', html: false, kind: 'ui' },
{ id: 'sn-sec04-title', key: 'sec04Title', html: false, kind: 'ui' },
{ id: 'sn-poi-eyebrow', key: 'poiPhotosEyebrow', html: false, kind: 'ui' },
{ id: 'sn-poi-title', key: 'poiPhotosTitle', html: false, kind: 'ui' },
{ id: 'sn-poi-sub', key: 'poiPhotosSub', html: true, kind: 'ui' },
{ id: 'sn-links-aside-head', key: 'linksAsideHead', html: false, kind: 'ui' },
{ id: 'sn-links-aside-intro', key: 'linksAsideIntro', html: true, kind: 'ui' },
{ id: 'sn-foot-left', key: 'footerLeft', html: false, kind: 'ui' },
{ id: 'sn-build-tag', key: 'footerBuild', html: false, kind: 'ui' },
{ id: 'sn-site-settings-h3', key: 'siteSettingsH3', html: false, kind: 'ui' },
{ id: 'sn-site-editor-lead', key: 'siteEditorLeadHtml', html: true, kind: 'ui' },
{ id: 'sn-about-prose', key: 'aboutHtml', html: true, kind: 'about' },
];
const WYSIWYG_BY_ID = (() => {
const m = {};
WYSIWYG_PAGE_CONFIG.forEach((r) => {
m[r.id] = r;
});
return m;
})();
function wysiFlushFromNode(el) {
const row = WYSIWYG_BY_ID[el.id];
if (!row) return;
if (!STATE.site.ui) STATE.site.ui = defaultUiTemplate();
const v = row.html ? el.innerHTML : el.textContent;
if (row.kind === 'about') {
STATE.site.aboutHtml = v;
} else if (row.kind === 'community') {
const c = ensureCommunity();
c[row.key] = v;
} else {
STATE.site.ui[row.key] = v;
}
try {
saveLocalSite();
} catch (_) {}
if (row.kind === 'ui') {
const inp = document.querySelector(`[data-vi-key="${row.key}"]`);
if (inp && document.activeElement !== inp) {
inp.value = v;
}
} else if (row.kind === 'about') {
const ta = document.getElementById('sn-editor-about-html');
if (ta && document.activeElement !== ta) {
ta.value = STATE.site.aboutHtml || DEFAULT_ABOUT_HTML;
}
} else if (row.kind === 'community') {
const map = {
lifeLabel: 'sn-comm-inp-life-label',
lifeValue: 'sn-comm-inp-life-value',
publicGroupName: 'sn-comm-inp-pub-gname',
publicGroupId: 'sn-comm-inp-pub-gid',
adminGroupName: 'sn-comm-inp-adm-gname',
adminGroupId: 'sn-comm-inp-adm-gid',
};
const iid = map[row.key];
if (iid) {
const ii = document.getElementById(iid);
if (ii && document.activeElement !== ii) ii.value = cVal(v);
}
}
try {
const uj = document.getElementById('sn-editor-ui-json');
if (uj && document.activeElement !== uj) uj.value = JSON.stringify(STATE.site.ui || {}, null, 2);
} catch (_) {}
}
function cVal(x) {
return x == null ? '' : String(x);
}
function updateWysiwygLayer() {
const app = document.getElementById('sn-app');
const tgl = document.getElementById('sn-wysiwyg-toggle');
const tlab = document.getElementById('sn-wysiwyg-toggle-label');
if (tgl) {
tgl.hidden = !STATE.inQortal || !STATE.userAccount;
tgl.setAttribute('aria-hidden', tgl.hidden ? 'true' : 'false');
}
if (tlab) {
tlab.textContent = STATE.wysiwygMode !== false ? 'Visual page · on' : 'Visual page · off';
}
if (tgl) {
tgl.setAttribute('aria-pressed', STATE.wysiwygMode !== false ? 'true' : 'false');
}
if (!isWysiwygContextActive()) {
if (app) app.classList.remove('sn-app--wysiwyg');
WYSIWYG_PAGE_CONFIG.forEach((row) => {
const n = document.getElementById(row.id);
if (!n) return;
n.removeAttribute('contenteditable');
n.classList.remove('sn-wysi-editable');
n.removeAttribute('data-sn-wysi-key');
n.removeAttribute('data-sn-wysi-html');
});
return;
}
if (app) app.classList.add('sn-app--wysiwyg');
WYSIWYG_PAGE_CONFIG.forEach((row) => {
const n = document.getElementById(row.id);
if (!n) return;
n.setAttribute('data-sn-wysi-key', row.key);
n.setAttribute('data-sn-wysi-html', row.html ? '1' : '0');
n.contentEditable = 'true';
n.setAttribute('spellcheck', 'true');
n.classList.add('sn-wysi-editable');
});
}
function wireWysiwygToolbarOnce() {
if (document.body.dataset.snWysiTb) return;
document.body.dataset.snWysiTb = '1';
const tb = document.getElementById('sn-wysi-toolbar');
if (!tb) return;
tb.addEventListener('mousedown', (e) => {
e.preventDefault();
});
tb.addEventListener('click', (e) => {
const b = e.target.closest('[data-wysi-cmd]');
if (!b) return;
e.preventDefault();
const cmd = b.getAttribute('data-wysi-cmd') || '';
if (cmd === 'link') {
const u = window.prompt('Link URL (qortal://):', 'qortal://');
if (u) document.execCommand('createLink', false, u);
} else {
try {
document.execCommand(cmd, false, null);
} catch (err) {
console.warn('execCommand', err);
}
}
});
}
function positionWysiwygToolbar() {
const tb = document.getElementById('sn-wysi-toolbar');
if (!tb || tb.hidden) return;
const sel = window.getSelection();
if (!sel || !sel.rangeCount) return;
const r = sel.getRangeAt(0);
if (r.collapsed) {
const el = r.commonAncestorContainer.nodeType === Node.ELEMENT_NODE
? r.commonAncestorContainer
: r.commonAncestorContainer.parentElement;
const h = el && el.closest && el.closest('[data-sn-wysi-html="1"]');
if (h) {
const rect = h.getBoundingClientRect();
const tw = Math.min(280, window.innerWidth - 16);
tb.style.left = Math.max(8, Math.min(window.innerWidth - tw - 8, rect.left)) + 'px';
tb.style.top = Math.max(8, rect.top - 44) + 'px';
return;
}
}
}
function wireWysiwygInputOnce() {
if (document.body.dataset.snWysiIn) return;
document.body.dataset.snWysiIn = '1';
let deb;
const go = (t) => {
if (t && t.id && WYSIWYG_BY_ID[t.id] && t.isContentEditable) {
wysiFlushFromNode(t);
if (WYSIWYG_BY_ID[t.id].html) {
const tb = document.getElementById('sn-wysi-toolbar');
if (tb) {
tb.hidden = false;
positionWysiwygToolbar();
}
}
updateBrandLine();
updateFloatingQdnBar();
applyCommunityDataToDom();
}
};
document.addEventListener('input', (e) => {
const t = e.target;
if (!t || !t.id || !WYSIWYG_BY_ID[t.id]) return;
if (deb) clearTimeout(deb);
deb = setTimeout(() => go(t), 120);
}, true);
document.addEventListener(
'blur',
(e) => {
const t = e.target;
if (t && t.id && WYSIWYG_BY_ID[t.id] && t.isContentEditable) {
wysiFlushFromNode(t);
}
setTimeout(() => {
const tb = document.getElementById('sn-wysi-toolbar');
if (tb && !tb.matches(':hover')) {
const ae = document.activeElement;
if (!ae || !ae.closest || !ae.closest('.sn-wysi-toolbar')) {
if (tb) tb.hidden = true;
}
}
}, 0);
},
true
);
document.addEventListener('selectionchange', () => {
if (!isWysiwygContextActive()) return;
const sel = window.getSelection();
if (!sel || !sel.rangeCount) return;
const r = sel.getRangeAt(0);
const el = r.commonAncestorContainer.nodeType === Node.ELEMENT_NODE
? r.commonAncestorContainer
: r.commonAncestorContainer.parentElement;
const h = el && el.closest && el.closest('[data-sn-wysi-html="1"]');
const tb = document.getElementById('sn-wysi-toolbar');
if (!h || h !== document.activeElement) {
if (tb) tb.hidden = !h;
}
if (h) {
if (tb) {
tb.hidden = false;
const rect = h.getBoundingClientRect();
const tw = Math.min(280, window.innerWidth - 16);
tb.style.left = Math.max(8, Math.min(window.innerWidth - tw - 8, rect.left)) + 'px';
tb.style.top = Math.max(8, rect.top - 40) + 'px';
}
}
});
wireWysiwygToolbarOnce();
}
/** Push `ui` strings into the static shell (ids in index.html). */
function applyChromeUi() {
const t = uiStr('docTitle', '');
if (t) document.title = t;
const set = (id, text, asHtml) => {
const n = document.getElementById(id);
if (!n) return;
if (wysiIsEditingNode(n)) return;
if (asHtml) n.innerHTML = text;
else n.textContent = text;
};
const setHtml = (id, h) => set(id, h, true);
updateBrandLine();
set('sn-hero-eyebrow', uiStr('heroEyebrow'));
set('sn-name-first', uiStr('nameFirst'));
set('sn-name-last', uiStr('nameLast'));
setHtml('sn-tagline', uiStr('tagline'));
set('sn-hero-lbl-apps', uiStr('heroBtnWorkshop'));
set('sn-hero-lbl-feed', uiStr('heroBtnQapps'));
set('sn-hero-lbl-community', uiStr('heroBtnCommunity'));
set('sn-id-name', uiStr('identityCardTitle'));
set('sn-owner-line-prefix', uiStr('ownerLinePrefix'));
set('sn-visitor-eyebrow', uiStr('visitorEyebrow'));
document.querySelectorAll('[data-sn-stat-l="qdn"]').forEach((e) => { if (!wysiIsEditingNode(e)) e.textContent = uiStr('statQdn'); });
document.querySelectorAll('[data-sn-stat-l="apps"]').forEach((e) => { if (!wysiIsEditingNode(e)) e.textContent = uiStr('statApps'); });
document.querySelectorAll('[data-sn-stat-l="arb"]').forEach((e) => { if (!wysiIsEditingNode(e)) e.textContent = uiStr('statArb'); });
document.querySelectorAll('[data-sn-stat-l="blocks"]').forEach((e) => { if (!wysiIsEditingNode(e)) e.textContent = uiStr('statMinted'); });
document.querySelectorAll('[data-sn-stat-l="balance"]').forEach((e) => { if (!wysiIsEditingNode(e)) e.textContent = uiStr('statQort'); });
document.querySelectorAll('[data-sn-stat-l="links"]').forEach((e) => { if (!wysiIsEditingNode(e)) e.textContent = uiStr('statLinks'); });
set('sn-sec01-eyebrow', uiStr('sec01Eyebrow'));
set('sn-sec01-title', uiStr('sec01Title'));
setHtml('sn-sec01-sub', uiStr('sec01Sub'));
set('sn-sec02-eyebrow', uiStr('sec02Eyebrow'));
set('sn-sec02-title', uiStr('sec02Title'));
setHtml('sn-sec02-sub', uiStr('sec02Sub'));
set('sn-embed-open-label', uiStr('embedOpenLabel'));
set('sn-embed-fallback-text', uiStr('embedFallbackText'));
const tabs = $('#sn-embed-tabs');
if (tabs) tabs.setAttribute('aria-label', uiStr('embedTabsAria'));
set('sn-sec03-eyebrow', uiStr('sec03Eyebrow'));
set('sn-sec03-title', uiStr('sec03Title'));
setHtml('sn-sec03-sub', uiStr('sec03Sub'));
set('sn-comm-eyebrow', uiStr('communityEyebrow'));
set('sn-comm-title', uiStr('communityTitle'));
setHtml('sn-comm-sub', uiStr('communitySub'));
applyCommunityDataToDom();
set('sn-sec04-eyebrow', uiStr('sec04Eyebrow'));
set('sn-sec04-title', uiStr('sec04Title'));
set('sn-poi-eyebrow', uiStr('poiPhotosEyebrow'));
set('sn-poi-title', uiStr('poiPhotosTitle'));
setHtml('sn-poi-sub', uiStr('poiPhotosSub'));
set('sn-links-aside-head', uiStr('linksAsideHead'));
setHtml('sn-links-aside-intro', uiStr('linksAsideIntro'));
const lgrid = $('#sn-link-previews');
if (lgrid) lgrid.setAttribute('aria-label', uiStr('linkPreviewsAria'));
set('sn-foot-left', uiStr('footerLeft'));
set('sn-build-tag', uiStr('footerBuild'));
set('sn-site-settings-h3', uiStr('siteSettingsH3'));
setHtml('sn-site-editor-lead', uiStr('siteEditorLeadHtml'));
updateWysiwygLayer();
updateFloatingQdnBar();
}
/**
* Site builder is always shown; Connect unlocks publish and ✎ site (in-place panes).
* "Publish to QDN" (DOCUMENT) only if this wallet also owns the tracked registered name.
*/
function updateBuilderChrome() {
const connected = STATE.inQortal && !!STATE.userAccount;
const hadSegment = !!STATE.segmentEditMode;
if (!connected) STATE.segmentEditMode = false;
const publishDoc = connected && !!STATE.canPublishDocument;
const publishBtn = $('#sn-site-publish');
const editJump = $('#sn-edit-jump');
const editor = $('#sn-site-editor');
const connectBtn = $('#sn-connect');
const pubWww = document.getElementById('sn-publish-website');
const reloadQdn = document.getElementById('sn-site-reload-qdn');
if (reloadQdn) {
const docQdn = isDocumentQdnEnabledForPublisher();
reloadQdn.hidden = !docQdn;
reloadQdn.setAttribute('aria-hidden', docQdn ? 'false' : 'true');
}
if (publishBtn) {
if (connected) {
publishBtn.hidden = true;
publishBtn.setAttribute('aria-hidden', 'true');
} else {
publishBtn.hidden = !publishDoc;
publishBtn.setAttribute('aria-hidden', !publishDoc ? 'true' : 'false');
}
}
if (editJump) {
editJump.hidden = !connected;
editJump.setAttribute('aria-hidden', connected ? 'false' : 'true');
if (!connected) {
editJump.setAttribute('aria-pressed', 'false');
editJump.title = 'Connect to toggle in-place panels next to each section';
} else {
editJump.setAttribute('aria-pressed', STATE.segmentEditMode ? 'true' : 'false');
editJump.title = STATE.segmentEditMode
? 'Single form in the site builder (turn off in-place panels)'
: 'In-place side panels next to each section (or single form below)';
}
}
if (editor) {
editor.hidden = false;
editor.removeAttribute('hidden');
}
if (connectBtn) {
connectBtn.classList.toggle('sn-btn-primary', !connected);
connectBtn.classList.toggle('sn-btn-ghost', connected);
}
if (pubWww) {
pubWww.classList.toggle('sn-btn-primary', connected);
pubWww.classList.toggle('sn-btn-ghost', !connected);
}
if (visualDesignerBuilt && hadSegment && !STATE.segmentEditMode) {
rebuildVisualFormLayout();
}
updateFloatingQdnBar();
updateWysiwygLayer();
}
/**
* Whether the connected wallet may publish the DOCUMENT for publisherName() (on-chain name owner).
*/
async function syncDocumentPublishEligibility() {
STATE.canPublishDocument = false;
if (!STATE.inQortal || !STATE.userAccount) return;
const userAddr = String(STATE.userAccount.address || '');
const ownerAddr = STATE.ownerAddress != null ? String(STATE.ownerAddress) : '';
if (ownerAddr && userAddr && userAddr === ownerAddr) {
STATE.canPublishDocument = true;
} else {
try {
const names = await qReq({
action: 'GET_ACCOUNT_NAMES',
address: STATE.userAccount.address,
limit: 200,
offset: 0,
});
let list = [];
if (Array.isArray(names)) list = names;
else if (names && Array.isArray(names.names)) list = names.names;
else list = normList(names);
const owns = list.some((n) => {
const nm = n && (n.name || n);
return String(nm || '') === publisherName();
});
STATE.canPublishDocument = owns;
} catch (_) {}
}
if (!isDocumentQdnEnabledForPublisher()) {
STATE.canPublishDocument = false;
}
}
/**
* Load wallet session (if any), align default registered name, resolve on-chain name owner, then editor + publish buttons.
* Editor opens whenever a wallet is connected; DOCUMENT publish only if the wallet owns the tracked name.
*/
async function applyWalletSessionForBuilder() {
if (!STATE.inQortal) return;
try {
if (!STATE.userAccount) {
const acc = await qReq({ action: 'GET_USER_ACCOUNT' });
if (acc && acc.address) STATE.userAccount = acc;
}
} catch (_) {
STATE.userAccount = null;
}
if (STATE.userAccount) {
await maybeSetPublisherNameFromWallet();
}
await resolveOwner();
if (STATE.userAccount) {
await syncDocumentPublishEligibility();
} else {
STATE.canPublishDocument = false;
}
updateBuilderChrome();
}
async function publishSiteToQDN() {
if (!isDocumentQdnEnabledForPublisher()) {
toast('This name is website-only — use “Publish my website” (qortal://WEBSITE/…), not on-chain DOCUMENT config.', 'warn');
return;
}
if (!STATE.canPublishDocument) {
toast('Wallet must own the tracked name «' + publisherName() + '» to publish this config to QDN', 'warn');
return;
}
try {
const payload = JSON.stringify(STATE.site);
const data64 = objectToBase64(JSON.parse(payload));
await qReq({
action: 'PUBLISH_QDN_RESOURCE',
name: publisherName(),
service: 'DOCUMENT',
identifier: SITE_DOCUMENT_ID,
data64,
title: publisherName() + ' — site config',
description: 'Portfolio config — About Me, links, pages, embeds (DOCUMENT)',
});
setSiteStatus('Published to QDN');
toast('Site config published');
setQdnBaselineFromCurrent();
updateFloatingQdnBar();
} catch (e) {
toast('Publish failed: ' + (e && e.message ? e.message : e), 'err');
}
}
async function loadSiteFromQDNButton() {
if (!STATE.inQortal) {
toast('Open inside Qortal to load from QDN', 'warn');
return;
}
if (!isDocumentQdnEnabledForPublisher()) {
toast('DOCUMENT sync is not used for this name — use “Publish my website” for the public site; config is kept in this browser.', 'warn');
return;
}
try {
const remote = await fetchSiteFromQDN();
if (!remote) {
toast('No config published yet', 'warn');
return;
}
STATE.site = remote;
setQdnBaselineFromCurrent();
saveLocalSite();
refreshAllUi();
setSiteStatus('Loaded from QDN');
toast('Loaded config from QDN');
} catch (e) {
toast('Load failed: ' + (e && e.message ? e.message : e), 'err');
}
}
function setSiteStatus(t) {
const n = $('#sn-site-status');
if (n) n.textContent = t || '';
}
async function openQApp(appName, identifier, path) {
const name = String(appName || '').trim();
if (!name) return false;
const payload = { action: 'LINK_TO_QDN_RESOURCE', service: 'APP', name };
if (identifier) payload.identifier = identifier;
if (path) payload.path = path;
try {
await qReq(payload);
return true;
} catch (e) {
try {
window.parent.postMessage(
{ action: 'SET_TAB', requestedHandler: 'UI', payload: { service: 'APP', name, identifier, path } },
'*'
);
return true;
} catch (e2) {
toast('Open inside Qortal to launch apps', 'warn');
return false;
}
}
}
async function openQortalResource(service, name, identifier, path) {
if (!name || !service) return false;
const payload = {
action: 'LINK_TO_QDN_RESOURCE',
service: String(service).toUpperCase(),
name,
};
if (identifier) payload.identifier = identifier;
if (path) payload.path = path;
try {
await qReq(payload);
return true;
} catch (e) {
try {
window.parent.postMessage(
{
action: 'SET_TAB',
requestedHandler: 'UI',
payload: {
service: String(service).toUpperCase(),
name,
identifier,
path,
},
},
'*'
);
return true;
} catch (e2) {
toast('Open inside Qortal to view this resource', 'warn');
return false;
}
}
}
function qdnUrl(service, name, identifier, filepath) {
let u = '/arbitrary/' + encodeURIComponent(String(service).toUpperCase()) + '/' + encodeURIComponent(name);
if (identifier) u += '/' + encodeURIComponent(identifier);
if (filepath) u += (u.indexOf('?') === -1 ? '?' : '&') + 'filepath=' + encodeURIComponent(filepath);
return u;
}
/**
* Q-Mintership-style URL for embedding qortal:// links in iframes:
* qortal://SERVICE/rest → /render/SERVICE/rest?theme=…
* (see MinterBoard.js processLink / AdminBoard processQortalLinkForRendering)
*/
function qortalLinkToRenderUrl(link) {
if (!link || typeof link !== 'string') return '';
if (!/^qortal:\/\//i.test(link)) return link;
const match = link.match(/^qortal:\/\/([^/]+)(\/.*)?$/i);
if (!match) return link;
const firstSeg = match[1].toUpperCase();
const rest = match[2] || '';
const theme =
typeof window._qdnTheme === 'string' && window._qdnTheme.trim()
? window._qdnTheme.trim()
: 'default';
return '/render/' + firstSeg + rest + '?theme=' + encodeURIComponent(theme);
}
/** After wallet connect, force embeds to reload so child Q-Apps see the same session. */
function appendAuthBust(url) {
if (!url || url === 'about:blank' || !STATE.sessionTag) return url;
const sep = url.indexOf('?') >= 0 ? '&' : '?';
return url + sep + '_snConn=' + STATE.sessionTag;
}
/**
* Prime the hub for each linked / embedded APP (GET_QDN_RESOURCE_URL) so permissions
* and routing align with the wallet session — same targets as link previews + Live Channels.
*/
async function primeLinkedAppResourcesAfterConnect() {
const seen = new Set();
const push = (href) => {
if (!href || typeof href !== 'string') return;
const h = href.trim();
if (!/^qortal:\/\//i.test(h)) return;
if (seen.has(h)) return;
seen.add(h);
};
(STATE.site.links || []).forEach((L) => push(L.href));
(STATE.site.embeds || []).forEach((E) => push(E.qortalHref));
if (STATE.site.workshop && STATE.site.workshop.qortalHref) {
push(String(STATE.site.workshop.qortalHref).trim());
}
for (const href of seen) {
const ap = parseQortalAppHref(href);
if (!ap) continue;
try {
const req = { action: 'GET_QDN_RESOURCE_URL', service: 'APP', name: ap.name };
if (ap.path) req.path = ap.path;
await qReq(req);
} catch (_) {
/* older cores / non-APP links */
}
await new Promise((r) => setTimeout(r, 35));
}
}
/** Resolve a URL suitable for an <iframe src> inside Qortal (prefers /render/…). */
async function resolveEmbedIframeSrc(qortalHref) {
const cacheKey = qortalHref;
if (STATE.embedSrcCache[cacheKey]) return STATE.embedSrcCache[cacheKey];
const renderUrl = qortalLinkToRenderUrl(qortalHref);
if (renderUrl.indexOf('/render/') === 0) {
STATE.embedSrcCache[cacheKey] = renderUrl;
return renderUrl;
}
const parsed = parseQortalAppHref(qortalHref);
if (!parsed) {
STATE.embedSrcCache[cacheKey] = 'about:blank';
return 'about:blank';
}
if (STATE.inQortal) {
try {
const req = {
action: 'GET_QDN_RESOURCE_URL',
service: 'APP',
name: parsed.name,
};
if (parsed.path) req.path = parsed.path;
const u = await qReq(req);
if (u && typeof u === 'string') {
STATE.embedSrcCache[cacheKey] = u;
return u;
}
} catch (_) {}
}
const fallback = parsed.path
? qdnUrl('APP', parsed.name, null, parsed.path)
: '/arbitrary/APP/' + encodeURIComponent(parsed.name);
STATE.embedSrcCache[cacheKey] = fallback;
return fallback;
}
function toast(msg, kind) {
const stack = $('#sn-toast-stack');
if (!stack) return;
const t = el('div', 'sn-toast' + (kind ? ' sn-toast-' + kind : ''), msg);
stack.appendChild(t);
setTimeout(() => {
t.style.opacity = '0';
t.style.transform = 'translateY(6px)';
setTimeout(() => t.remove(), 260);
}, 3600);
}
const HTML2PDF_CDN = 'https://cdnjs.cloudflare.com/ajax/libs/html2pdf.js/0.10.1/html2pdf.bundle.min.js';
let html2pdfLoadPromise = null;
function ensureHtml2Pdf() {
if (typeof window.html2pdf === 'function') return Promise.resolve();
if (html2pdfLoadPromise) return html2pdfLoadPromise;
html2pdfLoadPromise = new Promise((resolve, reject) => {
const s = document.createElement('script');
s.src = HTML2PDF_CDN;
s.async = true;
s.crossOrigin = 'anonymous';
s.onload = () => resolve();
s.onerror = () => {
html2pdfLoadPromise = null;
reject(new Error('cv-lib'));
};
document.head.appendChild(s);
});
return html2pdfLoadPromise;
}
function htmlToPlain(html) {
if (!html || typeof html !== 'string') return '';
const d = document.createElement('div');
d.innerHTML = html;
return (d.textContent || '').replace(/\s+/g, ' ').trim();
}
function buildCvExportRoot() {
const u = (STATE.site && STATE.site.ui) || defaultUiTemplate();
const site = STATE.site || defaultSiteConfig();
const comm = site.community && typeof site.community === 'object' ? site.community : {};
const name =
(String(u.nameFirst || '').trim() + ' ' + String(u.nameLast || '').trim()).trim() || 'Your name';
const pub = publisherName();
const appLine = 'qortal://APP/' + encodeURIComponent(pub);
const root = document.createElement('div');
root.setAttribute('data-sn-cv-export', '1');
root.style.cssText = [
'width:190mm',
'min-height:200mm',
'padding:14mm 16mm 18mm',
'box-sizing:border-box',
'font-family:Georgia,Cambria,serif',
'background:#faf9f4',
'color:#141816',
'font-size:10.5pt',
'line-height:1.5',
].join(';');
const bar = document.createElement('div');
bar.style.cssText = 'height:3px;background:linear-gradient(90deg,#0d3d2e,#2a7a5a);border-radius:2px;margin:0 0 14px;';
root.appendChild(bar);
const h1 = document.createElement('h1');
h1.textContent = name;
h1.style.cssText = 'margin:0 0 6px;font-size:22pt;font-weight:700;letter-spacing:0.02em;color:#0a1612;';
root.appendChild(h1);
const tag = htmlToPlain(u.tagline);
if (tag) {
const p = document.createElement('p');
p.textContent = tag;
p.style.cssText = 'margin:0 0 14px;font-size:10.5pt;color:#2e3530;';
root.appendChild(p);
}
const meta = document.createElement('div');
meta.style.cssText = 'font-size:9pt;font-family:ui-sans-serif,system-ui,sans-serif;margin-bottom:16px;padding-bottom:12px;border-bottom:1px solid #cfccc4;color:#3a4440;';
meta.textContent = 'Qortal name: ' + pub + ' · ' + appLine;
root.appendChild(meta);
function addSection(heading, body) {
const t = body == null ? '' : String(body).trim();
if (!t) return;
const box = document.createElement('section');
box.style.cssText = 'margin:0 0 14px;';
const h2 = document.createElement('h2');
h2.textContent = heading;
h2.style.cssText =
'margin:0 0 6px;font-size:9.5pt;font-weight:700;font-family:ui-sans-serif,system-ui,sans-serif;color:#0d3d2e;letter-spacing:0.1em;text-transform:uppercase;';
const div = document.createElement('div');
div.textContent = t;
div.style.cssText = 'font-size:10pt;white-space:pre-wrap;word-wrap:break-word;';
box.appendChild(h2);
box.appendChild(div);
root.appendChild(box);
}
addSection('Profile & story', htmlToPlain(site.aboutHtml));
if (site.workshop && (site.workshop.label || site.workshop.qortalHref)) {
addSection('Signature project', [site.workshop.label, site.workshop.qortalHref].filter(Boolean).join(' — '));
}
if (Array.isArray(site.embeds) && site.embeds.length) {
addSection(
'Published Q-Apps (tabs)',
site.embeds.map((e) => '• ' + (e.label || 'Q-App') + ' — ' + (e.qortalHref || '')).join('\n')
);
}
if (Array.isArray(site.pages) && site.pages.length) {
const short = (s, n) => {
const x = s.replace(/\s+/g, ' ').trim();
return x.length > n ? x.slice(0, n) + '…' : x;
};
addSection(
'More pages & long-form',
site.pages
.map((pg) => (pg.title || 'Page') + '\n' + short(String(pg.body || ''), 800))
.join('\n\n—\n\n')
);
}
if (Array.isArray(site.links) && site.links.length) {
addSection(
'Quick links & hub',
site.links.map((L) => '• ' + (L.label || 'Link') + ' — ' + (L.href || '')).join('\n')
);
}
const cg = [];
if (String(comm.lifeValue || '').trim()) {
cg.push(String(comm.lifeLabel || 'Life') + ': ' + String(comm.lifeValue).trim());
}
if (String(comm.publicGroupName || comm.publicGroupId || '').trim()) {
cg.push('Public group: ' + [comm.publicGroupName, comm.publicGroupId].filter(Boolean).join(' · '));
}
if (String(comm.adminGroupName || comm.adminGroupId || '').trim()) {
cg.push('Admin group: ' + [comm.adminGroupName, comm.adminGroupId].filter(Boolean).join(' · '));
}
if (Array.isArray(comm.privateGroups) && comm.privateGroups.length) {
cg.push(
'Private (listed):\n' +
comm.privateGroups
.map(
(g) =>
'• ' + (g.name || '') + (g.groupId ? ' (' + g.groupId + ')' : '') + (g.note ? ' — ' + g.note : '')
)
.join('\n')
);
}
if (cg.length) addSection('Community & groups', cg.join('\n'));
if (Array.isArray(site.poiPhotos) && site.poiPhotos.length) {
const line = site.poiPhotos
.map((p) => p.caption || p.href)
.filter((x) => x && String(x).trim())
.slice(0, 16)
.join(' · ');
if (line) addSection('World Map photos (summary)', line);
}
const fl = [htmlToPlain(u.footerLeft), htmlToPlain(u.footerBuild)].filter(Boolean);
if (fl.length) {
const foot = document.createElement('p');
foot.textContent = fl.join(' · ');
foot.style.cssText =
'margin:18px 0 0;padding-top:10px;border-top:1px solid #cfccc4;font-size:8pt;color:#6b6660;';
root.appendChild(foot);
}
return root;
}
async function exportCvPdf() {
try {
await ensureHtml2Pdf();
} catch (_) {
toast('Could not load the PDF engine. Try again with a network connection (CDN).', 'err');
return;
}
if (typeof window.html2pdf !== 'function') {
toast('CV export is unavailable in this environment.', 'err');
return;
}
const el = buildCvExportRoot();
el.style.position = 'fixed';
el.style.left = '-20000px';
el.style.top = '0';
document.body.appendChild(el);
await new Promise((r) => requestAnimationFrame(() => r()));
const safeName = String(publisherName() || 'cv')
.replace(/[\\/:*?"<>|]+/g, '')
.replace(/\s+/g, '-')
.slice(0, 80);
const opt = {
margin: 10,
filename: (safeName || 'portfolio') + '-cv.pdf',
image: { type: 'jpeg', quality: 0.95 },
html2canvas: { scale: 2, useCORS: true, logging: false },
jsPDF: { unit: 'mm', format: 'a4', orientation: 'portrait' },
pagebreak: { mode: ['css', 'legacy'] },
};
try {
toast('Rendering your CV…', 'warn');
await window.html2pdf().set(opt).from(el).save();
toast('CV PDF downloaded');
} catch (e) {
toast('CV export failed: ' + (e && e.message ? e.message : e), 'err');
} finally {
el.remove();
}
}
function bindStaticHandlers() {
$('#sn-connect').addEventListener('click', onConnectClick);
$('#sn-refresh').addEventListener('click', () => {
STATE.embedSrcCache = {};
STATE.poiImgSrcCache = {};
refreshEmbedsOnly();
if (STATE.inQortal) {
tryLoadSiteConfig().then(() => refreshAllUi()).catch(() => {});
loadEverything().catch(() => {});
} else {
hydratePreview();
}
});
$('#sn-edit-jump').addEventListener('click', (e) => {
if (!STATE.userAccount) {
e.preventDefault();
toast('Connect your wallet to use in-place panels (✎ site) and publish.', 'warn');
return;
}
STATE.segmentEditMode = !STATE.segmentEditMode;
rebuildVisualFormLayout();
const jump = $('#sn-edit-jump');
if (jump) {
jump.setAttribute('aria-pressed', STATE.segmentEditMode ? 'true' : 'false');
jump.title = STATE.segmentEditMode
? 'Single form in the site builder (turn off in-place panels)'
: 'In-place side panels next to each section (or single form below)';
}
if (STATE.segmentEditMode) {
const lane = document.getElementById('sn-lane-site');
if (lane) lane.scrollIntoView({ behavior: 'smooth', block: 'start' });
} else {
const ed = $('#sn-site-editor');
if (ed) ed.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
});
const wysiTgl = document.getElementById('sn-wysiwyg-toggle');
if (wysiTgl) {
wysiTgl.addEventListener('click', () => {
if (!STATE.inQortal || !STATE.userAccount) {
toast('Connect your wallet to edit the page in place (visual).', 'warn');
return;
}
const wasOn = STATE.wysiwygMode !== false;
STATE.wysiwygMode = !wasOn;
const tb = document.getElementById('sn-wysi-toolbar');
if (tb) tb.hidden = true;
updateWysiwygLayer();
toast(
!wasOn
? 'Visual page on — click highlighted text to edit; use the bar for bold and links in HTML areas.'
: 'Visual page off — the live page is read-only until you turn it on again.',
'ok',
);
});
}
wireWysiwygInputOnce();
document.addEventListener('click', onGlobalClick);
$('#sn-site-save').addEventListener('click', () => {
saveLocalSite();
setSiteStatus('Saved locally · ' + new Date().toLocaleTimeString());
toast('Site settings saved in this browser');
});
$('#sn-site-publish').addEventListener('click', () => publishSiteToQDN());
const floatPub = document.getElementById('sn-floating-qdn-publish');
if (floatPub) {
floatPub.addEventListener('click', () => publishSiteToQDN());
}
const cvExport = document.getElementById('sn-cv-export');
if (cvExport) {
cvExport.addEventListener('click', () => {
void exportCvPdf();
});
}
$('#sn-site-reload-qdn').addEventListener('click', () => loadSiteFromQDNButton());
(function wireCommunityForm() {
const bindComm = (elId, key, isCheckbox) => {
const n = document.getElementById(elId);
if (!n || n.dataset.commBound) return;
n.dataset.commBound = '1';
const run = () => {
ensureCommunity();
if (isCheckbox) STATE.site.community[key] = !!n.checked;
else STATE.site.community[key] = n.value;
applyChromeUi();
saveLocalSite();
};
n.addEventListener('input', run);
n.addEventListener('change', run);
};
bindComm('sn-comm-inp-life-label', 'lifeLabel');
bindComm('sn-comm-inp-life-value', 'lifeValue');
bindComm('sn-comm-inp-pub-gid', 'publicGroupId');
bindComm('sn-comm-inp-pub-gname', 'publicGroupName');
bindComm('sn-comm-inp-adm-gid', 'adminGroupId');
bindComm('sn-comm-inp-adm-gname', 'adminGroupName');
bindComm('sn-comm-hide-section', 'sectionHidden', true);
})();
const addPrivG = document.getElementById('sn-add-private-group');
if (addPrivG) {
addPrivG.addEventListener('click', () => {
ensureCommunity();
STATE.site.community.privateGroups.push({ name: 'Private group', groupId: '', note: '' });
renderCommunityEditor();
applyChromeUi();
saveLocalSite();
});
}
const addPoiBtn = $('#sn-add-poi-photo');
if (addPoiBtn) {
addPoiBtn.addEventListener('click', () => {
if (!Array.isArray(STATE.site.poiPhotos)) STATE.site.poiPhotos = [];
STATE.site.poiPhotos.push({ id: uid(), caption: '', href: '' });
STATE.poiImgSrcCache = {};
renderEditors();
void renderPoiPhotos();
saveLocalSite();
});
}
$('#sn-add-link').addEventListener('click', () => {
STATE.site.links.push({ id: uid(), label: 'New link', href: 'qortal://APP/' });
renderEditors();
saveLocalSite();
});
$('#sn-add-page').addEventListener('click', () => {
STATE.site.pages.push({ id: uid(), title: 'New page', body: '' });
renderEditors();
renderPages();
saveLocalSite();
});
$('#sn-add-embed').addEventListener('click', () => {
STATE.site.embeds.push({ id: uid(), label: 'Q-App tab', qortalHref: 'qortal://APP/' });
renderEditors();
refreshEmbedsOnly();
saveLocalSite();
});
const aboutTa = $('#sn-editor-about-html');
if (aboutTa) {
aboutTa.addEventListener('input', () => {
STATE.site.aboutHtml = aboutTa.value;
renderAbout();
saveLocalSite();
});
}
$('#sn-embed-open').addEventListener('click', () => {
const emb = STATE.site.embeds[STATE.activeEmbed];
if (!emb) return;
const p = parseQortalAppHref(emb.qortalHref);
if (p) openQApp(p.name, null, p.path || undefined);
else toast('Invalid embed URL', 'warn');
});
$$('[data-sn-jump]').forEach((b) => {
b.addEventListener('click', () => {
const target = b.getAttribute('data-sn-jump');
const id =
target === 'feed'
? 'sn-feed-section'
: target === 'community'
? 'sn-community-section'
: 'sn-apps-section';
const layoutKey = target === 'feed' ? 'feed' : target === 'community' ? 'community' : 'apps';
ensureLayoutState();
if (STATE.site.layout.hidden[layoutKey] === true) {
const ed = document.getElementById('sn-site-editor');
if (ed) ed.scrollIntoView({ behavior: 'smooth', block: 'start' });
toast('That section is hidden — use “Page sections” in the site builder to show it again.', 'warn');
return;
}
const sec = document.getElementById(id);
if (sec) sec.scrollIntoView({ behavior: 'smooth', block: 'start' });
});
});
const pubWww = $('#sn-publish-website');
if (pubWww) pubWww.addEventListener('click', () => void onPublishMyWebsite());
const pnEd = $('#sn-editor-publisher-name');
if (pnEd && !pnEd.dataset.snBound) {
pnEd.dataset.snBound = '1';
pnEd.addEventListener('input', () => {
STATE.site.publisherName = (pnEd.value || '').trim() || DEFAULT_PUBLISHER;
applyChromeUi();
saveLocalSite();
STATE.ownerAddress = null;
void (async () => {
if (STATE.inQortal) await loadEverything();
else updateStats();
})();
});
}
const ujEd = $('#sn-editor-ui-json');
if (ujEd && !ujEd.dataset.snBound) {
ujEd.dataset.snBound = '1';
let uiDebounce;
ujEd.addEventListener('input', () => {
clearTimeout(uiDebounce);
uiDebounce = setTimeout(() => {
try {
const obj = JSON.parse(ujEd.value);
if (!obj || typeof obj !== 'object') return;
STATE.site.ui = Object.assign({}, defaultUiTemplate(), obj);
applyChromeUi();
saveLocalSite();
syncVisualDesignerFields();
} catch (_) {
/* still editing */
}
}, 480);
});
ujEd.addEventListener('blur', () => {
try {
const obj = JSON.parse(ujEd.value);
if (obj && typeof obj === 'object') {
STATE.site.ui = Object.assign({}, defaultUiTemplate(), obj);
ujEd.value = JSON.stringify(STATE.site.ui, null, 2);
applyChromeUi();
saveLocalSite();
syncVisualDesignerFields();
}
} catch (e) {
toast('Page copy JSON is invalid — check syntax', 'warn');
}
});
}
wirePageLayoutOnce();
}
/**
* Click targets are often a Text node (e.g. inside <a>…</a>), which has no .closest().
* Without normalizing, delegated qortal:// handling throws and links feel “dead”.
*/
function clickEventTargetElement(ev) {
let t = ev.target;
if (t && t.nodeType === Node.TEXT_NODE) t = t.parentElement;
return t && t.nodeType === Node.ELEMENT_NODE ? t : null;
}
function onGlobalClick(e) {
const el = clickEventTargetElement(e);
const a = el && el.closest('a[data-sn-qlink]');
if (!a) return;
e.preventDefault();
const href = a.getAttribute('href') || '';
const app = parseQortalAppHref(href);
if (app) {
openQApp(app.name, null, app.path || undefined);
return;
}
const m = /^qortal:\/\/([^/]+)\/([^/?#]+)(?:\/([^?#]*))?$/i.exec(href);
if (m) {
openQortalResource(m[1], decodeURIComponentSafe(m[2]), m[3] ? decodeURIComponentSafe(m[3]) : null);
}
}
async function onConnectClick() {
if (!STATE.inQortal) {
toast('This page only connects from inside Qortal', 'warn');
return;
}
try {
const acc = await qReq({ action: 'GET_USER_ACCOUNT' });
STATE.userAccount = acc;
const belt = $('#sn-visitor-belt');
const vAddr = $('#sn-visitor-addr');
if (vAddr) vAddr.textContent = shortAddr(acc.address);
if (belt) belt.hidden = false;
renderVisitorStats(null, true);
updateLiveStatus();
STATE.embedSrcCache = {};
STATE.poiImgSrcCache = {};
STATE.sessionTag = Date.now();
await loadEverything();
await primeLinkedAppResourcesAfterConnect();
fetchVisitorWalletStats(acc.address)
.then((st) => {
STATE.visitorStats = st;
renderVisitorStats(st, false);
})
.catch(() => {
STATE.visitorStats = emptyVisitorStats();
renderVisitorStats(STATE.visitorStats, false);
});
toast('Connected as ' + shortAddr(acc.address) + ' — website builder unlocked · linked Q-Apps synced');
} catch (e) {
toast('Connect cancelled', 'warn');
}
}
function shortAddr(a) {
if (!a) return '';
const s = String(a);
return s.length > 10 ? s.slice(0, 5) + '…' + s.slice(-4) : s;
}
/** Raw on-chain amounts may be Qortoshi (1 QORT = 1e8); small values are already decimal QORT. */
function qortoshiToQortNumber(v) {
const n = Number(v);
if (!Number.isFinite(n)) return 0;
if (Math.abs(n) >= 1e6) return n / 1e8;
return n;
}
function formatQortAmount(n) {
if (n == null || !Number.isFinite(Number(n))) return '—';
const x = Number(n);
return x.toLocaleString(undefined, { minimumFractionDigits: 0, maximumFractionDigits: 4 });
}
function emptyVisitorStats() {
return {
names: 0,
publishes: 0,
apps: 0,
qortBuy: 0,
qortSell: 0,
blocksMinted: 0,
qortIncoming: 0,
};
}
/**
* Visitor wallet: names, publishes (ARBITRARY count), Q-Apps across owned names,
* buy/sell QORT from mintership (AT cross-chain), blocks minted, incoming native QORT (PAYMENT).
*/
async function fetchVisitorWalletStats(address) {
if (!address) return emptyVisitorStats();
const [msRes, namesRes] = await Promise.all([
fetch('/addresses/mintership/' + encodeURIComponent(address)).catch(() => null),
fetch('/names/address/' + encodeURIComponent(address) + '?limit=500').catch(() => null),
]);
let namesRows = [];
if (namesRes && namesRes.ok) {
try {
const arr = await namesRes.json();
namesRows = Array.isArray(arr) ? arr : [];
} catch (_) {}
}
let minter = null;
if (msRes && msRes.ok) {
try {
minter = await msRes.json();
} catch (_) {}
}
const publishes = minter && typeof minter.arbitraryCount === 'number' ? minter.arbitraryCount : 0;
const qortBuy = minter && typeof minter.buyAmount === 'number' ? minter.buyAmount : 0;
const qortSell = minter && typeof minter.sellAmount === 'number' ? minter.sellAmount : 0;
const blocksMinted = minter && typeof minter.blocksMinted === 'number' ? minter.blocksMinted : 0;
let apps = 0;
const nameStrs = namesRows
.map((row) => (typeof row === 'string' ? row : row && row.name))
.filter(Boolean)
.slice(0, 48);
if (STATE.inQortal && nameStrs.length) {
const batch = 6;
for (let i = 0; i < nameStrs.length; i += batch) {
const chunk = nameStrs.slice(i, i + batch);
const counts = await Promise.all(
chunk.map(async (name) => {
try {
const r = await qReq({
action: 'SEARCH_QDN_RESOURCES',
service: 'APP',
name,
limit: 200,
offset: 0,
});
return normList(r).length;
} catch (_) {
return 0;
}
})
);
apps += counts.reduce((a, b) => a + b, 0);
}
}
let qortIncoming = 0;
try {
const u = new URLSearchParams({
address,
txType: 'PAYMENT',
confirmationStatus: 'CONFIRMED',
limit: '8000',
reverse: 'true',
});
const pr = await fetch('/transactions/search?' + u.toString());
if (pr.ok) {
const arr = await pr.json();
if (Array.isArray(arr)) {
for (const t of arr) {
if (!t || t.recipient !== address) continue;
qortIncoming += qortoshiToQortNumber(t.amount);
}
}
}
} catch (_) {}
return {
names: namesRows.length,
publishes,
apps,
qortBuy,
qortSell,
blocksMinted,
qortIncoming,
};
}
function renderVisitorStats(stats, loading) {
const host = $('#sn-visitor-stats');
if (!host) return;
const val = (n, isFloat) => {
if (loading) return '…';
if (isFloat) return formatQortAmount(n);
const num = Number(n);
return Number.isFinite(num) ? String(Math.round(num)) : '—';
};
const q = stats || {};
const rows = [
{
k: 'vn',
title: 'Registered names owned by this address',
v: val(q.names, false),
l: 'Names',
},
{
k: 'pub',
title: 'ARBITRARY (QDN) publishes attributed to this wallet (mintership)',
v: val(q.publishes, false),
l: 'Publishes',
},
{
k: 'app',
title: 'APP resources published on your registered names (search, capped names)',
v: val(q.apps, false),
l: 'Q-Apps',
},
{
k: 'buy',
title: 'QORT bought via AT / cross-chain (node mintership aggregate)',
v: val(q.qortBuy, false),
l: 'QORT buy',
},
{
k: 'sell',
title: 'QORT sold via AT / cross-chain (node mintership aggregate)',
v: val(q.qortSell, false),
l: 'QORT sell',
},
{
k: 'mint',
title: 'Blocks minted by this address (same metric as Account Explorer)',
v: val(q.blocksMinted, false),
l: 'Minted',
},
{
k: 'in',
title: 'Sum of confirmed PAYMENT transactions received as native QORT',
v: val(q.qortIncoming, true),
l: 'QORT in',
},
];
host.innerHTML = rows
.map(
(r) =>
'<div class="sn-stat" title="' +
esc(r.title) +
'"><div class="sn-stat-n" data-v="' +
r.k +
'">' +
r.v +
'</div><div class="sn-stat-l">' +
esc(r.l) +
'</div></div>'
)
.join('');
}
function updateLiveStatus() {
const dot = $('#sn-dot');
const txt = $('#sn-live-text');
if (STATE.inQortal) {
dot.classList.add('sn-dot-on');
txt.textContent = STATE.userAccount ? 'connected · ' + shortAddr(STATE.userAccount.address) : 'qortal · live';
} else {
txt.textContent = 'preview mode';
}
}
async function resolveOwner() {
try {
const data = await qReq({ action: 'GET_NAME_DATA', name: publisherName() });
if (data && data.owner) {
STATE.ownerAddress = data.owner;
const oa = $('#sn-owner-addr');
if (oa) oa.textContent = shortAddr(data.owner);
}
} catch (_) {}
if (!STATE.ownerAddress && STATE.inQortal) {
try {
const r = await fetch('/names/' + encodeURIComponent(publisherName()));
if (r.ok) {
const d = await r.json();
if (d && d.owner) {
STATE.ownerAddress = d.owner;
const elAddr = $('#sn-owner-addr');
if (elAddr) elAddr.textContent = shortAddr(d.owner);
}
}
} catch (_) {}
}
const img = $('#sn-avatar-img');
const fallback = $('#sn-avatar-fallback');
try {
const url = qdnUrl('THUMBNAIL', publisherName(), 'qortal_avatar');
img.onload = () => { img.hidden = false; fallback.style.display = 'none'; };
img.onerror = () => { img.hidden = true; };
img.src = url;
} catch (_) {}
}
async function countAppResources() {
try {
const r = await qReq({
action: 'SEARCH_QDN_RESOURCES',
service: 'APP',
name: publisherName(),
limit: 50,
offset: 0,
});
const list = normList(r);
STATE.counts.apps = list.length || 1;
} catch (e) {
STATE.counts.apps = 1;
}
}
/**
* Total QDN resources for this registered name (paginated REST search).
* Aligns with Account Explorers notion of on-chain arbitrary / QDN publishes.
*/
async function countQdnResourcesForName(pubName) {
const limit = 100;
let offset = 0;
let total = 0;
const maxPages = 80;
for (let p = 0; p < maxPages; p++) {
const qs = new URLSearchParams({
name: pubName,
limit: String(limit),
offset: String(offset),
mode: 'LATEST',
});
const res = await fetch('/arbitrary/resources/search?' + qs.toString());
if (!res.ok) break;
const data = await res.json();
const rows = Array.isArray(data) ? data : (data && Array.isArray(data.resources) ? data.resources : []);
total += rows.length;
if (rows.length < limit) break;
offset += limit;
}
return total;
}
/** Wallet stats for the name owner — same fields Account Explorer surfaces from mintership. */
async function loadExplorerWalletStats(ownerAddr) {
if (!ownerAddr) return;
try {
const res = await fetch('/addresses/mintership/' + encodeURIComponent(ownerAddr));
if (res.ok) {
const ms = await res.json();
if (ms && typeof ms === 'object') {
if (typeof ms.arbitraryCount === 'number') STATE.counts.arb = ms.arbitraryCount;
if (typeof ms.blocksMinted === 'number') STATE.counts.blocks = ms.blocksMinted;
if (typeof ms.balance === 'number') STATE.counts.balance = ms.balance;
}
}
} catch (_) {}
try {
const res = await fetch('/addresses/balance/' + encodeURIComponent(ownerAddr));
if (res.ok) {
const t = await res.text();
const b = parseFloat(t);
if (!isNaN(b)) STATE.counts.balance = b;
}
} catch (_) {}
}
async function loadEverything() {
if (!STATE.inQortal) {
STATE.counts = {
qdn: 128,
apps: 7,
arb: 42,
blocks: 2100,
balance: 15.5,
};
updateStats();
renderWorkshop();
return;
}
await applyWalletSessionForBuilder();
STATE.counts.qdn = 0;
STATE.counts.arb = 0;
STATE.counts.blocks = 0;
STATE.counts.balance = null;
await Promise.all([
(async () => {
try {
STATE.counts.qdn = await countQdnResourcesForName(publisherName());
} catch (e) {
console.warn('QDN resource count:', e);
STATE.counts.qdn = 0;
}
})(),
countAppResources(),
loadExplorerWalletStats(STATE.ownerAddress),
]);
/** Prefer last site config published to QDN for the tracked name (after wallet + name are resolved). */
let loadedFromQdn = false;
if (STATE.userAccount) {
try {
const remote = await fetchSiteFromQDN();
if (remote) {
STATE.site = remote;
loadedFromQdn = true;
}
} catch (e) {
console.warn('QDN site config fetch:', e);
}
if (loadedFromQdn) {
setSiteStatus('Restored from QDN · ' + new Date().toLocaleTimeString());
}
}
setQdnBaselineFromCurrent();
if (loadedFromQdn) {
saveLocalSite();
}
refreshAllUi();
updateBuilderChrome();
}
function updateStats() {
const q = typeof STATE.counts.qdn === 'number' ? STATE.counts.qdn : 0;
$$('[data-k="qdn"]').forEach((node) => animateCount(node, q));
const apps = STATE.counts.apps || 1;
$$('[data-k="apps"]').forEach((node) => animateCount(node, apps));
const arb = typeof STATE.counts.arb === 'number' ? STATE.counts.arb : 0;
$$('[data-k="arb"]').forEach((node) => animateCount(node, arb));
const blocks = typeof STATE.counts.blocks === 'number' ? STATE.counts.blocks : 0;
$$('[data-k="blocks"]').forEach((node) => animateCount(node, blocks));
const bal = STATE.counts.balance;
$$('[data-k="balance"]').forEach((node) => {
node.textContent = bal != null && !isNaN(bal) ? Number(bal).toFixed(2) : '—';
});
const links = STATE.site.links ? STATE.site.links.length : 0;
$$('[data-k="links"]').forEach((node) => animateCount(node, links));
}
function animateCount(node, target) {
if (!node) return;
const start = 0;
const dur = 600;
const t0 = performance.now();
const step = (t) => {
const p = Math.min(1, (t - t0) / dur);
const eased = 1 - Math.pow(1 - p, 3);
node.textContent = String(Math.round(start + (target - start) * eased));
if (p < 1) requestAnimationFrame(step);
};
requestAnimationFrame(step);
}
function ensureWorkshop() {
const d = defaultSiteConfig().workshop;
if (!STATE.site.workshop || typeof STATE.site.workshop !== 'object') {
STATE.site.workshop = { label: d.label, qortalHref: d.qortalHref };
}
return STATE.site.workshop;
}
function renderWorkshop() {
const wrap = $('#sn-app-grid');
if (!wrap) return;
wrap.innerHTML = '';
const w = ensureWorkshop();
const href = String(w.qortalHref || '').trim();
const label = String(w.label || 'Featured Q-App').trim() || 'Featured Q-App';
const root = el('div', 'sn-workshop');
const previewHost = el('div', 'sn-workshop-preview');
if (STATE.inQortal && /^qortal:\/\//i.test(href)) {
const ifr = el('iframe');
ifr.className = 'sn-workshop-preview-iframe';
ifr.setAttribute(
'title',
'Preview: ' + label
);
ifr.setAttribute(
'sandbox',
'allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox'
);
ifr.loading = 'lazy';
ifr.src = appendAuthBust(qortalLinkToRenderUrl(href));
previewHost.appendChild(ifr);
} else {
const ph = el('div', 'sn-workshop-preview-ph sn-mono sn-dim');
ph.textContent = STATE.inQortal
? 'Set a featured qortal://APP/… link in Site settings (name owner)'
: 'Open inside Qortal for live preview';
previewHost.appendChild(ph);
}
const cta = el('button', 'sn-workshop-cta');
cta.type = 'button';
cta.innerHTML =
'<span class="sn-workshop-cta-glow" aria-hidden="true"></span>' +
'<span class="sn-workshop-cta-label">' + esc(label) + '</span>' +
'<span class="sn-workshop-cta-arrow" aria-hidden="true">↗</span>';
cta.setAttribute('title', 'Open in Qortal: ' + (href || 'set featured Q-App in Site settings'));
cta.addEventListener('click', () => {
const ap = parseQortalAppHref(href);
if (ap) openQApp(ap.name, null, ap.path || undefined);
else toast('Set a valid qortal://APP/… link for your featured app in Site settings', 'warn');
});
const urlRow = el('div', 'sn-workshop-url sn-mono');
urlRow.textContent = href || 'qortal://APP/…';
root.appendChild(previewHost);
root.appendChild(cta);
root.appendChild(urlRow);
wrap.appendChild(root);
}
function renderWorkshopEditor() {
const holder = $('#sn-editor-workshop');
if (!holder) return;
ensureWorkshop();
holder.innerHTML = '';
const wrap = el('div', 'sn-editor-workshop-fields');
wrap.innerHTML =
'<label class="sn-field"><span class="sn-field-l">Button label</span>' +
'<input type="text" class="sn-input" data-ws="label" autocomplete="off" /></label>' +
'<label class="sn-field"><span class="sn-field-l">qortal://APP/… URL</span>' +
'<input type="text" class="sn-input sn-input-mono" data-ws="qortalHref" spellcheck="false" autocomplete="off" /></label>';
const iLabel = wrap.querySelector('[data-ws="label"]');
const iHref = wrap.querySelector('[data-ws="qortalHref"]');
iLabel.value = STATE.site.workshop.label || '';
iHref.value = STATE.site.workshop.qortalHref || '';
const sync = () => {
STATE.site.workshop.label = iLabel.value;
STATE.site.workshop.qortalHref = iHref.value;
STATE.embedSrcCache = {};
renderWorkshop();
updateStats();
saveLocalSite();
};
[iLabel, iHref].forEach((inp) => {
inp.addEventListener('input', sync);
});
holder.appendChild(wrap);
}
function renderLinksList() {
const wrap = $('#sn-link-previews');
if (!wrap) return;
wrap.innerHTML = '';
STATE.site.links.forEach((L) => {
const card = el('article', 'sn-link-preview');
const bar = el('div', 'sn-link-preview-bar');
const title = el('a', 'sn-link-preview-title');
title.href = L.href;
title.setAttribute('data-sn-qlink', '');
title.textContent = L.label || L.href;
const actions = el('div', 'sn-link-preview-actions');
const mono = el('span', 'sn-mono sn-dim sn-link-preview-mono');
mono.textContent = L.href;
actions.appendChild(mono);
bar.appendChild(title);
bar.appendChild(actions);
card.appendChild(bar);
if (/^qortal:\/\//i.test(L.href)) {
const frameOuter = el('div', 'sn-link-preview-frame');
if (STATE.inQortal) {
const ifr = el('iframe');
ifr.className = 'sn-link-preview-iframe';
ifr.setAttribute('title', L.label || 'Link preview');
ifr.setAttribute(
'sandbox',
'allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox'
);
ifr.loading = 'lazy';
ifr.src = appendAuthBust(qortalLinkToRenderUrl(L.href));
frameOuter.appendChild(ifr);
} else {
const ph = el('div', 'sn-link-preview-ph sn-mono sn-dim');
ph.textContent = 'Open inside Qortal for live preview (/render/…)';
frameOuter.appendChild(ph);
}
card.appendChild(frameOuter);
} else {
const ext = el('p', 'sn-link-preview-external sn-mono sn-dim');
ext.textContent = 'Non-qortal URL — use the title link to open.';
card.appendChild(ext);
}
wrap.appendChild(card);
});
}
/**
* Whole-line qortal:// URLs become embedded /render/ previews (same pipeline as link cards).
* Other lines stay as plain text blocks (pre-wrap).
*/
function isStandaloneQortalEmbedLine(line) {
const t = line.trim();
if (!/^qortal:\/\//i.test(t)) return false;
if (t.length < 12) return false;
return true;
}
function buildQortalRenderEmbed(href) {
const outer = el('div', 'sn-page-embed');
const bar = el('div', 'sn-page-embed-bar');
const link = el('a', 'sn-page-embed-open');
link.href = href;
link.setAttribute('data-sn-qlink', '');
link.textContent = 'Open in Qortal ↗';
bar.appendChild(link);
const frame = el('div', 'sn-page-embed-frame');
if (STATE.inQortal && /^qortal:\/\//i.test(href)) {
const ifr = el('iframe');
ifr.className = 'sn-page-embed-iframe';
ifr.setAttribute('title', 'Embedded Q-App');
ifr.setAttribute(
'sandbox',
'allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox'
);
ifr.loading = 'lazy';
ifr.src = appendAuthBust(qortalLinkToRenderUrl(href));
frame.appendChild(ifr);
} else {
const ph = el('div', 'sn-page-embed-ph sn-mono sn-dim');
ph.textContent = 'Open inside Qortal for live preview (/render/…)';
frame.appendChild(ph);
}
outer.appendChild(bar);
outer.appendChild(frame);
return outer;
}
function renderPageBodyContent(bodyEl, raw) {
const lines = String(raw || '').split(/\r?\n/);
const buf = [];
function flush() {
if (!buf.length) return;
const prose = el('div', 'sn-page-prose');
prose.textContent = buf.join('\n');
buf.length = 0;
bodyEl.appendChild(prose);
}
for (const line of lines) {
if (isStandaloneQortalEmbedLine(line)) {
flush();
bodyEl.appendChild(buildQortalRenderEmbed(line.trim()));
} else {
buf.push(line);
}
}
flush();
}
function renderPages() {
const wrap = $('#sn-pages');
if (!wrap) return;
wrap.innerHTML = '';
if (!STATE.site.pages.length) {
const p = el('p', 'sn-pages-empty sn-mono sn-dim');
p.textContent = 'No custom pages yet — add them in Site settings.';
wrap.appendChild(p);
return;
}
STATE.site.pages.forEach((pg) => {
const card = el('article', 'sn-page-card');
const h = el('h3', 'sn-page-title', pg.title);
const body = el('div', 'sn-page-body');
renderPageBodyContent(body, pg.body || '');
card.appendChild(h);
card.appendChild(body);
wrap.appendChild(card);
});
}
function renderEmbedTabs() {
const tabs = $('#sn-embed-tabs');
const fallback = $('#sn-embed-fallback');
const iframe = $('#sn-embed-iframe');
if (!tabs) return;
tabs.innerHTML = '';
STATE.site.embeds.forEach((emb, i) => {
const btn = el('button', 'sn-embed-tab' + (i === STATE.activeEmbed ? ' sn-embed-tab-active' : ''));
btn.type = 'button';
btn.setAttribute('role', 'tab');
btn.setAttribute('aria-selected', i === STATE.activeEmbed ? 'true' : 'false');
btn.textContent = emb.label || ('Tab ' + (i + 1));
btn.addEventListener('click', () => {
STATE.activeEmbed = i;
renderEmbedTabs();
void loadActiveEmbed();
});
tabs.appendChild(btn);
});
if (fallback) fallback.style.display = STATE.inQortal ? 'none' : 'flex';
if (iframe) {
iframe.hidden = !STATE.inQortal;
if (!STATE.inQortal) iframe.removeAttribute('src');
}
void loadActiveEmbed();
}
async function loadActiveEmbed() {
const iframe = $('#sn-embed-iframe');
const fallback = $('#sn-embed-fallback');
const emb = STATE.site.embeds[STATE.activeEmbed];
updateEmbedChrome();
if (!iframe || !emb) return;
if (!STATE.inQortal) return;
if (fallback) fallback.style.display = 'none';
iframe.hidden = false;
const src = await resolveEmbedIframeSrc(emb.qortalHref);
iframe.setAttribute('title', emb.label || 'Embedded Q-App');
iframe.src = appendAuthBust(src);
}
function updateEmbedChrome() {
$$('.sn-embed-tab').forEach((t, i) => {
t.classList.toggle('sn-embed-tab-active', i === STATE.activeEmbed);
t.setAttribute('aria-selected', i === STATE.activeEmbed ? 'true' : 'false');
});
const emb = STATE.site.embeds[STATE.activeEmbed];
const hint = $('#sn-embed-url-hint');
if (hint && emb) hint.textContent = emb.qortalHref || '';
}
function refreshEmbedsOnly() {
STATE.embedSrcCache = {};
renderEmbedTabs();
}
function renderEditors() {
renderWorkshopEditor();
const poiW = $('#sn-editor-poi-photos');
const linkW = $('#sn-editor-links');
const pageW = $('#sn-editor-pages');
const embW = $('#sn-editor-embeds');
if (poiW) {
poiW.innerHTML = '';
(STATE.site.poiPhotos || []).forEach((R, idx) => {
poiW.appendChild(editorRow('poiPhoto', idx, R));
});
}
if (linkW) {
linkW.innerHTML = '';
STATE.site.links.forEach((L, idx) => {
linkW.appendChild(editorRow('link', idx, L));
});
}
if (pageW) {
pageW.innerHTML = '';
STATE.site.pages.forEach((P, idx) => {
pageW.appendChild(editorRow('page', idx, P));
});
}
if (embW) {
embW.innerHTML = '';
STATE.site.embeds.forEach((E, idx) => {
embW.appendChild(editorRow('embed', idx, E));
});
}
renderCommunityEditor();
ensureVisualDesigner();
syncVisualDesignerFields();
}
function renderCommunityEditor() {
const c = ensureCommunity();
const setVal = (id, key) => {
const n = document.getElementById(id);
if (!n) return;
const v = c[key];
n.value = v != null ? String(v) : '';
};
setVal('sn-comm-inp-life-label', 'lifeLabel');
setVal('sn-comm-inp-life-value', 'lifeValue');
setVal('sn-comm-inp-pub-gid', 'publicGroupId');
setVal('sn-comm-inp-pub-gname', 'publicGroupName');
setVal('sn-comm-inp-adm-gid', 'adminGroupId');
setVal('sn-comm-inp-adm-gname', 'adminGroupName');
const ch = document.getElementById('sn-comm-hide-section');
if (ch) ch.checked = c.sectionHidden === true;
const w = document.getElementById('sn-editor-private-groups');
if (w) {
w.innerHTML = '';
(c.privateGroups || []).forEach((row, idx) => {
w.appendChild(editorRow('privateGroup', idx, row));
});
}
}
function editorRow(kind, idx, row) {
const wrap = el('div', kind === 'poiPhoto' ? 'sn-editor-row sn-editor-row--poi' : 'sn-editor-row');
if (kind === 'link') {
wrap.innerHTML =
'<label class="sn-field"><span class="sn-field-l">Label</span>' +
'<input type="text" class="sn-input" data-k="label" /></label>' +
'<label class="sn-field"><span class="sn-field-l">qortal:// URL</span>' +
'<input type="text" class="sn-input sn-input-mono" data-k="href" spellcheck="false" /></label>' +
'<button type="button" class="sn-btn sn-btn-ghost sn-btn-sm sn-editor-remove" data-act="rm">remove</button>';
wrap.querySelector('[data-k="label"]').value = row.label || '';
wrap.querySelector('[data-k="href"]').value = row.href || '';
bindRowInput(wrap, 'link', idx, ['label', 'href']);
} else if (kind === 'page') {
wrap.innerHTML =
'<label class="sn-field"><span class="sn-field-l">Title</span>' +
'<input type="text" class="sn-input" data-k="title" /></label>' +
'<label class="sn-field sn-field-grow"><span class="sn-field-l">Body</span>' +
'<textarea class="sn-input sn-textarea" data-k="body" rows="6" placeholder="Paragraphs of plain text. Put one full qortal://… URL on its own line to embed that Q-App here (same as Section 02 /render/ previews)."></textarea></label>' +
'<button type="button" class="sn-btn sn-btn-ghost sn-btn-sm sn-editor-remove" data-act="rm">remove</button>';
wrap.querySelector('[data-k="title"]').value = row.title || '';
wrap.querySelector('[data-k="body"]').value = row.body || '';
bindRowInput(wrap, 'page', idx, ['title', 'body']);
} else if (kind === 'embed') {
wrap.innerHTML =
'<label class="sn-field"><span class="sn-field-l">Tab label</span>' +
'<input type="text" class="sn-input" data-k="label" /></label>' +
'<label class="sn-field sn-field-grow"><span class="sn-field-l">Q-App URL</span>' +
'<input type="text" class="sn-input sn-input-mono" data-k="qortalHref" placeholder="qortal://APP/…" spellcheck="false" /></label>' +
'<button type="button" class="sn-btn sn-btn-ghost sn-btn-sm sn-editor-remove" data-act="rm">remove</button>';
wrap.querySelector('[data-k="label"]').value = row.label || '';
wrap.querySelector('[data-k="qortalHref"]').value = row.qortalHref || '';
bindRowInput(wrap, 'embed', idx, ['label', 'qortalHref']);
} else if (kind === 'poiPhoto') {
wrap.innerHTML =
'<label class="sn-field sn-field-grow"><span class="sn-field-l">Caption <span class="sn-dim">(optional)</span></span>' +
'<input type="text" class="sn-input" data-k="caption" placeholder="e.g. Reykjavík office" /></label>' +
'<label class="sn-field sn-field-grow"><span class="sn-field-l">Picture link from World Map</span>' +
'<input type="text" class="sn-input sn-input-mono" data-k="href" placeholder="qortal://IMAGE/YourName/worldmap_marker_…_photo_0" spellcheck="false" autocomplete="off" /></label>' +
'<button type="button" class="sn-btn sn-btn-ghost sn-btn-sm sn-editor-remove" data-act="rm">remove</button>';
wrap.querySelector('[data-k="caption"]').value = row.caption || '';
wrap.querySelector('[data-k="href"]').value = row.href || '';
bindRowInput(wrap, 'poiPhoto', idx, ['caption', 'href']);
} else if (kind === 'privateGroup') {
wrap.innerHTML =
'<label class="sn-field"><span class="sn-field-l">Display name</span>' +
'<input type="text" class="sn-input" data-k="name" placeholder="e.g. Advisors" /></label>' +
'<label class="sn-field"><span class="sn-field-l">Group ID (optional)</span>' +
'<input type="text" class="sn-input sn-input-mono" data-k="groupId" placeholder="matches Q-Community / importer fields" spellcheck="false" autocomplete="off" /></label>' +
'<label class="sn-field sn-field-grow"><span class="sn-field-l">Note</span>' +
'<input type="text" class="sn-input" data-k="note" placeholder="Short description" /></label>' +
'<button type="button" class="sn-btn sn-btn-ghost sn-btn-sm sn-editor-remove" data-act="rm">remove</button>';
wrap.querySelector('[data-k="name"]').value = row.name || '';
wrap.querySelector('[data-k="groupId"]').value = row.groupId || '';
wrap.querySelector('[data-k="note"]').value = row.note || '';
bindRowInput(wrap, 'privateGroup', idx, ['name', 'groupId', 'note']);
}
wrap.querySelector('[data-act="rm"]').addEventListener('click', () => {
if (kind === 'link') STATE.site.links.splice(idx, 1);
else if (kind === 'page') STATE.site.pages.splice(idx, 1);
else if (kind === 'poiPhoto') STATE.site.poiPhotos.splice(idx, 1);
else if (kind === 'privateGroup') {
ensureCommunity();
STATE.site.community.privateGroups.splice(idx, 1);
} else STATE.site.embeds.splice(idx, 1);
if (STATE.activeEmbed >= STATE.site.embeds.length) STATE.activeEmbed = Math.max(0, STATE.site.embeds.length - 1);
STATE.poiImgSrcCache = {};
renderEditors();
renderLinksList();
renderPages();
refreshEmbedsOnly();
void renderPoiPhotos();
if (kind === 'privateGroup') applyChromeUi();
saveLocalSite();
});
return wrap;
}
function bindRowInput(wrap, kind, idx, keys) {
keys.forEach((k) => {
const inp = wrap.querySelector('[data-k="' + k + '"]');
if (!inp) return;
const ev = () => {
const v = inp.value;
if (kind === 'link') STATE.site.links[idx][k] = v;
else if (kind === 'page') STATE.site.pages[idx][k] = v;
else if (kind === 'poiPhoto') STATE.site.poiPhotos[idx][k] = v;
else if (kind === 'privateGroup') {
ensureCommunity();
STATE.site.community.privateGroups[idx][k] = v;
applyChromeUi();
} else STATE.site.embeds[idx][k] = v;
if (kind === 'link') renderLinksList();
if (kind === 'page') renderPages();
if (kind === 'poiPhoto') {
STATE.poiImgSrcCache = {};
void renderPoiPhotos();
}
if (kind === 'embed') {
STATE.embedSrcCache = {};
refreshEmbedsOnly();
}
updateStats();
updateFloatingQdnBar();
};
inp.addEventListener('input', ev);
inp.addEventListener('change', ev);
});
}
async function renderPoiPhotos() {
const grid = $('#sn-poi-photos');
if (!grid) return;
grid.innerHTML = '';
const list = Array.isArray(STATE.site.poiPhotos) ? STATE.site.poiPhotos.slice(0, 48) : [];
if (!list.length) {
const p = el('p', 'sn-poi-empty sn-mono sn-dim');
p.textContent =
'No photos yet. In Site settings → World Map photos, paste qortal://IMAGE/… links (from your public POI wall in World Map).';
grid.appendChild(p);
return;
}
for (let i = 0; i < list.length; i++) {
const row = list[i];
const href = String(row.href || '').trim();
const parsed = parseQortalImageHref(href);
const card = el('figure', 'sn-poi-photo-card');
const img = el('img', 'sn-poi-photo-img');
img.alt = row.caption || 'Photo from World Map POI';
img.loading = 'lazy';
const ph = el('div', 'sn-poi-photo-ph sn-mono sn-dim');
ph.textContent = STATE.inQortal ? 'Loading…' : 'Connect in Qortal to load';
if (parsed && STATE.inQortal) {
card.style.cursor = 'pointer';
card.title = 'Open this IMAGE on QDN';
card.addEventListener('click', () => {
void openQortalResource('IMAGE', parsed.name, parsed.identifier, null);
});
}
card.appendChild(ph);
card.appendChild(img);
if (row.caption && String(row.caption).trim()) {
const cap = el('figcaption', 'sn-poi-photo-cap', row.caption);
card.appendChild(cap);
}
grid.appendChild(card);
if (!parsed) {
ph.textContent = 'Use qortal://IMAGE/Name/identifier';
continue;
}
const cacheKey = href;
let src = STATE.poiImgSrcCache[cacheKey];
if (!src) {
try {
src = await resolvePoiImageSrc(parsed);
if (src) STATE.poiImgSrcCache[cacheKey] = src;
} catch (_) {
src = null;
}
}
if (src) {
img.onload = () => {
try {
ph.remove();
} catch (_) {}
};
img.onerror = () => {
ph.textContent = 'Image unavailable';
};
img.src = appendAuthBust(src);
} else {
ph.textContent = 'Could not resolve image URL';
}
}
}
function renderAbout() {
const host = $('#sn-about-prose');
if (!host) return;
if (wysiIsEditingNode(host)) return;
const raw = STATE.site.aboutHtml;
host.innerHTML = typeof raw === 'string' && raw.trim() ? raw : DEFAULT_ABOUT_HTML;
}
function syncAboutEditorField() {
const ta = $('#sn-editor-about-html');
const host = document.getElementById('sn-about-prose');
if (!ta) return;
if (document.activeElement === ta) return;
if (host && wysiIsEditingNode(host)) return;
ta.value = STATE.site.aboutHtml || DEFAULT_ABOUT_HTML;
}
function syncPublisherUiEditors() {
const pn = $('#sn-editor-publisher-name');
const uj = $('#sn-editor-ui-json');
if (pn && document.activeElement !== pn) {
pn.value = STATE.site.publisherName != null ? String(STATE.site.publisherName) : '';
}
if (uj && document.activeElement !== uj) {
try {
uj.value = JSON.stringify(STATE.site.ui || {}, null, 2);
} catch (_) {
uj.value = '{}';
}
}
}
function refreshAllUi() {
applyChromeUi();
renderWorkshop();
renderAbout();
renderLinksList();
renderPages();
renderEditors();
syncAboutEditorField();
syncPublisherUiEditors();
renderEmbedTabs();
void renderPoiPhotos();
updateStats();
applyPageLayout();
}
function hydratePreview() {
STATE.site = mergeSite(loadLocalSite());
refreshAllUi();
loadEverything().catch(() => {});
}
async function init() {
bindStaticHandlers();
updateBuilderChrome();
renderWorkshop();
updateLiveStatus();
await tryLoadSiteConfig();
if (STATE.inQortal) {
try {
await loadEverything();
} catch (e) {
console.error(e);
toast('Load error: ' + (e && e.message ? e.message : e), 'err');
refreshAllUi();
}
} else {
hydratePreview();
toast('Preview mode — open inside Qortal for live embeds & publish', 'warn');
}
requestAnimationFrame(() => {
const ed = document.getElementById('sn-site-editor');
if (ed) ed.scrollIntoView({ block: 'start', behavior: 'smooth' });
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();