b54a3139c7
Includes QWB, Qortal Web, and Q-Shops Q-Apps with shared packages and build scripts. Co-authored-by: Cursor <cursoragent@cursor.com>
3481 lines
124 KiB
JavaScript
3481 lines
124 KiB
JavaScript
/* =========================================================================
|
||
* 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-App’s 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 Crowetic’s 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 — visitor’s 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||
|
||
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 Explorer’s 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();
|
||
}
|
||
})();
|