312 lines
15 KiB
JavaScript
312 lines
15 KiB
JavaScript
|
|
// 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=20260916dns";
|
|||
|
|
|
|||
|
|
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: `<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 · Mon–Fri 9–18 · <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 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 `<!doctype html>
|
|||
|
|
<html lang="${esc(document.documentElement.lang || "en")}">
|
|||
|
|
<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">
|
|||
|
|
<style>
|
|||
|
|
${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() });
|
|||
|
|
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])}.<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;
|
|||
|
|
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; });
|