feat: slideout editor new new new!
This commit is contained in:
parent
c175a610da
commit
48ac1249c8
10 changed files with 482 additions and 112 deletions
3
bliss-cli/live-editor/inspector.js
Normal file
3
bliss-cli/live-editor/inspector.js
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
function handler(req, res) {
|
||||||
|
res.render("inspector/slideout", {});
|
||||||
|
}
|
||||||
79
bliss-cli/live-editor/install.js
Normal file
79
bliss-cli/live-editor/install.js
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
// Install/update the live editor using Bliss's public CLI workflow. This file
|
||||||
|
// intentionally does not open the Bliss database directly.
|
||||||
|
const { spawnSync } = require("node:child_process");
|
||||||
|
const path = require("node:path");
|
||||||
|
|
||||||
|
const repo = path.resolve(__dirname, "../..");
|
||||||
|
const cli = path.join(repo, "bliss-cli/bliss");
|
||||||
|
|
||||||
|
function bliss(...args) {
|
||||||
|
const result = spawnSync(process.execPath, [cli, ...args], {
|
||||||
|
cwd: repo,
|
||||||
|
env: process.env,
|
||||||
|
encoding: "utf8",
|
||||||
|
});
|
||||||
|
if (result.status !== 0) throw new Error(result.stderr || result.stdout);
|
||||||
|
return result.stdout.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function json(...args) {
|
||||||
|
return JSON.parse(bliss(...args));
|
||||||
|
}
|
||||||
|
|
||||||
|
const structures = json("structures");
|
||||||
|
let structure = structures.find((item) => item.name === "__bliss_live_editor");
|
||||||
|
if (!structure) {
|
||||||
|
const created = json("create-structure", "__bliss_live_editor");
|
||||||
|
structure = { id: created.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
const structureId = String(structure.id);
|
||||||
|
bliss("update-settings", structureId, "--route-prefix", "/_bliss");
|
||||||
|
let state = json("structure", structureId);
|
||||||
|
if (!state.dbs.some((db) => String(db.id) === "0" && db.alias === "bliss")) {
|
||||||
|
bliss("attach-db", structureId, "0", "bliss");
|
||||||
|
}
|
||||||
|
|
||||||
|
const templates = [
|
||||||
|
["inspector/slideout", "slideout.eta"],
|
||||||
|
["inspector/route_editor", "route-editor.eta"],
|
||||||
|
["inspector/template_editor", "template-editor.eta"],
|
||||||
|
];
|
||||||
|
for (const [name, filename] of templates) {
|
||||||
|
let template = state.templates.find((item) => item.name === name);
|
||||||
|
if (!template) {
|
||||||
|
template = json("create-template", structureId, name);
|
||||||
|
}
|
||||||
|
bliss(
|
||||||
|
"update-template",
|
||||||
|
structureId,
|
||||||
|
String(template.id),
|
||||||
|
"--content-file",
|
||||||
|
path.join(__dirname, filename),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const routes = [
|
||||||
|
["/inspector", "inspector.js"],
|
||||||
|
["/editor/route/:id", "route-editor.js"],
|
||||||
|
["/editor/template/:id", "template-editor.js"],
|
||||||
|
];
|
||||||
|
for (const [routePath, filename] of routes) {
|
||||||
|
let route = state.routes.find(
|
||||||
|
(item) => item.verb === "GET" && item.path === routePath,
|
||||||
|
);
|
||||||
|
if (!route) {
|
||||||
|
route = json("create-route", structureId, "GET", routePath);
|
||||||
|
}
|
||||||
|
bliss(
|
||||||
|
"update-route",
|
||||||
|
structureId,
|
||||||
|
String(route.id),
|
||||||
|
"--handler-file",
|
||||||
|
path.join(__dirname, filename),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`live editor installed as Structure ${structureId}`);
|
||||||
28
bliss-cli/live-editor/route-editor.eta
Normal file
28
bliss-cli/live-editor/route-editor.eta
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
<div data-bliss-ui>
|
||||||
|
<div class="mb-2 flex items-center justify-between border-b-4 border-double border-black pb-2"><strong><%= it.sourceRoute.verb %> <%= it.sourceRoute.path %></strong><span data-status class="text-xs"></span></div>
|
||||||
|
<% if (it.sourceRoute.verb !== "GET") { %>
|
||||||
|
<div class="mb-2 rounded border border-amber-600 bg-amber-950 p-2 text-amber-200">Rerunning this <%= it.sourceRoute.verb %> request can repeat writes or external side effects. Bliss will not rerun it automatically.</div>
|
||||||
|
<% } %>
|
||||||
|
<div data-source class="h-[55vh] w-full border-2 border-black"><%= it.sourceRoute.handler %></div>
|
||||||
|
<button data-save type="button" class="mt-2 border-2 border-black bg-white px-3 py-2 font-bold shadow-[3px_3px_0_#000] active:translate-x-0.5 active:translate-y-0.5 active:shadow-none">Save Route</button>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
const box = document.currentScript.previousElementSibling;
|
||||||
|
if (!box) return;
|
||||||
|
const editor = ace.edit(box.querySelector("[data-source]"));
|
||||||
|
editor.setTheme("ace/theme/monokai");
|
||||||
|
editor.session.setMode("ace/mode/javascript");
|
||||||
|
box.querySelector("[data-save]").onclick = async () => {
|
||||||
|
const status = box.querySelector("[data-status]");
|
||||||
|
status.textContent = "Saving…";
|
||||||
|
const response = await fetch("/workshop/<%= it.sourceRoute.structure_id %>/route/<%= it.sourceRoute.id %>", {
|
||||||
|
method: "PUT", headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ handler: editor.getValue() })
|
||||||
|
});
|
||||||
|
if (!response.ok) return void (status.textContent = "Save failed");
|
||||||
|
status.textContent = "Saved";
|
||||||
|
document.dispatchEvent(new CustomEvent("bliss:source-saved", { detail: { kind: "route", id: "<%= it.sourceRoute.id %>", method: "<%= it.sourceRoute.verb %>" } }));
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
8
bliss-cli/live-editor/route-editor.js
Normal file
8
bliss-cli/live-editor/route-editor.js
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
function handler(req, res) {
|
||||||
|
const sql = require("db")("bliss").sql;
|
||||||
|
const sourceRoute = sql
|
||||||
|
.prepare("SELECT * FROM routes WHERE id = ?")
|
||||||
|
.get(req.params.id);
|
||||||
|
if (!sourceRoute) return res.status(404).send("Route not found");
|
||||||
|
res.render("inspector/route_editor", { sourceRoute });
|
||||||
|
}
|
||||||
76
bliss-cli/live-editor/slideout.eta
Normal file
76
bliss-cli/live-editor/slideout.eta
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
<aside id="bliss-live-editor" data-bliss-ui
|
||||||
|
class="fixed inset-y-0 left-0 z-[2147483646] w-[min(28rem,90vw)] overflow-auto border-r-4 border-black bg-white text-black shadow-[8px_0_0_#000]"
|
||||||
|
style="font-family:ui-sans-serif,system-ui">
|
||||||
|
<header class="sticky top-0 flex items-center justify-between border-b-4 border-double border-black bg-white p-3">
|
||||||
|
<div><div class="text-lg font-black tracking-tight">Live Page</div><div class="text-xs">Objects currently on this card</div></div>
|
||||||
|
<button type="button" data-bliss-close class="h-7 w-7 border-2 border-black bg-white text-lg font-black leading-none shadow-[2px_2px_0_#000] active:translate-x-0.5 active:translate-y-0.5 active:shadow-none">×</button>
|
||||||
|
</header>
|
||||||
|
<div class="grid min-h-[calc(100vh-61px)] grid-rows-[auto_1fr]">
|
||||||
|
<nav data-bliss-tree class="border-b-4 border-double border-black p-2"></nav>
|
||||||
|
<section data-bliss-editor class="min-h-0 p-3 text-sm">Select a route or template to edit it.</section>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
const root = document.getElementById("bliss-live-editor");
|
||||||
|
if (!root) return;
|
||||||
|
const tree = root.querySelector("[data-bliss-tree]");
|
||||||
|
const editor = root.querySelector("[data-bliss-editor]");
|
||||||
|
root.querySelector("[data-bliss-close]").onclick = () =>
|
||||||
|
document.dispatchEvent(new CustomEvent("bliss:inspector-close"));
|
||||||
|
|
||||||
|
function sourceButton(label, kind, id, count) {
|
||||||
|
const button = document.createElement("button");
|
||||||
|
button.type = "button";
|
||||||
|
button.className = "block w-full border border-transparent px-2 py-1 text-left hover:border-black hover:bg-black hover:text-white";
|
||||||
|
button.textContent = label + (count > 1 ? ` (${count})` : "");
|
||||||
|
button.onmouseenter = () => document.dispatchEvent(new CustomEvent("bliss:highlight", { detail: { kind, id, on: true } }));
|
||||||
|
button.onmouseleave = () => document.dispatchEvent(new CustomEvent("bliss:highlight", { detail: { kind, id, on: false } }));
|
||||||
|
if (kind !== "structure") button.onclick = () => {
|
||||||
|
editor.textContent = "Loading…";
|
||||||
|
htmx.ajax("GET", `/_bliss/editor/${kind}/${id}`, { target: editor, swap: "innerHTML" });
|
||||||
|
};
|
||||||
|
return button;
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(items) {
|
||||||
|
tree.innerHTML = "";
|
||||||
|
const structures = new Map();
|
||||||
|
for (const item of items) {
|
||||||
|
if (!structures.has(item.structureId)) structures.set(item.structureId, { name: item.structureName, routes: new Map(), templates: new Map(), count: 0 });
|
||||||
|
const structure = structures.get(item.structureId);
|
||||||
|
structure.count++;
|
||||||
|
if (!structure.routes.has(item.routeId)) structure.routes.set(item.routeId, { name: item.routeName, count: 0 });
|
||||||
|
structure.routes.get(item.routeId).count++;
|
||||||
|
if (item.templateId) {
|
||||||
|
if (!structure.templates.has(item.templateId)) structure.templates.set(item.templateId, { name: item.templateName, count: 0 });
|
||||||
|
structure.templates.get(item.templateId).count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [structureId, structure] of structures) {
|
||||||
|
const group = document.createElement("details");
|
||||||
|
group.open = true;
|
||||||
|
const heading = document.createElement("summary");
|
||||||
|
heading.className = "cursor-pointer list-none font-semibold";
|
||||||
|
heading.append(sourceButton(structure.name, "structure", structureId, structure.count));
|
||||||
|
group.append(heading);
|
||||||
|
for (const [label, kind, entries] of [["Routes", "route", structure.routes], ["Templates", "template", structure.templates]]) {
|
||||||
|
if (!entries.size) continue;
|
||||||
|
const section = document.createElement("details");
|
||||||
|
section.open = true;
|
||||||
|
section.className = "ml-3";
|
||||||
|
const summary = document.createElement("summary");
|
||||||
|
summary.className = "cursor-pointer py-1 text-xs font-bold uppercase tracking-widest";
|
||||||
|
summary.textContent = label;
|
||||||
|
section.append(summary);
|
||||||
|
for (const [id, entry] of entries) section.append(sourceButton(entry.name, kind, id, entry.count));
|
||||||
|
group.append(section);
|
||||||
|
}
|
||||||
|
tree.append(group);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("bliss:inventory", (event) => render(event.detail));
|
||||||
|
document.dispatchEvent(new CustomEvent("bliss:inventory-request"));
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
25
bliss-cli/live-editor/template-editor.eta
Normal file
25
bliss-cli/live-editor/template-editor.eta
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
<div data-bliss-ui>
|
||||||
|
<div class="mb-2 flex items-center justify-between border-b-4 border-double border-black pb-2"><strong><%= it.sourceTemplate.name %></strong><span data-status class="text-xs"></span></div>
|
||||||
|
<div data-source class="h-[55vh] w-full border-2 border-black"><%= it.sourceTemplate.content %></div>
|
||||||
|
<button data-save type="button" class="mt-2 border-2 border-black bg-white px-3 py-2 font-bold shadow-[3px_3px_0_#000] active:translate-x-0.5 active:translate-y-0.5 active:shadow-none">Save Template</button>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
const box = document.currentScript.previousElementSibling;
|
||||||
|
if (!box) return;
|
||||||
|
const editor = ace.edit(box.querySelector("[data-source]"));
|
||||||
|
editor.setTheme("ace/theme/monokai");
|
||||||
|
editor.session.setMode("ace/mode/html");
|
||||||
|
box.querySelector("[data-save]").onclick = async () => {
|
||||||
|
const status = box.querySelector("[data-status]");
|
||||||
|
status.textContent = "Saving…";
|
||||||
|
const response = await fetch("/workshop/<%= it.sourceTemplate.structure_id %>/template/<%= it.sourceTemplate.id %>", {
|
||||||
|
method: "PUT", headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ content: editor.getValue() })
|
||||||
|
});
|
||||||
|
if (!response.ok) return void (status.textContent = "Save failed");
|
||||||
|
status.textContent = "Saved";
|
||||||
|
document.dispatchEvent(new CustomEvent("bliss:source-saved", { detail: { kind: "template", id: "<%= it.sourceTemplate.id %>" } }));
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
8
bliss-cli/live-editor/template-editor.js
Normal file
8
bliss-cli/live-editor/template-editor.js
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
function handler(req, res) {
|
||||||
|
const sql = require("db")("bliss").sql;
|
||||||
|
const sourceTemplate = sql
|
||||||
|
.prepare("SELECT * FROM templates WHERE id = ?")
|
||||||
|
.get(req.params.id);
|
||||||
|
if (!sourceTemplate) return res.status(404).send("Template not found");
|
||||||
|
res.render("inspector/template_editor", { sourceTemplate });
|
||||||
|
}
|
||||||
47
db.js
47
db.js
|
|
@ -3,6 +3,7 @@ const path = require("path");
|
||||||
const betterSqlite3 = require("better-sqlite3");
|
const betterSqlite3 = require("better-sqlite3");
|
||||||
const { LRUCache } = require("lru-cache");
|
const { LRUCache } = require("lru-cache");
|
||||||
const { Eta } = require("eta");
|
const { Eta } = require("eta");
|
||||||
|
const cheerio = require("cheerio");
|
||||||
|
|
||||||
const db = betterSqlite3("./dbs/0.sqlite");
|
const db = betterSqlite3("./dbs/0.sqlite");
|
||||||
|
|
||||||
|
|
@ -91,6 +92,37 @@ function getAllRoutes() {
|
||||||
|
|
||||||
const templateCache = new LRUCache({ max: 100 });
|
const templateCache = new LRUCache({ max: 100 });
|
||||||
|
|
||||||
|
function annotateTemplateHtml(html, template) {
|
||||||
|
function annotate($, element) {
|
||||||
|
const current = $(element).attr("data-bliss-templates");
|
||||||
|
let templates = [];
|
||||||
|
if (current) {
|
||||||
|
try {
|
||||||
|
templates = JSON.parse(current);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
if (!templates.some((item) => String(item.id) === String(template.id))) {
|
||||||
|
templates.push({ id: String(template.id), name: template.name });
|
||||||
|
}
|
||||||
|
$(element).attr("data-bliss-templates", JSON.stringify(templates));
|
||||||
|
if (!$(element).attr("data-bliss-template-id")) {
|
||||||
|
$(element).attr({
|
||||||
|
"data-bliss-template-id": String(template.id),
|
||||||
|
"data-bliss-template-name": template.name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const lower = html.trimStart().toLowerCase();
|
||||||
|
if (lower.startsWith("<html") || lower.startsWith("<!doctype")) {
|
||||||
|
const $ = cheerio.load(html);
|
||||||
|
annotate($, $("body")[0]);
|
||||||
|
return $.html();
|
||||||
|
}
|
||||||
|
const $ = cheerio.load(html, null, false);
|
||||||
|
for (const child of $.root().children()) annotate($, child);
|
||||||
|
return $.html();
|
||||||
|
}
|
||||||
|
|
||||||
function getTemplater(structId) {
|
function getTemplater(structId) {
|
||||||
let etaInstance = templateCache.get(structId);
|
let etaInstance = templateCache.get(structId);
|
||||||
|
|
||||||
|
|
@ -102,6 +134,13 @@ function getTemplater(structId) {
|
||||||
etaInstance.readFile = function (templateAlias) {
|
etaInstance.readFile = function (templateAlias) {
|
||||||
return getTemplateContentByName(structId, templateAlias).content;
|
return getTemplateContentByName(structId, templateAlias).content;
|
||||||
};
|
};
|
||||||
|
const etaRender = etaInstance.render;
|
||||||
|
etaInstance.render = function (templateName, data, meta) {
|
||||||
|
const html = etaRender.call(this, templateName, data, meta);
|
||||||
|
if (typeof templateName !== "string") return html;
|
||||||
|
const template = getTemplateByName(structId, templateName);
|
||||||
|
return template ? annotateTemplateHtml(html, template) : html;
|
||||||
|
};
|
||||||
templateCache.set(structId, etaInstance);
|
templateCache.set(structId, etaInstance);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -245,6 +284,7 @@ function updateStruct(struct) {
|
||||||
|
|
||||||
function updateTemplate(template) {
|
function updateTemplate(template) {
|
||||||
update("templates", ["content", "name", "test_object"], template);
|
update("templates", ["content", "name", "test_object"], template);
|
||||||
|
templateCache.delete(template.structure_id);
|
||||||
recordVersion("template", template.id, template.structure_id, template);
|
recordVersion("template", template.id, template.structure_id, template);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -266,6 +306,12 @@ function getTemplateContentByName(structId, name) {
|
||||||
.get(structId, name);
|
.get(structId, name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getTemplateByName(structId, name) {
|
||||||
|
return db
|
||||||
|
.prepare("SELECT * from templates where structure_id = ? AND name = ?")
|
||||||
|
.get(structId, name);
|
||||||
|
}
|
||||||
|
|
||||||
function createTemplate(structureId, name, content, testObjectString) {
|
function createTemplate(structureId, name, content, testObjectString) {
|
||||||
const id = db
|
const id = db
|
||||||
.prepare(
|
.prepare(
|
||||||
|
|
@ -686,6 +732,7 @@ module.exports = {
|
||||||
getTemplates,
|
getTemplates,
|
||||||
getTemplate,
|
getTemplate,
|
||||||
getTemplateContentByName,
|
getTemplateContentByName,
|
||||||
|
getTemplateByName,
|
||||||
createTemplate,
|
createTemplate,
|
||||||
getDbsForStructure,
|
getDbsForStructure,
|
||||||
getDb,
|
getDb,
|
||||||
|
|
|
||||||
27
index.js
27
index.js
|
|
@ -16,7 +16,7 @@ const app = express();
|
||||||
const _expressWs = require("express-ws")(app);
|
const _expressWs = require("express-ws")(app);
|
||||||
const bodyParser = require("body-parser");
|
const bodyParser = require("body-parser");
|
||||||
const model = require("./db");
|
const model = require("./db");
|
||||||
const PORT = 3000;
|
const PORT = process.env.PORT || 3000;
|
||||||
|
|
||||||
const db = model.db;
|
const db = model.db;
|
||||||
const wsRouter = express.Router()
|
const wsRouter = express.Router()
|
||||||
|
|
@ -340,6 +340,7 @@ function headChrome(headInjection) {
|
||||||
<script src="/js/hyperscript.js"></script>
|
<script src="/js/hyperscript.js"></script>
|
||||||
<script src="/js/tailwind.js"></script>
|
<script src="/js/tailwind.js"></script>
|
||||||
<script src="/js/htmx.js"></script>
|
<script src="/js/htmx.js"></script>
|
||||||
|
<script src="/js/ace/ace.js"></script>
|
||||||
<script src="https://unpkg.com/htmx.org@1.9.12/dist/ext/ws.js"></script>
|
<script src="https://unpkg.com/htmx.org@1.9.12/dist/ext/ws.js"></script>
|
||||||
<script src="/js/bliss_inspector.js"></script>
|
<script src="/js/bliss_inspector.js"></script>
|
||||||
<script>
|
<script>
|
||||||
|
|
@ -362,7 +363,17 @@ function blissAttrs(source) {
|
||||||
const attrs = {
|
const attrs = {
|
||||||
"data-bliss-route": `/workshop/${source.structureId}/route/${source.routeId}`,
|
"data-bliss-route": `/workshop/${source.structureId}/route/${source.routeId}`,
|
||||||
"data-bliss-clone": `/workshop/${source.structureId}/clone_modal/`,
|
"data-bliss-clone": `/workshop/${source.structureId}/clone_modal/`,
|
||||||
|
"data-bliss-structure-id": String(source.structureId),
|
||||||
|
"data-bliss-structure-name": source.structureName,
|
||||||
|
"data-bliss-route-id": String(source.routeId),
|
||||||
|
"data-bliss-route-name": `${source.verb} ${source.path}`,
|
||||||
|
"data-bliss-method": source.verb,
|
||||||
|
"data-bliss-request-url": source.requestUrl,
|
||||||
};
|
};
|
||||||
|
if (source.templateId) {
|
||||||
|
attrs["data-bliss-template-id"] = String(source.templateId);
|
||||||
|
attrs["data-bliss-template-name"] = source.templateName;
|
||||||
|
}
|
||||||
if (source.copyUrl) attrs["data-bliss-copy"] = source.copyUrl;
|
if (source.copyUrl) attrs["data-bliss-copy"] = source.copyUrl;
|
||||||
return attrs;
|
return attrs;
|
||||||
}
|
}
|
||||||
|
|
@ -435,10 +446,20 @@ function renderTemplate(structureId, templateName, context) {
|
||||||
|
|
||||||
// Provenance for a rendered response: GET routes are re-embeddable, so they
|
// Provenance for a rendered response: GET routes are re-embeddable, so they
|
||||||
// carry the copy-embed snippet; other verbs don't.
|
// carry the copy-embed snippet; other verbs don't.
|
||||||
function sourceFor(route, req) {
|
function sourceFor(route, req, templateName = null) {
|
||||||
|
const structure = model.getStructure(route.structure_id);
|
||||||
|
const template = templateName
|
||||||
|
? model.getTemplateByName(route.structure_id, templateName)
|
||||||
|
: null;
|
||||||
return {
|
return {
|
||||||
structureId: route.structure_id,
|
structureId: route.structure_id,
|
||||||
|
structureName: structure.name,
|
||||||
routeId: route.id,
|
routeId: route.id,
|
||||||
|
verb: route.verb,
|
||||||
|
path: route.path,
|
||||||
|
requestUrl: req.originalUrl,
|
||||||
|
templateId: template?.id,
|
||||||
|
templateName: template?.name,
|
||||||
copyUrl: route.verb === "GET" ? embedHTML(req.originalUrl) : null,
|
copyUrl: route.verb === "GET" ? embedHTML(req.originalUrl) : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -862,7 +883,7 @@ app.all("*", async (req, res) => {
|
||||||
res.send(
|
res.send(
|
||||||
decorate(renderTemplate(route.structure_id, template, context), {
|
decorate(renderTemplate(route.structure_id, template, context), {
|
||||||
headInjection: structure.head_injection,
|
headInjection: structure.head_injection,
|
||||||
source: sourceFor(route, req),
|
source: sourceFor(route, req, template),
|
||||||
fragment: req.headers["hx-request"],
|
fragment: req.headers["hx-request"],
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,120 +1,195 @@
|
||||||
document.addEventListener("DOMContentLoaded", function () {
|
(function () {
|
||||||
// Create the magnifying glass emoji button
|
if (window.__blissInspectorLoaded) return;
|
||||||
const magnifyingGlass = document.createElement("div");
|
window.__blissInspectorLoaded = true;
|
||||||
magnifyingGlass.innerHTML = "🔍";
|
|
||||||
magnifyingGlass.className =
|
|
||||||
"fixed bottom-2 right-2 cursor-pointer text-2xl z-50";
|
|
||||||
document.body.appendChild(magnifyingGlass);
|
|
||||||
|
|
||||||
let highlighted = false;
|
const ROUTE_SELECTOR = "[data-bliss-structure-id][data-bliss-route-id]";
|
||||||
|
const TEMPLATE_SELECTOR = "[data-bliss-template-id]";
|
||||||
|
let root = null;
|
||||||
|
let open = false;
|
||||||
|
|
||||||
// Function to highlight elements
|
function visibleElements(selector) {
|
||||||
function highlightElements() {
|
return [...document.querySelectorAll(selector)].filter(
|
||||||
const modal = document.createElement("div");
|
(element) => !element.closest("[data-bliss-ui]"),
|
||||||
modal.id = "inspector-modal";
|
|
||||||
document.body.appendChild(modal);
|
|
||||||
|
|
||||||
const elements = document.querySelectorAll("[data-bliss-route]");
|
|
||||||
elements.forEach((el) => {
|
|
||||||
if (!el.classList.contains("highlighted")) {
|
|
||||||
el.classList.add(
|
|
||||||
"border-2",
|
|
||||||
"border-yellow-500",
|
|
||||||
"highlighted",
|
|
||||||
"p-2",
|
|
||||||
);
|
);
|
||||||
let controls = document.createElement("div");
|
|
||||||
controls.className =
|
|
||||||
"flex absolute gap-1 -bottom-3 right-0 inspector-controls";
|
|
||||||
|
|
||||||
// Create the edit icon
|
|
||||||
const editIcon = document.createElement("a");
|
|
||||||
editIcon.href = el.getAttribute("data-bliss-route");
|
|
||||||
editIcon.innerHTML = "✏️";
|
|
||||||
editIcon.className =
|
|
||||||
"text-lg bg-white rounded-full p-1 shadow edit-icon";
|
|
||||||
controls.appendChild(editIcon);
|
|
||||||
|
|
||||||
// Create the copy icon
|
|
||||||
if (el.getAttribute("data-bliss-copy")) {
|
|
||||||
const copyIcon = document.createElement("button");
|
|
||||||
copyIcon.innerHTML = "📋";
|
|
||||||
copyIcon.className =
|
|
||||||
"text-lg bg-white rounded-full p-1 shadow copy-icon";
|
|
||||||
controls.appendChild(copyIcon);
|
|
||||||
copyIcon.addEventListener("click", () => {
|
|
||||||
navigator.clipboard
|
|
||||||
.writeText(el.getAttribute("data-bliss-copy"))
|
|
||||||
.then(() => {
|
|
||||||
copyIcon.innerHTML = "✅";
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
copyIcon.innerHTML = "❌";
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create the clone icon
|
function routeElements() {
|
||||||
const cloneIcon = document.createElement("div");
|
return visibleElements(ROUTE_SELECTOR);
|
||||||
cloneIcon.innerHTML = "👯♀️";
|
}
|
||||||
cloneIcon.className =
|
|
||||||
"text-lg bg-white rounded-full cursor-pointer p-1 shadow clone-icon";
|
|
||||||
cloneIcon.setAttribute("hx-get", el.getAttribute("data-bliss-clone"));
|
|
||||||
cloneIcon.setAttribute("hx-target", "#inspector-modal");
|
|
||||||
|
|
||||||
controls.setAttribute(
|
function templateElements() {
|
||||||
"_",
|
return visibleElements(TEMPLATE_SELECTOR);
|
||||||
`on mouseover set my.style.zIndex to 10000
|
}
|
||||||
on mouseout set my.style.zIndex to "initial"
|
|
||||||
`,
|
function templatesFor(element) {
|
||||||
|
try {
|
||||||
|
const templates = JSON.parse(element.dataset.blissTemplates || "[]");
|
||||||
|
if (templates.length) return templates;
|
||||||
|
} catch (_) {}
|
||||||
|
return element.dataset.blissTemplateId
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
id: element.dataset.blissTemplateId,
|
||||||
|
name: element.dataset.blissTemplateName,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function inventory() {
|
||||||
|
const items = [];
|
||||||
|
for (const owner of routeElements()) {
|
||||||
|
const templates = templateElements().filter(
|
||||||
|
(element) => element.closest(ROUTE_SELECTOR) === owner,
|
||||||
);
|
);
|
||||||
controls.appendChild(cloneIcon);
|
if (!templates.length) templates.push(null);
|
||||||
|
for (const templateElement of templates) {
|
||||||
el.appendChild(controls);
|
const renderedTemplates = templateElement
|
||||||
htmx.process(controls);
|
? templatesFor(templateElement)
|
||||||
_hyperscript.processNode(controls);
|
: [null];
|
||||||
}
|
for (const template of renderedTemplates) {
|
||||||
|
items.push({
|
||||||
|
structureId: owner.dataset.blissStructureId,
|
||||||
|
structureName: owner.dataset.blissStructureName,
|
||||||
|
routeId: owner.dataset.blissRouteId,
|
||||||
|
routeName: owner.dataset.blissRouteName,
|
||||||
|
templateId: template?.id || null,
|
||||||
|
templateName: template?.name || null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
// Function to remove highlights
|
function publishInventory() {
|
||||||
function removeHighlights() {
|
document.dispatchEvent(
|
||||||
const elements = document.querySelectorAll(
|
new CustomEvent("bliss:inventory", { detail: inventory() }),
|
||||||
"[data-bliss-route].highlighted",
|
|
||||||
);
|
);
|
||||||
elements.forEach((el) => {
|
}
|
||||||
el.classList.remove(
|
|
||||||
"border-2",
|
function matches(element, { kind, id }) {
|
||||||
"border-yellow-500",
|
if (kind === "template") {
|
||||||
"relative",
|
return templatesFor(element).some(
|
||||||
"highlighted",
|
(template) => String(template.id) === String(id),
|
||||||
"p-2",
|
|
||||||
);
|
);
|
||||||
el.querySelectorAll(".inspector-controls").forEach((el) => el.remove());
|
}
|
||||||
});
|
const field = {
|
||||||
|
structure: "blissStructureId",
|
||||||
|
route: "blissRouteId",
|
||||||
|
}[kind];
|
||||||
|
return field && element.dataset[field] === String(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
magnifyingGlass.addEventListener("click", function () {
|
function highlight(detail) {
|
||||||
if (!highlighted) {
|
const candidates =
|
||||||
highlightElements();
|
detail.kind === "template" ? templateElements() : routeElements();
|
||||||
magnifyingGlass.innerHTML = "❌";
|
for (const element of candidates.filter((el) => matches(el, detail))) {
|
||||||
} else {
|
element.classList.toggle("bliss-source-highlight", detail.on);
|
||||||
removeHighlights();
|
|
||||||
magnifyingGlass.innerHTML = "🔍";
|
|
||||||
}
|
}
|
||||||
highlighted = !highlighted;
|
}
|
||||||
|
|
||||||
|
async function refreshElement(element) {
|
||||||
|
if (element.dataset.blissMethod !== "GET") return;
|
||||||
|
const url = element.dataset.blissRequestUrl;
|
||||||
|
if (!url) return;
|
||||||
|
|
||||||
|
if (element === document.body) {
|
||||||
|
const ui = [...document.body.querySelectorAll(":scope > [data-bliss-ui]")];
|
||||||
|
const response = await fetch(url, { headers: { "HX-Request": "true" } });
|
||||||
|
if (!response.ok) throw new Error(`GET ${url} returned ${response.status}`);
|
||||||
|
const next = new DOMParser().parseFromString(await response.text(), "text/html");
|
||||||
|
for (const attribute of [...document.body.attributes]) {
|
||||||
|
document.body.removeAttribute(attribute.name);
|
||||||
|
}
|
||||||
|
for (const attribute of [...next.body.attributes]) {
|
||||||
|
document.body.setAttribute(attribute.name, attribute.value);
|
||||||
|
}
|
||||||
|
document.body.innerHTML = next.body.innerHTML;
|
||||||
|
for (const node of ui) document.body.appendChild(node);
|
||||||
|
htmx.process(document.body);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await htmx.ajax("GET", url, { target: element, swap: "outerHTML" });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshSources({ kind, id, method }) {
|
||||||
|
if (method && method !== "GET") return;
|
||||||
|
const elements = routeElements().filter((element) => {
|
||||||
|
if (kind === "template") {
|
||||||
|
return templateElements().some(
|
||||||
|
(template) =>
|
||||||
|
matches(template, { kind: "template", id }) &&
|
||||||
|
template.closest(ROUTE_SELECTOR) === element,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return matches(element, { kind, id });
|
||||||
|
});
|
||||||
|
const settled = await Promise.allSettled(elements.map(refreshElement));
|
||||||
|
const failure = settled.find((result) => result.status === "rejected");
|
||||||
|
if (failure) console.error("Bliss live refresh failed", failure.reason);
|
||||||
|
publishInventory();
|
||||||
|
}
|
||||||
|
|
||||||
|
function show() {
|
||||||
|
if (open) return;
|
||||||
|
open = true;
|
||||||
|
document.documentElement.classList.add("bliss-inspector-open");
|
||||||
|
root = document.createElement("div");
|
||||||
|
root.id = "bliss-inspector-root";
|
||||||
|
root.dataset.blissUi = "";
|
||||||
|
root.setAttribute("hx-get", "/_bliss/inspector");
|
||||||
|
root.setAttribute("hx-trigger", "load");
|
||||||
|
root.setAttribute("hx-swap", "innerHTML");
|
||||||
|
document.body.appendChild(root);
|
||||||
|
htmx.process(root);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hide() {
|
||||||
|
open = false;
|
||||||
|
document.documentElement.classList.remove("bliss-inspector-open");
|
||||||
|
root?.remove();
|
||||||
|
root = null;
|
||||||
|
for (const element of [...routeElements(), ...templateElements()]) {
|
||||||
|
element.classList.remove("bliss-source-highlight");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
const style = document.createElement("style");
|
||||||
|
style.dataset.blissUi = "";
|
||||||
|
style.textContent = `
|
||||||
|
html.bliss-inspector-open body > :not([data-bliss-ui]) {
|
||||||
|
transform: translateX(min(28rem, 90vw));
|
||||||
|
max-width: calc(100vw - min(28rem, 90vw));
|
||||||
|
}
|
||||||
|
[data-bliss-structure-id], [data-bliss-template-id] {
|
||||||
|
transition: outline-color 80ms linear, background-color 80ms linear;
|
||||||
|
}
|
||||||
|
.bliss-source-highlight {
|
||||||
|
outline: 4px solid #ff00a8 !important;
|
||||||
|
outline-offset: 3px !important;
|
||||||
|
background-color: rgba(255, 0, 168, .08) !important;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
document.head.appendChild(style);
|
||||||
|
const trigger = document.createElement("button");
|
||||||
|
trigger.type = "button";
|
||||||
|
trigger.textContent = "🔍";
|
||||||
|
trigger.title = "Open Bliss live editor";
|
||||||
|
trigger.dataset.blissUi = "";
|
||||||
|
trigger.style.cssText =
|
||||||
|
"position:fixed;right:.5rem;bottom:.5rem;z-index:2147483647;border:0;background:white;border-radius:999px;padding:.45rem;font-size:1.25rem;cursor:pointer;box-shadow:0 2px 8px #0004";
|
||||||
|
trigger.onclick = () => (open ? hide() : show());
|
||||||
|
document.body.appendChild(trigger);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Observe the document for changes
|
document.addEventListener("bliss:highlight", (event) => highlight(event.detail));
|
||||||
// const observer = new MutationObserver(function (mutations) {
|
document.addEventListener("bliss:source-saved", (event) => refreshSources(event.detail));
|
||||||
// if (highlighted) {
|
document.addEventListener("bliss:inventory-request", publishInventory);
|
||||||
// highlightElements();
|
document.addEventListener("bliss:inspector-close", hide);
|
||||||
// }
|
document.addEventListener("htmx:afterSwap", () => {
|
||||||
// });
|
if (open) publishInventory();
|
||||||
|
});
|
||||||
// // Configure the observer
|
})();
|
||||||
// observer.observe(document.body, {
|
|
||||||
// childList: true,
|
|
||||||
// subtree: true,
|
|
||||||
// });
|
|
||||||
});
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue