diff --git a/account/templates/fragments/auth_menu.html b/account/templates/fragments/auth_menu.html deleted file mode 100644 index eb68cdc..0000000 --- a/account/templates/fragments/auth_menu.html +++ /dev/null @@ -1,36 +0,0 @@ -{# Desktop auth menu #} - -{# Mobile auth menu #} - -{% if user_email %} - - - {{ user_email }} - -{% else %} - - - sign in or register - -{% endif %} - diff --git a/blog/templates/fragments/link_card.html b/blog/templates/fragments/link_card.html deleted file mode 100644 index cdd2575..0000000 --- a/blog/templates/fragments/link_card.html +++ /dev/null @@ -1,20 +0,0 @@ - -
- {% if feature_image %} - - {% else %} -
- -
- {% endif %} -
-
{{ title }}
- {% if excerpt %} -
{{ excerpt }}
- {% endif %} - {% if published_at %} -
{{ published_at.strftime('%d %b %Y') }}
- {% endif %} -
-
-
diff --git a/cart/templates/fragments/cart_mini.html b/cart/templates/fragments/cart_mini.html deleted file mode 100644 index 3b500e6..0000000 --- a/cart/templates/fragments/cart_mini.html +++ /dev/null @@ -1,27 +0,0 @@ -
- {% if cart_count == 0 %} -
- - - -
- {% else %} - - - - {{ cart_count }} - - - {% endif %} -
diff --git a/events/bp/fragments/routes.py b/events/bp/fragments/routes.py index 3f8e7a3..8d46096 100644 --- a/events/bp/fragments/routes.py +++ b/events/bp/fragments/routes.py @@ -176,6 +176,7 @@ def register(): async def _link_card_handler(): from shared.infrastructure.urls import events_url + from shared.sexp.jinja_bridge import sexp as render_sexp slug = request.args.get("slug", "") keys_raw = request.args.get("keys", "") @@ -193,12 +194,10 @@ def register(): g.s, "page", post.id, ) cal_names = ", ".join(c.name for c in calendars) if calendars else "" - parts.append(await render_template( - "fragments/link_card.html", - title=post.title, - feature_image=post.feature_image, - calendar_names=cal_names, - link=events_url(f"/{post.slug}"), + parts.append(render_sexp( + '(~link-card :title title :image image :subtitle subtitle :link link)', + title=post.title, image=post.feature_image, + subtitle=cal_names, link=events_url(f"/{post.slug}"), )) return "\n".join(parts) @@ -213,12 +212,10 @@ def register(): g.s, "page", post.id, ) cal_names = ", ".join(c.name for c in calendars) if calendars else "" - return await render_template( - "fragments/link_card.html", - title=post.title, - feature_image=post.feature_image, - calendar_names=cal_names, - link=events_url(f"/{post.slug}"), + return render_sexp( + '(~link-card :title title :image image :subtitle subtitle :link link)', + title=post.title, image=post.feature_image, + subtitle=cal_names, link=events_url(f"/{post.slug}"), ) _handlers["link-card"] = _link_card_handler diff --git a/events/templates/fragments/container_nav_calendars.html b/events/templates/fragments/container_nav_calendars.html deleted file mode 100644 index cdf50e3..0000000 --- a/events/templates/fragments/container_nav_calendars.html +++ /dev/null @@ -1,10 +0,0 @@ -{# Calendar links nav — served as fragment from events app #} -{% for calendar in calendars %} - {% set local_href=events_url('/' + post_slug + '/calendars/' + calendar.slug + '/') %} - - -
{{calendar.name}}
-
-{% endfor %} diff --git a/events/templates/fragments/container_nav_entries.html b/events/templates/fragments/container_nav_entries.html deleted file mode 100644 index d217565..0000000 --- a/events/templates/fragments/container_nav_entries.html +++ /dev/null @@ -1,28 +0,0 @@ -{# Calendar entries nav — served as fragment from events app #} -{% for entry in entries %} - {% set _entry_path = '/' + post_slug + '/calendars/' + entry.calendar_slug + '/' + entry.start_at.year|string + '/' + entry.start_at.month|string + '/' + entry.start_at.day|string + '/entries/' + entry.id|string + '/' %} - -
-
-
{{ entry.name }}
-
- {{ entry.start_at.strftime('%b %d, %Y at %H:%M') }} - {% if entry.end_at %} – {{ entry.end_at.strftime('%H:%M') }}{% endif %} -
-
-
-{% endfor %} - -{# Infinite scroll sentinel — URL points back to the consumer app #} -{% if has_more and paginate_url_base %} -
-
-{% endif %} diff --git a/events/templates/fragments/link_card.html b/events/templates/fragments/link_card.html deleted file mode 100644 index 9417330..0000000 --- a/events/templates/fragments/link_card.html +++ /dev/null @@ -1,17 +0,0 @@ - -
- {% if feature_image %} - - {% else %} -
- -
- {% endif %} -
-
{{ title }}
- {% if calendar_names %} -
{{ calendar_names }}
- {% endif %} -
-
-
diff --git a/federation/templates/fragments/link_card.html b/federation/templates/fragments/link_card.html deleted file mode 100644 index 357f819..0000000 --- a/federation/templates/fragments/link_card.html +++ /dev/null @@ -1,18 +0,0 @@ - -
- {% if avatar_url %} - - {% else %} -
- -
- {% endif %} -
-
{{ display_name or username }}
-
@{{ username }}
- {% if summary %} -
{{ summary }}
- {% endif %} -
-
-
diff --git a/market/bp/fragments/routes.py b/market/bp/fragments/routes.py index 358253a..a3dce41 100644 --- a/market/bp/fragments/routes.py +++ b/market/bp/fragments/routes.py @@ -6,7 +6,7 @@ by other coop apps via the fragment client. from __future__ import annotations -from quart import Blueprint, Response, g, render_template, request +from quart import Blueprint, Response, g, request from shared.infrastructure.fragments import FRAGMENT_HEADER from shared.services.registry import services @@ -65,6 +65,7 @@ def register(): from sqlalchemy import select from shared.models.market import Product from shared.infrastructure.urls import market_url + from shared.sexp.jinja_bridge import sexp as render_sexp slug = request.args.get("slug", "") keys_raw = request.args.get("keys", "") @@ -79,14 +80,16 @@ def register(): await g.s.execute(select(Product).where(Product.slug == s)) ).scalar_one_or_none() if product: - parts.append(await render_template( - "fragments/link_card.html", - title=product.title, - image=product.image, - description_short=product.description_short, - brand=product.brand, - regular_price=product.regular_price, - special_price=product.special_price, + subtitle = product.brand or "" + detail = "" + if product.special_price: + detail = f"{product.regular_price} {product.special_price}" + elif product.regular_price: + detail = str(product.regular_price) + parts.append(render_sexp( + '(~link-card :title title :image image :subtitle subtitle :detail detail :link link)', + title=product.title, image=product.image, + subtitle=subtitle, detail=detail, link=market_url(f"/product/{product.slug}/"), )) return "\n".join(parts) @@ -99,14 +102,16 @@ def register(): ).scalar_one_or_none() if not product: return "" - return await render_template( - "fragments/link_card.html", - title=product.title, - image=product.image, - description_short=product.description_short, - brand=product.brand, - regular_price=product.regular_price, - special_price=product.special_price, + subtitle = product.brand or "" + detail = "" + if product.special_price: + detail = f"{product.regular_price} {product.special_price}" + elif product.regular_price: + detail = str(product.regular_price) + return render_sexp( + '(~link-card :title title :image image :subtitle subtitle :detail detail :link link)', + title=product.title, image=product.image, + subtitle=subtitle, detail=detail, link=market_url(f"/product/{product.slug}/"), ) diff --git a/market/templates/fragments/container_nav_markets.html b/market/templates/fragments/container_nav_markets.html deleted file mode 100644 index 3c8814d..0000000 --- a/market/templates/fragments/container_nav_markets.html +++ /dev/null @@ -1,9 +0,0 @@ -{# Market links nav — served as fragment from market app #} -{% for m in markets %} - - -
{{m.name}}
-
-{% endfor %} diff --git a/market/templates/fragments/link_card.html b/market/templates/fragments/link_card.html deleted file mode 100644 index c3e52a4..0000000 --- a/market/templates/fragments/link_card.html +++ /dev/null @@ -1,28 +0,0 @@ - -
- {% if image %} - - {% else %} -
- -
- {% endif %} -
-
{{ title }}
- {% if brand %} -
{{ brand }}
- {% endif %} - {% if description_short %} -
{{ description_short }}
- {% endif %} -
- {% if special_price %} - £{{ "%.2f"|format(special_price) }} - £{{ "%.2f"|format(regular_price) }} - {% elif regular_price %} - £{{ "%.2f"|format(regular_price) }} - {% endif %} -
-
-
-
diff --git a/shared/sexp/components.py b/shared/sexp/components.py index f6db5eb..8e103d2 100644 --- a/shared/sexp/components.py +++ b/shared/sexp/components.py @@ -2,818 +2,17 @@ Shared s-expression component definitions. Loaded at app startup via ``load_shared_components()``. Each component -replaces a per-service Jinja fragment template with a single reusable -s-expression definition. +is defined in an external ``.sexp`` file under ``templates/``. """ from __future__ import annotations -from .jinja_bridge import register_components +import os + +from .jinja_bridge import load_sexp_dir def load_shared_components() -> None: """Register all shared s-expression components.""" - register_components(_LINK_CARD) - register_components(_CART_MINI) - register_components(_AUTH_MENU) - register_components(_ACCOUNT_NAV_ITEM) - register_components(_CALENDAR_ENTRY_NAV) - register_components(_CALENDAR_LINK_NAV) - register_components(_MARKET_LINK_NAV) - register_components(_POST_CARD) - register_components(_BASE_SHELL) - register_components(_ERROR_PAGE) - # Phase 6: layout infrastructure - register_components(_APP_SHELL) - register_components(_APP_LAYOUT) - register_components(_OOB_RESPONSE) - register_components(_HEADER_ROW) - register_components(_MENU_ROW) - register_components(_NAV_LINK) - register_components(_INFINITE_SCROLL) - register_components(_STATUS_PILL) - register_components(_SEARCH_MOBILE) - register_components(_SEARCH_DESKTOP) - register_components(_MOBILE_FILTER) - register_components(_ORDER_SUMMARY_CARD) - # Relation-driven components - register_components(_RELATION_NAV) - register_components(_RELATION_ATTACH) - register_components(_RELATION_DETACH) - - -# --------------------------------------------------------------------------- -# ~link-card -# --------------------------------------------------------------------------- -# Replaces: blog/templates/fragments/link_card.html -# market/templates/fragments/link_card.html -# events/templates/fragments/link_card.html -# federation/templates/fragments/link_card.html -# artdag/l1/app/templates/fragments/link_card.html -# -# Usage: -# sexp('(~link-card :link "/post/apple/" :title "Apple" :image "/img/a.jpg")') -# sexp('(~link-card :link url :title title :icon "fas fa-file-alt")', **ctx) -# --------------------------------------------------------------------------- - -_LINK_CARD = ''' -(defcomp ~link-card (&key link title image icon subtitle detail data-app) - (a :href link - :class "block rounded border border-stone-200 bg-white hover:bg-stone-50 transition-colors no-underline" - :data-fragment "link-card" - :data-app data-app - :data-hx-disable true - (div :class "flex flex-row items-start gap-3 p-3" - (if image - (img :src image :alt "" :class "flex-shrink-0 w-16 h-16 rounded object-cover") - (div :class "flex-shrink-0 w-16 h-16 rounded bg-stone-100 flex items-center justify-center text-stone-400" - (i :class icon))) - (div :class "flex-1 min-w-0" - (div :class "font-medium text-stone-900 text-sm clamp-2" title) - (when subtitle - (div :class "text-xs text-stone-500 mt-0.5" subtitle)) - (when detail - (div :class "text-xs text-stone-400 mt-1" detail)))))) -''' - - -# --------------------------------------------------------------------------- -# ~cart-mini -# --------------------------------------------------------------------------- -# Replaces: cart/templates/fragments/cart_mini.html -# -# Usage: -# sexp('(~cart-mini :cart-count count :blog-url burl :cart-url curl)', -# count=0, burl="https://blog.rose-ash.com", curl="https://cart.rose-ash.com") -# --------------------------------------------------------------------------- - -_CART_MINI = ''' -(defcomp ~cart-mini (&key cart-count blog-url cart-url oob) - (div :id "cart-mini" - :hx-swap-oob oob - (if (= cart-count 0) - (div :class "h-12 w-12 rounded-full overflow-hidden border border-stone-300 flex-shrink-0" - (a :href blog-url - :class "h-full w-full font-bold text-5xl flex-shrink-0 flex flex-row items-center gap-1" - (img :src (str blog-url "static/img/logo.jpg") - :class "h-full w-full rounded-full object-cover border border-stone-300 flex-shrink-0"))) - (a :href cart-url - :class "relative inline-flex items-center justify-center text-stone-700 hover:text-emerald-700" - (i :class "fa fa-shopping-cart text-5xl" :aria-hidden "true") - (span :class "absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 inline-flex items-center justify-center rounded-full bg-emerald-600 text-white text-sm w-5 h-5" - cart-count))))) -''' - - -# --------------------------------------------------------------------------- -# ~auth-menu -# --------------------------------------------------------------------------- -# Replaces: account/templates/fragments/auth_menu.html -# -# Usage: -# sexp('(~auth-menu :user-email email :account-url aurl)', -# email="user@example.com", aurl="https://account.rose-ash.com") -# --------------------------------------------------------------------------- - -_AUTH_MENU = ''' -(defcomp ~auth-menu (&key user-email account-url) - (<> - (span :id "auth-menu-desktop" :class "hidden md:inline-flex" - (if user-email - (a :href account-url - :class "justify-center cursor-pointer flex flex-row items-center p-3 gap-2 rounded bg-stone-200 text-black" - :data-close-details true - (i :class "fa-solid fa-user") - (span user-email)) - (a :href account-url - :class "justify-center cursor-pointer flex flex-row items-center p-3 gap-2 rounded bg-stone-200 text-black" - :data-close-details true - (i :class "fa-solid fa-key") - (span "sign in or register")))) - (span :id "auth-menu-mobile" :class "block md:hidden text-md font-bold" - (if user-email - (a :href account-url :data-close-details true - (i :class "fa-solid fa-user") - (span user-email)) - (a :href account-url - (i :class "fa-solid fa-key") - (span "sign in or register")))))) -''' - - -# --------------------------------------------------------------------------- -# ~account-nav-item -# --------------------------------------------------------------------------- -# Replaces: hardcoded HTML in cart/bp/fragments/routes.py -# and orders/bp/fragments/routes.py -# -# Usage: -# sexp('(~account-nav-item :href url :label "orders")', url=cart_url("/orders/")) -# --------------------------------------------------------------------------- - -_ACCOUNT_NAV_ITEM = ''' -(defcomp ~account-nav-item (&key href label) - (div :class "relative nav-group" - (a :href href - :class "justify-center cursor-pointer flex flex-row items-center gap-2 rounded bg-stone-200 text-black p-3" - :data-hx-disable true - label))) -''' - - -# --------------------------------------------------------------------------- -# ~calendar-entry-nav -# --------------------------------------------------------------------------- -# Replaces: events/templates/fragments/container_nav_entries.html (per-entry) -# -# Usage: -# sexp('(~calendar-entry-nav :href url :name name :date-str "Jan 15, 2026 at 14:00")', -# url="/events/...", name="Workshop") -# --------------------------------------------------------------------------- - -_CALENDAR_ENTRY_NAV = ''' -(defcomp ~calendar-entry-nav (&key href name date-str nav-class) - (a :href href :class nav-class - (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)))) -''' - - -# --------------------------------------------------------------------------- -# ~calendar-link-nav -# --------------------------------------------------------------------------- -# Replaces: events/templates/fragments/container_nav_calendars.html (per-calendar) -# -# Usage: -# sexp('(~calendar-link-nav :href url :name "My Calendar")', url="/events/...") -# --------------------------------------------------------------------------- - -_CALENDAR_LINK_NAV = ''' -(defcomp ~calendar-link-nav (&key href name nav-class) - (a :href href :class nav-class - (i :class "fa fa-calendar" :aria-hidden "true") - (div name))) -''' - - -# --------------------------------------------------------------------------- -# ~market-link-nav -# --------------------------------------------------------------------------- -# Replaces: market/templates/fragments/container_nav_markets.html (per-market) -# -# Usage: -# sexp('(~market-link-nav :href url :name "Farm Shop")', url="/market/...") -# --------------------------------------------------------------------------- - -_MARKET_LINK_NAV = ''' -(defcomp ~market-link-nav (&key href name nav-class) - (a :href href :class nav-class - (i :class "fa fa-shopping-bag" :aria-hidden "true") - (div name))) -''' - - -# --------------------------------------------------------------------------- -# ~post-card -# --------------------------------------------------------------------------- -# Replaces: blog/templates/_types/blog/_card.html -# -# A simplified s-expression version of the blog listing card. -# The full card is complex (like buttons, card widgets, at_bar with tag/author -# filtering). This component covers the core card structure; the at_bar and -# card_widgets are passed as pre-rendered HTML via :at-bar-html and -# :widgets-html kwargs for incremental migration. -# -# Usage: -# sexp('(~post-card :title t :slug s :href h ...)', **ctx) -# --------------------------------------------------------------------------- - -_POST_CARD = ''' -(defcomp ~post-card (&key title slug href feature-image excerpt - status published-at updated-at publish-requested - hx-select like-html widgets-html at-bar-html) - (article :class "border-b pb-6 last:border-b-0 relative" - (when like-html (raw! like-html)) - (a :href href - :hx-get href - :hx-target "#main-panel" - :hx-select hx-select - :hx-swap "outerHTML" - :hx-push-url "true" - :class "block rounded-xl bg-white shadow hover:shadow-md transition overflow-hidden" - (header :class "mb-2 text-center" - (h2 :class "text-4xl font-bold text-stone-900" title) - (cond - (= status "draft") - (begin - (div :class "flex justify-center gap-2 mt-1" - (span :class "inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-amber-100 text-amber-800" "Draft") - (when publish-requested - (span :class "inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-blue-100 text-blue-800" "Publish requested"))) - (when updated-at - (p :class "text-sm text-stone-500" (str "Updated: " updated-at)))) - published-at - (p :class "text-sm text-stone-500" (str "Published: " published-at)))) - (when feature-image - (div :class "mb-4" - (img :src feature-image :alt "" :class "rounded-lg w-full object-cover"))) - (when excerpt - (p :class "text-stone-700 text-lg leading-relaxed text-center" excerpt))) - (when widgets-html (raw! widgets-html)) - (when at-bar-html (raw! at-bar-html)))) -''' - - -# --------------------------------------------------------------------------- -# ~base-shell — full HTML document wrapper -# --------------------------------------------------------------------------- -# Replaces: shared/browser/templates/_types/root/index.html (the shell) -# -# Usage: For full-page s-expression rendering (Step 4 proof of concept) -# --------------------------------------------------------------------------- - -_BASE_SHELL = ''' -(defcomp ~base-shell (&key title asset-url &rest children) - (<> - (raw! "") - (html :lang "en" - (head - (meta :charset "utf-8") - (meta :name "viewport" :content "width=device-width, initial-scale=1") - (title title) - (style - "body{margin:0;min-height:100vh;display:flex;align-items:center;" - "justify-content:center;font-family:system-ui,sans-serif;" - "background:#fafaf9;color:#1c1917}") - (script :src "https://cdn.tailwindcss.com") - (link :rel "stylesheet" :href (str asset-url "/fontawesome/css/all.min.css"))) - (body :class "bg-stone-50 text-stone-900" - children)))) -''' - - -# --------------------------------------------------------------------------- -# ~error-page — styled error page -# --------------------------------------------------------------------------- -# Replaces: shared/browser/templates/_types/root/exceptions/_.html -# + base.html + 404/message.html + 404/img.html -# -# Usage: -# sexp('(~error-page :title "Not Found" :message "NOT FOUND" :image img-url :asset-url aurl)', -# img_url="/static/errors/404.gif", aurl="/static") -# --------------------------------------------------------------------------- - -_ERROR_PAGE = ''' -(defcomp ~error-page (&key title message image asset-url) - (~base-shell :title title :asset-url asset-url - (div :class "text-center p-8 max-w-lg mx-auto" - (div :class "font-bold text-2xl md:text-4xl text-red-500 mb-4" - (div message)) - (when image - (div :class "flex justify-center" - (img :src image :width "300" :height "300")))))) -''' - - -# =================================================================== -# Phase 6: Layout infrastructure components -# =================================================================== - -# --------------------------------------------------------------------------- -# ~app-shell — full HTML document with all required CSS/JS assets -# --------------------------------------------------------------------------- -# Replaces: _types/root/index.html ... shell -# -# This includes htmx, hyperscript, tailwind, fontawesome, prism, and -# all shared CSS/JS. ``~base-shell`` remains the lightweight error-page -# shell; ``~app-shell`` is for real app pages. -# -# Usage: -# sexp('(~app-shell :title t :asset-url a :meta-html m :body-html b)', **ctx) -# --------------------------------------------------------------------------- - -_APP_SHELL = r''' -(defcomp ~app-shell (&key title asset-url meta-html body-html body-end-html) - (<> - (raw! "") - (html :lang "en" - (head - (meta :charset "utf-8") - (meta :name "viewport" :content "width=device-width, initial-scale=1") - (meta :name "robots" :content "index,follow") - (meta :name "theme-color" :content "#ffffff") - (title title) - (when meta-html (raw! meta-html)) - (style "@media (min-width: 768px) { .js-mobile-sentinel { display:none !important; } }") - (link :rel "stylesheet" :type "text/css" :href (str asset-url "/styles/basics.css")) - (link :rel "stylesheet" :type "text/css" :href (str asset-url "/styles/cards.css")) - (link :rel "stylesheet" :type "text/css" :href (str asset-url "/styles/blog-content.css")) - (script :src "https://unpkg.com/htmx.org@2.0.8") - (script :src "https://unpkg.com/hyperscript.org@0.9.12") - (script :src "https://cdn.tailwindcss.com") - (link :rel "stylesheet" :href (str asset-url "/fontawesome/css/all.min.css")) - (link :rel "stylesheet" :href (str asset-url "/fontawesome/css/v4-shims.min.css")) - (link :href "https://unpkg.com/prismjs/themes/prism.css" :rel "stylesheet") - (script :src "https://unpkg.com/prismjs/prism.js") - (script :src "https://unpkg.com/prismjs/components/prism-javascript.min.js") - (script :src "https://unpkg.com/prismjs/components/prism-python.min.js") - (script :src "https://unpkg.com/prismjs/components/prism-bash.min.js") - (script :src "https://cdn.jsdelivr.net/npm/sweetalert2@11") - (script "if(matchMedia('(hover:hover) and (pointer:fine)').matches){document.documentElement.classList.add('hover-capable')}") - (script "document.addEventListener('click',function(e){var t=e.target.closest('[data-close-details]');if(!t)return;var d=t.closest('details');if(d)d.removeAttribute('open')})") - (style - "details[data-toggle-group=\"mobile-panels\"]>summary{list-style:none}" - "details[data-toggle-group=\"mobile-panels\"]>summary::-webkit-details-marker{display:none}" - "@media(min-width:768px){.nav-group:focus-within .submenu,.nav-group:hover .submenu{display:block}}" - "img{max-width:100%;height:auto}" - ".clamp-2{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}" - ".clamp-3{display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}" - ".no-scrollbar::-webkit-scrollbar{display:none}.no-scrollbar{-ms-overflow-style:none;scrollbar-width:none}" - "details.group{overflow:hidden}details.group>summary{list-style:none}details.group>summary::-webkit-details-marker{display:none}" - ".htmx-indicator{display:none}.htmx-request .htmx-indicator{display:inline-flex}" - ".js-wrap.open .js-pop{display:block}.js-wrap.open .js-backdrop{display:block}")) - (body :class "bg-stone-50 text-stone-900" - (raw! body-html) - (when body-end-html (raw! body-end-html)) - (script :src (str asset-url "/scripts/body.js")))))) -''' - - -# --------------------------------------------------------------------------- -# ~app-layout — page body layout (header + filter + aside + main-panel) -# --------------------------------------------------------------------------- -# Replaces: _types/root/index.html body structure -# -# The header uses a
/ pattern for mobile menu toggle. -# All content sections are passed as pre-rendered HTML strings. -# -# Usage: -# sexp('(~app-layout :title t :asset-url a :header-rows-html h -# :menu-html m :filter-html f :aside-html a :content-html c)', **ctx) -# --------------------------------------------------------------------------- - -_APP_LAYOUT = r''' -(defcomp ~app-layout (&key title asset-url meta-html menu-colour - header-rows-html menu-html - filter-html aside-html content-html - body-end-html) - (let* ((colour (or menu-colour "sky"))) - (~app-shell :title (or title "Rose Ash") :asset-url asset-url - :meta-html meta-html :body-end-html body-end-html - :body-html (str - "
" - "
" - "
" - "" - "
" - "
" - "
" - header-rows-html - "
" - "
" - "
" - "
" - "
" - (or menu-html "") - "
" - "
" - "
" - "
" - (or filter-html "") - "
" - "
" - "
" - "
" - "" - "
" - (or content-html "") - "
" - "
" - "
" - "
" - "
" - "
")))) -''' - - -# --------------------------------------------------------------------------- -# ~oob-response — HTMX OOB multi-target swap wrapper -# --------------------------------------------------------------------------- -# Replaces: oob_elements.html base template -# -# Each named region gets hx-swap-oob="outerHTML" on its wrapper div. -# The oobs-html param contains any extra OOB elements (header row swaps). -# -# Usage: -# sexp('(~oob-response :oobs-html oh :filter-html fh :aside-html ah -# :menu-html mh :content-html ch)', **ctx) -# --------------------------------------------------------------------------- - -_OOB_RESPONSE = ''' -(defcomp ~oob-response (&key oobs-html filter-html aside-html menu-html content-html) - (<> - (when oobs-html (raw! oobs-html)) - (div :id "filter" :hx-swap-oob "outerHTML" - (when filter-html (raw! filter-html))) - (aside :id "aside" :hx-swap-oob "outerHTML" - :class "hidden md:flex md:flex-col max-w-xs md:h-full md:min-h-0 mr-3" - (when aside-html (raw! aside-html))) - (div :id "root-menu" :hx-swap-oob "outerHTML" :class "md:hidden" - (when menu-html (raw! menu-html))) - (section :id "main-panel" - :class "flex-1 md:h-full md:min-h-0 overflow-y-auto overscroll-contain js-grid-viewport" - (when content-html (raw! content-html))))) -''' - - -# --------------------------------------------------------------------------- -# ~header-row — root header bar (cart-mini, title, nav-tree, auth-menu) -# --------------------------------------------------------------------------- -# Replaces: _types/root/header/_header.html header_row macro -# -# Usage: -# sexp('(~header-row :cart-mini-html cm :blog-url bu :site-title st -# :nav-tree-html nh :auth-menu-html ah :nav-panel-html np -# :settings-url su :is-admin ia)', **ctx) -# --------------------------------------------------------------------------- - -_HEADER_ROW = ''' -(defcomp ~header-row (&key cart-mini-html blog-url site-title - nav-tree-html auth-menu-html nav-panel-html - settings-url is-admin oob hamburger-html) - (<> - (div :id "root-row" - :hx-swap-oob (if oob "outerHTML" nil) - :class "flex flex-col items-center md:flex-row justify-center md:justify-between w-full p-1 bg-sky-500" - (div :class "w-full flex flex-row items-top" - (when cart-mini-html (raw! cart-mini-html)) - (div :class "font-bold text-5xl flex-1" - (a :href (or blog-url "/") :class "flex justify-center md:justify-start" - (h1 (or site-title "")))) - (nav :class "hidden md:flex gap-4 text-sm ml-2 justify-end items-center flex-0" - (when nav-tree-html (raw! nav-tree-html)) - (when auth-menu-html (raw! auth-menu-html)) - (when nav-panel-html (raw! nav-panel-html)) - (when (and is-admin settings-url) - (a :href settings-url :class "justify-center cursor-pointer flex flex-row items-center gap-2 rounded bg-stone-200 text-black p-3" - (i :class "fa fa-cog" :aria-hidden "true")))) - (when hamburger-html (raw! hamburger-html)))) - (div :class "block md:hidden text-md font-bold" - (when auth-menu-html (raw! auth-menu-html))))) -''' - - -# --------------------------------------------------------------------------- -# ~menu-row — section header row (wraps in colored bar) -# --------------------------------------------------------------------------- -# Replaces: macros/links.html menu_row macro -# -# Each nested header row gets a progressively lighter background. -# The route handler passes the level (0-based depth after root). -# -# Usage: -# sexp('(~menu-row :id "auth-row" :level 1 :colour "sky" -# :link-href url :link-label "account" :icon "fa-solid fa-user" -# :nav-html nh :child-id "auth-header-child" :child-html ch)', **ctx) -# --------------------------------------------------------------------------- - -_MENU_ROW = ''' -(defcomp ~menu-row (&key id level colour link-href link-label link-label-html icon - hx-select nav-html child-id child-html oob) - (let* ((c (or colour "sky")) - (lv (or level 1)) - (shade (str (- 500 (* lv 100))))) - (<> - (div :id id - :hx-swap-oob (if oob "outerHTML" nil) - :class (str "flex flex-col items-center md:flex-row justify-center md:justify-between w-full p-1 bg-" c "-" shade) - (div :class "relative nav-group" - (a :href link-href - :hx-get link-href - :hx-target "#main-panel" - :hx-select (or hx-select "#main-panel") - :hx-swap "outerHTML" - :hx-push-url "true" - :class "w-full whitespace-normal flex items-center gap-2 font-bold text-2xl px-3 py-2" - (when icon (i :class icon :aria-hidden "true")) - (if link-label-html (raw! link-label-html) - (when link-label (div link-label))))) - (when nav-html - (nav :class "hidden md:flex gap-4 text-sm ml-2 justify-end items-center flex-0" - (raw! nav-html)))) - (when child-id - (div :id child-id :class "flex flex-col w-full items-center" - (when child-html (raw! child-html))))))) -''' - - -# --------------------------------------------------------------------------- -# ~nav-link — HTMX navigation link (replaces macros/links.html link macro) -# --------------------------------------------------------------------------- - -_NAV_LINK = ''' -(defcomp ~nav-link (&key href hx-select label icon aclass select-colours) - (div :class "relative nav-group" - (a :href href - :hx-get href - :hx-target "#main-panel" - :hx-select (or hx-select "#main-panel") - :hx-swap "outerHTML" - :hx-push-url "true" - :class (or aclass - (str "justify-center cursor-pointer flex flex-row items-center gap-2 rounded bg-stone-200 text-black p-3 " - (or select-colours ""))) - (when icon (i :class icon :aria-hidden "true")) - (when label (span label))))) -''' - - -# --------------------------------------------------------------------------- -# ~infinite-scroll — pagination sentinel for table-based lists -# --------------------------------------------------------------------------- -# Replaces: sentinel pattern in _rows.html templates -# -# For table rows (orders, etc.): renders with intersection observer. -# Uses hyperscript for retry with exponential backoff. -# -# Usage: -# sexp('(~infinite-scroll :url next-url :page p :total-pages tp -# :id-prefix "orders" :colspan 5)', **ctx) -# --------------------------------------------------------------------------- - -_INFINITE_SCROLL = r''' -(defcomp ~infinite-scroll (&key url page total-pages id-prefix colspan) - (if (< page total-pages) - (raw! (str - " htmx.trigger(me, 'sentinel:retry'), myMs) " - "end " - "on htmx:beforeRequest " - "set me.style.pointerEvents to 'none' " - "set me.style.opacity to '0' " - "end " - "on htmx:afterSwap set me.dataset.retryMs to 1000 end " - "on htmx:sendError call backoff() " - "on htmx:responseError call backoff() " - "on htmx:timeout call backoff()" - "\"" - " role=\"status\" aria-live=\"polite\" aria-hidden=\"true\">" - "" - "
" - "
loading… " page " / " total-pages "
" - "" - "
" - "
" - "
loading… " page " / " total-pages "
" - "" - "
" - "")) - (raw! (str - "End of results")))) -''' - - -# --------------------------------------------------------------------------- -# ~status-pill — colored status indicator -# --------------------------------------------------------------------------- -# Replaces: inline Jinja status pill patterns across templates -# -# Usage: -# sexp('(~status-pill :status s :size "sm")', status="paid") -# --------------------------------------------------------------------------- - -_STATUS_PILL = ''' -(defcomp ~status-pill (&key status size) - (let* ((s (or status "pending")) - (lower (lower s)) - (sz (or size "xs")) - (colours (cond - (= lower "paid") "border-emerald-300 bg-emerald-50 text-emerald-700" - (= lower "confirmed") "border-emerald-300 bg-emerald-50 text-emerald-700" - (= lower "checked_in") "border-blue-300 bg-blue-50 text-blue-700" - (or (= lower "failed") (= lower "cancelled")) "border-rose-300 bg-rose-50 text-rose-700" - (= lower "provisional") "border-amber-300 bg-amber-50 text-amber-700" - (= lower "ordered") "border-blue-300 bg-blue-50 text-blue-700" - true "border-stone-300 bg-stone-50 text-stone-700"))) - (span :class (str "inline-flex items-center rounded-full border px-2 py-0.5 text-" sz " font-medium " colours) - s))) -''' - - -# --------------------------------------------------------------------------- -# ~search-mobile — mobile search input with htmx -# --------------------------------------------------------------------------- - -_SEARCH_MOBILE = ''' -(defcomp ~search-mobile (&key current-local-href search search-count hx-select search-headers-mobile) - (div :id "search-mobile-wrapper" - :class "flex flex-row gap-2 items-center flex-1 min-w-0 pr-2" - (input :id "search-mobile" - :type "text" :name "search" :aria-label "search" - :class "text-base md:text-sm col-span-5 rounded-md px-3 py-2 mb-2 w-full min-w-0 max-w-full border-2 border-stone-200 placeholder-shown:border-stone-200 [&:not(:placeholder-shown)]:border-yellow-200" - :hx-preserve true - :value (or search "") - :placeholder "search" - :hx-trigger "input changed delay:300ms" - :hx-target "#main-panel" - :hx-select (str (or hx-select "#main-panel") ", #search-mobile-wrapper, #search-desktop-wrapper") - :hx-get current-local-href - :hx-swap "outerHTML" - :hx-push-url "true" - :hx-headers search-headers-mobile - :hx-sync "this:replace" - :autocomplete "off") - (div :id "search-count-mobile" :aria-label "search count" - :class (if (not search-count) "text-xl text-red-500" "") - (when search (raw! (str search-count)))))) -''' - - -# --------------------------------------------------------------------------- -# ~search-desktop — desktop search input with htmx -# --------------------------------------------------------------------------- - -_SEARCH_DESKTOP = ''' -(defcomp ~search-desktop (&key current-local-href search search-count hx-select search-headers-desktop) - (div :id "search-desktop-wrapper" - :class "flex flex-row gap-2 items-center" - (input :id "search-desktop" - :type "text" :name "search" :aria-label "search" - :class "w-full mx-1 my-3 px-3 py-2 text-md rounded-xl border-2 shadow-sm border-white placeholder-shown:border-white [&:not(:placeholder-shown)]:border-yellow-200" - :hx-preserve true - :value (or search "") - :placeholder "search" - :hx-trigger "input changed delay:300ms" - :hx-target "#main-panel" - :hx-select (str (or hx-select "#main-panel") ", #search-mobile-wrapper, #search-desktop-wrapper") - :hx-get current-local-href - :hx-swap "outerHTML" - :hx-push-url "true" - :hx-headers search-headers-desktop - :hx-sync "this:replace" - :autocomplete "off") - (div :id "search-count-desktop" :aria-label "search count" - :class (if (not search-count) "text-xl text-red-500" "") - (when search (raw! (str search-count)))))) -''' - - -# --------------------------------------------------------------------------- -# ~mobile-filter — mobile filter details/summary panel -# --------------------------------------------------------------------------- -# Replaces: blog/templates/_types/blog/mobile/_filter/summary.html -# + macros/layout.html details/filter_summary -# -# Usage: -# sexp('(~mobile-filter :filter-summary-html fsh :action-buttons-html abh -# :filter-details-html fdh)', -# fsh="...", abh="...", fdh="...") -# --------------------------------------------------------------------------- - -_MOBILE_FILTER = ''' -(defcomp ~mobile-filter (&key filter-summary-html action-buttons-html filter-details-html) - (details :class "group/filter p-2 md:hidden" :data-toggle-group "mobile-panels" - (summary :class "bg-white/90" - (div :class "flex flex-row items-start" - (div - (div :class "md:hidden mx-2 bg-stone-200 rounded" - (span :class "flex items-center justify-center text-stone-600 text-lg h-12 w-12 transition-transform group-open/filter:hidden self-start" - (i :class "fa-solid fa-filter")) - (span - (svg :aria-hidden "true" :viewBox "0 0 24 24" - :class "w-12 h-12 rotate-180 transition-transform group-open/filter:block hidden self-start" - (path :d "M6 9l6 6 6-6" :fill "currentColor"))))) - (div :id "filter-summary-mobile" - :class "flex-1 md:hidden grid grid-cols-12 items-center gap-3" - (div :class "flex flex-col items-start gap-2" - (raw! filter-summary-html))))) - (raw! (or action-buttons-html "")) - (div :id "filter-details-mobile" :style "display:contents" - (raw! (or filter-details-html ""))))) -''' - - -# --------------------------------------------------------------------------- -# ~order-summary-card — reusable order summary card -# --------------------------------------------------------------------------- -_ORDER_SUMMARY_CARD = r''' -(defcomp ~order-summary-card (&key order-id created-at description status currency total-amount) - (div :class "rounded-2xl border border-stone-200 bg-white/80 p-4 sm:p-6 space-y-2 text-xs sm:text-sm text-stone-800" - (p (span :class "font-medium" "Order ID:") " " (span :class "font-mono" (str "#" order-id))) - (p (span :class "font-medium" "Created:") " " (or created-at "\u2014")) - (p (span :class "font-medium" "Description:") " " (or description "\u2013")) - (p (span :class "font-medium" "Status:") " " (~status-pill :status (or status "pending") :size "[11px]")) - (p (span :class "font-medium" "Currency:") " " (or currency "GBP")) - (p (span :class "font-medium" "Total:") " " - (if total-amount - (str (or currency "GBP") " " total-amount) - "\u2013")))) -''' - - -# --------------------------------------------------------------------------- -# ~relation-nav -# --------------------------------------------------------------------------- - -_RELATION_NAV = ''' -(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") - (when icon - (div :class "w-8 h-8 rounded bg-stone-200 flex items-center justify-center flex-shrink-0" - (i :class icon :aria-hidden "true"))) - (div :class "flex-1 min-w-0" - (div :class "font-medium truncate" name)))) -''' - - -# --------------------------------------------------------------------------- -# ~relation-attach -# --------------------------------------------------------------------------- - -_RELATION_ATTACH = ''' -(defcomp ~relation-attach (&key create-url label icon) - (a :href create-url - :hx-get create-url - :hx-target "#main-panel" - :hx-swap "outerHTML" - :hx-push-url "true" - :class "flex items-center gap-2 text-sm p-2 rounded hover:bg-stone-100 text-stone-500 hover:text-stone-700 transition-colors" - (when icon (i :class icon)) - (span (or label "Add")))) -''' - - -# --------------------------------------------------------------------------- -# ~relation-detach -# --------------------------------------------------------------------------- - -_RELATION_DETACH = ''' -(defcomp ~relation-detach (&key detach-url name) - (button :hx-delete detach-url - :hx-confirm (str "Remove " (or name "this item") "?") - :class "text-red-500 hover:text-red-700 text-sm p-1 rounded hover:bg-red-50 transition-colors" - (i :class "fa fa-times" :aria-hidden "true"))) -''' + templates_dir = os.path.join(os.path.dirname(__file__), "templates") + load_sexp_dir(templates_dir) diff --git a/shared/sexp/jinja_bridge.py b/shared/sexp/jinja_bridge.py index 4857821..4160583 100644 --- a/shared/sexp/jinja_bridge.py +++ b/shared/sexp/jinja_bridge.py @@ -20,6 +20,8 @@ Setup:: from __future__ import annotations +import glob +import os from typing import Any from .types import NIL, Symbol @@ -41,6 +43,13 @@ def get_component_env() -> dict[str, Any]: return _COMPONENT_ENV +def load_sexp_dir(directory: str) -> None: + """Load all .sexp files from a directory and register components.""" + for filepath in sorted(glob.glob(os.path.join(directory, "*.sexp"))): + with open(filepath, encoding="utf-8") as f: + register_components(f.read()) + + def register_components(sexp_source: str) -> None: """Parse and evaluate s-expression component definitions into the shared environment. diff --git a/shared/sexp/templates/cards.sexp b/shared/sexp/templates/cards.sexp new file mode 100644 index 0000000..10c974e --- /dev/null +++ b/shared/sexp/templates/cards.sexp @@ -0,0 +1,44 @@ +(defcomp ~post-card (&key title slug href feature-image excerpt + status published-at updated-at publish-requested + hx-select like-html widgets-html at-bar-html) + (article :class "border-b pb-6 last:border-b-0 relative" + (when like-html (raw! like-html)) + (a :href href + :hx-get href + :hx-target "#main-panel" + :hx-select hx-select + :hx-swap "outerHTML" + :hx-push-url "true" + :class "block rounded-xl bg-white shadow hover:shadow-md transition overflow-hidden" + (header :class "mb-2 text-center" + (h2 :class "text-4xl font-bold text-stone-900" title) + (cond + (= status "draft") + (begin + (div :class "flex justify-center gap-2 mt-1" + (span :class "inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-amber-100 text-amber-800" "Draft") + (when publish-requested + (span :class "inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-blue-100 text-blue-800" "Publish requested"))) + (when updated-at + (p :class "text-sm text-stone-500" (str "Updated: " updated-at)))) + published-at + (p :class "text-sm text-stone-500" (str "Published: " published-at)))) + (when feature-image + (div :class "mb-4" + (img :src feature-image :alt "" :class "rounded-lg w-full object-cover"))) + (when excerpt + (p :class "text-stone-700 text-lg leading-relaxed text-center" excerpt))) + (when widgets-html (raw! widgets-html)) + (when at-bar-html (raw! at-bar-html)))) + +(defcomp ~order-summary-card (&key order-id created-at description status currency total-amount) + (div :class "rounded-2xl border border-stone-200 bg-white/80 p-4 sm:p-6 space-y-2 text-xs sm:text-sm text-stone-800" + (p (span :class "font-medium" "Order ID:") " " (span :class "font-mono" (str "#" order-id))) + (p (span :class "font-medium" "Created:") " " (or created-at "\u2014")) + (p (span :class "font-medium" "Description:") " " (or description "\u2013")) + (p (span :class "font-medium" "Status:") " " (~status-pill :status (or status "pending") :size "[11px]")) + (p (span :class "font-medium" "Currency:") " " (or currency "GBP")) + (p (span :class "font-medium" "Total:") " " + (if total-amount + (str (or currency "GBP") " " total-amount) + "\u2013")))) diff --git a/shared/sexp/templates/controls.sexp b/shared/sexp/templates/controls.sexp new file mode 100644 index 0000000..3c41a54 --- /dev/null +++ b/shared/sexp/templates/controls.sexp @@ -0,0 +1,126 @@ +(defcomp ~search-mobile (&key current-local-href search search-count hx-select search-headers-mobile) + (div :id "search-mobile-wrapper" + :class "flex flex-row gap-2 items-center flex-1 min-w-0 pr-2" + (input :id "search-mobile" + :type "text" :name "search" :aria-label "search" + :class "text-base md:text-sm col-span-5 rounded-md px-3 py-2 mb-2 w-full min-w-0 max-w-full border-2 border-stone-200 placeholder-shown:border-stone-200 [&:not(:placeholder-shown)]:border-yellow-200" + :hx-preserve true + :value (or search "") + :placeholder "search" + :hx-trigger "input changed delay:300ms" + :hx-target "#main-panel" + :hx-select (str (or hx-select "#main-panel") ", #search-mobile-wrapper, #search-desktop-wrapper") + :hx-get current-local-href + :hx-swap "outerHTML" + :hx-push-url "true" + :hx-headers search-headers-mobile + :hx-sync "this:replace" + :autocomplete "off") + (div :id "search-count-mobile" :aria-label "search count" + :class (if (not search-count) "text-xl text-red-500" "") + (when search (raw! (str search-count)))))) + +(defcomp ~search-desktop (&key current-local-href search search-count hx-select search-headers-desktop) + (div :id "search-desktop-wrapper" + :class "flex flex-row gap-2 items-center" + (input :id "search-desktop" + :type "text" :name "search" :aria-label "search" + :class "w-full mx-1 my-3 px-3 py-2 text-md rounded-xl border-2 shadow-sm border-white placeholder-shown:border-white [&:not(:placeholder-shown)]:border-yellow-200" + :hx-preserve true + :value (or search "") + :placeholder "search" + :hx-trigger "input changed delay:300ms" + :hx-target "#main-panel" + :hx-select (str (or hx-select "#main-panel") ", #search-mobile-wrapper, #search-desktop-wrapper") + :hx-get current-local-href + :hx-swap "outerHTML" + :hx-push-url "true" + :hx-headers search-headers-desktop + :hx-sync "this:replace" + :autocomplete "off") + (div :id "search-count-desktop" :aria-label "search count" + :class (if (not search-count) "text-xl text-red-500" "") + (when search (raw! (str search-count)))))) + +(defcomp ~mobile-filter (&key filter-summary-html action-buttons-html filter-details-html) + (details :class "group/filter p-2 md:hidden" :data-toggle-group "mobile-panels" + (summary :class "bg-white/90" + (div :class "flex flex-row items-start" + (div + (div :class "md:hidden mx-2 bg-stone-200 rounded" + (span :class "flex items-center justify-center text-stone-600 text-lg h-12 w-12 transition-transform group-open/filter:hidden self-start" + (i :class "fa-solid fa-filter")) + (span + (svg :aria-hidden "true" :viewBox "0 0 24 24" + :class "w-12 h-12 rotate-180 transition-transform group-open/filter:block hidden self-start" + (path :d "M6 9l6 6 6-6" :fill "currentColor"))))) + (div :id "filter-summary-mobile" + :class "flex-1 md:hidden grid grid-cols-12 items-center gap-3" + (div :class "flex flex-col items-start gap-2" + (raw! filter-summary-html))))) + (raw! (or action-buttons-html "")) + (div :id "filter-details-mobile" :style "display:contents" + (raw! (or filter-details-html ""))))) + +(defcomp ~infinite-scroll (&key url page total-pages id-prefix colspan) + (if (< page total-pages) + (raw! (str + " htmx.trigger(me, 'sentinel:retry'), myMs) " + "end " + "on htmx:beforeRequest " + "set me.style.pointerEvents to 'none' " + "set me.style.opacity to '0' " + "end " + "on htmx:afterSwap set me.dataset.retryMs to 1000 end " + "on htmx:sendError call backoff() " + "on htmx:responseError call backoff() " + "on htmx:timeout call backoff()" + "\"" + " role=\"status\" aria-live=\"polite\" aria-hidden=\"true\">" + "" + "
" + "
loading\u2026 " page " / " total-pages "
" + "" + "
" + "
" + "
loading\u2026 " page " / " total-pages "
" + "" + "
" + "")) + (raw! (str + "End of results")))) + +(defcomp ~status-pill (&key status size) + (let* ((s (or status "pending")) + (lower (lower s)) + (sz (or size "xs")) + (colours (cond + (= lower "paid") "border-emerald-300 bg-emerald-50 text-emerald-700" + (= lower "confirmed") "border-emerald-300 bg-emerald-50 text-emerald-700" + (= lower "checked_in") "border-blue-300 bg-blue-50 text-blue-700" + (or (= lower "failed") (= lower "cancelled")) "border-rose-300 bg-rose-50 text-rose-700" + (= lower "provisional") "border-amber-300 bg-amber-50 text-amber-700" + (= lower "ordered") "border-blue-300 bg-blue-50 text-blue-700" + true "border-stone-300 bg-stone-50 text-stone-700"))) + (span :class (str "inline-flex items-center rounded-full border px-2 py-0.5 text-" sz " font-medium " colours) + s))) diff --git a/shared/sexp/templates/fragments.sexp b/shared/sexp/templates/fragments.sexp new file mode 100644 index 0000000..babdf05 --- /dev/null +++ b/shared/sexp/templates/fragments.sexp @@ -0,0 +1,62 @@ +(defcomp ~link-card (&key link title image icon subtitle detail data-app) + (a :href link + :class "block rounded border border-stone-200 bg-white hover:bg-stone-50 transition-colors no-underline" + :data-fragment "link-card" + :data-app data-app + :data-hx-disable true + (div :class "flex flex-row items-start gap-3 p-3" + (if image + (img :src image :alt "" :class "flex-shrink-0 w-16 h-16 rounded object-cover") + (div :class "flex-shrink-0 w-16 h-16 rounded bg-stone-100 flex items-center justify-center text-stone-400" + (i :class icon))) + (div :class "flex-1 min-w-0" + (div :class "font-medium text-stone-900 text-sm clamp-2" title) + (when subtitle + (div :class "text-xs text-stone-500 mt-0.5" subtitle)) + (when detail + (div :class "text-xs text-stone-400 mt-1" detail)))))) + +(defcomp ~cart-mini (&key cart-count blog-url cart-url oob) + (div :id "cart-mini" + :hx-swap-oob oob + (if (= cart-count 0) + (div :class "h-12 w-12 rounded-full overflow-hidden border border-stone-300 flex-shrink-0" + (a :href blog-url + :class "h-full w-full font-bold text-5xl flex-shrink-0 flex flex-row items-center gap-1" + (img :src (str blog-url "static/img/logo.jpg") + :class "h-full w-full rounded-full object-cover border border-stone-300 flex-shrink-0"))) + (a :href cart-url + :class "relative inline-flex items-center justify-center text-stone-700 hover:text-emerald-700" + (i :class "fa fa-shopping-cart text-5xl" :aria-hidden "true") + (span :class "absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 inline-flex items-center justify-center rounded-full bg-emerald-600 text-white text-sm w-5 h-5" + cart-count))))) + +(defcomp ~auth-menu (&key user-email account-url) + (<> + (span :id "auth-menu-desktop" :class "hidden md:inline-flex" + (if user-email + (a :href account-url + :class "justify-center cursor-pointer flex flex-row items-center p-3 gap-2 rounded bg-stone-200 text-black" + :data-close-details true + (i :class "fa-solid fa-user") + (span user-email)) + (a :href account-url + :class "justify-center cursor-pointer flex flex-row items-center p-3 gap-2 rounded bg-stone-200 text-black" + :data-close-details true + (i :class "fa-solid fa-key") + (span "sign in or register")))) + (span :id "auth-menu-mobile" :class "block md:hidden text-md font-bold" + (if user-email + (a :href account-url :data-close-details true + (i :class "fa-solid fa-user") + (span user-email)) + (a :href account-url + (i :class "fa-solid fa-key") + (span "sign in or register")))))) + +(defcomp ~account-nav-item (&key href label) + (div :class "relative nav-group" + (a :href href + :class "justify-center cursor-pointer flex flex-row items-center gap-2 rounded bg-stone-200 text-black p-3" + :data-hx-disable true + label))) diff --git a/shared/sexp/templates/layout.sexp b/shared/sexp/templates/layout.sexp new file mode 100644 index 0000000..9d96a9c --- /dev/null +++ b/shared/sexp/templates/layout.sexp @@ -0,0 +1,164 @@ +(defcomp ~app-shell (&key title asset-url meta-html body-html body-end-html) + (<> + (raw! "") + (html :lang "en" + (head + (meta :charset "utf-8") + (meta :name "viewport" :content "width=device-width, initial-scale=1") + (meta :name "robots" :content "index,follow") + (meta :name "theme-color" :content "#ffffff") + (title title) + (when meta-html (raw! meta-html)) + (style "@media (min-width: 768px) { .js-mobile-sentinel { display:none !important; } }") + (link :rel "stylesheet" :type "text/css" :href (str asset-url "/styles/basics.css")) + (link :rel "stylesheet" :type "text/css" :href (str asset-url "/styles/cards.css")) + (link :rel "stylesheet" :type "text/css" :href (str asset-url "/styles/blog-content.css")) + (script :src "https://unpkg.com/htmx.org@2.0.8") + (script :src "https://unpkg.com/hyperscript.org@0.9.12") + (script :src "https://cdn.tailwindcss.com") + (link :rel "stylesheet" :href (str asset-url "/fontawesome/css/all.min.css")) + (link :rel "stylesheet" :href (str asset-url "/fontawesome/css/v4-shims.min.css")) + (link :href "https://unpkg.com/prismjs/themes/prism.css" :rel "stylesheet") + (script :src "https://unpkg.com/prismjs/prism.js") + (script :src "https://unpkg.com/prismjs/components/prism-javascript.min.js") + (script :src "https://unpkg.com/prismjs/components/prism-python.min.js") + (script :src "https://unpkg.com/prismjs/components/prism-bash.min.js") + (script :src "https://cdn.jsdelivr.net/npm/sweetalert2@11") + (script "if(matchMedia('(hover:hover) and (pointer:fine)').matches){document.documentElement.classList.add('hover-capable')}") + (script "document.addEventListener('click',function(e){var t=e.target.closest('[data-close-details]');if(!t)return;var d=t.closest('details');if(d)d.removeAttribute('open')})") + (style + "details[data-toggle-group=\"mobile-panels\"]>summary{list-style:none}" + "details[data-toggle-group=\"mobile-panels\"]>summary::-webkit-details-marker{display:none}" + "@media(min-width:768px){.nav-group:focus-within .submenu,.nav-group:hover .submenu{display:block}}" + "img{max-width:100%;height:auto}" + ".clamp-2{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}" + ".clamp-3{display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}" + ".no-scrollbar::-webkit-scrollbar{display:none}.no-scrollbar{-ms-overflow-style:none;scrollbar-width:none}" + "details.group{overflow:hidden}details.group>summary{list-style:none}details.group>summary::-webkit-details-marker{display:none}" + ".htmx-indicator{display:none}.htmx-request .htmx-indicator{display:inline-flex}" + ".js-wrap.open .js-pop{display:block}.js-wrap.open .js-backdrop{display:block}")) + (body :class "bg-stone-50 text-stone-900" + (raw! body-html) + (when body-end-html (raw! body-end-html)) + (script :src (str asset-url "/scripts/body.js")))))) + +(defcomp ~app-layout (&key title asset-url meta-html menu-colour + header-rows-html menu-html + filter-html aside-html content-html + body-end-html) + (let* ((colour (or menu-colour "sky"))) + (~app-shell :title (or title "Rose Ash") :asset-url asset-url + :meta-html meta-html :body-end-html body-end-html + :body-html (str + "
" + "
" + "
" + "" + "
" + "
" + "
" + header-rows-html + "
" + "
" + "
" + "
" + "
" + (or menu-html "") + "
" + "
" + "
" + "
" + (or filter-html "") + "
" + "
" + "
" + "
" + "" + "
" + (or content-html "") + "
" + "
" + "
" + "
" + "
" + "
")))) + +(defcomp ~oob-response (&key oobs-html filter-html aside-html menu-html content-html) + (<> + (when oobs-html (raw! oobs-html)) + (div :id "filter" :hx-swap-oob "outerHTML" + (when filter-html (raw! filter-html))) + (aside :id "aside" :hx-swap-oob "outerHTML" + :class "hidden md:flex md:flex-col max-w-xs md:h-full md:min-h-0 mr-3" + (when aside-html (raw! aside-html))) + (div :id "root-menu" :hx-swap-oob "outerHTML" :class "md:hidden" + (when menu-html (raw! menu-html))) + (section :id "main-panel" + :class "flex-1 md:h-full md:min-h-0 overflow-y-auto overscroll-contain js-grid-viewport" + (when content-html (raw! content-html))))) + +(defcomp ~header-row (&key cart-mini-html blog-url site-title + nav-tree-html auth-menu-html nav-panel-html + settings-url is-admin oob hamburger-html) + (<> + (div :id "root-row" + :hx-swap-oob (if oob "outerHTML" nil) + :class "flex flex-col items-center md:flex-row justify-center md:justify-between w-full p-1 bg-sky-500" + (div :class "w-full flex flex-row items-top" + (when cart-mini-html (raw! cart-mini-html)) + (div :class "font-bold text-5xl flex-1" + (a :href (or blog-url "/") :class "flex justify-center md:justify-start" + (h1 (or site-title "")))) + (nav :class "hidden md:flex gap-4 text-sm ml-2 justify-end items-center flex-0" + (when nav-tree-html (raw! nav-tree-html)) + (when auth-menu-html (raw! auth-menu-html)) + (when nav-panel-html (raw! nav-panel-html)) + (when (and is-admin settings-url) + (a :href settings-url :class "justify-center cursor-pointer flex flex-row items-center gap-2 rounded bg-stone-200 text-black p-3" + (i :class "fa fa-cog" :aria-hidden "true")))) + (when hamburger-html (raw! hamburger-html)))) + (div :class "block md:hidden text-md font-bold" + (when auth-menu-html (raw! auth-menu-html))))) + +(defcomp ~menu-row (&key id level colour link-href link-label link-label-html icon + hx-select nav-html child-id child-html oob) + (let* ((c (or colour "sky")) + (lv (or level 1)) + (shade (str (- 500 (* lv 100))))) + (<> + (div :id id + :hx-swap-oob (if oob "outerHTML" nil) + :class (str "flex flex-col items-center md:flex-row justify-center md:justify-between w-full p-1 bg-" c "-" shade) + (div :class "relative nav-group" + (a :href link-href + :hx-get link-href + :hx-target "#main-panel" + :hx-select (or hx-select "#main-panel") + :hx-swap "outerHTML" + :hx-push-url "true" + :class "w-full whitespace-normal flex items-center gap-2 font-bold text-2xl px-3 py-2" + (when icon (i :class icon :aria-hidden "true")) + (if link-label-html (raw! link-label-html) + (when link-label (div link-label))))) + (when nav-html + (nav :class "hidden md:flex gap-4 text-sm ml-2 justify-end items-center flex-0" + (raw! nav-html)))) + (when child-id + (div :id child-id :class "flex flex-col w-full items-center" + (when child-html (raw! child-html))))))) + +(defcomp ~nav-link (&key href hx-select label icon aclass select-colours) + (div :class "relative nav-group" + (a :href href + :hx-get href + :hx-target "#main-panel" + :hx-select (or hx-select "#main-panel") + :hx-swap "outerHTML" + :hx-push-url "true" + :class (or aclass + (str "justify-center cursor-pointer flex flex-row items-center gap-2 rounded bg-stone-200 text-black p-3 " + (or select-colours ""))) + (when icon (i :class icon :aria-hidden "true")) + (when label (span label))))) diff --git a/shared/sexp/templates/navigation.sexp b/shared/sexp/templates/navigation.sexp new file mode 100644 index 0000000..5c5cd0c --- /dev/null +++ b/shared/sexp/templates/navigation.sexp @@ -0,0 +1,24 @@ +(defcomp ~calendar-entry-nav (&key href name date-str nav-class) + (a :href href :class nav-class + (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 ~calendar-link-nav (&key href name nav-class) + (a :href href :class nav-class + (i :class "fa fa-calendar" :aria-hidden "true") + (div name))) + +(defcomp ~market-link-nav (&key href name nav-class) + (a :href href :class nav-class + (i :class "fa fa-shopping-bag" :aria-hidden "true") + (div 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") + (when icon + (div :class "w-8 h-8 rounded bg-stone-200 flex items-center justify-center flex-shrink-0" + (i :class icon :aria-hidden "true"))) + (div :class "flex-1 min-w-0" + (div :class "font-medium truncate" name)))) diff --git a/shared/sexp/templates/pages.sexp b/shared/sexp/templates/pages.sexp new file mode 100644 index 0000000..9f3e39e --- /dev/null +++ b/shared/sexp/templates/pages.sexp @@ -0,0 +1,25 @@ +(defcomp ~base-shell (&key title asset-url &rest children) + (<> + (raw! "") + (html :lang "en" + (head + (meta :charset "utf-8") + (meta :name "viewport" :content "width=device-width, initial-scale=1") + (title title) + (style + "body{margin:0;min-height:100vh;display:flex;align-items:center;" + "justify-content:center;font-family:system-ui,sans-serif;" + "background:#fafaf9;color:#1c1917}") + (script :src "https://cdn.tailwindcss.com") + (link :rel "stylesheet" :href (str asset-url "/fontawesome/css/all.min.css"))) + (body :class "bg-stone-50 text-stone-900" + children)))) + +(defcomp ~error-page (&key title message image asset-url) + (~base-shell :title title :asset-url asset-url + (div :class "text-center p-8 max-w-lg mx-auto" + (div :class "font-bold text-2xl md:text-4xl text-red-500 mb-4" + (div message)) + (when image + (div :class "flex justify-center" + (img :src image :width "300" :height "300")))))) diff --git a/shared/sexp/templates/relations.sexp b/shared/sexp/templates/relations.sexp new file mode 100644 index 0000000..ba50984 --- /dev/null +++ b/shared/sexp/templates/relations.sexp @@ -0,0 +1,15 @@ +(defcomp ~relation-attach (&key create-url label icon) + (a :href create-url + :hx-get create-url + :hx-target "#main-panel" + :hx-swap "outerHTML" + :hx-push-url "true" + :class "flex items-center gap-2 text-sm p-2 rounded hover:bg-stone-100 text-stone-500 hover:text-stone-700 transition-colors" + (when icon (i :class icon)) + (span (or label "Add")))) + +(defcomp ~relation-detach (&key detach-url name) + (button :hx-delete detach-url + :hx-confirm (str "Remove " (or name "this item") "?") + :class "text-red-500 hover:text-red-700 text-sm p-1 rounded hover:bg-red-50 transition-colors" + (i :class "fa fa-times" :aria-hidden "true")))