sirius/js/studio.js

542 lines
35 KiB
JavaScript
Raw Normal View History

// Sirius Studio — a site builder for BCNR names.
//
// GrapesJS (BSD-3, vendored under ./vendor/grapesjs/) edits the page. On
// Publish the exported HTML+CSS becomes index.html in the name's own folder
// on Sia — bns/<name>/ — uploaded through the gateway's /api/site route,
// every request signed with the wallet key that holds the name's NFT. The
// editor's project data is saved next to it (_studio.json) so the site can
// be reopened and edited later. If the name's on-chain s3 record does not
// point at that folder yet, Publish also sends one UPD that sets it.
//
// The gateway never holds a key: it verifies each upload's signature
// against the current NFT owner and refuses writes outside bns/<name>/.
import * as BNS from "https://silentmode.st/js/bns-register.js?v=20260920usd";
import { initAssistant } from "./studio-ai.js?v=20260920ai";
const API = "https://silentmode.st";
const $ = (id) => document.getElementById(id);
const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
const name = (new URLSearchParams(location.search).get("name") || "").toLowerCase().trim();
const folder = name ? `bns/${name}/` : null;
const readUrl = (path) => `${API}/api/site/${encodeURIComponent(name)}/${path}`;
const siteUrl = () => `${API}/bns/${encodeURIComponent(name)}/`;
let wallet = null;
let entry = null; // { records, category, ... } from /api/name
let editor = null;
let dirty = false;
function status(text, cls = "") { const el = $("status"); el.textContent = text; el.className = "st " + cls; }
// ---------- session ----------
async function restoreWallet() {
if (window.siriusWallet) return window.siriusWallet;
const S = window.siriusSession;
if (!S?.restore) return null;
try {
const s = await S.restore();
if (!s?.mnemonic) return null;
return await BNS.BuiltInWallet.fromMnemonic(s.mnemonic, BNS.CHIPNET_PREFIX, s.accountPath || undefined);
} catch { return null; }
}
// ---------- signed uploads ----------
const hex = (buf) => [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join("");
async function signedHeaders(path, bodyBytes) {
const ts = String(Date.now());
const bodyHash = hex(await crypto.subtle.digest("SHA-256", bodyBytes));
const msg = `BNS-SITE1\n${name}\n${path}\n${bodyHash}\n${ts}`;
const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(msg)));
const sig = wallet.signMessage(digest);
return { "x-bns-ts": ts, "x-bns-sig": btoa(String.fromCharCode(...sig)) };
}
async function putFile(path, body, contentType) {
const bytes = body instanceof Uint8Array ? body : new Uint8Array(await new Blob([body]).arrayBuffer());
const headers = { "content-type": contentType, ...(await signedHeaders(path, bytes)) };
const r = await fetch(readUrl(path), { method: "PUT", headers, body: bytes });
const j = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(`${path}: ${j.error || r.status}`);
return j;
}
async function listFiles(live = false) {
const r = await fetch(`${API}/api/site/${encodeURIComponent(name)}${live ? "?src=live" : ""}`, { cache: "no-store" });
if (!r.ok) return { files: [], prefix: null, source: null };
const j = await r.json().catch(() => ({}));
return { files: j.files || [], prefix: j.prefix ?? null, source: j.source ?? null };
}
// ---------- import the page the name serves right now ----------
// Sites are hosted three ways: the Studio folder bns/<name>/, a CLI-published
// bucket the `s3` record points at (bns/<label>/ by convention), or inline
// HTML in the `h` record. The editor must start from whichever one is live,
// not from an empty Studio folder. Relative URLs are made absolute against
// the public site URL so the canvas shows the real images and links, and
// stylesheets are inlined so the imported page keeps its look.
let extraHeadLinks = []; // cross-origin stylesheets we could not inline (fonts CDNs)
// Rules the editor's CSS parser drops or cannot target — :root variables,
// html/body background and fonts, @import. Kept verbatim: injected into the
// canvas so the page looks like the live one, saved with the draft, and
// emitted ahead of the editor's CSS on export.
let importedCss = "";
// Attributes of the source page's <html> (lang, data-theme, class): themed
// sites key their variables on them, so they go on the canvas root and on
// the exported page.
let importedHtmlAttrs = {};
function splitRootCss(css) {
const root = [], rest = [];
// Whole selectors that only address the document root — including themed
// variants such as html[data-theme="dark"] or :root:not([data-theme="light"])
// — but never descendants (body .hero stays with the editor).
const isRootSel = (sel) => /^(:root|html|body)[^\s,>+~]*(\s*,\s*(:root|html|body)[^\s,>+~]*)*$/i.test(sel.trim());
let sheet;
try { sheet = new CSSStyleSheet(); sheet.replaceSync(css); } catch { return { root: css, rest: "" }; }
const walk = (rules, into) => {
for (const r of rules) {
if (r.type === CSSRule.STYLE_RULE) (isRootSel(r.selectorText) ? into.root : into.rest).push(r.cssText);
else if (r.type === CSSRule.MEDIA_RULE) {
const sub = { root: [], rest: [] }; walk(r.cssRules, sub);
if (sub.root.length) into.root.push(`@media ${r.conditionText}{${sub.root.join("\n")}}`);
if (sub.rest.length) into.rest.push(`@media ${r.conditionText}{${sub.rest.join("\n")}}`);
} else if (r.type === CSSRule.IMPORT_RULE) into.root.push(r.cssText);
else into.rest.push(r.cssText);
}
};
walk(sheet.cssRules, { root, rest });
return { root: root.join("\n"), rest: rest.join("\n") };
}
function injectImportedCss() {
try {
const doc = editor.Canvas.getDocument(); if (!doc) return;
let st = doc.getElementById("sirius-imported");
if (!st) { st = doc.createElement("style"); st.id = "sirius-imported"; }
// The editor's base sheet paints <body> white, which hides a page whose
// background lives on <html> (common in app bundles). Body stays
// transparent in the canvas; the export never had that rule anyway.
st.textContent = "html body{background-color:transparent}\n" + importedCss.replace(/(^|[,{}\s])body(?=[\s,{.:\[#>])/g, "$1html body");
doc.head.appendChild(st);
for (const [k, v] of Object.entries(importedHtmlAttrs)) doc.documentElement.setAttribute(k, v);
for (const href of extraHeadLinks) if (!doc.head.querySelector(`link[href="${href}"]`)) { const l = doc.createElement("link"); l.rel = "stylesheet"; l.href = href; doc.head.appendChild(l); }
} catch {}
}
function liveSource() {
const rec = entry?.records || {};
const s3 = typeof rec.s3 === "string" ? rec.s3.trim().replace(/\/?$/, "/") : "";
if (s3 && s3 !== folder) return { kind: "s3", label: s3 };
if (!s3 && typeof rec.h === "string" && rec.h) return { kind: "inline", label: "the inline h record" };
if (s3 === folder) return { kind: "studio", label: folder };
return null;
}
async function fetchLiveHtml() {
const src = liveSource();
if (!src) return null;
const url = src.kind === "studio" ? readUrl("index.html") : readUrl("index.html") + "?src=live";
const r = await fetch(url, { cache: "no-store" });
if (!r.ok) return null;
return { html: await r.text(), base: siteUrl(), src };
}
async function importHtml(html, base) {
const doc = new DOMParser().parseFromString(html, "text/html");
doc.querySelectorAll("script, base, noscript").forEach((n) => n.remove());
const isRel = (u) => !!u && !/^(https?:|data:|blob:|mailto:|tel:|#|\/\/|javascript:)/i.test(u.trim());
const abs = (u) => { try { return new URL(u.trim(), base).href; } catch { return u; } };
doc.querySelectorAll("[src]").forEach((el) => { const v = el.getAttribute("src"); if (isRel(v)) el.setAttribute("src", abs(v)); });
doc.querySelectorAll("[poster]").forEach((el) => { const v = el.getAttribute("poster"); if (isRel(v)) el.setAttribute("poster", abs(v)); });
doc.querySelectorAll("a[href]").forEach((el) => { const v = el.getAttribute("href"); if (isRel(v)) el.setAttribute("href", abs(v)); });
doc.querySelectorAll("[srcset]").forEach((el) => {
el.setAttribute("srcset", el.getAttribute("srcset").split(",").map((part) => { const [u, d] = part.trim().split(/\s+/); return (isRel(u) ? abs(u) : u) + (d ? " " + d : ""); }).join(", "));
});
let css = "";
extraHeadLinks = [];
for (const link of [...doc.querySelectorAll('link[rel~="stylesheet"][href]')]) {
const href = link.getAttribute("href");
const url = isRel(href) ? abs(href) : href;
try {
const r = await fetch(url, { cache: "no-store" });
if (!r.ok) throw new Error(String(r.status));
css += `\n/* ${url} */\n` + (await r.text()).replace(/url\(\s*(['"]?)([^'")]+)\1\s*\)/g, (m, q, u) => (isRel(u) ? `url(${q}${new URL(u.trim(), url).href}${q})` : m));
} catch { extraHeadLinks.push(url); }
link.remove();
}
doc.querySelectorAll("style").forEach((st) => { css += "\n" + st.textContent; st.remove(); });
css = css.replace(/url\(\s*(['"]?)([^'")]+)\1\s*\)/g, (m, q, u) => (isRel(u) ? `url(${q}${abs(u)}${q})` : m));
const parts = splitRootCss(css);
importedCss = parts.root;
importedHtmlAttrs = Object.fromEntries([...doc.documentElement.attributes].map((a) => [a.name, a.value]).filter(([k]) => k === "lang" || k === "class" || k.startsWith("data-")));
const bodyClass = doc.body.getAttribute("class");
if (bodyClass) editor.getWrapper().addClass(bodyClass.split(/\s+/).filter(Boolean));
editor.setComponents(doc.body.innerHTML);
editor.setStyle(parts.rest);
injectImportedCss();
setTimeout(injectImportedCss, 50);
editor.once("canvas:frame:load", injectImportedCss);
}
async function importLive({ confirmIfDirty = true } = {}) {
if (confirmIfDirty && dirty && !confirm("Replace your unsaved changes with the page the name serves right now?")) return false;
status("Importing the live site…", "busy");
try {
const live = await fetchLiveHtml();
if (!live) { status("The name serves no page yet", "err"); return false; }
await importHtml(live.html, live.base);
dirty = true; $("btn-draft").disabled = false;
$("notice").hidden = true;
status(`Imported the live site from ${live.src.label}`, "ok");
return true;
} catch (e) { status("Import failed: " + (e.message || e), "err"); return false; }
}
function notice(text) { $("notice-text").textContent = text; $("notice").hidden = false; }
// ---------- templates ----------
const TEMPLATES = {
blank: { html: `<section style="padding:60px 20px;text-align:center"><h1>${esc(name)}</h1><p>Start building.</p></section>`, css: `body{font-family:system-ui,sans-serif;margin:0;color:#111}` },
landing: {
html: `
<header class="hero">
<h1>Hello from ${esc(name)}</h1>
<p>A name on the Bitcoin Cash chain, a site on Sia. No host, no renewal, no permission.</p>
<a class="cta" href="#more">Learn more</a>
</header>
<section id="more" class="features">
<div class="f"><h3>Yours</h3><p>The certificate sits in your wallet. Nobody can take it back.</p></div>
<div class="f"><h3>Fast</h3><p>Served from Sia through any BCNR resolver or the public gateway.</p></div>
<div class="f"><h3>Simple</h3><p>Edit this page in Sirius Studio and publish in one click.</p></div>
</section>
<footer class="foot">© ${new Date().getFullYear()} ${esc(name)} · built with Sirius Studio</footer>`,
css: `
body{margin:0;font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;color:#12161f;background:#fff}
.hero{padding:96px 24px 72px;text-align:center;background:linear-gradient(180deg,#0b0e14,#141a24);color:#f1f4fa}
.hero h1{font-size:44px;margin:0 0 12px;letter-spacing:-.01em}
.hero p{font-size:18px;color:#b8c2d4;max-width:560px;margin:0 auto 26px}
.cta{display:inline-block;background:#d6ff3d;color:#0b0e14;font-weight:700;padding:12px 22px;border-radius:10px;text-decoration:none}
.features{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:20px;max-width:960px;margin:0 auto;padding:56px 24px}
.f{background:#f5f7fb;border-radius:14px;padding:22px}
.f h3{margin:0 0 8px}
.f p{margin:0;color:#4a5568}
.foot{text-align:center;padding:28px;color:#6a7488;font-size:13px;border-top:1px solid #e6e9f0}`,
},
profile: {
html: `
<main class="card">
<div class="avatar"></div>
<h1>${esc(name)}</h1>
<p class="bio">One line about you. Edit me.</p>
<a class="link" href="https://">Website</a>
<a class="link" href="https://">Nostr</a>
<a class="link" href="https://">Bitcoin Cash tips</a>
<a class="link" href="mailto:">Email</a>
</main>`,
css: `
body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;background:radial-gradient(circle at 50% 0,#1a2233,#050810);font-family:system-ui,sans-serif;color:#f1f4fa}
.card{width:min(420px,92vw);text-align:center;padding:36px 24px}
.avatar{width:88px;height:88px;border-radius:50%;background:#d6ff3d;color:#0b0e14;font-size:40px;line-height:88px;margin:0 auto 14px}
h1{margin:0 0 6px;font-size:26px}
.bio{color:#b8c2d4;margin:0 0 22px}
.link{display:block;background:#141a24;border:1px solid rgba(255,255,255,.1);color:#f1f4fa;text-decoration:none;padding:14px;border-radius:12px;margin:10px 0;font-weight:600}
.link:hover{border-color:#d6ff3d}`,
},
business: {
html: `
<nav class="nav"><b>${esc(name)}</b><span><a href="#about">About</a><a href="#services">Services</a><a href="#contact">Contact</a></span></nav>
<header class="top"><h1>We are open</h1><p>Say what you do in one sentence.</p></header>
<section id="about" class="sec"><h2>About</h2><p>Two or three sentences about the business, the people and the place.</p></section>
<section id="services" class="sec alt"><h2>Services</h2>
<ul class="grid"><li><b>Service one</b><span>Short description.</span></li><li><b>Service two</b><span>Short description.</span></li><li><b>Service three</b><span>Short description.</span></li></ul></section>
<section id="contact" class="sec"><h2>Contact</h2><p>Street 1, City · MonFri 918 · <a href="mailto:">hello@example</a></p></section>
<footer class="foot">© ${new Date().getFullYear()} ${esc(name)}</footer>`,
css: `
body{margin:0;font-family:Georgia,serif;color:#2b1d12;background:#fff8f0}
.nav{display:flex;justify-content:space-between;align-items:center;padding:16px 28px;border-bottom:1px solid #eadfd0;font-family:system-ui,sans-serif}
.nav a{margin-left:18px;color:#7a3b00;text-decoration:none}
.top{text-align:center;padding:80px 24px;background:#7a3b00;color:#fff4e6}
.top h1{font-size:42px;margin:0 0 10px}
.sec{max-width:820px;margin:0 auto;padding:48px 24px}
.sec.alt{max-width:none;background:#fff1e0}
.sec.alt h2,.sec.alt ul{max-width:820px;margin-left:auto;margin-right:auto}
.grid{list-style:none;padding:0;display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:16px}
.grid li{background:#fff;border-radius:12px;padding:18px;box-shadow:0 2px 10px rgba(0,0,0,.05)}
.grid b{display:block;margin-bottom:6px}
.foot{text-align:center;padding:24px;color:#8a6d55;font-size:13px}`,
},
};
// ---------- editor ----------
function plugin(nameOrObj) {
const p = window[nameOrObj];
return p && (p.default || p);
}
function initEditor() {
const plugins = [];
const pluginsOpts = {};
const use = (global, opts = {}) => { const p = plugin(global); if (p) { plugins.push(p); pluginsOpts[p] = opts; } };
use("gjs-blocks-basic", { flexGrid: true, category: "Layout" }); // the bundle registers itself as gjs-blocks-basic
// useCustomTheme:false — the preset's mauve demo palette used to override
// ours and made the panels low-contrast; the theme lives in studio.html.
use("grapesjs-preset-webpage", { modalImportTitle: "Import HTML", blocks: [], useCustomTheme: false, showStylesOnChange: true });
use("grapesjs-plugin-forms", { category: "Forms" });
use("grapesjs-navbar");
use("grapesjs-tabs");
use("grapesjs-tooltip");
use("grapesjs-custom-code");
use("grapesjs-component-countdown");
use("grapesjs-style-bg");
use("grapesjs-touch");
// PostCSS parser: the browser CSSOM path drops shorthands that contain
// var() (background: var(--surface) came back empty), which stripped
// imported pages of their panels and borders.
use("grapesjs-parser-postcss");
editor = grapesjs.init({
container: "#gjs",
height: "100%",
fromElement: false,
storageManager: false,
plugins,
pluginsOpts,
canvas: { styles: [] },
assetManager: {
upload: false,
uploadFile: async (e) => {
const files = e.dataTransfer ? e.dataTransfer.files : e.target.files;
for (const f of files) await uploadAsset(f);
},
},
});
editor.on("update", () => { dirty = true; $("btn-draft").disabled = false; });
setupEditing();
try { initAssistant(editor, { esc, name }); } catch (e) { console.warn("assistant unavailable:", e); }
// Ctrl/Cmd+S saves a draft.
document.addEventListener("keydown", (e) => { if ((e.ctrlKey || e.metaKey) && e.key === "s") { e.preventDefault(); saveDraft(); } });
window.addEventListener("beforeunload", (e) => { if (dirty) { e.preventDefault(); e.returnValue = ""; } });
}
// ---------- Tilda-style editing: resize handles, richer text toolbar,
// ready-made sections, style panel on select ----------
function setupEditing() {
// 1. Every box can be dragged to size: right, bottom and corner handles set
// width/height on the element (images keep their own ratio-aware resizer).
const rz = { tl: 0, tc: 0, tr: 0, cl: 0, cr: 1, bl: 0, bc: 1, br: 1, minDim: 16 };
for (const t of ["default", "text", "link", "video", "map", "table", "row", "cell", "svg", "iframe"]) {
if (editor.Components.getType(t)) editor.Components.addType(t, { model: { defaults: { resizable: rz } } });
}
// 2. Text toolbar: size, colour, highlight, alignment, headings.
const rte = editor.RichTextEditor;
const wrapSel = (r, style) => { const t = String(r.selection()); if (t) r.insertHTML(`<span style="${style}">${esc(t)}</span>`); };
rte.add("fontsize", {
icon: `<select title="Text size">${["", "12px", "14px", "16px", "18px", "20px", "24px", "28px", "32px", "40px", "48px", "64px"].map((v) => `<option value="${v}">${v || "Size"}</option>`).join("")}</select>`,
event: "change",
result: (r, action) => { const v = action.btn.querySelector("select").value; if (v) wrapSel(r, `font-size:${v}`); action.btn.querySelector("select").value = ""; },
});
rte.add("forecolor", { icon: `<input type="color" title="Text colour" value="#111111">`, event: "change", result: (r, action) => r.exec("foreColor", action.btn.querySelector("input").value) });
rte.add("hilite", { icon: `<input type="color" title="Highlight" value="#fff59d">`, event: "change", result: (r, action) => r.exec("hiliteColor", action.btn.querySelector("input").value) });
for (const [name, cmd, glyph, title] of [["alignL", "justifyLeft", "≡", "Align left"], ["alignC", "justifyCenter", "☰", "Centre"], ["alignR", "justifyRight", "≡", "Align right"]]) {
rte.add(name, { icon: `<b title="${title}" style="font-style:normal">${glyph}</b>`, result: (r) => r.exec(cmd) });
}
rte.add("heading", {
icon: `<select title="Paragraph style"><option value="">Style</option><option value="h1">Heading 1</option><option value="h2">Heading 2</option><option value="h3">Heading 3</option><option value="p">Paragraph</option><option value="blockquote">Quote</option></select>`,
event: "change",
result: (r, action) => { const v = action.btn.querySelector("select").value; if (v) r.exec("formatBlock", v); action.btn.querySelector("select").value = ""; },
});
// 3. Fonts the exported page can rely on without loading anything.
const ff = editor.StyleManager.getProperty("typography", "font-family");
if (ff) {
const extra = [
["'DM Sans', system-ui, sans-serif", "DM Sans"], ["Fraunces, Georgia, serif", "Fraunces"], ["Ubuntu, system-ui, sans-serif", "Ubuntu"],
["system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif", "System"], ["Georgia, 'Times New Roman', serif", "Georgia"],
["'JetBrains Mono', ui-monospace, Menlo, monospace", "Mono"],
].map(([id, label]) => ({ id, label }));
ff.set("options", [...extra, ...(ff.get("options") || [])]);
}
// 4. Ready-made sections, Tilda style: drop, then edit text and images in place.
const bm = editor.BlockManager;
const sec = (id, label, html, css) => bm.add(`sx-${id}`, { label, category: "Sections", media: SECTION_ICON, content: `<style>${css}</style>${html}` });
sec("hero", "Hero", `<section class="sx-hero"><div class="sx-wrap"><p class="sx-kicker">NEW</p><h1>A headline that earns the scroll</h1><p class="sx-lead">One sentence on what this is and who it is for. Keep it honest and short.</p><a class="sx-btn" href="#">Get started</a></div></section>`,
`.sx-hero{padding:96px 24px;background:#0b0e14;color:#f1f4fa;text-align:center}.sx-wrap{max-width:820px;margin:0 auto}.sx-kicker{letter-spacing:.2em;font-size:12px;color:#d6ff3d;margin:0 0 12px}.sx-hero h1{font-size:44px;line-height:1.1;margin:0 0 16px}.sx-lead{font-size:18px;color:#b8c2d4;margin:0 0 28px}.sx-btn{display:inline-block;background:#d6ff3d;color:#0b0e14;padding:12px 22px;border-radius:10px;text-decoration:none;font-weight:600}`);
sec("features", "Three features", `<section class="sx-feat"><div class="sx-grid3"><div><h3>Fast</h3><p>Say what it does in one line.</p></div><div><h3>Simple</h3><p>Say why it is easy.</p></div><div><h3>Yours</h3><p>Say what the visitor keeps.</p></div></div></section>`,
`.sx-feat{padding:64px 24px;background:#fff;color:#111}.sx-grid3{max-width:1000px;margin:0 auto;display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:28px}.sx-feat h3{margin:0 0 8px;font-size:20px}.sx-feat p{margin:0;color:#555;line-height:1.6}`);
sec("split", "Image + text", `<section class="sx-split"><div class="sx-split-in"><img src="https://silentmode.st/sirius-x/brand/banner.svg" alt=""><div><h2>Show, then tell</h2><p>A picture on one side, the explanation on the other. Swap the image by clicking it.</p><a class="sx-btn2" href="#">Learn more →</a></div></div></section>`,
`.sx-split{padding:64px 24px;background:#f6f7f9;color:#111}.sx-split-in{max-width:1000px;margin:0 auto;display:grid;grid-template-columns:1fr 1fr;gap:40px;align-items:center}.sx-split img{width:100%;border-radius:14px}.sx-split h2{font-size:32px;margin:0 0 12px}.sx-split p{color:#555;line-height:1.6}.sx-btn2{color:#111;font-weight:600;text-decoration:none}@media(max-width:700px){.sx-split-in{grid-template-columns:1fr}}`);
sec("gallery", "Gallery", `<section class="sx-gal"><div class="sx-gal-grid">${Array.from({ length: 6 }, () => `<img src="https://silentmode.st/sirius-x/brand/avatar.svg" alt="">`).join("")}</div></section>`,
`.sx-gal{padding:48px 24px;background:#fff}.sx-gal-grid{max-width:1000px;margin:0 auto;display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:12px}.sx-gal img{width:100%;aspect-ratio:1;object-fit:cover;border-radius:12px;background:#eee}`);
sec("quote", "Testimonial", `<section class="sx-quote"><blockquote>“Put the kindest true thing a customer said right here.”</blockquote><p class="sx-who">— A real person, their role</p></section>`,
`.sx-quote{padding:72px 24px;background:#0b0e14;color:#f1f4fa;text-align:center}.sx-quote blockquote{max-width:760px;margin:0 auto 14px;font-size:26px;line-height:1.35;font-style:italic}.sx-who{color:#b8c2d4;margin:0}`);
sec("pricing", "Pricing", `<section class="sx-price"><div class="sx-grid3"><div class="sx-plan"><h3>Basic</h3><p class="sx-amt">$0</p><ul><li>One page</li><li>Your name</li></ul><a class="sx-btn" href="#">Choose</a></div><div class="sx-plan sx-hot"><h3>Pro</h3><p class="sx-amt">$9</p><ul><li>Everything in Basic</li><li>Priority help</li></ul><a class="sx-btn" href="#">Choose</a></div><div class="sx-plan"><h3>Team</h3><p class="sx-amt">$29</p><ul><li>Everything in Pro</li><li>Five seats</li></ul><a class="sx-btn" href="#">Choose</a></div></div></section>`,
`.sx-price{padding:64px 24px;background:#f6f7f9;color:#111}.sx-price .sx-grid3{max-width:1000px;margin:0 auto;display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:20px}.sx-plan{background:#fff;border:1px solid #e6e8ee;border-radius:16px;padding:28px 24px;text-align:center}.sx-hot{border-color:#111}.sx-amt{font-size:40px;font-weight:700;margin:8px 0 16px}.sx-plan ul{list-style:none;padding:0;margin:0 0 20px;color:#555;line-height:1.9}.sx-price .sx-btn{display:inline-block;background:#111;color:#fff;padding:10px 20px;border-radius:10px;text-decoration:none;font-weight:600}`);
sec("cta", "Call to action", `<section class="sx-cta"><h2>Ready when you are</h2><p>One line that removes the last doubt.</p><a class="sx-btn" href="#">Do the thing →</a></section>`,
`.sx-cta{padding:72px 24px;text-align:center;background:#d6ff3d;color:#0b0e14}.sx-cta h2{font-size:34px;margin:0 0 8px}.sx-cta p{margin:0 0 24px;font-size:17px}.sx-cta .sx-btn{display:inline-block;background:#0b0e14;color:#d6ff3d;padding:12px 22px;border-radius:10px;text-decoration:none;font-weight:600}`);
sec("contact", "Contact form", `<section class="sx-contact"><div class="sx-wrap"><h2>Get in touch</h2><form class="sx-form" method="post"><input type="text" name="name" placeholder="Your name"><input type="email" name="email" placeholder="Email"><textarea name="message" rows="4" placeholder="Message"></textarea><button type="submit" class="sx-btn">Send</button></form></div></section>`,
`.sx-contact{padding:64px 24px;background:#fff;color:#111}.sx-contact .sx-wrap{max-width:560px;margin:0 auto}.sx-contact h2{margin:0 0 16px}.sx-form{display:grid;gap:10px}.sx-form input,.sx-form textarea{width:100%;padding:12px;border:1px solid #d9dce3;border-radius:10px;font:inherit}.sx-contact .sx-btn{background:#111;color:#fff;border:0;padding:12px 20px;border-radius:10px;font-weight:600;cursor:pointer}`);
sec("footer", "Footer", `<footer class="sx-footer"><div class="sx-wrap sx-foot-in"><span>© <b>your name</b> · on Bitcoin Cash</span><nav><a href="#">About</a><a href="#">Contact</a><a href="#">Privacy</a></nav></div></footer>`,
`.sx-footer{padding:28px 24px;background:#0b0e14;color:#b8c2d4;font-size:14px}.sx-foot-in{max-width:1000px;margin:0 auto;display:flex;justify-content:space-between;gap:14px;flex-wrap:wrap}.sx-footer a{color:#b8c2d4;text-decoration:none;margin-left:16px}.sx-footer b{color:#f1f4fa}`);
// Group the plugin blocks under readable categories.
const cat = { navbar: "Sections", tabs: "Widgets", tooltip: "Widgets", "custom-code": "Widgets", countdown: "Widgets" };
bm.getAll().forEach((b) => { const c = cat[b.getId()]; if (c) b.set("category", c); });
// 5. Selecting anything opens the style panel; the layer tree is one click away.
editor.on("component:selected", () => { const btn = editor.Panels.getButton("views", "open-sm"); if (btn && !btn.get("active")) btn.set("active", true); });
}
const SECTION_ICON = `<svg viewBox="0 0 24 24" width="36" height="36"><rect x="2" y="4" width="20" height="5" rx="1.5" fill="currentColor" opacity=".9"/><rect x="2" y="11" width="20" height="9" rx="1.5" fill="currentColor" opacity=".45"/></svg>`;
function loadTemplate(key) {
const t = TEMPLATES[key] || TEMPLATES.blank;
importedCss = ""; extraHeadLinks = []; importedHtmlAttrs = {}; injectImportedCss();
editor.setComponents(t.html);
editor.setStyle(t.css);
dirty = true; $("btn-draft").disabled = false;
}
async function uploadAsset(file) {
const safe = file.name.toLowerCase().replace(/[^a-z0-9._-]/g, "-").replace(/-+/g, "-");
const path = `assets/${Date.now().toString(36)}-${safe}`;
status(`Uploading ${file.name}`, "busy");
try {
const bytes = new Uint8Array(await file.arrayBuffer());
await putFile(path, bytes, file.type || "application/octet-stream");
editor.AssetManager.add({ src: readUrl(path), name: file.name, type: "image" });
status(`Uploaded ${file.name}`, "ok");
} catch (e) { status("Upload failed: " + (e.message || e), "err"); }
}
// Exported page: inline CSS, asset URLs rewritten to be relative to the
// site folder so the page works from Sia, any resolver and the gateway.
function exportHtml() {
const html = editor.getHtml();
const css = editor.getCss();
const rel = (s) => s.split(readUrl("")).join("");
const links = extraHeadLinks.map((h) => `<link rel="stylesheet" href="${esc(h)}">`).join("\n");
const htmlAttrs = Object.entries({ lang: document.documentElement.lang || "en", ...importedHtmlAttrs }).map(([k, v]) => `${k}="${esc(v)}"`).join(" ");
return `<!doctype html>
<html ${htmlAttrs}>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${esc(name)}</title>
<meta name="generator" content="Sirius Studio">
${links}
<style>
${rel(importedCss)}
${rel(css)}
</style>
</head>
${rel(html)}
</html>
`;
}
// ---------- draft / publish ----------
function pushStep(t) { const d = document.createElement("div"); d.textContent = t; $("pub-steps").appendChild(d); $("pub-steps").scrollTop = 1e6; }
async function saveDraft() {
if (!editor || !wallet) return;
status("Saving draft…", "busy");
try {
const data = JSON.stringify({ v: 1, name, saved_at: new Date().toISOString(), project: editor.getProjectData(), importedCss, extraHeadLinks, importedHtmlAttrs });
await putFile("_studio.json", data, "application/json");
dirty = false; $("btn-draft").disabled = true;
status("Draft saved on Sia", "ok");
} catch (e) { status("Draft not saved: " + (e.message || e), "err"); }
}
async function publish() {
if (!editor || !wallet) return;
$("pub-name").textContent = name; $("pub-steps").innerHTML = ""; $("pub-view").hidden = true; $("pub").hidden = false;
$("btn-publish").disabled = true;
try {
const html = exportHtml();
pushStep(`exported page · ${html.length.toLocaleString()} bytes`);
const res = await putFile("index.html", html, "text/html; charset=utf-8");
pushStep(`uploaded index.html → ${res.sia_key}`);
const data = JSON.stringify({ v: 1, name, saved_at: new Date().toISOString(), project: editor.getProjectData(), importedCss, extraHeadLinks, importedHtmlAttrs });
await putFile("_studio.json", data, "application/json");
pushStep("saved editor project (_studio.json)");
dirty = false; $("btn-draft").disabled = true;
// Point the name at the folder if it does not already. Re-read the
// registry first: `entry` from boot can be stale or missing (a name
// indexed after the page opened), and a stale view here means a
// needless on-chain transaction on every publish.
try { const r = await fetch(`${API}/api/name/${encodeURIComponent(name)}`, { cache: "no-store" }); if (r.ok) entry = await r.json(); } catch {}
const rec = entry?.records || {};
const norm = (v) => String(v || "").trim().replace(/\/+$/, "") + "/";
const pointsHere = norm(rec.s3) === folder;
if (!pointsHere && !entry) {
pushStep("the registry has not indexed this name yet — files are up; pointing the name is skipped until it appears (or set Hosting in the dashboard)");
} else if (!pointsHere) {
pushStep(`name points at ${rec.s3 ? `"${rec.s3}"` : "nothing"} — setting s3 = ${folder} on chain`);
const next = { ...rec, s3: folder };
delete next.h; // inline HTML would shadow the Sia site
let el = null;
try {
el = await BNS.connect();
const r = await BNS.setRecordsWithBuiltInWallet(el, { wallet, name, records: next, onProgress: (s) => pushStep(String(s)) });
pushStep(`broadcast ${r.txid || r.txId || ""}`);
entry = { ...(entry || {}), records: next };
} finally { try { el?.close?.(); } catch {} }
pushStep("resolvers switch to the new site within a block");
} else {
pushStep("name already points at this folder — live now");
}
$("pub-view").href = siteUrl(); $("pub-view").hidden = false;
$("btn-view").href = siteUrl(); $("btn-view").hidden = false;
status("Published", "ok");
} catch (e) {
pushStep("error: " + (e.message || e));
status("Publish failed", "err");
} finally { $("btn-publish").disabled = false; }
}
// ---------- boot ----------
(async function boot() {
if (!name) { location.replace("./portal.html#studio"); return; }
$("site-name").innerHTML = `${esc(name.split(".")[0])}.<span class="tld">${esc(name.split(".").slice(1).join("."))}</span>`;
$("picker-name").textContent = name;
document.title = `${name} — Sirius Studio`;
wallet = await restoreWallet();
if (!wallet) { $("gate-link").href = `./portal.html?next=${encodeURIComponent(location.pathname + location.search)}`; $("gate").hidden = false; status("Not signed in", "err"); return; }
status("Loading name…");
try { const r = await fetch(`${API}/api/name/${encodeURIComponent(name)}`, { cache: "no-store" }); entry = r.ok ? await r.json() : null; } catch { entry = null; }
initEditor();
$("btn-publish").disabled = false;
const src = liveSource();
if (src) { $("btn-view").href = siteUrl(); $("btn-view").hidden = false; $("btn-import").hidden = false; }
// Start from the right thing: the saved Studio draft if there is one and
// it is what the name serves; otherwise the page the name serves right now
// (wherever it lives); otherwise the template picker.
const { files } = await listFiles();
const draft = files.find((f) => f.path === "_studio.json");
const studioIndex = files.find((f) => f.path === "index.html");
if (draft) {
try {
const j = await (await fetch(readUrl("_studio.json"), { cache: "no-store" })).json();
if (j?.project) {
editor.loadProjectData(j.project);
importedCss = typeof j.importedCss === "string" ? j.importedCss : ""; extraHeadLinks = Array.isArray(j.extraHeadLinks) ? j.extraHeadLinks : [];
importedHtmlAttrs = j.importedHtmlAttrs && typeof j.importedHtmlAttrs === "object" ? j.importedHtmlAttrs : {};
editor.once("canvas:frame:load", injectImportedCss); injectImportedCss();
dirty = false; status(`Loaded draft from ${j.saved_at ? new Date(j.saved_at).toLocaleString() : "Sia"}`, "ok");
}
if (src && src.kind !== "studio") notice(`This draft is not what visitors see: the name currently serves ${src.label}.`);
else if (studioIndex && j?.saved_at && studioIndex.modified && Date.parse(studioIndex.modified) > Date.parse(j.saved_at) + 60_000) notice("index.html on Sia is newer than this draft — it was published by another tool.");
} catch (e) { status("Could not load the saved project: " + (e.message || e), "err"); $("picker").hidden = false; }
} else if (src) {
const ok = await importLive({ confirmIfDirty: false });
if (ok) { dirty = false; $("btn-draft").disabled = true; if (src.kind !== "studio") notice(`Imported the page the name serves from ${src.label}. Publishing writes to ${folder} and repoints the name there.`); }
else { $("picker").hidden = false; }
} else {
$("picker").hidden = false; status("New site");
}
})();
$("btn-import").addEventListener("click", () => importLive());
$("notice-import").addEventListener("click", () => importLive());
$("notice-close").addEventListener("click", () => { $("notice").hidden = true; });
$("picker").addEventListener("click", (e) => {
const b = e.target.closest("[data-tpl]"); if (!b) return;
loadTemplate(b.dataset.tpl); $("picker").hidden = true; status("Template loaded — edit, then Publish");
});
$("tpl-select").addEventListener("change", (e) => {
const v = e.target.value; e.target.value = "";
if (!v) return;
if (!confirm("Replace the current page with this template?")) return;
loadTemplate(v);
});
$("btn-preview").addEventListener("click", () => {
const w = window.open("", "_blank"); if (!w) return;
w.document.open(); w.document.write(exportHtml()); w.document.close();
});
$("btn-draft").addEventListener("click", saveDraft);
$("btn-publish").addEventListener("click", publish);
$("pub-close").addEventListener("click", () => { $("pub").hidden = true; });