// 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// — 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//. import * as BNS from "https://silentmode.st/js/bns-register.js?v=20260917sig"; const API = "https://silentmode.st"; const $ = (id) => document.getElementById(id); const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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() { const r = await fetch(`${API}/api/site/${encodeURIComponent(name)}`, { cache: "no-store" }); if (!r.ok) return []; const j = await r.json().catch(() => ({})); return j.files || []; } // ---------- templates ---------- const TEMPLATES = { blank: { html: `

${esc(name)}

Start building.

`, css: `body{font-family:system-ui,sans-serif;margin:0;color:#111}` }, landing: { html: `

Hello from ${esc(name)}

A name on the Bitcoin Cash chain, a site on Sia. No host, no renewal, no permission.

Learn more

Yours

The certificate sits in your wallet. Nobody can take it back.

Fast

Served from Sia through any BCNR resolver or the public gateway.

Simple

Edit this page in Sirius Studio and publish in one click.

© ${new Date().getFullYear()} ${esc(name)} · built with Sirius Studio
`, 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: `

${esc(name)}

One line about you. Edit me.

Website Nostr Bitcoin Cash tips Email
`, 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: `

We are open

Say what you do in one sentence.

About

Two or three sentences about the business, the people and the place.

Services

  • Service oneShort description.
  • Service twoShort description.
  • Service threeShort description.

Contact

Street 1, City · Mon–Fri 9–18 · hello@example

© ${new Date().getFullYear()} ${esc(name)}
`, 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 basic = plugin("grapesjs-blocks-basic"); if (basic) { plugins.push(basic); pluginsOpts[basic] = { flexGrid: true }; } const preset = plugin("grapesjs-preset-webpage"); if (preset) { plugins.push(preset); pluginsOpts[preset] = { modalImportTitle: "Import HTML", blocks: [] }; } 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; }); // 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 = ""; } }); } function loadTemplate(key) { const t = TEMPLATES[key] || TEMPLATES.blank; 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(""); return ` ${esc(name)} ${rel(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() }); 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() }); 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. const rec = entry?.records || {}; if (rec.s3 !== folder) { 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])}.${esc(name.split(".").slice(1).join("."))}`; $("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; if (entry?.records?.s3 === folder) { $("btn-view").href = siteUrl(); $("btn-view").hidden = false; } // Existing project? const files = await listFiles(); const hasProject = files.some((f) => f.path === "_studio.json"); if (hasProject) { try { const j = await (await fetch(readUrl("_studio.json"), { cache: "no-store" })).json(); if (j?.project) { editor.loadProjectData(j.project); dirty = false; status(`Loaded draft from ${j.saved_at ? new Date(j.saved_at).toLocaleString() : "Sia"}`, "ok"); } } catch (e) { status("Could not load the saved project: " + (e.message || e), "err"); $("picker").hidden = false; } } else if (files.some((f) => f.path === "index.html")) { // A site exists but was not made here — import its HTML as a starting point. try { const html = await (await fetch(readUrl("index.html"), { cache: "no-store" })).text(); editor.setComponents(html); status("Imported existing index.html", "ok"); } catch { $("picker").hidden = false; } } else { $("picker").hidden = false; status("New site"); } })(); $("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; });