Refactor SX templates: shared components, Python migration, cleanup
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 6m0s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 6m0s
- Extract shared components (empty-state, delete-btn, sentinel, crud-*, view-toggle, img-or-placeholder, avatar, sumup-settings-form, auth forms, order tables/detail/checkout) - Migrate all Python sx_call() callers to use shared components directly - Remove 55+ thin wrapper defcomps from domain .sx files - Remove trivial passthrough wrappers (blog-header-label, market-card-text, etc) - Unify duplicate auth flows (account + federation) into shared/sx/templates/auth.sx - Unify duplicate order views (cart + orders) into shared/sx/templates/orders.sx - Disable static file caching in dev (SEND_FILE_MAX_AGE_DEFAULT=0) - Add SX response validation and debug headers Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -223,16 +223,21 @@ def errors(app):
|
||||
errnum='403'
|
||||
)
|
||||
else:
|
||||
try:
|
||||
html = _sx_error_page(
|
||||
"403", "FORBIDDEN",
|
||||
image="/static/errors/403.gif",
|
||||
)
|
||||
except Exception:
|
||||
html = await render_template(
|
||||
"_types/root/exceptions/_.html",
|
||||
errnum='403',
|
||||
)
|
||||
html = await _rich_error_page(
|
||||
"403", "FORBIDDEN",
|
||||
image="/static/errors/403.gif",
|
||||
)
|
||||
if html is None:
|
||||
try:
|
||||
html = _sx_error_page(
|
||||
"403", "FORBIDDEN",
|
||||
image="/static/errors/403.gif",
|
||||
)
|
||||
except Exception:
|
||||
html = await render_template(
|
||||
"_types/root/exceptions/_.html",
|
||||
errnum='403',
|
||||
)
|
||||
|
||||
return await make_response(html, 403)
|
||||
|
||||
@@ -291,7 +296,11 @@ def errors(app):
|
||||
status,
|
||||
)
|
||||
|
||||
# Raw HTML — avoids context processor / fragment loop on errors.
|
||||
return await make_response(_error_page(
|
||||
"WELL THIS IS EMBARRASSING…"
|
||||
), status)
|
||||
errnum = str(status)
|
||||
html = await _rich_error_page(
|
||||
errnum, "WELL THIS IS EMBARRASSING\u2026",
|
||||
image="/static/errors/error.gif",
|
||||
)
|
||||
if html is None:
|
||||
html = _error_page("WELL THIS IS EMBARRASSING…")
|
||||
return await make_response(html, status)
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<link rel="stylesheet" type="text/css" href="{{asset_url('styles/blog-content.css')}}">
|
||||
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
<link rel="stylesheet" type="text/css" href="{{asset_url('styles/tw.css')}}">
|
||||
<script src="https://cdn.tailwindcss.com?plugins=typography"></script>
|
||||
<link rel="stylesheet" href="{{asset_url('fontawesome/css/all.min.css')}}">
|
||||
<link rel="stylesheet" href="{{asset_url('fontawesome/css/v4-shims.min.css')}}">
|
||||
<link href="https://unpkg.com/prismjs/themes/prism.css" rel="stylesheet" />
|
||||
|
||||
@@ -77,6 +77,8 @@ def create_base_app(
|
||||
template_folder=TEMPLATE_DIR,
|
||||
root_path=str(BASE_DIR),
|
||||
)
|
||||
# Disable aggressive browser caching of static files in dev
|
||||
app.config["SEND_FILE_MAX_AGE_DEFAULT"] = 0
|
||||
|
||||
configure_logging(name)
|
||||
|
||||
|
||||
11
shared/static/scripts/editor.css
Normal file
11
shared/static/scripts/editor.css
Normal file
File diff suppressed because one or more lines are too long
1872
shared/static/scripts/editor.js
Normal file
1872
shared/static/scripts/editor.js
Normal file
File diff suppressed because one or more lines are too long
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* sx.js — S-expression parser, evaluator, and DOM renderer.
|
||||
* sx.js — S-expression parser, evaluator, and DOM renderer. [v2-debug]
|
||||
*
|
||||
* Client-side counterpart to shared/sx/ Python modules.
|
||||
* Parses s-expression text, evaluates it, and renders to DOM nodes.
|
||||
@@ -1293,8 +1293,13 @@
|
||||
|
||||
// Component management
|
||||
loadComponents: function (text) {
|
||||
var exprs = parseAll(text);
|
||||
for (var i = 0; i < exprs.length; i++) sxEval(exprs[i], _componentEnv);
|
||||
try {
|
||||
var exprs = parseAll(text);
|
||||
for (var i = 0; i < exprs.length; i++) sxEval(exprs[i], _componentEnv);
|
||||
} catch (err) {
|
||||
console.error("sx.js loadComponents error [v2]:", err, "\ntext first 500:", text ? text.substring(0, 500) : "(empty)");
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
getEnv: function () { return _componentEnv; },
|
||||
@@ -1630,79 +1635,124 @@
|
||||
return resp.text().then(function (text) {
|
||||
dispatch(el, "sx:afterRequest", { response: resp });
|
||||
|
||||
// Check for text/sx content type
|
||||
// Process the response
|
||||
var swapStyle = el.getAttribute("sx-swap") || DEFAULT_SWAP;
|
||||
var target = resolveTarget(el, null);
|
||||
var selectSel = el.getAttribute("sx-select");
|
||||
|
||||
// Check for text/sx content type — use direct DOM rendering path
|
||||
var ct = resp.headers.get("Content-Type") || "";
|
||||
if (ct.indexOf("text/sx") >= 0) {
|
||||
try {
|
||||
// Strip and load any <script type="text/sx" data-components> blocks
|
||||
text = text.replace(/<script[^>]*type="text\/sx"[^>]*data-components[^>]*>([\s\S]*?)<\/script>/gi,
|
||||
function (_, defs) { Sx.loadComponents(defs); return ""; });
|
||||
text = Sx.renderToString(text.trim());
|
||||
}
|
||||
catch (err) {
|
||||
console.error("sx.js render error:", err);
|
||||
var sxSource = text.trim();
|
||||
|
||||
// Parse and render to live DOM nodes (skip renderToString + DOMParser)
|
||||
if (sxSource && sxSource.charAt(0) !== "(") {
|
||||
console.error("sx.js: sxSource does not start with '(' — first 200 chars:", sxSource.substring(0, 200));
|
||||
}
|
||||
var sxDom = Sx.render(sxSource);
|
||||
|
||||
// Wrap in container for querySelectorAll (DocumentFragment doesn't support it)
|
||||
var container = document.createElement("div");
|
||||
container.appendChild(sxDom);
|
||||
|
||||
// OOB processing on live DOM nodes
|
||||
var oobs = container.querySelectorAll("[sx-swap-oob]");
|
||||
oobs.forEach(function (oob) {
|
||||
var oobSwap = oob.getAttribute("sx-swap-oob") || "outerHTML";
|
||||
var oobTarget = document.getElementById(oob.id);
|
||||
oob.removeAttribute("sx-swap-oob");
|
||||
oob.parentNode.removeChild(oob);
|
||||
if (oobTarget) _swapDOM(oobTarget, oob, oobSwap);
|
||||
});
|
||||
|
||||
// hx-swap-oob compat
|
||||
var hxOobs = container.querySelectorAll("[hx-swap-oob]");
|
||||
hxOobs.forEach(function (oob) {
|
||||
var oobSwap = oob.getAttribute("hx-swap-oob") || "outerHTML";
|
||||
var oobTarget = document.getElementById(oob.id);
|
||||
oob.removeAttribute("hx-swap-oob");
|
||||
oob.parentNode.removeChild(oob);
|
||||
if (oobTarget) _swapDOM(oobTarget, oob, oobSwap);
|
||||
});
|
||||
|
||||
// sx-select filtering
|
||||
var selectedDOM;
|
||||
if (selectSel) {
|
||||
selectedDOM = document.createDocumentFragment();
|
||||
selectSel.split(",").forEach(function (sel) {
|
||||
container.querySelectorAll(sel.trim()).forEach(function (m) {
|
||||
selectedDOM.appendChild(m);
|
||||
});
|
||||
});
|
||||
} else {
|
||||
// Use all remaining children
|
||||
selectedDOM = document.createDocumentFragment();
|
||||
while (container.firstChild) selectedDOM.appendChild(container.firstChild);
|
||||
}
|
||||
|
||||
// Main swap using DOM morph
|
||||
if (swapStyle !== "none" && target) {
|
||||
_swapDOM(target, selectedDOM, swapStyle);
|
||||
_hoistHeadElements(target);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("sx.js render error [v2]:", err, "\nsxSource first 500:", sxSource ? sxSource.substring(0, 500) : "(empty)");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Process the response
|
||||
var swapStyle = el.getAttribute("sx-swap") || DEFAULT_SWAP;
|
||||
var target = resolveTarget(el, null);
|
||||
|
||||
// sx-select: extract subset from response
|
||||
var selectSel = el.getAttribute("sx-select");
|
||||
|
||||
// Parse response into DOM for OOB + select processing
|
||||
var parser = new DOMParser();
|
||||
var doc = parser.parseFromString(text, "text/html");
|
||||
|
||||
// Process any sx script blocks in the response (e.g. cross-domain component defs)
|
||||
Sx.processScripts(doc);
|
||||
|
||||
// OOB processing: extract elements with sx-swap-oob
|
||||
var oobs = doc.querySelectorAll("[sx-swap-oob]");
|
||||
oobs.forEach(function (oob) {
|
||||
var oobSwap = oob.getAttribute("sx-swap-oob") || "outerHTML";
|
||||
var oobTarget = document.getElementById(oob.id);
|
||||
oob.removeAttribute("sx-swap-oob");
|
||||
if (oobTarget) {
|
||||
_swapContent(oobTarget, oob.outerHTML, oobSwap);
|
||||
}
|
||||
oob.parentNode.removeChild(oob);
|
||||
});
|
||||
|
||||
// Also support hx-swap-oob during migration
|
||||
var hxOobs = doc.querySelectorAll("[hx-swap-oob]");
|
||||
hxOobs.forEach(function (oob) {
|
||||
var oobSwap = oob.getAttribute("hx-swap-oob") || "outerHTML";
|
||||
var oobTarget = document.getElementById(oob.id);
|
||||
oob.removeAttribute("hx-swap-oob");
|
||||
if (oobTarget) {
|
||||
_swapContent(oobTarget, oob.outerHTML, oobSwap);
|
||||
}
|
||||
oob.parentNode.removeChild(oob);
|
||||
});
|
||||
|
||||
// Build final content
|
||||
var content;
|
||||
if (selectSel) {
|
||||
// sx-select may be comma-separated
|
||||
var parts = selectSel.split(",").map(function (s) { return s.trim(); });
|
||||
var frags = [];
|
||||
parts.forEach(function (sel) {
|
||||
var matches = doc.querySelectorAll(sel);
|
||||
matches.forEach(function (m) { frags.push(m.outerHTML); });
|
||||
});
|
||||
content = frags.join("");
|
||||
} else {
|
||||
content = doc.body ? doc.body.innerHTML : text;
|
||||
}
|
||||
// HTML string path — existing DOMParser pipeline
|
||||
var parser = new DOMParser();
|
||||
var doc = parser.parseFromString(text, "text/html");
|
||||
|
||||
// Main swap
|
||||
if (swapStyle !== "none" && target) {
|
||||
_swapContent(target, content, swapStyle);
|
||||
// Auto-hoist any head elements that ended up in body
|
||||
_hoistHeadElements(target);
|
||||
// Process any sx script blocks in the response
|
||||
Sx.processScripts(doc);
|
||||
|
||||
// OOB processing
|
||||
var oobs = doc.querySelectorAll("[sx-swap-oob]");
|
||||
oobs.forEach(function (oob) {
|
||||
var oobSwap = oob.getAttribute("sx-swap-oob") || "outerHTML";
|
||||
var oobTarget = document.getElementById(oob.id);
|
||||
oob.removeAttribute("sx-swap-oob");
|
||||
if (oobTarget) {
|
||||
_swapContent(oobTarget, oob.outerHTML, oobSwap);
|
||||
}
|
||||
oob.parentNode.removeChild(oob);
|
||||
});
|
||||
|
||||
var hxOobs = doc.querySelectorAll("[hx-swap-oob]");
|
||||
hxOobs.forEach(function (oob) {
|
||||
var oobSwap = oob.getAttribute("hx-swap-oob") || "outerHTML";
|
||||
var oobTarget = document.getElementById(oob.id);
|
||||
oob.removeAttribute("hx-swap-oob");
|
||||
if (oobTarget) {
|
||||
_swapContent(oobTarget, oob.outerHTML, oobSwap);
|
||||
}
|
||||
oob.parentNode.removeChild(oob);
|
||||
});
|
||||
|
||||
// Build final content
|
||||
var content;
|
||||
if (selectSel) {
|
||||
var parts = selectSel.split(",").map(function (s) { return s.trim(); });
|
||||
var frags = [];
|
||||
parts.forEach(function (sel) {
|
||||
var matches = doc.querySelectorAll(sel);
|
||||
matches.forEach(function (m) { frags.push(m.outerHTML); });
|
||||
});
|
||||
content = frags.join("");
|
||||
} else {
|
||||
content = doc.body ? doc.body.innerHTML : text;
|
||||
}
|
||||
|
||||
// Main swap
|
||||
if (swapStyle !== "none" && target) {
|
||||
_swapContent(target, content, swapStyle);
|
||||
_hoistHeadElements(target);
|
||||
}
|
||||
}
|
||||
|
||||
// History
|
||||
@@ -1733,7 +1783,180 @@
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Swap engine ------------------------------------------------------
|
||||
// ---- DOM morphing ------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Lightweight DOM reconciler — patches oldNode to match newNode in-place,
|
||||
* preserving event listeners, focus, scroll position, and form state on
|
||||
* keyed (id) elements.
|
||||
*/
|
||||
function _morphDOM(oldNode, newNode) {
|
||||
// Different node types or tag names → replace wholesale
|
||||
if (oldNode.nodeType !== newNode.nodeType ||
|
||||
oldNode.nodeName !== newNode.nodeName) {
|
||||
oldNode.parentNode.replaceChild(newNode.cloneNode(true), oldNode);
|
||||
return;
|
||||
}
|
||||
|
||||
// Text/comment nodes → update content
|
||||
if (oldNode.nodeType === 3 || oldNode.nodeType === 8) {
|
||||
if (oldNode.nodeValue !== newNode.nodeValue)
|
||||
oldNode.nodeValue = newNode.nodeValue;
|
||||
return;
|
||||
}
|
||||
|
||||
// Element nodes → sync attributes, then recurse children
|
||||
if (oldNode.nodeType === 1) {
|
||||
// Skip morphing focused input to preserve user's in-progress edits
|
||||
if (oldNode === document.activeElement &&
|
||||
(oldNode.tagName === "INPUT" || oldNode.tagName === "TEXTAREA" || oldNode.tagName === "SELECT")) {
|
||||
_syncAttrs(oldNode, newNode); // sync non-value attrs (class, style, etc.)
|
||||
return; // don't touch value or children
|
||||
}
|
||||
_syncAttrs(oldNode, newNode);
|
||||
_morphChildren(oldNode, newNode);
|
||||
}
|
||||
}
|
||||
|
||||
function _syncAttrs(old, neu) {
|
||||
// Add/update attributes from new
|
||||
var newAttrs = neu.attributes;
|
||||
for (var i = 0; i < newAttrs.length; i++) {
|
||||
var a = newAttrs[i];
|
||||
if (old.getAttribute(a.name) !== a.value)
|
||||
old.setAttribute(a.name, a.value);
|
||||
}
|
||||
// Remove attributes not in new
|
||||
var oldAttrs = old.attributes;
|
||||
for (var j = oldAttrs.length - 1; j >= 0; j--) {
|
||||
if (!neu.hasAttribute(oldAttrs[j].name))
|
||||
old.removeAttribute(oldAttrs[j].name);
|
||||
}
|
||||
}
|
||||
|
||||
function _morphChildren(oldParent, newParent) {
|
||||
var oldChildren = Array.prototype.slice.call(oldParent.childNodes);
|
||||
var newChildren = Array.prototype.slice.call(newParent.childNodes);
|
||||
|
||||
// Build ID map of old children for keyed matching
|
||||
var oldById = {};
|
||||
for (var k = 0; k < oldChildren.length; k++) {
|
||||
var kid = oldChildren[k];
|
||||
if (kid.id) oldById[kid.id] = kid;
|
||||
}
|
||||
|
||||
var oi = 0;
|
||||
for (var ni = 0; ni < newChildren.length; ni++) {
|
||||
var newChild = newChildren[ni];
|
||||
var matchById = newChild.id ? oldById[newChild.id] : null;
|
||||
|
||||
if (matchById) {
|
||||
// Keyed match — move into position if needed, then morph
|
||||
if (matchById !== oldChildren[oi]) {
|
||||
oldParent.insertBefore(matchById, oldChildren[oi] || null);
|
||||
}
|
||||
_morphDOM(matchById, newChild);
|
||||
oi++;
|
||||
} else if (oi < oldChildren.length) {
|
||||
// Positional match — morph in place
|
||||
var oldChild = oldChildren[oi];
|
||||
if (oldChild.id && !newChild.id) {
|
||||
// Old has ID, new doesn't — insert new before old (don't clobber keyed)
|
||||
oldParent.insertBefore(newChild.cloneNode(true), oldChild);
|
||||
} else {
|
||||
_morphDOM(oldChild, newChild);
|
||||
oi++;
|
||||
}
|
||||
} else {
|
||||
// Extra new children — append
|
||||
oldParent.appendChild(newChild.cloneNode(true));
|
||||
}
|
||||
}
|
||||
|
||||
// Remove leftover old children
|
||||
while (oi < oldChildren.length) {
|
||||
var leftover = oldChildren[oi];
|
||||
if (leftover.parentNode === oldParent) oldParent.removeChild(leftover);
|
||||
oi++;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- DOM-native swap engine --------------------------------------------
|
||||
|
||||
/**
|
||||
* Swap using live DOM nodes (from Sx.render) instead of HTML strings.
|
||||
* Uses _morphDOM for innerHTML/outerHTML to preserve state.
|
||||
*/
|
||||
function _swapDOM(target, newNodes, strategy) {
|
||||
// newNodes is a DocumentFragment, Element, or Text node
|
||||
var wrapper;
|
||||
switch (strategy) {
|
||||
case "innerHTML":
|
||||
// Morph children of target to match newNodes
|
||||
if (newNodes.nodeType === 11) {
|
||||
// DocumentFragment — morph its children into target
|
||||
_morphChildren(target, newNodes);
|
||||
} else {
|
||||
wrapper = document.createElement("div");
|
||||
wrapper.appendChild(newNodes);
|
||||
_morphChildren(target, wrapper);
|
||||
}
|
||||
break;
|
||||
case "outerHTML":
|
||||
var parent = target.parentNode;
|
||||
if (newNodes.nodeType === 11) {
|
||||
// Fragment — morph first child, insert rest
|
||||
var first = newNodes.firstChild;
|
||||
if (first) {
|
||||
_morphDOM(target, first);
|
||||
var sib = first.nextSibling; // skip first (used as morph template, not consumed)
|
||||
while (sib) {
|
||||
var next = sib.nextSibling;
|
||||
parent.insertBefore(sib, target.nextSibling);
|
||||
sib = next;
|
||||
}
|
||||
} else {
|
||||
parent.removeChild(target);
|
||||
}
|
||||
} else {
|
||||
_morphDOM(target, newNodes);
|
||||
}
|
||||
_activateScripts(parent);
|
||||
Sx.processScripts(parent);
|
||||
Sx.hydrate(parent);
|
||||
SxEngine.process(parent);
|
||||
return; // early return like existing outerHTML
|
||||
case "afterend":
|
||||
target.parentNode.insertBefore(newNodes, target.nextSibling);
|
||||
break;
|
||||
case "beforeend":
|
||||
target.appendChild(newNodes);
|
||||
break;
|
||||
case "afterbegin":
|
||||
target.insertBefore(newNodes, target.firstChild);
|
||||
break;
|
||||
case "beforebegin":
|
||||
target.parentNode.insertBefore(newNodes, target);
|
||||
break;
|
||||
case "delete":
|
||||
target.parentNode.removeChild(target);
|
||||
return;
|
||||
default: // fallback = innerHTML
|
||||
if (newNodes.nodeType === 11) {
|
||||
_morphChildren(target, newNodes);
|
||||
} else {
|
||||
wrapper = document.createElement("div");
|
||||
wrapper.appendChild(newNodes);
|
||||
_morphChildren(target, wrapper);
|
||||
}
|
||||
}
|
||||
_activateScripts(target);
|
||||
Sx.processScripts(target);
|
||||
Sx.hydrate(target);
|
||||
SxEngine.process(target);
|
||||
}
|
||||
|
||||
// ---- Swap engine (string-based, kept as fallback) ----------------------
|
||||
|
||||
/** Scripts inserted via innerHTML/insertAdjacentHTML don't execute.
|
||||
* Recreate them as live elements so the browser fetches & runs them. */
|
||||
@@ -1942,26 +2165,44 @@
|
||||
return resp.text();
|
||||
}).then(function (text) {
|
||||
// Strip and load any <script type="text/sx" data-components> blocks
|
||||
var hadScript = false;
|
||||
text = text.replace(/<script[^>]*type="text\/sx"[^>]*data-components[^>]*>([\s\S]*?)<\/script>/gi,
|
||||
function (_, defs) { hadScript = true; Sx.loadComponents(defs); return ""; });
|
||||
if (hadScript) text = text.trim();
|
||||
function (_, defs) { Sx.loadComponents(defs); return ""; });
|
||||
text = text.trim();
|
||||
|
||||
if (text.charAt(0) === "(") {
|
||||
try { text = Sx.renderToString(text); } catch (e) {}
|
||||
}
|
||||
var parser = new DOMParser();
|
||||
var doc = parser.parseFromString(text, "text/html");
|
||||
var newMain = doc.getElementById("main-panel");
|
||||
if (newMain) {
|
||||
main.innerHTML = newMain.innerHTML;
|
||||
_activateScripts(main);
|
||||
Sx.processScripts(main);
|
||||
Sx.hydrate(main);
|
||||
SxEngine.process(main);
|
||||
dispatch(document.body, "sx:afterSettle", { target: main });
|
||||
window.scrollTo(0, e.state && e.state.scrollY || 0);
|
||||
// sx response — render to live DOM, morph into main
|
||||
try {
|
||||
var popDom = Sx.render(text);
|
||||
var popContainer = document.createElement("div");
|
||||
popContainer.appendChild(popDom);
|
||||
var newMain = popContainer.querySelector("#main-panel");
|
||||
_morphChildren(main, newMain || popContainer);
|
||||
_activateScripts(main);
|
||||
Sx.processScripts(main);
|
||||
Sx.hydrate(main);
|
||||
SxEngine.process(main);
|
||||
dispatch(document.body, "sx:afterSettle", { target: main });
|
||||
window.scrollTo(0, e.state && e.state.scrollY || 0);
|
||||
} catch (err) {
|
||||
console.error("sx.js popstate render error [v2]:", err, "\ntext first 500:", text ? text.substring(0, 500) : "(empty)");
|
||||
location.reload();
|
||||
}
|
||||
} else {
|
||||
location.reload();
|
||||
// HTML response — parse and morph
|
||||
var parser = new DOMParser();
|
||||
var doc = parser.parseFromString(text, "text/html");
|
||||
var newMain = doc.getElementById("main-panel");
|
||||
if (newMain) {
|
||||
_morphChildren(main, newMain);
|
||||
_activateScripts(main);
|
||||
Sx.processScripts(main);
|
||||
Sx.hydrate(main);
|
||||
SxEngine.process(main);
|
||||
dispatch(document.body, "sx:afterSettle", { target: main });
|
||||
window.scrollTo(0, e.state && e.state.scrollY || 0);
|
||||
} else {
|
||||
location.reload();
|
||||
}
|
||||
}
|
||||
}).catch(function () {
|
||||
location.reload();
|
||||
@@ -2038,7 +2279,7 @@
|
||||
// Auto-init in browser
|
||||
// =========================================================================
|
||||
|
||||
Sx.VERSION = "2026-03-01a";
|
||||
Sx.VERSION = "2026-03-01b-debug";
|
||||
|
||||
if (typeof document !== "undefined") {
|
||||
var init = function () {
|
||||
|
||||
@@ -127,7 +127,7 @@ def post_header_sx(ctx: dict, *, oob: bool = False) -> str:
|
||||
if has_admin and slug:
|
||||
from quart import request
|
||||
admin_href = call_url(ctx, "blog_url", f"/{slug}/admin/")
|
||||
is_admin_page = "/admin" in request.path
|
||||
is_admin_page = ctx.get("is_admin_section") or "/admin" in request.path
|
||||
sel_cls = "!bg-stone-500 !text-white" if is_admin_page else ""
|
||||
base_cls = ("justify-center cursor-pointer flex flex-row"
|
||||
" items-center gap-2 rounded bg-stone-200 text-black p-3")
|
||||
@@ -223,7 +223,7 @@ def oob_header_sx(parent_id: str, child_id: str, row_sx: str) -> str:
|
||||
def header_child_sx(inner_sx: str, *, id: str = "root-header-child") -> str:
|
||||
"""Wrap inner sx in a header-child div."""
|
||||
return sx_call("header-child-sx",
|
||||
id=id, inner=SxExpr(inner_sx),
|
||||
id=id, inner=SxExpr(f"(<> {inner_sx})"),
|
||||
)
|
||||
|
||||
|
||||
@@ -231,7 +231,7 @@ def oob_page_sx(*, oobs: str = "", filter: str = "", aside: str = "",
|
||||
content: str = "", menu: str = "") -> str:
|
||||
"""Build OOB response as sx call string."""
|
||||
return sx_call("oob-sx",
|
||||
oobs=SxExpr(oobs) if oobs else None,
|
||||
oobs=SxExpr(f"(<> {oobs})") if oobs else None,
|
||||
filter=SxExpr(filter) if filter else None,
|
||||
aside=SxExpr(aside) if aside else None,
|
||||
menu=SxExpr(menu) if menu else None,
|
||||
@@ -249,7 +249,7 @@ def full_page_sx(ctx: dict, *, header_rows: str,
|
||||
meta: sx source for meta tags — auto-hoisted to <head> by sx.js.
|
||||
"""
|
||||
body_sx = sx_call("app-body",
|
||||
header_rows=SxExpr(header_rows) if header_rows else None,
|
||||
header_rows=SxExpr(f"(<> {header_rows})") if header_rows else None,
|
||||
filter=SxExpr(filter) if filter else None,
|
||||
aside=SxExpr(aside) if aside else None,
|
||||
menu=SxExpr(menu) if menu else None,
|
||||
@@ -345,6 +345,14 @@ def sx_response(source_or_component: str, status: int = 200,
|
||||
source = source_or_component
|
||||
|
||||
body = source
|
||||
# Validate the sx source parses as a single expression
|
||||
try:
|
||||
from .parser import parse as _parse_check
|
||||
_parse_check(source)
|
||||
except Exception as _e:
|
||||
import logging
|
||||
logging.getLogger("sx").error("sx_response parse error: %s\nSource (first 500): %s", _e, source[:500])
|
||||
|
||||
# For SX requests, prepend missing component definitions
|
||||
if request.headers.get("SX-Request"):
|
||||
comp_defs = components_for_request()
|
||||
@@ -353,6 +361,9 @@ def sx_response(source_or_component: str, status: int = 200,
|
||||
f'{comp_defs}</script>\n{body}')
|
||||
|
||||
resp = Response(body, status=status, content_type="text/sx")
|
||||
resp.headers["X-SX-Body-Len"] = str(len(body))
|
||||
resp.headers["X-SX-Source-Len"] = str(len(source))
|
||||
resp.headers["X-SX-Has-Defs"] = "1" if "<script" in body else "0"
|
||||
if headers:
|
||||
for k, v in headers.items():
|
||||
resp.headers[k] = v
|
||||
@@ -379,7 +390,7 @@ _SX_PAGE_TEMPLATE = """\
|
||||
<link rel="stylesheet" type="text/css" href="{asset_url}/styles/cards.css">
|
||||
<link rel="stylesheet" type="text/css" href="{asset_url}/styles/blog-content.css">
|
||||
<meta name="csrf-token" content="{csrf}">
|
||||
<link rel="stylesheet" type="text/css" href="{asset_url}/styles/tw.css">
|
||||
<script src="https://cdn.tailwindcss.com?plugins=typography"></script>
|
||||
<link rel="stylesheet" href="{asset_url}/fontawesome/css/all.min.css">
|
||||
<link rel="stylesheet" href="{asset_url}/fontawesome/css/v4-shims.min.css">
|
||||
<link href="https://unpkg.com/prismjs/themes/prism.css" rel="stylesheet">
|
||||
|
||||
@@ -138,6 +138,7 @@ class Tokenizer:
|
||||
content = content.replace("\\n", "\n")
|
||||
content = content.replace("\\t", "\t")
|
||||
content = content.replace('\\"', '"')
|
||||
content = content.replace("\\/", "/")
|
||||
content = content.replace("\\\\", "\\")
|
||||
return content
|
||||
|
||||
@@ -284,6 +285,7 @@ def serialize(expr: Any, indent: int = 0, pretty: bool = False) -> str:
|
||||
.replace('"', '\\"')
|
||||
.replace("\n", "\\n")
|
||||
.replace("\t", "\\t")
|
||||
.replace("</script", "<\\/script")
|
||||
)
|
||||
return f'"{escaped}"'
|
||||
|
||||
|
||||
33
shared/sx/templates/auth.sx
Normal file
33
shared/sx/templates/auth.sx
Normal file
@@ -0,0 +1,33 @@
|
||||
;; Shared auth components — login flow, check email
|
||||
;; Used by account and federation services.
|
||||
|
||||
(defcomp ~auth-error-banner (&key error)
|
||||
(when error
|
||||
(div :class "bg-red-50 border border-red-200 text-red-700 p-3 rounded mb-4"
|
||||
error)))
|
||||
|
||||
(defcomp ~auth-login-form (&key error action csrf-token email)
|
||||
(div :class "py-8 max-w-md mx-auto"
|
||||
(h1 :class "text-2xl font-bold mb-6" "Sign in")
|
||||
error
|
||||
(form :method "post" :action action :class "space-y-4"
|
||||
(input :type "hidden" :name "csrf_token" :value csrf-token)
|
||||
(div
|
||||
(label :for "email" :class "block text-sm font-medium mb-1" "Email address")
|
||||
(input :type "email" :name "email" :id "email" :value email :required true :autofocus true
|
||||
:class "w-full border border-stone-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-stone-500"))
|
||||
(button :type "submit"
|
||||
:class "w-full bg-stone-800 text-white py-2 px-4 rounded hover:bg-stone-700 transition"
|
||||
"Send magic link"))))
|
||||
|
||||
(defcomp ~auth-check-email-error (&key error)
|
||||
(when error
|
||||
(div :class "bg-yellow-50 border border-yellow-200 text-yellow-700 p-3 rounded mt-4"
|
||||
error)))
|
||||
|
||||
(defcomp ~auth-check-email (&key email error)
|
||||
(div :class "py-8 max-w-md mx-auto text-center"
|
||||
(h1 :class "text-2xl font-bold mb-4" "Check your email")
|
||||
(p :class "text-stone-600 mb-2" "We sent a sign-in link to " (strong email) ".")
|
||||
(p :class "text-stone-500 text-sm" "Click the link in the email to sign in. The link expires in 15 minutes.")
|
||||
error))
|
||||
@@ -68,7 +68,9 @@
|
||||
(when cart-mini cart-mini)
|
||||
(div :class "font-bold text-5xl flex-1"
|
||||
(a :href (or blog-url "/") :class "flex justify-center md:justify-start items-baseline gap-2"
|
||||
(h1 (or site-title ""))))
|
||||
(h1 (or site-title ""))
|
||||
(when app-label
|
||||
(span :class "text-lg text-white/80 font-normal" app-label))))
|
||||
(nav :class "hidden md:flex gap-4 text-sm ml-2 justify-end items-center flex-0"
|
||||
(when nav-tree nav-tree)
|
||||
(when auth-menu auth-menu)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
;; Miscellaneous shared components for Phase 3 conversion
|
||||
;; Miscellaneous shared components
|
||||
|
||||
;; The single place where raw! lives — for CMS content (Ghost post body,
|
||||
;; product descriptions, etc.) that arrives as pre-rendered HTML.
|
||||
@@ -33,3 +33,226 @@
|
||||
:sx-select hx-select :sx-swap "outerHTML"
|
||||
:sx-push-url "true" :class nav-class
|
||||
label)))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Shared sentinel components — infinite scroll triggers
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~sentinel-mobile (&key id next-url hyperscript)
|
||||
(div :id id :class "block md:hidden h-[60vh] opacity-0 pointer-events-none js-mobile-sentinel"
|
||||
:sx-get next-url :sx-trigger "intersect once delay:250ms, sentinelmobile:retry"
|
||||
:sx-swap "outerHTML" :_ hyperscript
|
||||
:role "status" :aria-live "polite" :aria-hidden "true"
|
||||
(div :class "js-loading hidden flex justify-center py-8"
|
||||
(div :class "animate-spin h-8 w-8 border-4 border-stone-300 border-t-stone-600 rounded-full"))
|
||||
(div :class "js-neterr hidden text-center py-8 text-stone-400"
|
||||
(i :class "fa fa-exclamation-triangle text-2xl")
|
||||
(p :class "mt-2" "Loading failed \u2014 retrying\u2026"))))
|
||||
|
||||
(defcomp ~sentinel-desktop (&key id next-url hyperscript)
|
||||
(div :id id :class "hidden md:block h-4 opacity-0 pointer-events-none"
|
||||
:sx-get next-url :sx-trigger "intersect once delay:250ms, sentinel:retry"
|
||||
:sx-swap "outerHTML" :_ hyperscript
|
||||
:role "status" :aria-live "polite" :aria-hidden "true"
|
||||
(div :class "js-loading hidden flex justify-center py-2"
|
||||
(div :class "animate-spin h-6 w-6 border-2 border-stone-300 border-t-stone-600 rounded-full"))
|
||||
(div :class "js-neterr hidden text-center py-2 text-stone-400 text-sm" "Retry\u2026")))
|
||||
|
||||
(defcomp ~sentinel-simple (&key id next-url)
|
||||
(div :id id :class "h-4 opacity-0 pointer-events-none"
|
||||
:sx-get next-url :sx-trigger "intersect once delay:250ms" :sx-swap "outerHTML"
|
||||
:role "status" :aria-hidden "true"
|
||||
(div :class "text-center text-xs text-stone-400" "loading...")))
|
||||
|
||||
(defcomp ~end-of-results (&key cls)
|
||||
(div :class (or cls "col-span-full mt-4 text-center text-xs text-stone-400") "End of results"))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Shared empty state — icon + message + optional action
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~empty-state (&key icon message cls &rest children)
|
||||
(div :class (or cls "p-8 text-center text-stone-400")
|
||||
(when icon (div (i :class (str icon " text-4xl mb-2") :aria-hidden "true")))
|
||||
(p message)
|
||||
children))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Shared badge — inline pill with configurable colours
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~badge (&key label cls)
|
||||
(span :class (str "inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium " (or cls "bg-stone-100 text-stone-700"))
|
||||
label))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Shared delete button with confirm dialog
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~delete-btn (&key url trigger-target title text confirm-text cancel-text
|
||||
sx-headers cls)
|
||||
(button :type "button"
|
||||
:data-confirm "" :data-confirm-title (or title "Delete?")
|
||||
:data-confirm-text (or text "Are you sure?")
|
||||
:data-confirm-icon "warning"
|
||||
:data-confirm-confirm-text (or confirm-text "Yes, delete")
|
||||
:data-confirm-cancel-text (or cancel-text "Cancel")
|
||||
:data-confirm-event "confirmed"
|
||||
:sx-delete url :sx-trigger "confirmed"
|
||||
:sx-target trigger-target :sx-swap "outerHTML"
|
||||
:sx-headers sx-headers
|
||||
:class (or cls "px-3 py-1 text-sm bg-red-200 hover:bg-red-300 rounded text-red-800")
|
||||
(i :class "fa fa-trash") " Delete"))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Shared price display — special + regular with strikethrough
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~price (&key special-price regular-price)
|
||||
(div :class "mt-1 flex items-baseline gap-2 justify-center"
|
||||
(when special-price (div :class "text-lg font-semibold text-emerald-700" special-price))
|
||||
(when (and special-price regular-price) (div :class "text-sm line-through text-stone-500" regular-price))
|
||||
(when (and (not special-price) regular-price) (div :class "mt-1 text-lg font-semibold" regular-price))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Shared image-or-placeholder
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~img-or-placeholder (&key src alt size-cls placeholder-icon placeholder-text)
|
||||
(if src
|
||||
(img :src src :alt (or alt "") :class (or size-cls "w-12 h-12 rounded-full object-cover flex-shrink-0"))
|
||||
(div :class (str (or size-cls "w-12 h-12 rounded-full") " bg-stone-200 flex items-center justify-center flex-shrink-0")
|
||||
(if placeholder-icon
|
||||
(i :class (str placeholder-icon " text-stone-400") :aria-hidden "true")
|
||||
(when placeholder-text placeholder-text)))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Shared view toggle — list/tile view switcher
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~list-svg ()
|
||||
(svg :xmlns "http://www.w3.org/2000/svg" :class "h-5 w-5" :fill "none" :viewBox "0 0 24 24"
|
||||
:stroke "currentColor" :stroke-width "2"
|
||||
(path :stroke-linecap "round" :stroke-linejoin "round" :d "M4 6h16M4 12h16M4 18h16")))
|
||||
|
||||
(defcomp ~tile-svg ()
|
||||
(svg :xmlns "http://www.w3.org/2000/svg" :class "h-5 w-5" :fill "none" :viewBox "0 0 24 24"
|
||||
:stroke "currentColor" :stroke-width "2"
|
||||
(path :stroke-linecap "round" :stroke-linejoin "round"
|
||||
:d "M4 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM14 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1V5zM4 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1v-4zM14 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z")))
|
||||
|
||||
(defcomp ~view-toggle (&key list-href tile-href hx-select list-cls tile-cls
|
||||
storage-key list-svg tile-svg)
|
||||
(div :class "hidden md:flex justify-end px-3 pt-3 gap-1"
|
||||
(a :href list-href :sx-get list-href :sx-target "#main-panel" :sx-select hx-select
|
||||
:sx-swap "outerHTML" :sx-push-url "true" :class (str "p-1.5 rounded " list-cls) :title "List view"
|
||||
:_ (str "on click js localStorage.removeItem('" (or storage-key "view") "') end")
|
||||
(or list-svg (~list-svg)))
|
||||
(a :href tile-href :sx-get tile-href :sx-target "#main-panel" :sx-select hx-select
|
||||
:sx-swap "outerHTML" :sx-push-url "true" :class (str "p-1.5 rounded " tile-cls) :title "Tile view"
|
||||
:_ (str "on click js localStorage.setItem('" (or storage-key "view") "','tile') end")
|
||||
(or tile-svg (~tile-svg)))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Shared CRUD admin panel — for calendars, markets, etc.
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~crud-create-form (&key create-url csrf errors-id list-id placeholder label btn-label)
|
||||
(<>
|
||||
(div :id (or errors-id "crud-create-errors") :class "mt-2 text-sm text-red-600")
|
||||
(form :class "mt-4 flex gap-2 items-end" :sx-post create-url
|
||||
:sx-target (str "#" (or list-id "crud-list")) :sx-select (str "#" (or list-id "crud-list")) :sx-swap "outerHTML"
|
||||
:sx-on:beforeRequest (str "document.querySelector('#" (or errors-id "crud-create-errors") "').textContent='';")
|
||||
:sx-on:responseError (str "document.querySelector('#" (or errors-id "crud-create-errors") "').textContent='Error'; if(event.detail.response){event.detail.response.clone().text().then(function(t){event.target.closest('form').querySelector('[id$=errors]').innerHTML=t})}")
|
||||
(input :type "hidden" :name "csrf_token" :value csrf)
|
||||
(div :class "flex-1"
|
||||
(label :class "block text-sm text-gray-600" (or label "Name"))
|
||||
(input :name "name" :type "text" :required true :class "w-full border rounded px-3 py-2"
|
||||
:placeholder (or placeholder "Name")))
|
||||
(button :type "submit" :class "border rounded px-3 py-2" (or btn-label "Add")))))
|
||||
|
||||
(defcomp ~crud-panel (&key form list list-id)
|
||||
(section :class "p-4"
|
||||
form
|
||||
(div :id (or list-id "crud-list") :class "mt-6" list)))
|
||||
|
||||
(defcomp ~crud-item (&key href name slug del-url csrf-hdr list-id
|
||||
confirm-title confirm-text)
|
||||
(div :class "mt-6 border rounded-lg p-4"
|
||||
(div :class "flex items-center justify-between gap-3"
|
||||
(a :class "flex items-baseline gap-3" :href href
|
||||
:sx-get href :sx-target "#main-panel" :sx-select "#main-panel" :sx-swap "outerHTML" :sx-push-url "true"
|
||||
(h3 :class "font-semibold" name)
|
||||
(h4 :class "text-gray-500" (str "/" slug "/")))
|
||||
(button :class "text-sm border rounded px-3 py-1 hover:bg-red-50 hover:border-red-400"
|
||||
:data-confirm true :data-confirm-title (or confirm-title "Delete?")
|
||||
:data-confirm-text (or confirm-text "This will be soft deleted")
|
||||
:data-confirm-icon "warning" :data-confirm-confirm-text "Yes, delete it"
|
||||
:data-confirm-cancel-text "Cancel" :data-confirm-event "confirmed"
|
||||
:sx-delete del-url :sx-trigger "confirmed"
|
||||
:sx-target (str "#" (or list-id "crud-list")) :sx-select (str "#" (or list-id "crud-list")) :sx-swap "outerHTML"
|
||||
:sx-headers csrf-hdr
|
||||
(i :class "fa-solid fa-trash")))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Shared SumUp settings form — payment credentials (merchant code, API key,
|
||||
;; checkout prefix) used by blog, events, and cart admin panels.
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~sumup-settings-form (&key update-url csrf merchant-code placeholder
|
||||
input-cls sumup-configured checkout-prefix
|
||||
panel-id sx-select)
|
||||
(div :id (or panel-id "payments-panel") :class "space-y-4 p-4 bg-white rounded-lg border border-stone-200"
|
||||
(h3 :class "text-lg font-semibold text-stone-800"
|
||||
(i :class "fa fa-credit-card text-purple-600 mr-1") " SumUp Payment")
|
||||
(p :class "text-xs text-stone-400 mt-1 mb-3"
|
||||
"Configure per-page SumUp credentials. Leave blank to use the global merchant account.")
|
||||
(form :sx-put update-url
|
||||
:sx-target (str "#" (or panel-id "payments-panel"))
|
||||
:sx-swap "outerHTML"
|
||||
:sx-select sx-select
|
||||
:class "space-y-3"
|
||||
(when csrf (input :type "hidden" :name "csrf_token" :value csrf))
|
||||
(div (label :class "block text-xs font-medium text-stone-600 mb-1" "Merchant Code")
|
||||
(input :type "text" :name "merchant_code" :value merchant-code :placeholder "e.g. ME4J6100"
|
||||
:class (or input-cls "w-full px-3 py-1.5 text-sm border border-stone-300 rounded focus:ring-purple-500 focus:border-purple-500")))
|
||||
(div (label :class "block text-xs font-medium text-stone-600 mb-1" "API Key")
|
||||
(input :type "password" :name "api_key" :value "" :placeholder placeholder
|
||||
:class (or input-cls "w-full px-3 py-1.5 text-sm border border-stone-300 rounded focus:ring-purple-500 focus:border-purple-500"))
|
||||
(when sumup-configured (p :class "text-xs text-stone-400 mt-0.5" "Key is set. Leave blank to keep current key.")))
|
||||
(div (label :class "block text-xs font-medium text-stone-600 mb-1" "Checkout Reference Prefix")
|
||||
(input :type "text" :name "checkout_prefix" :value checkout-prefix :placeholder "e.g. ROSE-"
|
||||
:class (or input-cls "w-full px-3 py-1.5 text-sm border border-stone-300 rounded focus:ring-purple-500 focus:border-purple-500")))
|
||||
(button :type "submit"
|
||||
:class "px-4 py-1.5 text-sm font-medium text-white bg-purple-600 rounded hover:bg-purple-700 focus:ring-2 focus:ring-purple-500"
|
||||
"Save SumUp Settings")
|
||||
(when sumup-configured (span :class "ml-2 text-xs text-green-600"
|
||||
(i :class "fa fa-check-circle") " Connected")))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Shared avatar — image or initial-letter placeholder
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~avatar (&key src cls initial)
|
||||
(if src
|
||||
(img :src src :alt "" :class cls)
|
||||
(div :class cls initial)))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Shared scroll-nav wrapper — horizontal scrollable nav with arrows
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~scroll-nav-wrapper (&key wrapper-id container-id arrow-cls left-hs scroll-hs right-hs items oob)
|
||||
(div :class "flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl"
|
||||
:id wrapper-id :sx-swap-oob (if oob "outerHTML" nil)
|
||||
(button :class (str (or arrow-cls "nav-arrow") " hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded")
|
||||
:aria-label "Scroll left"
|
||||
:_ left-hs (i :class "fa fa-chevron-left"))
|
||||
(div :id container-id
|
||||
:class "overflow-y-auto sm:overflow-x-auto sm:overflow-y-visible scrollbar-hide max-h-[50vh] sm:max-h-none"
|
||||
:style "scroll-behavior: smooth;" :_ scroll-hs
|
||||
(div :class "flex flex-col sm:flex-row gap-1" items))
|
||||
(style ".scrollbar-hide::-webkit-scrollbar { display: none; } .scrollbar-hide { -ms-overflow-style: none; scrollbar-width: none; }")
|
||||
(button :class (str (or arrow-cls "nav-arrow") " hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded")
|
||||
:aria-label "Scroll right"
|
||||
:_ right-hs (i :class "fa fa-chevron-right"))))
|
||||
|
||||
@@ -3,10 +3,6 @@
|
||||
(defcomp ~blog-nav-empty (&key wrapper-id)
|
||||
(div :id wrapper-id :sx-swap-oob "outerHTML"))
|
||||
|
||||
(defcomp ~blog-nav-item-image (&key src label)
|
||||
(if src (img :src src :alt label :class "w-8 h-8 rounded-full object-cover flex-shrink-0")
|
||||
(div :class "w-8 h-8 rounded-full bg-stone-200 flex-shrink-0")))
|
||||
|
||||
(defcomp ~blog-nav-item-link (&key href hx-get selected nav-cls img label)
|
||||
(div (a :href href :sx-get hx-get :sx-target "#main-panel"
|
||||
:sx-swap "outerHTML" :sx-push-url "true"
|
||||
@@ -17,51 +13,13 @@
|
||||
(div (a :href href :aria-selected selected :class nav-cls
|
||||
img (span label))))
|
||||
|
||||
(defcomp ~blog-nav-wrapper (&key arrow-cls container-id left-hs scroll-hs right-hs items)
|
||||
(div :class "flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl"
|
||||
:id "menu-items-nav-wrapper" :sx-swap-oob "outerHTML"
|
||||
(button :class (str arrow-cls " hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded")
|
||||
:aria-label "Scroll left"
|
||||
:_ left-hs (i :class "fa fa-chevron-left"))
|
||||
(div :id container-id
|
||||
:class "overflow-y-auto sm:overflow-x-auto sm:overflow-y-visible scrollbar-hide max-h-[50vh] sm:max-h-none"
|
||||
:style "scroll-behavior: smooth;" :_ scroll-hs
|
||||
(div :class "flex flex-col sm:flex-row gap-1" items))
|
||||
(style ".scrollbar-hide::-webkit-scrollbar { display: none; } .scrollbar-hide { -ms-overflow-style: none; scrollbar-width: none; }")
|
||||
(button :class (str arrow-cls " hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded")
|
||||
:aria-label "Scroll right"
|
||||
:_ right-hs (i :class "fa fa-chevron-right"))))
|
||||
|
||||
;; Nav entries
|
||||
|
||||
(defcomp ~blog-nav-entries-empty ()
|
||||
(div :id "entries-calendars-nav-wrapper" :sx-swap-oob "true"))
|
||||
|
||||
(defcomp ~blog-nav-entry-item (&key href nav-cls name date-str)
|
||||
(a :href href :class nav-cls
|
||||
(div :class "w-8 h-8 rounded bg-stone-200 flex-shrink-0")
|
||||
(div :class "flex-1 min-w-0"
|
||||
(div :class "font-medium truncate" name)
|
||||
(div :class "text-xs text-stone-600 truncate" date-str))))
|
||||
|
||||
(defcomp ~blog-nav-calendar-item (&key href nav-cls name)
|
||||
(a :href href :class nav-cls
|
||||
(i :class "fa fa-calendar" :aria-hidden "true")
|
||||
(div name)))
|
||||
|
||||
(defcomp ~blog-nav-entries-wrapper (&key scroll-hs items)
|
||||
(div :class "flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl"
|
||||
:id "entries-calendars-nav-wrapper" :sx-swap-oob "true"
|
||||
(button :class "entries-nav-arrow hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded"
|
||||
:aria-label "Scroll left"
|
||||
:_ "on click set #associated-items-container.scrollLeft to #associated-items-container.scrollLeft - 200"
|
||||
(i :class "fa fa-chevron-left"))
|
||||
(div :id "associated-items-container"
|
||||
:class "overflow-y-auto sm:overflow-x-auto sm:overflow-y-visible scrollbar-hide max-h-[50vh] sm:max-h-none"
|
||||
:style "scroll-behavior: smooth;" :_ scroll-hs
|
||||
(div :class "flex flex-col sm:flex-row gap-1" items))
|
||||
(style ".scrollbar-hide::-webkit-scrollbar { display: none; } .scrollbar-hide { -ms-overflow-style: none; scrollbar-width: none; }")
|
||||
(button :class "entries-nav-arrow hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded"
|
||||
:aria-label "Scroll right"
|
||||
:_ "on click set #associated-items-container.scrollLeft to #associated-items-container.scrollLeft + 200"
|
||||
(i :class "fa fa-chevron-right"))))
|
||||
|
||||
@@ -5,15 +5,22 @@
|
||||
(div :class "font-medium truncate" name)
|
||||
(div :class "text-xs text-stone-600 truncate" date-str))))
|
||||
|
||||
(defcomp ~calendar-link-nav (&key href name nav-class)
|
||||
(a :href href :class nav-class
|
||||
(defcomp ~calendar-link-nav (&key href name nav-class is-selected select-colours)
|
||||
(a :href href
|
||||
:sx-get href
|
||||
:sx-target "#main-panel"
|
||||
:sx-select "#main-panel"
|
||||
:sx-swap "outerHTML"
|
||||
:sx-push-url "true"
|
||||
:aria-selected (when is-selected "true")
|
||||
:class (str (or nav-class "") " " (or select-colours ""))
|
||||
(i :class "fa fa-calendar" :aria-hidden "true")
|
||||
(div name)))
|
||||
(span name)))
|
||||
|
||||
(defcomp ~market-link-nav (&key href name nav-class)
|
||||
(a :href href :class nav-class
|
||||
(defcomp ~market-link-nav (&key href name nav-class select-colours)
|
||||
(a :href href :class (str (or nav-class "") " " (or select-colours ""))
|
||||
(i :class "fa fa-shopping-bag" :aria-hidden "true")
|
||||
(div name)))
|
||||
(span name)))
|
||||
|
||||
(defcomp ~relation-nav (&key href name icon nav-class relation-type)
|
||||
(a :href href :class (or nav-class "flex items-center gap-3 rounded-lg py-2 px-3 text-sm text-stone-700 hover:bg-stone-100 transition-colors")
|
||||
|
||||
142
shared/sx/templates/orders.sx
Normal file
142
shared/sx/templates/orders.sx
Normal file
@@ -0,0 +1,142 @@
|
||||
;; Shared order components — used by both cart and orders services
|
||||
;;
|
||||
;; Order table (list view), order detail panels, and checkout error screens.
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Order table rows
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~order-row-desktop (&key oid created desc total pill status url)
|
||||
(tr :class "hidden sm:table-row border-t border-stone-100 hover:bg-stone-50/60"
|
||||
(td :class "px-3 py-2 align-top" (span :class "font-mono text-[11px] sm:text-xs" oid))
|
||||
(td :class "px-3 py-2 align-top text-stone-700 text-xs sm:text-sm" created)
|
||||
(td :class "px-3 py-2 align-top text-stone-700 text-xs sm:text-sm" desc)
|
||||
(td :class "px-3 py-2 align-top text-stone-700 text-xs sm:text-sm" total)
|
||||
(td :class "px-3 py-2 align-top" (span :class pill status))
|
||||
(td :class "px-3 py-0.5 align-top text-right"
|
||||
(a :href url :class "inline-flex items-center px-3 py-1.5 text-xs sm:text-sm rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition" "View"))))
|
||||
|
||||
(defcomp ~order-row-mobile (&key oid created total pill status url)
|
||||
(tr :class "sm:hidden border-t border-stone-100"
|
||||
(td :colspan "5" :class "px-3 py-3"
|
||||
(div :class "flex flex-col gap-2 text-xs"
|
||||
(div :class "flex items-center justify-between gap-2"
|
||||
(span :class "font-mono text-[11px] text-stone-700" oid)
|
||||
(span :class pill status))
|
||||
(div :class "text-[11px] text-stone-500 break-words" created)
|
||||
(div :class "flex items-center justify-between gap-2"
|
||||
(div :class "font-medium text-stone-800" total)
|
||||
(a :href url :class "inline-flex items-center px-2 py-1 text-[11px] rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition shrink-0" "View"))))))
|
||||
|
||||
(defcomp ~order-end-row ()
|
||||
(tr (td :colspan "5" :class "px-3 py-4 text-center text-xs text-stone-400"
|
||||
(~end-of-results :cls "text-center text-xs text-stone-400"))))
|
||||
|
||||
(defcomp ~order-empty-state ()
|
||||
(div :class "max-w-full px-3 py-3 space-y-3"
|
||||
(div :class "rounded-2xl border border-dashed border-stone-300 bg-white/80 p-4 sm:p-6 text-sm text-stone-700"
|
||||
"No orders yet.")))
|
||||
|
||||
(defcomp ~order-table (&key rows)
|
||||
(div :class "max-w-full px-3 py-3 space-y-3"
|
||||
(div :class "overflow-x-auto rounded-2xl border border-stone-200 bg-white/80"
|
||||
(table :class "min-w-full text-xs sm:text-sm"
|
||||
(thead :class "bg-stone-50 border-b border-stone-200 text-stone-600"
|
||||
(tr
|
||||
(th :class "px-3 py-2 text-left font-medium" "Order")
|
||||
(th :class "px-3 py-2 text-left font-medium" "Created")
|
||||
(th :class "px-3 py-2 text-left font-medium" "Description")
|
||||
(th :class "px-3 py-2 text-left font-medium" "Total")
|
||||
(th :class "px-3 py-2 text-left font-medium" "Status")
|
||||
(th :class "px-3 py-2 text-left font-medium" "")))
|
||||
(tbody rows)))))
|
||||
|
||||
(defcomp ~order-list-header (&key search-mobile)
|
||||
(header :class "mb-6 sm:mb-8 flex flex-col sm:flex-row sm:items-center justify-between gap-3 sm:gap-4"
|
||||
(div :class "space-y-1"
|
||||
(p :class "text-xs sm:text-sm text-stone-600" "Recent orders placed via the checkout."))
|
||||
(div :class "md:hidden" search-mobile)))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Order detail panels
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~order-item-image (&key src alt)
|
||||
(img :src src :alt alt :class "w-full h-full object-contain object-center" :loading "lazy" :decoding "async"))
|
||||
|
||||
(defcomp ~order-item-no-image ()
|
||||
(div :class "w-full h-full flex items-center justify-center text-[9px] text-stone-400" "No image"))
|
||||
|
||||
(defcomp ~order-item-row (&key href img title pid qty price)
|
||||
(li (a :class "w-full py-2 flex gap-3" :href href
|
||||
(div :class "w-12 h-12 sm:w-14 sm:h-14 rounded-md bg-stone-100 flex-shrink-0 overflow-hidden" img)
|
||||
(div :class "flex-1 flex justify-between gap-3"
|
||||
(div
|
||||
(p :class "font-medium" title)
|
||||
(p :class "text-[11px] text-stone-500" pid))
|
||||
(div :class "text-right whitespace-nowrap"
|
||||
(p qty)
|
||||
(p price))))))
|
||||
|
||||
(defcomp ~order-items-panel (&key items)
|
||||
(div :class "rounded-2xl border border-stone-200 bg-white/80 p-4 sm:p-6"
|
||||
(h2 :class "text-sm sm:text-base font-semibold mb-3" "Items")
|
||||
(ul :class "divide-y divide-stone-100 text-xs sm:text-sm" items)))
|
||||
|
||||
(defcomp ~order-calendar-entry (&key name pill status date-str cost)
|
||||
(li :class "px-4 py-3 flex items-start justify-between text-sm"
|
||||
(div (div :class "font-medium flex items-center gap-2"
|
||||
name (span :class pill status))
|
||||
(div :class "text-xs text-stone-500" date-str))
|
||||
(div :class "ml-4 font-medium" cost)))
|
||||
|
||||
(defcomp ~order-calendar-section (&key items)
|
||||
(section :class "mt-6 space-y-3"
|
||||
(h2 :class "text-base sm:text-lg font-semibold" "Calendar bookings in this order")
|
||||
(ul :class "divide-y divide-stone-200 rounded-2xl border border-stone-200 bg-white/80" items)))
|
||||
|
||||
(defcomp ~order-detail-panel (&key summary items calendar)
|
||||
(div :class "max-w-full px-3 py-3 space-y-4" summary items calendar))
|
||||
|
||||
(defcomp ~order-pay-btn (&key url)
|
||||
(a :href url :class "inline-flex items-center px-3 py-2 text-xs sm:text-sm rounded-full border border-emerald-600 bg-emerald-600 text-white hover:bg-emerald-700 transition"
|
||||
(i :class "fa fa-credit-card mr-2" :aria-hidden "true") "Open payment page"))
|
||||
|
||||
(defcomp ~order-detail-filter (&key info list-url recheck-url csrf pay)
|
||||
(header :class "mb-6 sm:mb-8 flex flex-col sm:flex-row sm:items-center justify-between gap-3 sm:gap-4"
|
||||
(div :class "space-y-1"
|
||||
(p :class "text-xs sm:text-sm text-stone-600" info))
|
||||
(div :class "flex w-full sm:w-auto justify-start sm:justify-end gap-2"
|
||||
(a :href list-url :class "inline-flex items-center px-3 py-2 text-xs sm:text-sm rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition"
|
||||
(i :class "fa-solid fa-list mr-2" :aria-hidden "true") "All orders")
|
||||
(form :method "post" :action recheck-url :class "inline"
|
||||
(input :type "hidden" :name "csrf_token" :value csrf)
|
||||
(button :type "submit" :class "inline-flex items-center px-3 py-2 text-xs sm:text-sm rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition"
|
||||
(i :class "fa-solid fa-rotate mr-2" :aria-hidden "true") "Re-check status"))
|
||||
pay)))
|
||||
|
||||
(defcomp ~order-detail-header-stack (&key auth orders order)
|
||||
(~header-child-sx :inner
|
||||
(<> auth (~header-child-sx :id "auth-header-child" :inner
|
||||
(<> orders (~header-child-sx :id "orders-header-child" :inner order))))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Checkout error screens
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~checkout-error-header ()
|
||||
(header :class "mb-6 sm:mb-8"
|
||||
(h1 :class "text-xl sm:text-2xl md:text-3xl font-semibold tracking-tight" "Checkout error")
|
||||
(p :class "text-xs sm:text-sm text-stone-600" "We tried to start your payment with SumUp but hit a problem.")))
|
||||
|
||||
(defcomp ~checkout-error-order-id (&key oid)
|
||||
(p :class "text-xs text-rose-800/80" "Order ID: " (span :class "font-mono" oid)))
|
||||
|
||||
(defcomp ~checkout-error-content (&key msg order back-url)
|
||||
(div :class "max-w-full px-3 py-3 space-y-4"
|
||||
(div :class "rounded-2xl border border-rose-200 bg-rose-50/80 p-4 sm:p-6 text-sm text-rose-900 space-y-2"
|
||||
(p :class "font-medium" "Something went wrong.")
|
||||
(p msg)
|
||||
order)
|
||||
(div (a :href back-url :class "inline-flex items-center px-3 py-2 text-xs sm:text-sm rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition"
|
||||
(i :class "fa fa-shopping-cart mr-2" :aria-hidden "true") "Back to cart"))))
|
||||
Reference in New Issue
Block a user